1
0

Compare commits

...

19 Commits

Author SHA1 Message Date
8bb0c56d01 Create calendar 2025-07-18 21:02:21 +02:00
a15956a960 Fix notes 2025-07-18 19:48:31 +02:00
dfa54ae883 Fix notes 2025-07-18 19:41:21 +02:00
edd1478aa5 Add fullscreen & fix notes 2025-07-18 19:30:16 +02:00
82c88243b4 Create test input methods 2025-07-18 15:43:53 +02:00
7b9a1eeb70 Create new icons for mobile-calendar 2025-07-18 14:37:03 +02:00
74d5037f09 Create docker implementation 2025-07-18 14:36:45 +02:00
b7e8d1c921 Add birthday support for mobile calendar 2025-07-18 14:12:57 +02:00
ddb3c682f5 add database.sqlite to .gitignore 2025-07-18 13:21:47 +02:00
7d55a1369d Implement CALDAV for nextcloud 2025-07-18 13:21:07 +02:00
eaf4d6b085 Improve mobile calendar 2025-07-18 13:12:26 +02:00
3608750616 Create mobile calender service 2025-07-18 11:49:28 +02:00
53e2b15351 Add calender support in server 2025-07-18 11:49:16 +02:00
88d47dc4e0 Add logos and README 2025-07-18 11:15:19 +02:00
9fc5e7c078 Test os configuration 2025-07-18 11:03:55 +02:00
ffa00654d8 Create mobile-shopping app 2025-07-18 10:54:23 +02:00
99d531ba8c Create shopping server 2025-07-18 10:48:00 +02:00
6426e333f9 Add shopping feature to dashboard 2025-07-18 10:47:44 +02:00
68480757dc Implement header, notes and google in dashboard 2025-07-18 10:08:03 +02:00
99 changed files with 24960 additions and 29 deletions

4
.gitignore vendored
View File

@@ -136,3 +136,7 @@ dist
.yarn/install-state.gz
.pnp.*
# server files
server/database.sqlite

293
README.md
View File

@@ -1,3 +1,292 @@
# OpenWall
# 🏠 OpenWall
Smart home dashboard for calendar syncing, notes, and shopping lists with seamless mobile and tablet integration.
<div align="center">
![OpenWall Logo](assets/logo.svg)
**Ein Smart Home Dashboard für modernes Wohnen**
*Kalender-Synchronisation, Notizen und Einkaufslisten mit nahtloser Handy- und Tablet-Integration*
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org/)
[![React](https://img.shields.io/badge/React-19.1.0-blue.svg)](https://reactjs.org/)
[![Electron](https://img.shields.io/badge/Electron-35.1.5-purple.svg)](https://electronjs.org/)
[Funktionen](#-funktionen) • [Architektur](#-architektur) • [Schnellstart](#-schnellstart) • [Installation](#-installation) • [Entwicklung](#-entwicklung)
</div>
---
## 🌟 Funktionen
### 📅 **Kalender-Integration**
- Synchronisation mit Ihren bevorzugten Kalender-Diensten
- Saubere, intuitive Benutzeroberfläche optimiert für Touch-Displays
- Perfekt für wandmontierte Tablets und Smart Displays
### 📝 **Notizen-Verwaltung**
- Schnelle Notizerstellung und -organisation
- Nahtlose Synchronisation zwischen Geräten
- Touch-freundliche Benutzeroberfläche für einfache Bedienung
### 🛒 **Intelligente Einkaufslisten**
- Gemeinsame Einkaufslisten für Familien
- Echtzeit-Synchronisation zwischen Geräten
- Mobile-optimierte Begleit-App
- Automatische Bereinigung erledigter Einträge
### 🎨 **Schönes Design**
- Moderne, responsive Benutzeroberfläche
- Optimiert für Hochformat-Displays (9:16 Verhältnis)
- Atemberaubende Hintergrundbilder
- Touch-First Interaktionsdesign
### 📱 **Multi-Platform Unterstützung**
- **Desktop**: Vollständiges Electron Dashboard
- **Mobile**: Progressive Web App für Einkaufslisten
- **Embedded**: Benutzerdefinierte Linux OS Installation für dedizierte Displays
## 🏗️ Architektur
OpenWall ist als modernes, skalierbares System mit mehreren Komponenten aufgebaut:
```
📦 OpenWall
├── 🖥️ dashboard/ # Electron Desktop-Anwendung
├── 📱 mobile-shopping/ # Mobile PWA für Einkaufslisten
├── 🔧 server/ # Express.js Backend API
└── 🐧 os/ # Linux OS Installer & Konfiguration
```
### Technologie-Stack
| Komponente | Technologie | Zweck |
|------------|-------------|-------|
| **Frontend** | React 19 + Vite | Moderne, schnelle UI-Entwicklung |
| **Desktop** | Electron 35 | Plattformübergreifende Desktop-App |
| **Mobile** | PWA + React | Native-ähnliche mobile Erfahrung |
| **Backend** | Express.js + SQLite | RESTful API & Datenpersistierung |
| **Styling** | Sass | Schöne, wartbare Stylesheets |
| **Package Manager** | pnpm | Schnelles, effizientes Dependency Management |
## 🚀 Schnellstart
### Voraussetzungen
- **Node.js** 18 oder höher
- **pnpm** (empfohlen) oder npm
- **Git**
### 1. Repository klonen
```bash
git clone https://github.com/yourusername/OpenWall.git
cd OpenWall
```
### 2. Backend-Server starten
```bash
cd server
pnpm install
pnpm start
```
Der Server startet auf `http://localhost:3001`
### 3. Dashboard starten
```bash
cd ../dashboard
pnpm install
pnpm dev
```
Das Dashboard öffnet sich automatisch im Entwicklungsmodus.
### 4. Mobile App ausprobieren (Optional)
```bash
cd ../mobile-shopping
pnpm install
pnpm dev
```
Zugriff auf die mobile App unter `http://localhost:5173`
## 🔧 Installation
### Option 1: Entwicklungsumgebung
Perfekt für Entwicklung und Tests:
```bash
# Alle Abhängigkeiten installieren
pnpm install
# Alle Services im Entwicklungsmodus starten
pnpm dev
```
### Option 2: Produktions-Deployment
Für dedizierte Wanddisplays und Smart Home Setups:
#### Manuelle Installation
```bash
# Dashboard bauen
cd dashboard
pnpm install
pnpm build
# Mobile App bauen
cd ../mobile-shopping
pnpm install
pnpm build
# Produktionsserver starten
cd ../server
pnpm install
NODE_ENV=production pnpm start
```
#### Automatisierte Linux Installation
Für Debian-basierte Systeme mit automatischem Kiosk-Modus Setup:
```bash
# Als root ausführen
sudo ./os/install.sh
```
Dieser Installer wird:
- ✅ Einen dedizierten Benutzeraccount erstellen
- ✅ Alle Abhängigkeiten installieren
- ✅ Wayland/Sway für Touch-Displays konfigurieren
- ✅ Auto-Start beim Booten einrichten
- ✅ Für 9:16 Hochformat-Displays optimieren
- ✅ Touch-Input-Kalibrierung aktivieren
## 💻 Entwicklung
### Projektstruktur
```
dashboard/ # Haupt-Electron-Anwendung
├── src/
│ ├── main/ # Electron Hauptprozess
│ ├── preload/ # Preload-Skripte
│ └── renderer/ # React Frontend
│ ├── src/
│ │ ├── pages/ # Anwendungsseiten
│ │ ├── common/ # Geteilte Komponenten
│ │ ├── services/ # API Services
│ │ └── utils/ # Hilfsfunktionen
│ └── index.html
├── build/ # Build-Assets (Icons, etc.)
└── package.json
mobile-shopping/ # Mobile PWA
├── src/
│ ├── services/ # API Integration
│ └── utils/ # Geteilte Hilfsmittel
└── public/ # PWA Assets
server/ # Backend API
├── models/ # Datenbank-Modelle
├── routes/ # API Routen
├── database.sqlite # SQLite Datenbank
└── index.js # Haupt-Server-Datei
os/ # System-Installation
├── install.sh # Automatisierter Installer
└── build-iso.sh # Custom ISO Builder
```
### Verfügbare Skripte
#### Dashboard (Electron)
```bash
pnpm dev # Entwicklungsmodus mit Hot Reload
pnpm build # Für Produktion bauen
pnpm start # Gebaute Anwendung starten
pnpm build:mac # macOS App bauen
pnpm build:win # Windows App bauen
pnpm build:linux # Linux App bauen
```
#### Mobile Shopping (PWA)
```bash
pnpm dev # Entwicklungsserver
pnpm build # Für Produktion bauen
pnpm preview # Gebaute App anzeigen
```
#### Server (Express.js)
```bash
pnpm start # Produktionsserver starten
pnpm dev # Entwicklung mit nodemon
```
### API Endpunkte
| Methode | Endpunkt | Beschreibung |
|---------|----------|--------------|
| `GET` | `/api/health` | Server-Gesundheitscheck |
| `GET` | `/api/shopping` | Alle Einkaufsartikel abrufen |
| `POST` | `/api/shopping` | Neuen Einkaufsartikel erstellen |
| `PUT` | `/api/shopping/:id` | Einkaufsartikel aktualisieren |
| `DELETE` | `/api/shopping/:id` | Einkaufsartikel löschen |
### Umgebungsvariablen
Erstellen Sie `.env` Dateien im Server-Verzeichnis:
```env
PORT=3001
NODE_ENV=development
DATABASE_URL=sqlite:./database.sqlite
```
## 🎯 Anwendungsfälle
### 🏠 **Smart Home Display**
Perfekt für wandmontierte Tablets in Küchen, Fluren oder Familienzimmern. Die Hochformat-Ausrichtung und touch-optimierte Benutzeroberfläche machen es ideal für schnelle Interaktionen.
### 👨‍👩‍👧‍👦 **Familienorganisation**
Halten Sie alle mit geteilten Kalendern, Familiennotizen und gemeinsamen Einkaufslisten synchron, die nahtlos auf allen Geräten funktionieren.
### 🏢 **Büro & Co-Working**
Verwenden Sie es in Büroumgebungen für Teamkalender, geteilte Notizen und Büromaterial-Verwaltung.
### 🎛️ **Digitaler Kiosk**
Einsatz als eigenständiges Kiosk-System mit der enthaltenen Linux-Installation für dedizierte Displays.
## 📄 Lizenz
Dieses Projekt ist unter der MIT-Lizenz lizenziert - siehe die [LICENSE](LICENSE) Datei für Details.
## 🙏 Danksagungen
- **Electron** Team für das exzellente Desktop-Framework
- **React** Community für das großartige Ökosystem
- **Pixabay** für die schönen Hintergrundbilder
## 📞 Support
- 🐛 **Issues**: [GitHub Issues](https://github.com/yourusername/OpenWall/issues)
- 💬 **Diskussionen**: [GitHub Discussions](https://github.com/yourusername/OpenWall/discussions)
- 📧 **E-Mail**: [your-email@example.com](mailto:your-email@example.com)
---
<div align="center">
**Erstellt mit ❤️ von [Mathias Wagner](https://git.gnm.dev/Mathias/OpenWall)**
*Verwandeln Sie Ihren Raum mit OpenWall in eine intelligente, vernetzte Umgebung*
</div>

31
assets/logo-small.svg Normal file
View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
<linearGradient id="screenGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8fafc"/>
<stop offset="100%" style="stop-color:#e2e8f0"/>
</linearGradient>
</defs>
<!-- Background -->
<circle cx="32" cy="32" r="30" fill="url(#bgGradient)"/>
<!-- Screen -->
<rect x="18" y="12" width="28" height="40" rx="3" fill="url(#screenGrad)" stroke="#cbd5e1"/>
<rect x="20" y="14" width="24" height="36" rx="2" fill="#1e293b"/>
<rect x="22" y="16" width="20" height="32" rx="1" fill="#f1f5f9"/>
<!-- UI Elements -->
<rect x="24" y="18" width="6" height="4" rx="0.5" fill="#3b82f6"/>
<rect x="32" y="18" width="6" height="4" fill="#10b981"/>
<rect x="24" y="24" width="8" height="6" fill="#e5e7eb" stroke="#cbd5e1" stroke-width="0.5"/>
<rect x="34" y="24" width="6" height="6" fill="#e5e7eb" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Status indicator -->
<circle cx="32" cy="54" r="2" fill="#10b981">
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

143
assets/logo.svg Normal file
View File

@@ -0,0 +1,143 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<!-- Gradient for the wall background -->
<linearGradient id="wallGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
</linearGradient>
<!-- Gradient for the screen glow -->
<radialGradient id="screenGlow" cx="50%" cy="50%" r="60%">
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:0.8" />
<stop offset="70%" style="stop-color:#3b82f6;stop-opacity:0.4" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:0" />
</radialGradient>
<!-- Gradient for the screen -->
<linearGradient id="screenGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8fafc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e2e8f0;stop-opacity:1" />
</linearGradient>
<!-- Drop shadow filter -->
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="4" stdDeviation="3" flood-color="#1e293b" flood-opacity="0.3"/>
</filter>
<!-- Inner shadow for depth -->
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="2" result="offset-blur"/>
<feFlood flood-color="#334155" flood-opacity="0.2"/>
<feComposite in2="offset-blur" operator="in"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode/>
</feMerge>
</filter>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="95" fill="url(#wallGradient)" filter="url(#dropShadow)"/>
<!-- Wall texture lines -->
<g opacity="0.1">
<line x1="20" y1="40" x2="180" y2="40" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="80" x2="180" y2="80" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="120" x2="180" y2="120" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="160" x2="180" y2="160" stroke="#fff" stroke-width="1"/>
<line x1="40" y1="20" x2="40" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="80" y1="20" x2="80" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="120" y1="20" x2="120" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="160" y1="20" x2="160" y2="180" stroke="#fff" stroke-width="1"/>
</g>
<!-- Screen glow effect -->
<ellipse cx="100" cy="85" rx="45" ry="55" fill="url(#screenGlow)" opacity="0.6"/>
<!-- Main screen/tablet -->
<rect x="65" y="35" width="70" height="100" rx="8" ry="8"
fill="url(#screenGradient)"
stroke="#cbd5e1"
stroke-width="2"
filter="url(#innerShadow)"/>
<!-- Screen bezel -->
<rect x="68" y="38" width="64" height="94" rx="4" ry="4"
fill="#1e293b"
opacity="0.9"/>
<!-- Active screen content area -->
<rect x="72" y="45" width="56" height="80" rx="2" ry="2"
fill="#f1f5f9"/>
<!-- Calendar icon representation -->
<g transform="translate(75, 48)">
<!-- Calendar header -->
<rect x="0" y="0" width="20" height="4" rx="1" fill="#3b82f6"/>
<!-- Calendar body -->
<rect x="0" y="4" width="20" height="16" fill="#e2e8f0" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Calendar grid lines -->
<line x1="5" y1="4" x2="5" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="10" y1="4" x2="10" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="15" y1="4" x2="15" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="8" x2="20" y2="8" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="12" x2="20" y2="12" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="16" x2="20" y2="16" stroke="#94a3b8" stroke-width="0.3"/>
<!-- Today highlight -->
<rect x="5" y="8" width="5" height="4" fill="#3b82f6" opacity="0.7"/>
</g>
<!-- Notes icon -->
<g transform="translate(100, 48)">
<rect x="0" y="0" width="16" height="20" rx="1" fill="#f8fafc" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Note lines -->
<line x1="2" y1="4" x2="14" y2="4" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="7" x2="12" y2="7" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="10" x2="14" y2="10" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="13" x2="10" y2="13" stroke="#94a3b8" stroke-width="0.4"/>
</g>
<!-- Shopping list icon -->
<g transform="translate(75, 75)">
<rect x="0" y="0" width="20" height="24" rx="1" fill="#f8fafc" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Shopping list items -->
<circle cx="3" cy="4" r="1" fill="#10b981"/>
<line x1="6" y1="4" x2="16" y2="4" stroke="#64748b" stroke-width="0.4"/>
<circle cx="3" cy="8" r="1" fill="#10b981"/>
<line x1="6" y1="8" x2="14" y2="8" stroke="#64748b" stroke-width="0.4"/>
<circle cx="3" cy="12" r="1" fill="#e5e7eb" stroke="#9ca3af" stroke-width="0.4"/>
<line x1="6" y1="12" x2="15" y2="12" stroke="#9ca3af" stroke-width="0.4"/>
<circle cx="3" cy="16" r="1" fill="#e5e7eb" stroke="#9ca3af" stroke-width="0.4"/>
<line x1="6" y1="16" x2="12" y2="16" stroke="#9ca3af" stroke-width="0.4"/>
</g>
<!-- Smart home connectivity dots -->
<g transform="translate(100, 75)">
<!-- WiFi/connectivity symbol -->
<circle cx="8" cy="12" r="1.5" fill="#10b981" opacity="0.8"/>
<circle cx="14" cy="8" r="1" fill="#3b82f6" opacity="0.6"/>
<circle cx="12" cy="16" r="0.8" fill="#8b5cf6" opacity="0.7"/>
<circle cx="18" cy="12" r="1.2" fill="#f59e0b" opacity="0.6"/>
<!-- Connection lines -->
<line x1="8" y1="12" x2="14" y2="8" stroke="#10b981" stroke-width="0.5" opacity="0.4"/>
<line x1="8" y1="12" x2="12" y2="16" stroke="#10b981" stroke-width="0.5" opacity="0.4"/>
<line x1="14" y1="8" x2="18" y2="12" stroke="#3b82f6" stroke-width="0.5" opacity="0.4"/>
</g>
<!-- Home base indicator -->
<g transform="translate(100, 145)">
<!-- Simple house shape -->
<polygon points="0,8 8,0 16,8 16,16 0,16" fill="#64748b" opacity="0.7"/>
<rect x="6" y="10" width="4" height="6" fill="#475569"/>
<rect x="2" y="8" width="3" height="3" fill="#1e293b" opacity="0.6"/>
<rect x="11" y="8" width="3" height="3" fill="#1e293b" opacity="0.6"/>
</g>
<!-- Power/status indicator -->
<circle cx="100" cy="170" r="3" fill="#10b981" opacity="0.8">
<animate attributeName="opacity" values="0.4;0.8;0.4" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -15,6 +15,15 @@ export default defineConfig({
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()]
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false
}
}
}
}
})

View File

@@ -15,11 +15,22 @@
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
"build:linux": "npm run build && electron-builder --linux",
"fullscreen": "KIOSK=false npm run start",
"kiosk": "KIOSK=true npm run start",
"electron": "electron",
"electron:kiosk": "electron . --kiosk",
"electron:fullscreen": "electron .",
"test:fullscreen": "node test-fullscreen.js"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0"
"@electron-toolkit/utils": "^4.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.7.0",
"robotjs": "^0.6.0",
"sass": "^1.89.2",
"tesseract.js": "^6.0.1"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^2.0.0",

657
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<!-- Gradient for the wall background -->
<linearGradient id="wallGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
</linearGradient>
<!-- Gradient for the screen glow -->
<radialGradient id="screenGlow" cx="50%" cy="50%" r="60%">
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:0.8" />
<stop offset="70%" style="stop-color:#3b82f6;stop-opacity:0.4" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:0" />
</radialGradient>
<!-- Gradient for the screen -->
<linearGradient id="screenGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8fafc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e2e8f0;stop-opacity:1" />
</linearGradient>
<!-- Drop shadow filter -->
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="4" stdDeviation="3" flood-color="#1e293b" flood-opacity="0.3"/>
</filter>
<!-- Inner shadow for depth -->
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="2" result="offset-blur"/>
<feFlood flood-color="#334155" flood-opacity="0.2"/>
<feComposite in2="offset-blur" operator="in"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode/>
</feMerge>
</filter>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="95" fill="url(#wallGradient)" filter="url(#dropShadow)"/>
<!-- Wall texture lines -->
<g opacity="0.1">
<line x1="20" y1="40" x2="180" y2="40" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="80" x2="180" y2="80" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="120" x2="180" y2="120" stroke="#fff" stroke-width="1"/>
<line x1="20" y1="160" x2="180" y2="160" stroke="#fff" stroke-width="1"/>
<line x1="40" y1="20" x2="40" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="80" y1="20" x2="80" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="120" y1="20" x2="120" y2="180" stroke="#fff" stroke-width="1"/>
<line x1="160" y1="20" x2="160" y2="180" stroke="#fff" stroke-width="1"/>
</g>
<!-- Screen glow effect -->
<ellipse cx="100" cy="85" rx="45" ry="55" fill="url(#screenGlow)" opacity="0.6"/>
<!-- Main screen/tablet -->
<rect x="65" y="35" width="70" height="100" rx="8" ry="8"
fill="url(#screenGradient)"
stroke="#cbd5e1"
stroke-width="2"
filter="url(#innerShadow)"/>
<!-- Screen bezel -->
<rect x="68" y="38" width="64" height="94" rx="4" ry="4"
fill="#1e293b"
opacity="0.9"/>
<!-- Active screen content area -->
<rect x="72" y="45" width="56" height="80" rx="2" ry="2"
fill="#f1f5f9"/>
<!-- Calendar icon representation -->
<g transform="translate(75, 48)">
<!-- Calendar header -->
<rect x="0" y="0" width="20" height="4" rx="1" fill="#3b82f6"/>
<!-- Calendar body -->
<rect x="0" y="4" width="20" height="16" fill="#e2e8f0" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Calendar grid lines -->
<line x1="5" y1="4" x2="5" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="10" y1="4" x2="10" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="15" y1="4" x2="15" y2="20" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="8" x2="20" y2="8" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="12" x2="20" y2="12" stroke="#94a3b8" stroke-width="0.3"/>
<line x1="0" y1="16" x2="20" y2="16" stroke="#94a3b8" stroke-width="0.3"/>
<!-- Today highlight -->
<rect x="5" y="8" width="5" height="4" fill="#3b82f6" opacity="0.7"/>
</g>
<!-- Notes icon -->
<g transform="translate(100, 48)">
<rect x="0" y="0" width="16" height="20" rx="1" fill="#f8fafc" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Note lines -->
<line x1="2" y1="4" x2="14" y2="4" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="7" x2="12" y2="7" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="10" x2="14" y2="10" stroke="#94a3b8" stroke-width="0.4"/>
<line x1="2" y1="13" x2="10" y2="13" stroke="#94a3b8" stroke-width="0.4"/>
</g>
<!-- Shopping list icon -->
<g transform="translate(75, 75)">
<rect x="0" y="0" width="20" height="24" rx="1" fill="#f8fafc" stroke="#cbd5e1" stroke-width="0.5"/>
<!-- Shopping list items -->
<circle cx="3" cy="4" r="1" fill="#10b981"/>
<line x1="6" y1="4" x2="16" y2="4" stroke="#64748b" stroke-width="0.4"/>
<circle cx="3" cy="8" r="1" fill="#10b981"/>
<line x1="6" y1="8" x2="14" y2="8" stroke="#64748b" stroke-width="0.4"/>
<circle cx="3" cy="12" r="1" fill="#e5e7eb" stroke="#9ca3af" stroke-width="0.4"/>
<line x1="6" y1="12" x2="15" y2="12" stroke="#9ca3af" stroke-width="0.4"/>
<circle cx="3" cy="16" r="1" fill="#e5e7eb" stroke="#9ca3af" stroke-width="0.4"/>
<line x1="6" y1="16" x2="12" y2="16" stroke="#9ca3af" stroke-width="0.4"/>
</g>
<!-- Smart home connectivity dots -->
<g transform="translate(100, 75)">
<!-- WiFi/connectivity symbol -->
<circle cx="8" cy="12" r="1.5" fill="#10b981" opacity="0.8"/>
<circle cx="14" cy="8" r="1" fill="#3b82f6" opacity="0.6"/>
<circle cx="12" cy="16" r="0.8" fill="#8b5cf6" opacity="0.7"/>
<circle cx="18" cy="12" r="1.2" fill="#f59e0b" opacity="0.6"/>
<!-- Connection lines -->
<line x1="8" y1="12" x2="14" y2="8" stroke="#10b981" stroke-width="0.5" opacity="0.4"/>
<line x1="8" y1="12" x2="12" y2="16" stroke="#10b981" stroke-width="0.5" opacity="0.4"/>
<line x1="14" y1="8" x2="18" y2="12" stroke="#3b82f6" stroke-width="0.5" opacity="0.4"/>
</g>
<!-- Home base indicator -->
<g transform="translate(100, 145)">
<!-- Simple house shape -->
<polygon points="0,8 8,0 16,8 16,16 0,16" fill="#64748b" opacity="0.7"/>
<rect x="6" y="10" width="4" height="6" fill="#475569"/>
<rect x="2" y="8" width="3" height="3" fill="#1e293b" opacity="0.6"/>
<rect x="11" y="8" width="3" height="3" fill="#1e293b" opacity="0.6"/>
</g>
<!-- Power/status indicator -->
<circle cx="100" cy="170" r="3" fill="#10b981" opacity="0.8">
<animate attributeName="opacity" values="0.4;0.8;0.4" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -1,23 +1,84 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
let mainWindow
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
// Get the primary display dimensions
const primaryDisplay = screen.getPrimaryDisplay()
const { width, height } = primaryDisplay.workAreaSize
// Check for kiosk mode - enable if in production, has kiosk flag, or KIOSK env var
const isKioskMode = process.env.NODE_ENV === 'production' ||
process.argv.includes('--kiosk') ||
process.env.KIOSK === 'true'
mainWindow = new BrowserWindow({
width: width,
height: height,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
fullscreen: true,
kiosk: isKioskMode,
frame: false, // Remove window frame for true fullscreen experience
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
sandbox: false,
nodeIntegration: false,
contextIsolation: true,
webSecurity: false // Allow loading external resources
},
...(process.platform === 'linux' ? { icon } : {})
})
// Disable zoom and context menu for touch interface
mainWindow.webContents.on('before-input-event', (event, input) => {
// Prevent zooming with Ctrl++ and Ctrl+-
if (input.control && (input.key === '+' || input.key === '-' || input.key === '=' || input.key === '0')) {
event.preventDefault()
}
// Prevent F11 fullscreen toggle in kiosk mode
if (isKioskMode && input.key === 'F11') {
event.preventDefault()
}
})
// Disable right-click context menu
mainWindow.webContents.on('context-menu', (event) => {
event.preventDefault()
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
// Ensure fullscreen mode is enabled
if (!mainWindow.isFullScreen()) {
mainWindow.setFullScreen(true)
}
// Focus the window
mainWindow.focus()
})
// Prevent accidentally leaving fullscreen in kiosk mode
mainWindow.on('leave-full-screen', () => {
if (isKioskMode) {
mainWindow.setFullScreen(true)
}
})
// Handle window focus events to maintain fullscreen in kiosk mode
mainWindow.on('focus', () => {
if (isKioskMode && !mainWindow.isFullScreen()) {
mainWindow.setFullScreen(true)
}
})
// Prevent window from being minimized in kiosk mode
mainWindow.on('minimize', () => {
if (isKioskMode) {
mainWindow.restore()
}
})
mainWindow.webContents.setWindowOpenHandler((details) => {
@@ -32,6 +93,35 @@ const createWindow = () => {
}
}
// IPC handlers for input overlay
ipcMain.on('show-input-overlay', (event, data) => {
if (mainWindow) {
mainWindow.webContents.send('show-input-overlay', data)
}
})
ipcMain.on('hide-input-overlay', () => {
if (mainWindow) {
mainWindow.webContents.send('hide-input-overlay')
}
})
ipcMain.on('input-overlay-submit', (event, data) => {
console.log('Input overlay submit:', data)
// You can handle the submitted input here
// For example, send it to other parts of your application
})
// Function to show input overlay (can be called globally)
const showInputOverlay = (options = {}) => {
if (mainWindow) {
mainWindow.webContents.send('show-input-overlay', options)
}
}
// Export for potential use by other modules
global.showInputOverlay = showInputOverlay
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')

View File

@@ -1,7 +1,17 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
const api = {}
const api = {
// Input overlay API
showInputOverlay: (options) => ipcRenderer.send('show-input-overlay', options),
hideInputOverlay: () => ipcRenderer.send('hide-input-overlay'),
onInputOverlayShow: (callback) => ipcRenderer.on('show-input-overlay', callback),
onInputOverlayHide: (callback) => ipcRenderer.on('hide-input-overlay', callback),
removeInputOverlayListeners: () => {
ipcRenderer.removeAllListeners('show-input-overlay')
ipcRenderer.removeAllListeners('hide-input-overlay')
}
}
if (process.contextIsolated) {
try {

View File

@@ -3,10 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>OpenWall</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
<!-- CSP disabled for development -->
</head>
<body>

View File

@@ -1,9 +1,40 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Header from './common/Header'
import Calendar from './pages/Calendar'
import Shopping from './pages/Shopping'
import Notes from './pages/Notes'
import Search from './pages/Search'
import InputOverlay from './components/InputOverlay'
// Configuration
const BACKGROUND_IMAGE_URL = 'https://i.imgur.com/SjjtyaO.jpeg'
const APP_BACKGROUND_STYLE = {
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
minHeight: '100vh'
}
const App = () => {
return (
<>
<h1>Not implemented yet</h1>
</>
<Router>
<div className="app" style={APP_BACKGROUND_STYLE}>
<Header />
<main className="app__main">
<Routes>
<Route path="/" element={<Calendar />} />
<Route path="/shopping" element={<Shopping />} />
<Route path="/notes" element={<Notes />} />
<Route path="/search" element={<Search />} />
</Routes>
</main>
<InputOverlay />
</div>
</Router>
)
}
export default App;
export default App

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { FiCalendar, FiShoppingBag, FiBookOpen, FiSearch } from 'react-icons/fi'
import './styles.sass'
// Static color configuration
const HEADER_COLORS = {
primary: 'rgba(76, 175, 160, 0.25)',
secondary: 'rgba(34, 139, 156, 0.35)',
tertiary: 'rgba(21, 94, 117, 0.4)',
accent: 'rgba(147, 197, 253, 0.3)'
}
// Icon-specific colors for better differentiation
const ICON_COLORS = {
calendar: '#FF6B6B', // Red
shopping: '#4ECDC4', // Teal
notes: '#45B7D1', // Blue
search: '#FFA726' // Orange
}
const Header = () => {
const [currentTime, setCurrentTime] = useState(new Date())
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date())
}, 1000)
return () => clearInterval(timer)
}, [])
const formatTime = (date) => {
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})
}
const getCurrentMonth = (date) => {
return date.toLocaleDateString('de-DE', { month: 'long' })
}
const navigationItems = [
{ id: 'calendar', icon: FiCalendar, label: 'Kalender', path: '/' },
{ id: 'shopping', icon: FiShoppingBag, label: 'Einkaufen', path: '/shopping' },
{ id: 'notes', icon: FiBookOpen, label: 'Notizen', path: '/notes' },
{ id: 'search', icon: FiSearch, label: 'Suchen', path: '/search' }
]
const getActiveIcon = () => {
const currentPath = location.pathname
const activeItem = navigationItems.find(item => item.path === currentPath)
return activeItem ? activeItem.id : 'calendar'
}
const handleNavigation = (path) => {
navigate(path)
}
const headerStyle = {
background: `linear-gradient(135deg, ${HEADER_COLORS.primary}, ${HEADER_COLORS.secondary}, ${HEADER_COLORS.tertiary})`,
borderColor: HEADER_COLORS.accent
}
const buttonStyle = {
background: `linear-gradient(135deg, ${HEADER_COLORS.accent}, ${HEADER_COLORS.primary})`,
borderColor: HEADER_COLORS.accent,
color: '#ffffff',
boxShadow: `0 8px 32px ${HEADER_COLORS.tertiary}, inset 0 1px 0 ${HEADER_COLORS.accent}`
}
const activeButtonStyle = {
background: `linear-gradient(135deg, ${HEADER_COLORS.primary}, ${HEADER_COLORS.secondary})`,
borderColor: HEADER_COLORS.primary,
color: '#ffffff',
boxShadow: `0 12px 40px ${HEADER_COLORS.primary}, inset 0 2px 0 ${HEADER_COLORS.accent}`
}
return (
<header className="header" style={headerStyle}>
<nav className="header__nav">
<div className="header__left">
<span className="header__month">{getCurrentMonth(currentTime)}</span>
<span className="header__time">{formatTime(currentTime)}</span>
</div>
<div className="header__right">
{navigationItems.map(({ id, icon: Icon, label, path }) => {
const isActive = getActiveIcon() === id
const iconColor = ICON_COLORS[id]
const buttonStyleWithIcon = {
...(isActive ? activeButtonStyle : buttonStyle),
'--icon-color': iconColor
}
return (
<button
key={id}
className={`header__nav-item ${isActive ? 'header__nav-item--active' : ''}`}
onClick={() => handleNavigation(path)}
aria-label={label}
title={label}
style={buttonStyleWithIcon}
>
<Icon style={{ color: iconColor, filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))' }} />
</button>
)
})}
</div>
</nav>
</header>
)
}
export default Header

View File

@@ -0,0 +1 @@
export { default } from './Header'

View File

@@ -0,0 +1,161 @@
// Header Component - Dynamic Color Extraction from Background
.header
position: fixed
top: 0.5rem
left: 0.5rem
right: 0.5rem
z-index: 1000
padding: 1rem 2rem
backdrop-filter: blur(80px) saturate(180%)
border-radius: 24px
border: 1px solid
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.15), 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.4)
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1)
&:hover
backdrop-filter: blur(100px) saturate(200%)
box-shadow: 0 24px 96px rgba(0, 0, 0, 0.25), 0 12px 48px rgba(0, 0, 0, 0.15), inset 0 2px 0 rgba(255, 255, 255, 0.5)
transform: translateY(-1px)
&__nav
display: flex
justify-content: space-between
align-items: center
max-width: 100%
margin: 0 auto
&__left
display: flex
flex-direction: column
align-items: flex-start
gap: 0.125rem
&__month
font-size: 1.5rem
font-weight: 700
color: rgba(255, 255, 255, 0.95)
letter-spacing: -0.02em
text-shadow: 0 2px 8px rgba(21, 94, 117, 0.6), 0 1px 2px rgba(0, 0, 0, 0.3)
line-height: 1.1
&__time
font-size: 2.25rem
font-weight: 800
color: #ffffff
font-variant-numeric: tabular-nums
text-shadow: 0 3px 12px rgba(21, 94, 117, 0.8), 0 1px 4px rgba(0, 0, 0, 0.4)
line-height: 0.9
letter-spacing: -0.05em
&__right
display: flex
align-items: center
gap: 1rem
&__nav-item
display: flex
align-items: center
justify-content: center
width: 56px
height: 56px
border: none
border-radius: 18px
cursor: pointer
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1)
backdrop-filter: blur(40px)
border: 1px solid
font-size: 1.375rem
position: relative
overflow: hidden
// Allow custom icon colors
svg
transition: all 0.3s ease
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
opacity: 0
transition: opacity 0.3s ease
border-radius: 18px
&:hover
transform: translateY(-3px) scale(1.08)
svg
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)) !important
&::before
opacity: 1
&:active
transform: translateY(-1px) scale(1.03)
&--active
border: 1px solid
svg
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5)) !important
&::before
opacity: 1
&:hover
transform: translateY(-3px) scale(1.08)
// Responsive Design
@media (max-width: 768px)
.header
top: 0.375rem
left: 0.375rem
right: 0.375rem
padding: 0.875rem 1.75rem
border-radius: 20px
&__left
gap: 0.0625rem
&__month
font-size: 1.25rem
&__time
font-size: 1.875rem
&__right
gap: 0.875rem
&__nav-item
width: 52px
height: 52px
border-radius: 16px
font-size: 1.25rem
@media (max-width: 480px)
.header
top: 0.25rem
left: 0.25rem
right: 0.25rem
padding: 0.75rem 1.5rem
border-radius: 18px
&__left
gap: 0.0625rem
&__month
font-size: 1.125rem
&__time
font-size: 1.625rem
&__right
gap: 0.75rem
&__nav-item
width: 48px
height: 48px
border-radius: 14px
font-size: 1.125rem

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react'
import { showInputOverlay, setupAutoInputOverlay, onInputOverlaySubmit } from '../../utils/InputOverlayHelper'
import './InputDemo.sass'
const InputDemo = () => {
const [manualInput, setManualInput] = useState('')
const [autoInput, setAutoInput] = useState('')
useEffect(() => {
// Setup automatic input overlay for focused elements
const cleanupAutoOverlay = setupAutoInputOverlay()
// Listen for input overlay submissions
const cleanupSubmitListener = onInputOverlaySubmit((data) => {
console.log('Input overlay submitted:', data)
})
return () => {
cleanupAutoOverlay()
cleanupSubmitListener()
}
}, [])
const handleManualTrigger = () => {
showInputOverlay({
targetElement: null,
initialValue: manualInput,
preferredMode: 'keyboard'
})
}
const handleDrawingTrigger = () => {
showInputOverlay({
targetElement: null,
initialValue: manualInput,
preferredMode: 'drawing'
})
}
return (
<div className="input-demo">
<h3>🎯 Intelligente Eingabehilfe</h3>
<div className="demo-section">
<h4> Automatische Aktivierung</h4>
<p>Klicken Sie in die Eingabefelder unten, um die Eingabehilfe automatisch zu öffnen:</p>
<input
type="text"
placeholder="Klicken Sie hier für Tastatur-Eingabe..."
value={autoInput}
onChange={(e) => setAutoInput(e.target.value)}
className="demo-input"
/>
<textarea
placeholder="Oder hier für längere Texte..."
value={autoInput}
onChange={(e) => setAutoInput(e.target.value)}
className="demo-textarea"
/>
</div>
<div className="demo-section">
<h4>🎮 Manuelle Steuerung</h4>
<p>Verwenden Sie die Buttons unten, um die Eingabehilfe manuell zu öffnen:</p>
<div className="demo-controls">
<button onClick={handleManualTrigger} className="demo-btn demo-btn--keyboard">
Tastatur öffnen
</button>
<button onClick={handleDrawingTrigger} className="demo-btn demo-btn--drawing">
Handschrift öffnen
</button>
</div>
<input
type="text"
placeholder="Manueller Eingabetext..."
value={manualInput}
onChange={(e) => setManualInput(e.target.value)}
className="demo-input"
readOnly
/>
</div>
<div className="demo-section">
<h4>📖 Funktionen</h4>
<ul className="demo-usage">
<li>Die Eingabehilfe öffnet sich automatisch bei Fokus auf Eingabefelder</li>
<li>Wählen Sie zwischen Tastatur-Eingabe und Handschrift-Erkennung</li>
<li>Die OCR unterstützt deutsche Texterkennung mit Tesseract.js</li>
<li>Verwenden Sie ESC zum Schließen oder klicken Sie außerhalb der Eingabehilfe</li>
<li>Vollständig responsive für Touch-Displays und mobile Geräte</li>
</ul>
</div>
</div>
)
}
export default InputDemo

View File

@@ -0,0 +1,175 @@
.input-demo
padding: 32px
max-width: 900px
margin: 0 auto
h3
color: rgba(255, 255, 255, 0.95)
margin-bottom: 32px
font-size: 28px
font-weight: 700
text-align: center
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3)
h4
color: rgba(255, 255, 255, 0.9)
margin: 32px 0 16px 0
font-size: 20px
font-weight: 600
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2)
.demo-section
margin-bottom: 40px
padding: 32px
background: rgba(255, 255, 255, 0.1)
backdrop-filter: blur(20px)
-webkit-backdrop-filter: blur(20px)
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 20px
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
p
color: rgba(255, 255, 255, 0.8)
margin-bottom: 24px
font-size: 16px
line-height: 1.6
.demo-input, .demo-textarea
width: 100%
padding: 16px 20px
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 16px
font-size: 15px
margin-bottom: 16px
background: rgba(255, 255, 255, 0.1)
color: rgba(255, 255, 255, 0.95)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
&::placeholder
color: rgba(255, 255, 255, 0.5)
&:focus
outline: none
border-color: rgba(59, 130, 246, 0.5)
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 15px rgba(0, 0, 0, 0.15)
background: rgba(255, 255, 255, 0.15)
transform: translateY(-1px)
.demo-textarea
min-height: 100px
resize: vertical
.demo-controls
display: flex
gap: 16px
margin-bottom: 24px
.demo-btn
padding: 14px 24px
border: none
border-radius: 16px
cursor: pointer
font-size: 15px
font-weight: 500
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
position: relative
overflow: hidden
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
opacity: 0
transition: opacity 0.3s ease
&:hover
transform: translateY(-2px)
&::before
opacity: 1
&--keyboard
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
color: white
border: 1px solid rgba(59, 130, 246, 0.3)
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&:hover
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&--drawing
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))
color: white
border: 1px solid rgba(34, 197, 94, 0.3)
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&:hover
box-shadow: 0 8px 25px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
.demo-usage
color: rgba(255, 255, 255, 0.8)
list-style: none
margin-left: 0
padding-left: 0
li
margin-bottom: 12px
padding-left: 24px
position: relative
line-height: 1.5
&::before
content: ''
position: absolute
left: 0
top: 0
font-size: 16px
@media (max-width: 768px)
.input-demo
padding: 24px 20px
.demo-section
padding: 24px 20px
.demo-controls
flex-direction: column
.demo-btn
width: 100%
padding: 12px 20px
h3
font-size: 24px
h4
font-size: 18px
@media (max-width: 480px)
.input-demo
padding: 20px 16px
.demo-section
padding: 20px 16px
border-radius: 16px
.demo-input, .demo-textarea
padding: 12px 16px
border-radius: 12px
.demo-btn
padding: 10px 16px
font-size: 14px
border-radius: 12px
.demo-usage li
padding-left: 20px

View File

@@ -0,0 +1 @@
export { default } from './InputDemo'

View File

@@ -0,0 +1,165 @@
import { useRef, useState, useEffect } from 'react'
import './DrawingCanvas.sass'
const DrawingCanvas = ({ onDrawingComplete, disabled }) => {
const canvasRef = useRef(null)
const [isDrawing, setIsDrawing] = useState(false)
const [brushSize, setBrushSize] = useState(3)
const [hasDrawing, setHasDrawing] = useState(false)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
// Set canvas size
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
// Set drawing styles
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.strokeStyle = '#000000'
ctx.lineWidth = brushSize
}, [brushSize])
const startDrawing = (e) => {
if (disabled) return
setIsDrawing(true)
const canvas = canvasRef.current
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const ctx = canvas.getContext('2d')
ctx.beginPath()
ctx.moveTo(x, y)
}
const draw = (e) => {
if (!isDrawing || disabled) return
const canvas = canvasRef.current
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const ctx = canvas.getContext('2d')
ctx.lineTo(x, y)
ctx.stroke()
setHasDrawing(true)
}
const stopDrawing = () => {
setIsDrawing(false)
}
// Touch event handlers for mobile/tablet support
const handleTouchStart = (e) => {
e.preventDefault()
const touch = e.touches[0]
const mouseEvent = new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY
})
startDrawing(mouseEvent)
}
const handleTouchMove = (e) => {
e.preventDefault()
const touch = e.touches[0]
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY
})
draw(mouseEvent)
}
const handleTouchEnd = (e) => {
e.preventDefault()
stopDrawing()
}
const clearCanvas = () => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
setHasDrawing(false)
}
const processDrawing = () => {
if (!hasDrawing || disabled) return
const canvas = canvasRef.current
onDrawingComplete(canvas)
}
return (
<div className="drawing-canvas-container">
<div className="drawing-canvas-controls">
<div className="brush-size-control">
<label htmlFor="brush-size">Pinselgröße:</label>
<input
id="brush-size"
type="range"
min="1"
max="10"
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
disabled={disabled}
/>
<span>{brushSize}px</span>
</div>
<div className="canvas-actions">
<button
onClick={clearCanvas}
className="canvas-btn canvas-btn--secondary"
disabled={disabled}
>
🗑 Löschen
</button>
<button
onClick={processDrawing}
className="canvas-btn canvas-btn--primary"
disabled={!hasDrawing || disabled}
>
🔍 Text erkennen
</button>
</div>
</div>
<div className="drawing-canvas-wrapper">
<canvas
ref={canvasRef}
className={`drawing-canvas ${disabled ? 'disabled' : ''}`}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
{!hasDrawing && !disabled && (
<div className="drawing-canvas-placeholder">
Schreiben oder zeichnen Sie hier...
</div>
)}
{disabled && (
<div className="drawing-canvas-overlay">
<div className="processing-spinner" />
<span>🔍 Verarbeitung läuft...</span>
</div>
)}
</div>
</div>
)
}
export default DrawingCanvas

View File

@@ -0,0 +1,249 @@
.drawing-canvas-container
display: flex
flex-direction: column
gap: 20px
max-width: 900px
.drawing-canvas-controls
display: flex
justify-content: space-between
align-items: center
padding: 20px 24px
background: rgba(255, 255, 255, 0.1)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
border-radius: 16px
border: 1px solid rgba(255, 255, 255, 0.2)
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
.brush-size-control
display: flex
align-items: center
gap: 16px
label
font-size: 14px
font-weight: 500
color: rgba(255, 255, 255, 0.9)
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
input[type="range"]
width: 120px
height: 6px
background: rgba(255, 255, 255, 0.2)
border-radius: 3px
outline: none
-webkit-appearance: none
backdrop-filter: blur(5px)
&::-webkit-slider-thumb
-webkit-appearance: none
width: 20px
height: 20px
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))
border-radius: 50%
cursor: pointer
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
transition: all 0.2s ease
&:hover
transform: scale(1.1)
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&::-moz-range-thumb
width: 20px
height: 20px
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))
border-radius: 50%
cursor: pointer
border: none
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3)
span
font-size: 13px
font-weight: 500
color: rgba(255, 255, 255, 0.7)
min-width: 35px
text-align: center
background: rgba(255, 255, 255, 0.1)
padding: 4px 8px
border-radius: 8px
backdrop-filter: blur(5px)
.canvas-actions
display: flex
gap: 12px
.canvas-btn
padding: 10px 20px
border-radius: 12px
font-size: 14px
font-weight: 500
cursor: pointer
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
border: none
position: relative
overflow: hidden
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
opacity: 0
transition: opacity 0.3s ease
&:hover:not(:disabled)
transform: translateY(-2px)
&::before
opacity: 1
&:disabled
cursor: not-allowed
opacity: 0.5
transform: none
&--primary
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
color: white
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&:hover:not(:disabled)
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&:disabled
background: rgba(107, 114, 128, 0.3)
box-shadow: none
&--secondary
background: rgba(239, 68, 68, 0.15)
color: rgba(248, 113, 113, 0.9)
border: 1px solid rgba(239, 68, 68, 0.3)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
&:hover:not(:disabled)
background: rgba(239, 68, 68, 0.25)
border-color: rgba(239, 68, 68, 0.4)
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2)
.drawing-canvas-wrapper
position: relative
width: 100%
height: 320px
border: 2px solid rgba(255, 255, 255, 0.2)
border-radius: 16px
overflow: hidden
background: rgba(255, 255, 255, 0.95)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5)
.drawing-canvas
width: 100%
height: 100%
cursor: crosshair
display: block
background: white
&.disabled
cursor: not-allowed
opacity: 0.7
.drawing-canvas-placeholder
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
color: rgba(107, 114, 128, 0.6)
font-style: italic
font-size: 16px
font-weight: 500
pointer-events: none
text-align: center
background: rgba(255, 255, 255, 0.8)
padding: 12px 20px
border-radius: 12px
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05)
.drawing-canvas-overlay
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(255, 255, 255, 0.9)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
display: flex
flex-direction: column
align-items: center
justify-content: center
gap: 16px
border-radius: 14px
span
font-size: 16px
font-weight: 500
color: rgba(59, 130, 246, 0.8)
.processing-spinner
width: 40px
height: 40px
border: 3px solid rgba(59, 130, 246, 0.2)
border-top: 3px solid rgba(59, 130, 246, 0.8)
border-radius: 50%
animation: spin 1s linear infinite
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2)
@keyframes spin
0%
transform: rotate(0deg)
100%
transform: rotate(360deg)
@media (max-width: 768px)
.drawing-canvas-controls
flex-direction: column
gap: 16px
align-items: stretch
padding: 16px 20px
.brush-size-control
justify-content: center
.canvas-actions
justify-content: center
.drawing-canvas-wrapper
height: 280px
.drawing-canvas-placeholder
font-size: 14px
padding: 10px 16px
@media (max-width: 480px)
.drawing-canvas-container
gap: 16px
.drawing-canvas-controls
padding: 14px 16px
.brush-size-control
gap: 12px
input[type="range"]
width: 100px
.canvas-btn
padding: 8px 16px
font-size: 13px
.drawing-canvas-wrapper
height: 240px

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useRef } from 'react'
import { createWorker } from 'tesseract.js'
import OnScreenKeyboard from './OnScreenKeyboard'
import DrawingCanvas from './DrawingCanvas'
import './InputOverlay.sass'
const InputOverlay = () => {
const [isVisible, setIsVisible] = useState(false)
const [inputMode, setInputMode] = useState('keyboard') // 'keyboard' or 'drawing'
const [currentInput, setCurrentInput] = useState('')
const [targetElement, setTargetElement] = useState(null)
const [isProcessingOCR, setIsProcessingOCR] = useState(false)
const workerRef = useRef(null)
// Initialize Tesseract worker
useEffect(() => {
const initWorker = async () => {
try {
workerRef.current = await createWorker('deu') // German language
} catch (error) {
console.error('Failed to initialize Tesseract worker:', error)
}
}
initWorker()
return () => {
if (workerRef.current) {
workerRef.current.terminate()
}
}
}, [])
// Listen for Electron events to show/hide the input overlay
useEffect(() => {
const handleShowInputOverlay = (event, data) => {
setTargetElement(data.targetElement || null)
setCurrentInput(data.initialValue || '')
setInputMode(data.preferredMode || 'keyboard')
setIsVisible(true)
}
const handleHideInputOverlay = () => {
setIsVisible(false)
setCurrentInput('')
setTargetElement(null)
}
// Listen for IPC events from main process
if (window.electron && window.electron.ipcRenderer) {
window.electron.ipcRenderer.on('show-input-overlay', handleShowInputOverlay)
window.electron.ipcRenderer.on('hide-input-overlay', handleHideInputOverlay)
}
// Listen for custom DOM events as fallback
document.addEventListener('show-input-overlay', handleShowInputOverlay)
document.addEventListener('hide-input-overlay', handleHideInputOverlay)
return () => {
if (window.electron && window.electron.ipcRenderer) {
window.electron.ipcRenderer.removeAllListeners('show-input-overlay')
window.electron.ipcRenderer.removeAllListeners('hide-input-overlay')
}
document.removeEventListener('show-input-overlay', handleShowInputOverlay)
document.removeEventListener('hide-input-overlay', handleHideInputOverlay)
}
}, [])
const handleKeyboardInput = (value) => {
setCurrentInput(value)
}
const handleDrawingComplete = async (canvas) => {
if (!workerRef.current || isProcessingOCR) return
setIsProcessingOCR(true)
try {
const { data: { text } } = await workerRef.current.recognize(canvas)
const cleanedText = text.trim().replace(/\n/g, ' ')
setCurrentInput(prev => prev + cleanedText)
} catch (error) {
console.error('OCR recognition failed:', error)
} finally {
setIsProcessingOCR(false)
}
}
const handleSubmit = () => {
// Send the input back to the target element or emit an event
if (targetElement) {
// Try to set value for input elements
if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') {
targetElement.value = currentInput
targetElement.dispatchEvent(new Event('input', { bubbles: true }))
targetElement.dispatchEvent(new Event('change', { bubbles: true }))
}
}
// Emit custom event with the input value
const event = new CustomEvent('input-overlay-submit', {
detail: { value: currentInput, targetElement }
})
document.dispatchEvent(event)
// Also send via Electron IPC if available
if (window.electron && window.electron.ipcRenderer) {
window.electron.ipcRenderer.send('input-overlay-submit', {
value: currentInput,
targetElement: targetElement ? targetElement.id || targetElement.className : null
})
}
handleClose()
}
const handleClose = () => {
setIsVisible(false)
setCurrentInput('')
setTargetElement(null)
}
const handleClear = () => {
setCurrentInput('')
}
if (!isVisible) return null
return (
<div className="input-overlay">
<div className="input-overlay__backdrop" onClick={handleClose} />
<div className="input-overlay__container">
<div className="input-overlay__header">
<h3>Eingabehilfe</h3>
<div className="input-overlay__mode-toggle">
<button
className={inputMode === 'keyboard' ? 'active' : ''}
onClick={() => setInputMode('keyboard')}
>
Tastatur
</button>
<button
className={inputMode === 'drawing' ? 'active' : ''}
onClick={() => setInputMode('drawing')}
>
Handschrift
</button>
</div>
<button className="input-overlay__close" onClick={handleClose}>
×
</button>
</div>
<div className="input-overlay__content">
<div className="input-overlay__preview">
<textarea
value={currentInput}
onChange={(e) => setCurrentInput(e.target.value)}
placeholder="Ihr Text wird hier angezeigt..."
className="input-overlay__preview-text"
/>
<div className="input-overlay__preview-actions">
<button onClick={handleClear} className="input-overlay__btn input-overlay__btn--secondary">
Zurücksetzen
</button>
</div>
</div>
<div className="input-overlay__input-area">
{inputMode === 'keyboard' ? (
<OnScreenKeyboard
value={currentInput}
onChange={handleKeyboardInput}
/>
) : (
<div className="input-overlay__drawing-container">
<DrawingCanvas
onDrawingComplete={handleDrawingComplete}
disabled={isProcessingOCR}
/>
{isProcessingOCR && (
<div className="input-overlay__ocr-status">
🔍 Texterkennung läuft...
</div>
)}
</div>
)}
</div>
</div>
<div className="input-overlay__footer">
<button
onClick={handleSubmit}
className="input-overlay__btn input-overlay__btn--primary"
>
Übernehmen
</button>
<button
onClick={handleClose}
className="input-overlay__btn input-overlay__btn--secondary"
>
Abbrechen
</button>
</div>
</div>
</div>
)
}
export default InputOverlay

View File

@@ -0,0 +1,295 @@
.input-overlay
position: fixed
top: 0
left: 0
width: 100vw
height: 100vh
z-index: 9999
display: flex
align-items: center
justify-content: center
animation: overlayFadeIn 0.3s ease-out
&__backdrop
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(0, 0, 0, 0.4)
backdrop-filter: blur(12px)
-webkit-backdrop-filter: blur(12px)
&__container
position: relative
background: rgba(255, 255, 255, 0.15)
backdrop-filter: blur(20px)
-webkit-backdrop-filter: blur(20px)
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 24px
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.1)
max-width: 90vw
max-height: 90vh
overflow: hidden
display: flex
flex-direction: column
animation: containerSlideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)
&__header
padding: 24px 32px 20px 32px
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
display: flex
align-items: center
justify-content: space-between
h3
margin: 0
font-size: 20px
font-weight: 600
color: rgba(255, 255, 255, 0.95)
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
&__mode-toggle
display: flex
gap: 4px
background: rgba(255, 255, 255, 0.1)
padding: 4px
border-radius: 16px
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
button
padding: 10px 20px
border: none
background: transparent
border-radius: 12px
cursor: pointer
font-size: 14px
font-weight: 500
color: rgba(255, 255, 255, 0.7)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
position: relative
overflow: hidden
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))
opacity: 0
transition: opacity 0.3s ease
border-radius: 12px
&:hover
color: rgba(255, 255, 255, 0.9)
transform: translateY(-1px)
&::before
opacity: 1
&.active
color: white
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
transform: translateY(-1px)
&::before
opacity: 0
&__close
background: rgba(255, 255, 255, 0.1)
border: 1px solid rgba(255, 255, 255, 0.2)
font-size: 20px
cursor: pointer
color: rgba(255, 255, 255, 0.7)
padding: 0
width: 40px
height: 40px
display: flex
align-items: center
justify-content: center
border-radius: 12px
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
&:hover
background: rgba(239, 68, 68, 0.2)
color: rgb(248, 113, 113)
transform: translateY(-1px)
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2)
&__content
display: flex
flex-direction: column
flex: 1
min-height: 0
&__preview
padding: 24px 32px
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
display: flex
gap: 20px
align-items: flex-start
&__preview-text
flex: 1
min-height: 100px
max-height: 140px
padding: 16px 20px
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 16px
font-family: inherit
font-size: 15px
resize: vertical
background: rgba(255, 255, 255, 0.1)
color: rgba(255, 255, 255, 0.95)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
transition: all 0.3s ease
&::placeholder
color: rgba(255, 255, 255, 0.5)
&:focus
outline: none
border-color: rgba(59, 130, 246, 0.5)
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 15px rgba(0, 0, 0, 0.1)
background: rgba(255, 255, 255, 0.15)
&__preview-actions
display: flex
flex-direction: column
gap: 12px
&__input-area
flex: 1
padding: 24px 32px
overflow-y: auto
&__drawing-container
position: relative
&__ocr-status
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
background: rgba(0, 0, 0, 0.8)
color: white
padding: 16px 24px
border-radius: 16px
font-size: 14px
font-weight: 500
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3)
&__footer
padding: 24px 32px
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02))
border-top: 1px solid rgba(255, 255, 255, 0.1)
display: flex
gap: 16px
justify-content: flex-end
&__btn
padding: 12px 24px
border-radius: 16px
font-size: 14px
font-weight: 500
cursor: pointer
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
border: none
position: relative
overflow: hidden
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
opacity: 0
transition: opacity 0.3s ease
&:hover
transform: translateY(-2px)
&::before
opacity: 1
&--primary
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))
color: white
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&:hover
box-shadow: 0 8px 25px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
&--secondary
background: rgba(255, 255, 255, 0.1)
color: rgba(255, 255, 255, 0.8)
border: 1px solid rgba(255, 255, 255, 0.2)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
&:hover
background: rgba(255, 255, 255, 0.15)
color: rgba(255, 255, 255, 0.95)
@keyframes overlayFadeIn
from
opacity: 0
to
opacity: 1
@keyframes containerSlideUp
from
opacity: 0
transform: translateY(30px) scale(0.95)
to
opacity: 1
transform: translateY(0) scale(1)
@media (max-width: 768px)
.input-overlay
&__container
max-width: 95vw
max-height: 95vh
border-radius: 20px
&__header
padding: 20px 24px 16px 24px
h3
font-size: 18px
&__mode-toggle
button
padding: 8px 16px
font-size: 13px
&__close
width: 36px
height: 36px
font-size: 18px
&__preview, &__input-area, &__footer
padding: 20px 24px
&__preview
flex-direction: column
gap: 16px
&__preview-actions
flex-direction: row
justify-content: center

View File

@@ -0,0 +1,168 @@
import { useState } from 'react'
import './OnScreenKeyboard.sass'
const OnScreenKeyboard = ({ value, onChange }) => {
const [isShiftActive, setIsShiftActive] = useState(false)
const [isAltGrActive, setIsAltGrActive] = useState(false)
// German keyboard layout
const keyboardLayout = [
[
{ key: '1', shift: '!', altGr: null },
{ key: '2', shift: '"', altGr: '²' },
{ key: '3', shift: '§', altGr: '³' },
{ key: '4', shift: '$', altGr: null },
{ key: '5', shift: '%', altGr: null },
{ key: '6', shift: '&', altGr: null },
{ key: '7', shift: '/', altGr: '{' },
{ key: '8', shift: '(', altGr: '[' },
{ key: '9', shift: ')', altGr: ']' },
{ key: '0', shift: '=', altGr: '}' },
{ key: 'ß', shift: '?', altGr: '\\' },
{ key: '´', shift: '`', altGr: null },
],
[
{ key: 'q', shift: 'Q', altGr: '@' },
{ key: 'w', shift: 'W', altGr: null },
{ key: 'e', shift: 'E', altGr: '€' },
{ key: 'r', shift: 'R', altGr: null },
{ key: 't', shift: 'T', altGr: null },
{ key: 'z', shift: 'Z', altGr: null },
{ key: 'u', shift: 'U', altGr: null },
{ key: 'i', shift: 'I', altGr: null },
{ key: 'o', shift: 'O', altGr: null },
{ key: 'p', shift: 'P', altGr: null },
{ key: 'ü', shift: 'Ü', altGr: null },
{ key: '+', shift: '*', altGr: '~' },
],
[
{ key: 'a', shift: 'A', altGr: null },
{ key: 's', shift: 'S', altGr: null },
{ key: 'd', shift: 'D', altGr: null },
{ key: 'f', shift: 'F', altGr: null },
{ key: 'g', shift: 'G', altGr: null },
{ key: 'h', shift: 'H', altGr: null },
{ key: 'j', shift: 'J', altGr: null },
{ key: 'k', shift: 'K', altGr: null },
{ key: 'l', shift: 'L', altGr: null },
{ key: 'ö', shift: 'Ö', altGr: null },
{ key: 'ä', shift: 'Ä', altGr: null },
{ key: '#', shift: "'", altGr: null },
],
[
{ key: '<', shift: '>', altGr: '|' },
{ key: 'y', shift: 'Y', altGr: null },
{ key: 'x', shift: 'X', altGr: null },
{ key: 'c', shift: 'C', altGr: null },
{ key: 'v', shift: 'V', altGr: null },
{ key: 'b', shift: 'B', altGr: null },
{ key: 'n', shift: 'N', altGr: null },
{ key: 'm', shift: 'M', altGr: 'µ' },
{ key: ',', shift: ';', altGr: null },
{ key: '.', shift: ':', altGr: null },
{ key: '-', shift: '_', altGr: null },
]
]
const getKeyValue = (keyObj) => {
if (isAltGrActive && keyObj.altGr) return keyObj.altGr
if (isShiftActive && keyObj.shift) return keyObj.shift
return keyObj.key
}
const handleKeyPress = (keyObj) => {
const keyValue = getKeyValue(keyObj)
const newValue = value + keyValue
onChange(newValue)
// Reset shift after key press (unless it's a modifier key)
if (isShiftActive && keyObj.key !== 'Shift') {
setIsShiftActive(false)
}
}
const handleBackspace = () => {
onChange(value.slice(0, -1))
}
const handleSpace = () => {
onChange(value + ' ')
}
const handleShift = () => {
setIsShiftActive(!isShiftActive)
if (isAltGrActive) setIsAltGrActive(false)
}
const handleAltGr = () => {
setIsAltGrActive(!isAltGrActive)
if (isShiftActive) setIsShiftActive(false)
}
const handleEnter = () => {
onChange(value + '\n')
}
return (
<div className="on-screen-keyboard">
{keyboardLayout.map((row, rowIndex) => (
<div key={rowIndex} className="keyboard-row">
{row.map((keyObj, keyIndex) => (
<button
key={`${rowIndex}-${keyIndex}`}
className="keyboard-key"
onClick={() => handleKeyPress(keyObj)}
>
<span className="key-main">{getKeyValue(keyObj)}</span>
{(keyObj.shift || keyObj.altGr) && (
<div className="key-alternates">
{keyObj.shift && (
<span className="key-shift">{keyObj.shift}</span>
)}
{keyObj.altGr && (
<span className="key-altgr">{keyObj.altGr}</span>
)}
</div>
)}
</button>
))}
</div>
))}
<div className="keyboard-row keyboard-row--bottom">
<button
className={`keyboard-key keyboard-key--modifier ${isShiftActive ? 'active' : ''}`}
onClick={handleShift}
>
Shift
</button>
<button
className={`keyboard-key keyboard-key--modifier ${isAltGrActive ? 'active' : ''}`}
onClick={handleAltGr}
>
AltGr
</button>
<button
className="keyboard-key keyboard-key--space"
onClick={handleSpace}
>
Leertaste
</button>
<button
className="keyboard-key keyboard-key--function"
onClick={handleEnter}
>
Enter
</button>
<button
className="keyboard-key keyboard-key--function"
onClick={handleBackspace}
>
</button>
</div>
</div>
)
}
export default OnScreenKeyboard

View File

@@ -0,0 +1,187 @@
.on-screen-keyboard
display: flex
flex-direction: column
gap: 12px
max-width: 900px
padding: 24px
background: rgba(255, 255, 255, 0.05)
border-radius: 20px
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
border: 1px solid rgba(255, 255, 255, 0.1)
.keyboard-row
display: flex
gap: 8px
justify-content: center
&--bottom
margin-top: 12px
.keyboard-key
position: relative
min-width: 52px
height: 52px
border: 1px solid rgba(255, 255, 255, 0.2)
background: rgba(255, 255, 255, 0.1)
backdrop-filter: blur(10px)
-webkit-backdrop-filter: blur(10px)
border-radius: 12px
cursor: pointer
font-family: inherit
font-size: 15px
font-weight: 500
color: rgba(255, 255, 255, 0.9)
display: flex
align-items: center
justify-content: center
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1)
user-select: none
overflow: hidden
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))
opacity: 0
transition: opacity 0.2s ease
border-radius: 12px
&:hover
transform: translateY(-2px)
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2)
border-color: rgba(255, 255, 255, 0.3)
&::before
opacity: 1
&:active
transform: translateY(0)
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)
&--modifier
min-width: 90px
background: rgba(99, 102, 241, 0.2)
border-color: rgba(99, 102, 241, 0.3)
&.active
background: linear-gradient(135deg, rgba(99, 102, 241, 0.8), rgba(67, 56, 202, 0.8))
color: white
border-color: rgba(99, 102, 241, 0.6)
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
transform: translateY(-1px)
&::before
opacity: 0
&--space
min-width: 240px
background: rgba(59, 130, 246, 0.15)
border-color: rgba(59, 130, 246, 0.3)
&:hover
background: rgba(59, 130, 246, 0.25)
border-color: rgba(59, 130, 246, 0.4)
&--function
min-width: 90px
background: rgba(34, 197, 94, 0.15)
border-color: rgba(34, 197, 94, 0.3)
color: rgba(255, 255, 255, 0.95)
&:hover
background: rgba(34, 197, 94, 0.25)
border-color: rgba(34, 197, 94, 0.4)
.key-main
font-size: 17px
font-weight: 600
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
.key-alternates
position: absolute
top: 4px
right: 4px
display: flex
flex-direction: column
gap: 1px
.key-shift
font-size: 9px
color: rgba(255, 255, 255, 0.6)
line-height: 1
text-shadow: none
background: rgba(0, 0, 0, 0.2)
padding: 1px 3px
border-radius: 3px
.key-altgr
font-size: 9px
color: rgba(59, 130, 246, 0.8)
line-height: 1
text-shadow: none
background: rgba(59, 130, 246, 0.1)
padding: 1px 3px
border-radius: 3px
// Special key animations
.keyboard-key--modifier.active
animation: pulseActive 2s infinite
@keyframes pulseActive
0%, 100%
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
50%
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2)
@media (max-width: 768px)
.on-screen-keyboard
padding: 16px
gap: 8px
.keyboard-key
min-width: 44px
height: 44px
font-size: 13px
border-radius: 10px
&--modifier, &--function
min-width: 70px
&--space
min-width: 180px
.key-main
font-size: 15px
.key-alternates
top: 2px
right: 2px
.key-shift, .key-altgr
font-size: 8px
@media (max-width: 480px)
.on-screen-keyboard
padding: 12px
gap: 6px
.keyboard-key
min-width: 36px
height: 36px
font-size: 12px
border-radius: 8px
&--modifier, &--function
min-width: 60px
&--space
min-width: 140px
.key-main
font-size: 13px

View File

@@ -0,0 +1 @@
export { default } from './InputOverlay'

View File

@@ -0,0 +1,127 @@
// Global Styles - Light Mode with Waterfall Background
// Reset and base styles
*
margin: 0
padding: 0
box-sizing: border-box
// Improve touch interactions
-webkit-tap-highlight-color: transparent
-webkit-touch-callout: none
-webkit-user-select: none
-khtml-user-select: none
-moz-user-select: none
-ms-user-select: none
user-select: none
html, body
height: 100vh
width: 100vw
overflow: hidden
// Prevent scrolling and bouncing on touch devices
-webkit-overflow-scrolling: touch
overscroll-behavior: none
body
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif
color: #1e293b
font-weight: 400
letter-spacing: -0.01em
line-height: 1.5
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
// Prevent pull-to-refresh and other touch gestures
overscroll-behavior-y: none
-webkit-user-select: none
#root
height: 100vh
width: 100vw
display: flex
flex-direction: column
// Prevent touch scrolling
touch-action: none
// App Layout Styles
.app
height: 100vh
width: 100vw
display: flex
flex-direction: column
// Prevent touch scrolling
touch-action: none
&__main
flex: 1
padding-top: 5rem
display: flex
align-items: center
justify-content: center
&__content
text-align: center
backdrop-filter: blur(40px) saturate(180%)
background: rgba(255, 255, 255, 0.25)
padding: 4rem
border-radius: 32px
border: 1px solid rgba(255, 255, 255, 0.3)
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1), 0 8px 32px rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.4)
max-width: 600px
margin: 0 auto
h1
font-size: 3rem
font-weight: 800
margin-bottom: 1.5rem
background: linear-gradient(135deg, #1e293b, #475569, #0f766e)
-webkit-background-clip: text
-webkit-text-fill-color: transparent
background-clip: text
line-height: 1.2
p
font-size: 1.25rem
color: rgba(30, 41, 59, 0.8)
font-weight: 500
line-height: 1.6
// Touch-friendly interactive elements
button, a, [role="button"], .interactive
// Improve touch target size
min-height: 44px
min-width: 44px
// Add touch feedback
transition: all 0.2s ease
cursor: pointer
&:hover, &:focus
transform: scale(1.05)
&:active
transform: scale(0.95)
opacity: 0.8
// Prevent text selection on interactive elements
button, a, [role="button"], .nav-item, .interactive
-webkit-user-select: none
-moz-user-select: none
-ms-user-select: none
user-select: none
// Allow text selection on input fields and content areas
input, textarea, [contenteditable], .selectable
-webkit-user-select: text
-moz-user-select: text
-ms-user-select: text
user-select: text
// Re-enable touch actions for inputs
touch-action: manipulation
// Fullscreen specific styles
@media (display-mode: fullscreen)
body
background-attachment: fixed
.app
// Ensure content fits fullscreen properly
min-height: 100vh
max-height: 100vh

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.sass'
import App from './App'
createRoot(document.getElementById('root')).render(

View File

@@ -0,0 +1,361 @@
import React, { useState, useEffect } from 'react';
import { FaPlus, FaTrash, FaCalendarAlt, FaTimes, FaSave } from 'react-icons/fa';
import calendarService from '../services/CalendarService';
import './Calendar.sass';
const Calendar = () => {
const [appointments, setAppointments] = useState({});
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const [showModal, setShowModal] = useState(false);
const [selectedDay, setSelectedDay] = useState(null);
const [newAppointment, setNewAppointment] = useState({
title: '',
user: ''
});
useEffect(() => {
loadUsers();
}, []);
useEffect(() => {
loadAppointments();
}, [currentDate]);
const loadUsers = async () => {
try {
const userData = await calendarService.getUsers();
setUsers(userData);
if (userData.length > 0 && !newAppointment.user) {
setNewAppointment(prev => ({ ...prev, user: userData[0].name }));
}
} catch (err) {
setError('Failed to load users');
console.error('Error loading users:', err);
}
};
const loadAppointments = async () => {
try {
setLoading(true);
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const eventData = await calendarService.getEventsForMonth(year, month, null, 'family');
// Group events by day
const appointmentsByDay = {};
eventData.forEach(event => {
const day = parseInt(event.date.split('-')[2]);
if (!appointmentsByDay[day]) {
appointmentsByDay[day] = [];
}
appointmentsByDay[day].push({
id: event.id,
title: event.text,
user: event.user,
type: event.type
});
});
setAppointments(appointmentsByDay);
setError(null);
} catch (err) {
setError('Failed to load appointments');
console.error('Error loading appointments:', err);
} finally {
setLoading(false);
}
};
const handleAddAppointment = async (e) => {
e.preventDefault();
if (!newAppointment.title.trim() || !newAppointment.user) return;
try {
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(selectedDay).padStart(2, '0');
const date = `${year}-${month}-${day}`;
const eventData = {
user: newAppointment.user,
date: date,
text: newAppointment.title
};
const createdEvent = await calendarService.createEvent(eventData);
// Update local state
const newAppt = {
id: createdEvent.id,
title: newAppointment.title,
user: newAppointment.user
};
setAppointments(prev => ({
...prev,
[selectedDay]: [...(prev[selectedDay] || []), newAppt]
}));
// Reset form
setNewAppointment({ title: '', user: users[0]?.name || '' });
setShowModal(false);
setSelectedDay(null);
} catch (err) {
setError('Failed to create appointment');
console.error('Error creating appointment:', err);
}
};
const handleDeleteAppointment = async (day, appointmentId, user) => {
try {
if (appointmentId.includes('birthday-')) {
setError('Birthday events cannot be deleted');
return;
}
await calendarService.deleteEvent(appointmentId, user);
setAppointments(prev => ({
...prev,
[day]: prev[day].filter(appt => appt.id !== appointmentId)
}));
} catch (err) {
setError('Failed to delete appointment');
console.error('Error deleting appointment:', err);
}
};
const openAddModal = (day) => {
setSelectedDay(day);
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setSelectedDay(null);
setNewAppointment({ title: '', user: users[0]?.name || '' });
};
const goToToday = () => {
setCurrentDate(new Date());
};
const navigateMonth = (direction) => {
const newDate = new Date(currentDate);
newDate.setMonth(newDate.getMonth() + direction);
setCurrentDate(newDate);
};
const getDayName = (day) => {
const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day);
return date.toLocaleDateString('de-DE', { weekday: 'short' }).toUpperCase();
};
const isWeekend = (day) => {
const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
};
const isToday = (day) => {
const today = new Date();
return (
day === today.getDate() &&
currentDate.getMonth() === today.getMonth() &&
currentDate.getFullYear() === today.getFullYear()
);
};
const getCurrentMonthYear = () => {
return currentDate.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
});
};
const getDaysInMonth = () => {
return new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
};
const getUserColor = (userName) => {
const colors = [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#06b6d4', // cyan
'#ec4899', // pink
'#84cc16' // lime
];
const userIndex = users.findIndex(user => user.name === userName);
return colors[userIndex % colors.length];
};
if (loading) {
return (
<div className="calendar-container loading">
<div className="loading-spinner">
<FaCalendarAlt />
<p>Loading calendar...</p>
</div>
</div>
);
}
return (
<div className="calendar-container">
{/* Header */}
<header className="calendar-header">
<div className="header-content">
<div className="month-navigation">
<button
className="nav-btn prev"
onClick={() => navigateMonth(-1)}
>
</button>
<h1 className="month-title">{getCurrentMonthYear()}</h1>
<button
className="nav-btn next"
onClick={() => navigateMonth(1)}
>
</button>
</div>
<button className="today-btn" onClick={goToToday}>
Today
</button>
</div>
</header>
{/* Error Message */}
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{/* Calendar Grid */}
<main className="calendar-grid">
{Array.from({ length: getDaysInMonth() }, (_, i) => {
const day = i + 1;
const dayAppointments = appointments[day] || [];
return (
<div
key={day}
className={`day-row ${isWeekend(day) ? 'weekend' : ''} ${isToday(day) ? 'today' : ''}`}
>
{/* Day Info Box */}
<div className="day-info">
<span className="day-number">{day}</span>
<span className="day-name">{getDayName(day)}</span>
</div>
{/* Add Button */}
<button
className="add-button"
onClick={() => openAddModal(day)}
title={`Add appointment for ${day}`}
>
<FaPlus />
</button>
{/* Appointments Container */}
<div className="appointments-container">
{dayAppointments.map((appointment, index) => (
<div
key={appointment.id}
className={`appointment-card ${appointment.type === 'birthday' ? 'birthday' : ''}`}
style={{ borderLeftColor: getUserColor(appointment.user) }}
>
<div className="appointment-content">
<div className="appointment-title">{appointment.title}</div>
<div className="appointment-user" style={{ color: getUserColor(appointment.user) }}>
{appointment.user}
</div>
</div>
{appointment.type !== 'birthday' && (
<button
className="delete-btn"
onClick={() => handleDeleteAppointment(day, appointment.id, appointment.user)}
title="Delete appointment"
>
<FaTrash />
</button>
)}
</div>
))}
</div>
</div>
);
})}
</main>
{/* Add Appointment Modal */}
{showModal && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Add Appointment - Day {selectedDay}</h3>
<button className="close-btn" onClick={closeModal}>
<FaTimes />
</button>
</div>
<form onSubmit={handleAddAppointment} className="appointment-form">
<div className="form-group">
<label htmlFor="user">User</label>
<select
id="user"
value={newAppointment.user}
onChange={(e) => setNewAppointment({ ...newAppointment, user: e.target.value })}
required
>
{users.map(user => (
<option key={user.id} value={user.name}>
{user.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
placeholder="Enter appointment title..."
value={newAppointment.title}
onChange={(e) => setNewAppointment({ ...newAppointment, title: e.target.value })}
autoFocus
required
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={closeModal}>
<FaTimes />
Cancel
</button>
<button type="submit" className="btn btn-primary">
<FaSave />
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Calendar;

View File

@@ -0,0 +1,663 @@
// Calendar.sass - Wall-mounted display calendar with glassmorphism design
// Optimized for 9:16 aspect ratio, touch interaction, no scrolling
.calendar-container
height: calc(100vh - 5rem - 60px) // Full viewport minus app header minus margins
width: calc(100vw - 40px) // Full width minus left/right margins
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.2)
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif
color: #1a1a1a
position: fixed
top: calc(5rem + 40px) // Account for the app header plus top margin
left: 20px // Left margin
right: 20px // Right margin
bottom: 20px // Bottom margin
overflow: hidden
display: flex
flex-direction: column
margin: 0
padding: 0
border-radius: 20px
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
// Loading state
&.loading
display: flex
justify-content: center
align-items: center
.loading-spinner
background: rgba(255, 255, 255, 0.25)
backdrop-filter: blur(40px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 20px
padding: 40px
text-align: center
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
svg
font-size: 3rem
color: #ffffff
margin-bottom: 20px
animation: pulse 2s infinite
p
color: #ffffff
font-size: 1.2rem
margin: 0
// Header with glassmorphism
.calendar-header
background: rgba(255, 255, 255, 0.15)
backdrop-filter: blur(30px) saturate(180%)
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
padding: 10px 15px
position: sticky
top: 0
z-index: 100
flex-shrink: 0
height: 60px
box-sizing: border-box
width: 100%
margin: 0
border-radius: 20px 20px 0 0
.header-content
display: flex
justify-content: space-between
align-items: center
width: 100%
height: 100%
margin: 0
padding: 0
.month-navigation
display: flex
align-items: center
gap: 20px
.nav-btn
background: rgba(255, 255, 255, 0.2)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
color: #ffffff
font-size: 1.5rem
width: 45px
height: 45px
border-radius: 50%
cursor: pointer
transition: all 0.3s ease
display: flex
align-items: center
justify-content: center
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: rgba(255, 255, 255, 0.3)
transform: scale(1.05)
box-shadow: 0 6px 20px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&:active
transform: scale(0.95)
.month-title
color: #ffffff
font-size: 1.8rem
font-weight: 600
margin: 0
text-shadow: none
min-width: 250px
text-align: center
.today-btn
background: rgba(255, 255, 255, 0.2)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
color: #ffffff
padding: 10px 20px
border-radius: 25px
cursor: pointer
font-size: 0.9rem
font-weight: 500
transition: all 0.3s ease
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: rgba(255, 255, 255, 0.3)
transform: translateY(-2px)
box-shadow: 0 6px 20px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
// Error message
.error-message
background: rgba(220, 38, 38, 0.9)
color: white
padding: 10px 15px
margin: 0
border-radius: 0
display: flex
justify-content: space-between
align-items: center
backdrop-filter: blur(10px)
animation: slideDown 0.3s ease
width: 100%
box-sizing: border-box
button
background: none
border: none
color: white
font-size: 1.2rem
cursor: pointer
padding: 0
width: 30px
height: 30px
border-radius: 50%
display: flex
align-items: center
justify-content: center
&:hover
background: rgba(255, 255, 255, 0.2)
// Main calendar grid - optimized for no scrolling
.calendar-grid
flex: 1
padding: 12px
display: grid
grid-template-columns: 1fr
gap: 3px
width: 100%
box-sizing: border-box
margin: 0
// Calculate exact height using the remaining space after header
height: calc(100% - 60px) // Full container height minus calendar header
grid-template-rows: repeat(31, 1fr)
overflow: hidden
// Individual day row with three distinct sections
.day-row
display: grid
grid-template-columns: 120px 50px 1fr
gap: 8px
align-items: stretch
height: 100%
padding: 0
margin: 0
transition: all 0.3s ease
width: 100%
box-sizing: border-box
// Weekend styling
&.weekend
.day-info
background: rgba(200, 200, 200, 0.2)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(200, 200, 200, 0.3)
.day-number
color: #ffffff
.day-name
color: #e5e7eb
.appointments-container
background: rgba(200, 200, 200, 0.15)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(200, 200, 200, 0.25)
// Today highlighting
&.today
.day-info
background: rgba(59, 130, 246, 0.25)
backdrop-filter: blur(20px) saturate(180%)
border: 2px solid rgba(59, 130, 246, 0.4)
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
.day-number
color: #1e40af
font-weight: 800
.day-name
color: #3b82f6
font-weight: 700
.appointments-container
background: rgba(59, 130, 246, 0.15)
border: 1px solid rgba(59, 130, 246, 0.3)
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
// Day info box (left section)
.day-info
background: rgba(255, 255, 255, 0.2)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 12px
padding: 8px
display: flex
flex-direction: row
align-items: center
justify-content: center
gap: 6px
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
transition: all 0.3s ease
height: 100%
width: 100%
box-sizing: border-box
.day-number
font-size: 1.3rem
font-weight: 700
color: #ffffff
line-height: 1
text-shadow: none
.day-name
font-size: 0.7rem
color: #e5e7eb
font-weight: 600
text-transform: uppercase
letter-spacing: 0.3px
text-shadow: none
// Add button (center section)
.add-button
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(16, 185, 129, 0.8))
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
color: white
width: 45px
height: 45px
border-radius: 50%
cursor: pointer
display: flex
align-items: center
justify-content: center
font-size: 1.1rem
transition: all 0.3s ease
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4)
align-self: center
&:hover
background: linear-gradient(135deg, rgba(34, 197, 94, 1), rgba(16, 185, 129, 1))
transform: scale(1.1)
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&:active
transform: scale(0.95)
svg
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2))
// Appointments container (right section)
.appointments-container
background: rgba(255, 255, 255, 0.2)
backdrop-filter: blur(20px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 12px
padding: 6px
display: flex
flex-wrap: wrap
gap: 4px
height: 100%
width: 100%
align-items: flex-start
align-content: flex-start
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
transition: all 0.3s ease
box-sizing: border-box
// When empty, show subtle hint
&:empty::after
content: ""
width: 100%
height: 100%
display: block
// Individual appointment cards
.appointment-card
background: rgba(255, 255, 255, 0.25)
backdrop-filter: blur(15px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 8px
padding: 6px 8px
border-left: 3px solid #3b82f6
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
display: flex
align-items: center
gap: 6px
transition: all 0.3s ease
min-width: 80px
max-width: 150px
font-size: 0.7rem
&:hover
transform: translateY(-2px)
background: rgba(255, 255, 255, 0.3)
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&.birthday
border-left-color: #f59e0b
background: rgba(251, 191, 36, 0.2)
.appointment-content
flex: 1
min-width: 0
.appointment-title
font-size: 0.65rem
font-weight: 600
color: #ffffff
line-height: 1.2
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
text-shadow: none
.appointment-user
font-size: 0.55rem
font-weight: 600
margin-top: 2px
text-transform: uppercase
letter-spacing: 0.3px
text-shadow: none
color: #d1d5db
.delete-btn
background: rgba(239, 68, 68, 0.2)
backdrop-filter: blur(10px)
border: 1px solid rgba(255, 255, 255, 0.3)
color: #dc2626
width: 22px
height: 22px
border-radius: 50%
cursor: pointer
display: flex
align-items: center
justify-content: center
font-size: 0.6rem
transition: all 0.3s ease
opacity: 0.8
flex-shrink: 0
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: rgba(239, 68, 68, 0.3)
opacity: 1
transform: scale(1.1)
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&:active
transform: scale(0.9)
// Modal styling with glassmorphism
.modal-overlay
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background: rgba(0, 0, 0, 0.5)
display: flex
align-items: center
justify-content: center
z-index: 1000
backdrop-filter: blur(10px)
animation: fadeIn 0.3s ease
.modal-content
background: rgba(255, 255, 255, 0.15)
backdrop-filter: blur(40px) saturate(180%)
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 24px
padding: 0
width: 90%
max-width: 500px
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
animation: slideUp 0.3s ease
overflow: hidden
.modal-header
background: rgba(59, 130, 246, 0.15)
backdrop-filter: blur(20px) saturate(180%)
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
padding: 20px 25px
display: flex
justify-content: space-between
align-items: center
h3
margin: 0
color: #ffffff
font-size: 1.3rem
font-weight: 600
text-shadow: none
.close-btn
background: rgba(239, 68, 68, 0.15)
backdrop-filter: blur(15px)
border: 1px solid rgba(255, 255, 255, 0.3)
color: #dc2626
font-size: 1.2rem
cursor: pointer
width: 35px
height: 35px
border-radius: 50%
display: flex
align-items: center
justify-content: center
transition: all 0.3s ease
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: rgba(239, 68, 68, 0.25)
transform: scale(1.1)
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
// Form styling
.appointment-form
padding: 25px
.form-group
margin-bottom: 20px
label
display: block
margin-bottom: 8px
color: #ffffff
font-weight: 500
font-size: 0.9rem
input, select
width: 100%
padding: 12px 15px
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 12px
font-size: 1rem
background: rgba(255, 255, 255, 0.2)
backdrop-filter: blur(20px) saturate(180%)
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.4)
transition: all 0.3s ease
box-sizing: border-box
color: #000000
&:focus
outline: none
border-color: rgba(59, 130, 246, 0.5)
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.5)
background: rgba(255, 255, 255, 0.3)
&::placeholder
color: #9ca3af
.form-actions
display: flex
gap: 15px
justify-content: flex-end
margin-top: 30px
.btn
padding: 12px 20px
border: 1px solid rgba(255, 255, 255, 0.3)
border-radius: 12px
font-size: 1rem
font-weight: 500
cursor: pointer
display: flex
align-items: center
gap: 8px
transition: all 0.3s ease
backdrop-filter: blur(20px) saturate(180%)
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
&.btn-secondary
background: rgba(107, 114, 128, 0.15)
color: #ffffff
&:hover
background: rgba(107, 114, 128, 0.25)
transform: translateY(-2px)
box-shadow: 0 4px 12px rgba(107, 114, 128, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&.btn-primary
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(29, 78, 216, 0.8))
color: white
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2)
&:hover
background: linear-gradient(135deg, rgba(59, 130, 246, 1), rgba(29, 78, 216, 1))
transform: translateY(-2px)
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5)
&:active
transform: translateY(0)
// Animations
@keyframes fadeIn
from
opacity: 0
to
opacity: 1
@keyframes slideUp
from
opacity: 0
transform: translateY(50px)
to
opacity: 1
transform: translateY(0)
@keyframes slideDown
from
opacity: 0
transform: translateY(-20px)
to
opacity: 1
transform: translateY(0)
@keyframes pulse
0%, 100%
opacity: 1
50%
opacity: 0.5
// Responsive adjustments for different screen sizes
@media (max-height: 900px)
.calendar-container
top: calc(5rem + 15px)
left: 15px
right: 15px
bottom: 15px
width: calc(100vw - 30px)
height: calc(100vh - 5rem - 30px)
.calendar-grid
gap: 2px
padding: 8px
.day-row
gap: 6px
grid-template-columns: 110px 45px 1fr
.day-info
padding: 6px
.day-number
font-size: 1rem
.day-name
font-size: 0.6rem
.appointments-container
padding: 4px
@media (max-height: 800px)
.calendar-container
top: calc(5rem + 10px)
left: 10px
right: 10px
bottom: 10px
width: calc(100vw - 20px)
height: calc(100vh - 5rem - 20px)
.calendar-grid
gap: 1px
padding: 6px
.day-row
gap: 4px
grid-template-columns: 100px 40px 1fr
.day-info
padding: 4px
.day-number
font-size: 0.9rem
.day-name
font-size: 0.55rem
.appointments-container
padding: 3px
.add-button
width: 35px
height: 35px
font-size: 0.9rem
@media (min-width: 1400px)
.calendar-container
top: calc(5rem + 30px)
left: 30px
right: 30px
bottom: 30px
width: calc(100vw - 60px)
height: calc(100vh - 5rem - 60px)
.calendar-grid
gap: 4px
padding: 16px
.day-row
gap: 10px
grid-template-columns: 140px 55px 1fr
// Touch optimization for wall displays
@media (pointer: coarse)
.add-button
width: 50px
height: 50px
.delete-btn
width: 28px
height: 28px
.nav-btn
width: 50px
height: 50px
.today-btn
padding: 15px 30px
font-size: 1.1rem

View File

@@ -0,0 +1,418 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
import { FiEdit3, FiTrash, FiChevronDown } from 'react-icons/fi'
import { TbEraser } from 'react-icons/tb'
import './Notes.sass'
const Notes = () => {
const canvasRef = useRef(null)
const ctxRef = useRef(null)
const rectRef = useRef(null)
const saveTimeoutRef = useRef(null)
const lastDrawTimeRef = useRef(0)
const pathPointsRef = useRef([])
const animationFrameRef = useRef(null)
const [isDrawing, setIsDrawing] = useState(false)
const [tool, setTool] = useState('pen') // 'pen' or 'eraser'
const [penColor, setPenColor] = useState('#2d3748')
const [showColorPicker, setShowColorPicker] = useState(false)
// Memoize colors array to prevent recreation on every render
const colors = useMemo(() => [
'#2d3748', // Dark gray
'#000000', // Black
'#e53e3e', // Red
'#3182ce', // Blue
'#38a169', // Green
'#d69e2e', // Yellow
'#805ad5', // Purple
'#dd6b20', // Orange
'#e91e63', // Pink
'#00acc1' // Cyan
], [])
const loadCanvasFromStorage = useCallback(() => {
const canvas = canvasRef.current
const ctx = ctxRef.current
if (!canvas || !ctx) return
const savedData = localStorage.getItem('notes-canvas')
if (savedData) {
const img = new Image()
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
}
img.src = savedData
}
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
ctxRef.current = ctx
// Set canvas size and cache the rect
const resizeCanvas = () => {
const container = canvas.parentElement
if (!container) return
canvas.width = container.clientWidth
canvas.height = container.clientHeight
// Set white background
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Set drawing properties
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.imageSmoothingEnabled = true
// Cache the bounding rect
rectRef.current = canvas.getBoundingClientRect()
// Load saved canvas data from storage
loadCanvasFromStorage()
}
resizeCanvas()
// Debounced resize handler
let resizeTimeout
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resizeCanvas, 150)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimeout)
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [loadCanvasFromStorage])
// Optimized save function with compression and size check
const debouncedSave = useCallback(() => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
saveTimeoutRef.current = setTimeout(() => {
const canvas = canvasRef.current
if (!canvas) return
try {
// Use JPEG for better compression on large canvases
const dataURL = canvas.width * canvas.height > 500000
? canvas.toDataURL('image/jpeg', 0.8)
: canvas.toDataURL('image/png')
// Check if data is too large for localStorage (usually 5-10MB limit)
if (dataURL.length < 4900000) { // ~5MB safety margin
localStorage.setItem('notes-canvas', dataURL)
}
} catch (error) {
console.warn('Failed to save canvas:', error)
}
}, 500) // Save 500ms after user stops drawing
}, [])
const saveCanvasToStorage = useCallback(() => {
debouncedSave()
}, [debouncedSave])
// Optimized drawing with path smoothing and throttling
const drawSmoothPath = useCallback(() => {
const ctx = ctxRef.current
const points = pathPointsRef.current
if (!ctx || points.length < 2) return
// Use quadratic curves for smoother lines
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length - 1; i++) {
const currentPoint = points[i]
const nextPoint = points[i + 1]
const cpx = (currentPoint.x + nextPoint.x) / 2
const cpy = (currentPoint.y + nextPoint.y) / 2
ctx.quadraticCurveTo(currentPoint.x, currentPoint.y, cpx, cpy)
}
// Draw the last point
if (points.length > 1) {
const lastPoint = points[points.length - 1]
ctx.lineTo(lastPoint.x, lastPoint.y)
}
ctx.stroke()
}, [])
const startDrawing = useCallback((e) => {
setIsDrawing(true)
const ctx = ctxRef.current
const rect = rectRef.current || canvasRef.current.getBoundingClientRect()
if (!ctx) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Reset path points for new stroke
pathPointsRef.current = [{ x, y }]
// Set tool properties once at the start of drawing
if (tool === 'pen') {
ctx.globalCompositeOperation = 'source-over'
ctx.strokeStyle = penColor
ctx.lineWidth = 3
} else if (tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out'
ctx.lineWidth = 40
}
// Draw initial dot using stroke for consistency
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x + 0.1, y + 0.1) // Small offset to ensure a visible dot
ctx.stroke()
}, [tool, penColor])
// Throttled drawing with requestAnimationFrame for smooth performance
const draw = useCallback((e) => {
if (!isDrawing) return
const now = performance.now()
const timeDiff = now - lastDrawTimeRef.current
// Throttle to ~60fps for performance
if (timeDiff < 16) return
const rect = rectRef.current || canvasRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Add point to path
pathPointsRef.current.push({ x, y })
// Limit path points to prevent memory issues
if (pathPointsRef.current.length > 50) {
pathPointsRef.current = pathPointsRef.current.slice(-25)
}
// Cancel previous animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
// Schedule drawing on next frame
animationFrameRef.current = requestAnimationFrame(() => {
const ctx = ctxRef.current
if (!ctx) return
// Simple line drawing for real-time feedback
const points = pathPointsRef.current
if (points.length >= 2) {
const lastTwo = points.slice(-2)
ctx.beginPath()
ctx.moveTo(lastTwo[0].x, lastTwo[0].y)
ctx.lineTo(lastTwo[1].x, lastTwo[1].y)
ctx.stroke()
}
})
lastDrawTimeRef.current = now
}, [isDrawing])
const stopDrawing = useCallback(() => {
if (!isDrawing) return
setIsDrawing(false)
// Cancel any pending animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
// Draw final smooth path if we have enough points
if (pathPointsRef.current.length > 2) {
drawSmoothPath()
}
// Clear path points
pathPointsRef.current = []
// Save canvas state after drawing (debounced)
saveCanvasToStorage()
}, [isDrawing, drawSmoothPath, saveCanvasToStorage])
// Optimized touch event handlers
const handleTouchStart = useCallback((e) => {
e.preventDefault()
e.stopPropagation()
// Only handle single touch
if (e.touches.length !== 1) return
const touch = e.touches[0]
const mouseEvent = new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
cancelable: true
})
startDrawing(mouseEvent)
}, [startDrawing])
const handleTouchMove = useCallback((e) => {
e.preventDefault()
e.stopPropagation()
// Only handle single touch
if (e.touches.length !== 1) return
const touch = e.touches[0]
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
cancelable: true
})
draw(mouseEvent)
}, [draw])
const handleTouchEnd = useCallback((e) => {
e.preventDefault()
e.stopPropagation()
stopDrawing()
}, [stopDrawing])
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current
const ctx = ctxRef.current
if (!canvas || !ctx) return
// Clear and set white background
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Clear saved state
localStorage.removeItem('notes-canvas')
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}, [])
// Update rect when canvas is clicked (in case of layout changes)
const updateRect = useCallback(() => {
rectRef.current = canvasRef.current.getBoundingClientRect()
}, [])
// Memoized color picker to prevent recreation
const colorPicker = useMemo(() => (
showColorPicker && (
<div className="color-picker-popover">
<div className="color-grid">
{colors.map((color) => (
<button
key={color}
className={`color-option ${penColor === color ? 'active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => {
setPenColor(color)
setShowColorPicker(false)
}}
title={`Farbe: ${color}`}
/>
))}
</div>
</div>
)
), [showColorPicker, colors, penColor])
// Memoized toolbar buttons to prevent unnecessary re-renders
const toolbarButtons = useMemo(() => (
<>
<button
className={`tool-button ${tool === 'pen' ? 'active' : ''}`}
onClick={() => setTool('pen')}
title="Stift"
style={{ backgroundColor: tool === 'pen' ? penColor + '20' : undefined }}
>
<FiEdit3 style={{ color: tool === 'pen' ? penColor : undefined }} />
</button>
<button
className="color-picker-button"
onClick={() => setShowColorPicker(!showColorPicker)}
title="Farbe wählen"
style={{ backgroundColor: penColor }}
>
<FiChevronDown />
</button>
</>
), [tool, penColor, showColorPicker])
return (
<div className="notes-page">
<div className="notes-container">
{/* Toolbar */}
<div className="notes-toolbar">
<div className="pen-tool-group">
{toolbarButtons}
{colorPicker}
</div>
<button
className={`tool-button ${tool === 'eraser' ? 'active' : ''}`}
onClick={() => setTool('eraser')}
title="Radiergummi"
>
<TbEraser />
</button>
<button
className="tool-button clear-button"
onClick={clearCanvas}
title="Alles löschen"
>
<FiTrash />
</button>
</div>
{/* Canvas Container */}
<div className="canvas-container">
<canvas
ref={canvasRef}
className="drawing-canvas"
onMouseDown={(e) => {
updateRect()
startDrawing(e)
}}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ touchAction: 'none' }}
/>
</div>
</div>
</div>
)
}
export default Notes

View File

@@ -0,0 +1,258 @@
// Notes Page - Drawing Canvas with Glassmorphism (Matching Search Page Structure)
.notes-page
padding: 2rem 0.75rem 0.75rem 0.75rem
height: calc(100vh - 6rem)
width: 100%
.notes-container
position: relative
width: 100%
height: 100%
backdrop-filter: blur(60px) saturate(200%)
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.2))
border-radius: 28px
border: 1px solid rgba(255, 255, 255, 0.5)
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1), 0 8px 32px rgba(0, 0, 0, 0.05), inset 0 2px 0 rgba(255, 255, 255, 0.6), inset 0 -1px 0 rgba(0, 0, 0, 0.1)
padding: 1.5rem
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
&:hover
backdrop-filter: blur(80px) saturate(220%)
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3))
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.15), 0 10px 40px rgba(0, 0, 0, 0.08), inset 0 2px 0 rgba(255, 255, 255, 0.7)
transform: translateY(-2px)
.notes-toolbar
position: absolute
bottom: 1rem
left: 50%
transform: translateX(-50%)
z-index: 10
display: flex
align-items: center
gap: 0.75rem
padding: 0.75rem 1rem
backdrop-filter: blur(40px) saturate(180%)
background: rgba(255, 255, 255, 0.2)
border-radius: 16px
border: 1px solid rgba(255, 255, 255, 0.3)
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.4)
.pen-tool-group
position: relative
display: flex
align-items: center
gap: 0.25rem
.color-picker-button
display: flex
align-items: center
justify-content: center
width: 24px
height: 44px
border: none
border-radius: 0 14px 14px 0
border: 1px solid rgba(255, 255, 255, 0.4)
border-left: none
cursor: pointer
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
font-size: 0.875rem
color: white
&:hover
transform: translateY(-2px)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15)
.color-picker-popover
position: absolute
bottom: calc(100% + 0.5rem)
left: 0
backdrop-filter: blur(40px) saturate(180%)
background: rgba(255, 255, 255, 0.25)
border-radius: 16px
border: 1px solid rgba(255, 255, 255, 0.3)
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4)
padding: 0.75rem
z-index: 20
.color-grid
display: grid
grid-template-columns: repeat(5, 1fr)
gap: 0.5rem
.color-option
width: 32px
height: 32px
border: 2px solid rgba(255, 255, 255, 0.3)
border-radius: 12px
cursor: pointer
transition: all 0.2s ease
position: relative
&:hover
transform: scale(1.1)
border-color: rgba(255, 255, 255, 0.6)
&.active
transform: scale(1.15)
border-color: rgba(255, 255, 255, 0.8)
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2)
&::after
content: ''
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
color: white
font-size: 0.875rem
font-weight: bold
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8)
.toolbar-section
display: flex
align-items: center
gap: 0.75rem
.tool-button
display: flex
align-items: center
justify-content: center
width: 44px
height: 44px
border: none
border-radius: 14px
background: rgba(255, 255, 255, 0.3)
backdrop-filter: blur(20px)
border: 1px solid rgba(255, 255, 255, 0.4)
color: rgba(50, 50, 50, 0.8)
cursor: pointer
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
font-size: 1.125rem
&:hover
background: rgba(255, 255, 255, 0.5)
color: rgba(50, 50, 50, 1)
transform: translateY(-2px)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15)
&.active
background: rgba(255, 255, 255, 0.6)
color: rgba(50, 50, 50, 1)
border-color: rgba(255, 255, 255, 0.6)
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2)
&.clear-button
background: rgba(239, 68, 68, 0.3)
border-color: rgba(239, 68, 68, 0.4)
color: rgba(50, 50, 50, 0.8)
&:hover
background: rgba(239, 68, 68, 0.5)
color: rgba(50, 50, 50, 1)
transform: translateY(-2px)
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.2)
// Special styling for pen tool in group
.pen-tool-group &:first-child
border-radius: 14px 0 0 14px
border-right: none
.canvas-container
width: 100%
height: 100%
border-radius: 20px
background: rgba(255, 255, 255, 0.9)
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5)
overflow: hidden
// Ensure touch events can reach the canvas
touch-action: auto !important
position: relative
.drawing-canvas
width: 100%
height: 100%
cursor: crosshair
display: block
background: #ffffff
border-radius: 20px
// Enable touch interactions - override global touch-action: none
touch-action: auto !important
-webkit-user-select: none
-moz-user-select: none
-ms-user-select: none
user-select: none
&.eraser-mode
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="2"/></svg>') 10 10, auto
// Responsive Design
@media (max-width: 768px)
.notes-page
padding: 1.5rem 0.5rem 0.5rem 0.5rem
.notes-container
border-radius: 24px
padding: 1.25rem
.canvas-container
border-radius: 16px
.drawing-canvas
border-radius: 16px
.notes-toolbar
bottom: 0.75rem
padding: 0.625rem 0.875rem
gap: 0.625rem
.tool-button
width: 40px
height: 40px
font-size: 1rem
.color-picker-button
width: 20px
height: 40px
.color-option
width: 28px
height: 28px
@media (max-width: 480px)
.notes-page
padding: 1rem 0.375rem 0.375rem 0.375rem
.notes-container
border-radius: 20px
padding: 1rem
.canvas-container
border-radius: 12px
.drawing-canvas
border-radius: 12px
.notes-toolbar
bottom: 0.5rem
padding: 0.5rem 0.75rem
gap: 0.5rem
border-radius: 14px
.tool-button
width: 36px
height: 36px
border-radius: 12px
font-size: 0.875rem
.color-picker-button
width: 18px
height: 36px
.color-option
width: 24px
height: 24px
.color-grid
grid-template-columns: repeat(4, 1fr)

View File

@@ -0,0 +1,20 @@
import './Search.sass'
const Search = () => {
return (
<div className="search-page">
<div className="search-container">
<div className="google-embed-container">
<iframe
src="https://www.google.com/search?igu=1"
className="google-iframe"
title="Google Search"
allow="microphone"
/>
</div>
</div>
</div>
)
}
export default Search

View File

@@ -0,0 +1,58 @@
// Search Page - Full Screen Glassmorphism Google Embed
.search-page
padding: 2rem 0.75rem 0.75rem 0.75rem
height: calc(100vh - 6rem)
width: 100%
.search-container
width: 100%
height: 100%
.google-embed-container
width: 100%
height: 100%
backdrop-filter: blur(60px) saturate(200%)
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.2))
border-radius: 28px
border: 1px solid rgba(255, 255, 255, 0.5)
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1), 0 8px 32px rgba(0, 0, 0, 0.05), inset 0 2px 0 rgba(255, 255, 255, 0.6), inset 0 -1px 0 rgba(0, 0, 0, 0.1)
padding: 1.5rem
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
&:hover
backdrop-filter: blur(80px) saturate(220%)
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3))
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.15), 0 10px 40px rgba(0, 0, 0, 0.08), inset 0 2px 0 rgba(255, 255, 255, 0.7)
transform: translateY(-2px)
.google-iframe
width: 100%
height: 100%
border: none
border-radius: 20px
background: rgba(255, 255, 255, 0.9)
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5)
// Responsive Design
@media (max-width: 768px)
.search-page
padding: 1.5rem 0.5rem 0.5rem 0.5rem
.google-embed-container
border-radius: 24px
padding: 1.25rem
.google-iframe
border-radius: 16px
@media (max-width: 480px)
.search-page
padding: 1rem 0.375rem 0.375rem 0.375rem
.google-embed-container
border-radius: 20px
padding: 1rem
.google-iframe
border-radius: 12px

View File

@@ -0,0 +1,379 @@
import React, { useState, useEffect } from 'react';
import { FaPlus, FaTrash, FaCheck, FaEdit, FaShoppingCart, FaClock } from 'react-icons/fa';
import shoppingService from '../services/ShoppingService';
import './Shopping.sass';
const Shopping = () => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [initialLoad, setInitialLoad] = useState(true);
const [error, setError] = useState(null);
const [newItem, setNewItem] = useState({ name: '', amount: '1' });
const [editingItem, setEditingItem] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
// Load shopping items on component mount
useEffect(() => {
loadItems();
// Set up auto-refresh every 5 seconds
const interval = setInterval(() => {
loadItems();
}, 5000);
// Cleanup interval on component unmount
return () => clearInterval(interval);
}, []);
const loadItems = async () => {
try {
if (initialLoad) {
setLoading(true);
}
const data = await shoppingService.getItems();
setItems(data);
setError(null);
} catch (err) {
setError('Failed to load shopping items');
console.error('Error loading items:', err);
} finally {
if (initialLoad) {
setLoading(false);
setInitialLoad(false);
}
}
};
const handleAddItem = async (e) => {
e.preventDefault();
if (!newItem.name.trim()) return;
try {
const createdItem = await shoppingService.createItem({
name: newItem.name.trim(),
amount: newItem.amount.trim() || '1'
});
setItems([createdItem, ...items]);
setNewItem({ name: '', amount: '1' });
setShowAddForm(false);
} catch (err) {
setError('Failed to add item');
console.error('Error adding item:', err);
}
};
const handleToggleItem = async (id) => {
try {
const updatedItem = await shoppingService.toggleItem(id);
setItems(items.map(item =>
item.id === id ? updatedItem : item
));
} catch (err) {
setError('Failed to update item');
console.error('Error toggling item:', err);
}
};
const handleDeleteItem = async (id) => {
try {
await shoppingService.deleteItem(id);
setItems(items.filter(item => item.id !== id));
} catch (err) {
setError('Failed to delete item');
console.error('Error deleting item:', err);
}
};
const handleUpdateItem = async (id, updatedData) => {
try {
const updatedItem = await shoppingService.updateItem(id, updatedData);
setItems(items.map(item =>
item.id === id ? updatedItem : item
));
setEditingItem(null);
} catch (err) {
setError('Failed to update item');
console.error('Error updating item:', err);
}
};
const handleDeleteChecked = async () => {
try {
await shoppingService.deleteCheckedItems();
setItems(items.filter(item => !item.checked));
} catch (err) {
setError('Failed to delete checked items');
console.error('Error deleting checked items:', err);
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTimeUntilDeletion = (checkedAt) => {
if (!checkedAt) return null;
const twoHoursLater = new Date(new Date(checkedAt).getTime() + 2 * 60 * 60 * 1000);
const now = new Date();
const timeDiff = twoHoursLater - now;
if (timeDiff <= 0) return 'Wird gelöscht...';
const minutes = Math.floor(timeDiff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours > 0) {
return `${hours}h ${remainingMinutes}m`;
}
return `${minutes}m`;
};
const uncheckedItems = items.filter(item => !item.checked);
const checkedItems = items.filter(item => item.checked);
if (loading && initialLoad) {
return (
<div className="shopping-container">
<div className="loading">
<FaShoppingCart className="loading-icon" />
<p>Einkaufsliste wird geladen...</p>
</div>
</div>
);
}
return (
<div className="shopping-container">
<div className="shopping-header">
<h1>
<FaShoppingCart />
Einkaufsliste
</h1>
<div className="header-actions">
<button
className="btn btn-primary"
onClick={() => setShowAddForm(!showAddForm)}
>
<FaPlus />
Artikel hinzufügen
</button>
{checkedItems.length > 0 && (
<button
className="btn btn-danger"
onClick={handleDeleteChecked}
>
<FaTrash />
Erledigte löschen ({checkedItems.length})
</button>
)}
</div>
</div>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{showAddForm && (
<form className="add-item-form" onSubmit={handleAddItem}>
<div className="form-group">
<input
type="text"
placeholder="Artikelname..."
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
autoFocus
/>
<input
type="text"
placeholder="Menge"
value={newItem.amount}
onChange={(e) => setNewItem({ ...newItem, amount: e.target.value })}
/>
<button type="submit" className="btn btn-primary">
<FaPlus />
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setShowAddForm(false);
setNewItem({ name: '', amount: '1' });
}}
>
Abbrechen
</button>
</div>
</form>
)}
<div className="shopping-lists">
{/* Unchecked Items */}
<div className="shopping-section">
<h2>Zu kaufen ({uncheckedItems.length})</h2>
<div className="items-list">
{uncheckedItems.length === 0 ? (
<div className="empty-state">
<FaShoppingCart />
<p>Keine Artikel auf der Einkaufsliste</p>
</div>
) : (
uncheckedItems.map(item => (
<ShoppingItem
key={item.id}
item={item}
onToggle={handleToggleItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
editingItem={editingItem}
setEditingItem={setEditingItem}
formatDate={formatDate}
/>
))
)}
</div>
</div>
{/* Checked Items */}
{checkedItems.length > 0 && (
<div className="shopping-section">
<h2>Erledigt ({checkedItems.length})</h2>
<div className="items-list">
{checkedItems.map(item => (
<ShoppingItem
key={item.id}
item={item}
onToggle={handleToggleItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
editingItem={editingItem}
setEditingItem={setEditingItem}
formatDate={formatDate}
getTimeUntilDeletion={getTimeUntilDeletion}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
// Shopping Item Component
const ShoppingItem = ({
item,
onToggle,
onDelete,
onUpdate,
editingItem,
setEditingItem,
formatDate,
getTimeUntilDeletion
}) => {
const [editData, setEditData] = useState({ name: item.name, amount: item.amount });
const handleEdit = () => {
setEditingItem(item.id);
setEditData({ name: item.name, amount: item.amount });
};
const handleSave = () => {
onUpdate(item.id, editData);
};
const handleCancel = () => {
setEditingItem(null);
setEditData({ name: item.name, amount: item.amount });
};
const isEditing = editingItem === item.id;
return (
<div className={`shopping-item ${item.checked ? 'checked' : ''}`}>
<button
className="check-btn"
onClick={() => onToggle(item.id)}
>
<FaCheck />
</button>
<div className="item-content">
{isEditing ? (
<div className="edit-form">
<input
type="text"
value={editData.name}
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
<input
type="text"
value={editData.amount}
onChange={(e) => setEditData({ ...editData, amount: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
/>
</div>
) : (
<>
<div className="item-main">
<span className="item-name">{item.name}</span>
<span className="item-amount">{item.amount}</span>
</div>
<div className="item-meta">
<span className="item-date">
{formatDate(item.date)}
</span>
{item.checked && item.checkedAt && getTimeUntilDeletion && (
<span className="deletion-timer">
<FaClock />
{getTimeUntilDeletion(item.checkedAt)}
</span>
)}
</div>
</>
)}
</div>
<div className="item-actions">
{isEditing ? (
<>
<button className="btn btn-primary btn-small" onClick={handleSave}>
<FaCheck />
</button>
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
×
</button>
</>
) : (
<>
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
<FaEdit />
</button>
<button className="btn btn-danger btn-small" onClick={() => onDelete(item.id)}>
<FaTrash />
</button>
</>
)}
</div>
</div>
);
};
export default Shopping;

View File

@@ -0,0 +1,477 @@
// Shopping Page - Single Unified Glassmorphism Container
.shopping-container
position: absolute
top: 7rem
left: 0.5rem
right: 0.5rem
bottom: 0.5rem
background: rgba(255, 255, 255, 0.35)
border-radius: 24px
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.4)
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1)
padding: 2rem
display: flex
flex-direction: column
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif
overflow: hidden
.loading
display: flex
flex-direction: column
align-items: center
justify-content: center
flex: 1
color: #1e293b
.loading-icon
font-size: 3rem
margin-bottom: 1rem
color: #4CAF50
animation: spin 2s linear infinite
p
font-size: 1.1rem
margin: 0
font-weight: 600
.shopping-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
padding-bottom: 1.5rem
border-bottom: 1px solid rgba(255, 255, 255, 0.3)
flex-wrap: wrap
gap: 1.5rem
flex-shrink: 0
h1
display: flex
align-items: center
gap: 0.75rem
margin: 0
color: #1e293b
font-size: 2.25rem
font-weight: 700
svg
color: #4CAF50
font-size: 2rem
.header-actions
display: flex
gap: 0.75rem
flex-wrap: wrap
.error-message
background: rgba(244, 67, 54, 0.1)
border: 1px solid rgba(244, 67, 54, 0.3)
color: #dc2626
padding: 1rem 1.5rem
border-radius: 16px
margin-bottom: 1.5rem
display: flex
justify-content: space-between
align-items: center
border-left: 4px solid #f44336
font-weight: 600
flex-shrink: 0
button
background: rgba(244, 67, 54, 0.1)
border: 1px solid rgba(244, 67, 54, 0.3)
font-size: 1.2rem
cursor: pointer
color: #dc2626
padding: 0.5rem
border-radius: 8px
transition: all 0.3s ease
font-weight: 600
&:hover
background: rgba(244, 67, 54, 0.2)
transform: scale(1.1)
.add-item-form
margin-bottom: 2rem
flex-shrink: 0
transition: all 0.3s ease
.form-group
display: flex
gap: 1rem
align-items: center
flex-wrap: wrap
input
flex: 1
min-width: 200px
padding: 1rem 1.25rem
border: 1px solid rgba(255, 255, 255, 0.5)
border-radius: 12px
background: rgba(255, 255, 255, 0.6)
backdrop-filter: blur(20px)
font-size: 1rem
color: #1e293b
font-weight: 500
transition: all 0.3s ease
&:focus
outline: none
border-color: rgba(76, 175, 80, 0.7)
background: rgba(255, 255, 255, 0.8)
box-shadow: 0 8px 32px rgba(76, 175, 80, 0.2)
transform: translateY(-1px)
&::placeholder
color: rgba(30, 41, 59, 0.7)
font-weight: 500
.shopping-lists
flex: 1
display: flex
flex-direction: column
gap: 2rem
overflow-y: auto
padding-right: 0.5rem
&::-webkit-scrollbar
width: 6px
&::-webkit-scrollbar-track
background: rgba(255, 255, 255, 0.1)
border-radius: 3px
&::-webkit-scrollbar-thumb
background: rgba(255, 255, 255, 0.3)
border-radius: 3px
&:hover
background: rgba(255, 255, 255, 0.5)
.shopping-section
flex-shrink: 0
h2
color: #1e293b
margin: 0 0 1.5rem 0
font-size: 1.5rem
font-weight: 700
display: flex
align-items: center
gap: 0.5rem
padding-bottom: 1rem
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
.empty-state
display: flex
flex-direction: column
align-items: center
padding: 3rem 2rem
color: #64748b
text-align: center
background: rgba(255, 255, 255, 0.2)
border-radius: 16px
border: 1px solid rgba(255, 255, 255, 0.3)
margin-top: 1rem
svg
font-size: 3rem
margin-bottom: 1rem
opacity: 0.6
p
font-size: 1.1rem
margin: 0
font-weight: 600
.items-list
display: flex
flex-direction: column
gap: 1rem
margin-top: 1rem
.shopping-item
display: flex
align-items: center
gap: 1.25rem
padding: 1.5rem
background: rgba(255, 255, 255, 0.4)
border: 1px solid rgba(255, 255, 255, 0.6)
border-radius: 16px
transition: all 0.3s ease
backdrop-filter: blur(20px)
min-height: 80px
&:hover
transform: translateY(-2px)
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15)
background: rgba(255, 255, 255, 0.55)
border-color: rgba(255, 255, 255, 0.8)
&.checked
background: rgba(76, 175, 80, 0.15)
border-color: rgba(76, 175, 80, 0.4)
.item-name
text-decoration: line-through
color: #64748b
.check-btn
background: linear-gradient(135deg, #4CAF50, #388e3c)
border-color: #4CAF50
color: white
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4)
.check-btn
width: 48px
height: 48px
border-radius: 50%
border: 2px solid rgba(76, 175, 80, 0.5)
background: rgba(255, 255, 255, 0.6)
display: flex
align-items: center
justify-content: center
cursor: pointer
transition: all 0.3s ease
flex-shrink: 0
backdrop-filter: blur(10px)
&:hover
border-color: #4CAF50
background: rgba(76, 175, 80, 0.2)
transform: scale(1.1)
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.3)
svg
font-size: 1.1rem
font-weight: 700
.item-content
flex: 1
min-width: 0
.item-main
display: flex
justify-content: space-between
align-items: center
margin-bottom: 0.75rem
.item-name
font-weight: 700
color: #1e293b
font-size: 1.15rem
flex: 1
min-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.item-amount
background: rgba(59, 130, 246, 0.2)
color: #1e40af
padding: 0.5rem 1rem
border-radius: 10px
font-size: 0.9rem
font-weight: 700
white-space: nowrap
margin-left: 1rem
border: 1px solid rgba(59, 130, 246, 0.4)
backdrop-filter: blur(10px)
.item-meta
display: flex
justify-content: space-between
align-items: center
gap: 1rem
.item-date
color: #64748b
font-size: 0.9rem
font-weight: 600
.deletion-timer
display: flex
align-items: center
gap: 0.5rem
color: #f59e0b
font-size: 0.9rem
font-weight: 700
background: rgba(245, 158, 11, 0.15)
padding: 0.375rem 0.75rem
border-radius: 8px
border: 1px solid rgba(245, 158, 11, 0.4)
backdrop-filter: blur(10px)
svg
font-size: 0.8rem
.edit-form
display: flex
gap: 1rem
width: 100%
input
padding: 1rem
border: 1px solid rgba(255, 255, 255, 0.6)
border-radius: 10px
font-size: 1rem
background: rgba(255, 255, 255, 0.7)
backdrop-filter: blur(10px)
color: #1e293b
font-weight: 600
&:focus
outline: none
border-color: rgba(76, 175, 80, 0.7)
background: rgba(255, 255, 255, 0.85)
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.2)
input:first-child
flex: 2
input:last-child
flex: 1
min-width: 100px
.item-actions
display: flex
gap: 0.75rem
flex-shrink: 0
.btn
padding: 0.875rem 1.75rem
border: 1px solid rgba(255, 255, 255, 0.5)
border-radius: 12px
cursor: pointer
font-size: 0.95rem
font-weight: 700
display: flex
align-items: center
gap: 0.75rem
transition: all 0.3s ease
white-space: nowrap
backdrop-filter: blur(20px)
&:hover
transform: translateY(-2px)
&.btn-primary
background: linear-gradient(135deg, rgba(76, 175, 80, 0.4), rgba(56, 142, 60, 0.4))
color: white
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.2)
border-color: rgba(76, 175, 80, 0.5)
&:hover
background: linear-gradient(135deg, rgba(76, 175, 80, 0.6), rgba(56, 142, 60, 0.6))
box-shadow: 0 12px 40px rgba(76, 175, 80, 0.3)
&.btn-secondary
background: linear-gradient(135deg, rgba(100, 116, 139, 0.4), rgba(71, 85, 105, 0.4))
color: white
border-color: rgba(100, 116, 139, 0.5)
&:hover
background: linear-gradient(135deg, rgba(100, 116, 139, 0.6), rgba(71, 85, 105, 0.6))
box-shadow: 0 12px 40px rgba(100, 116, 139, 0.3)
&.btn-danger
background: linear-gradient(135deg, rgba(244, 67, 54, 0.4), rgba(211, 47, 47, 0.4))
color: white
border-color: rgba(244, 67, 54, 0.5)
&:hover
background: linear-gradient(135deg, rgba(244, 67, 54, 0.6), rgba(211, 47, 47, 0.6))
box-shadow: 0 12px 40px rgba(244, 67, 54, 0.3)
&.btn-small
padding: 0.625rem 1rem
font-size: 0.85rem
min-width: 44px
justify-content: center
svg
margin: 0
font-size: 0.9rem
@keyframes spin
from
transform: rotate(0deg)
to
transform: rotate(360deg)
// Enhanced Responsive Design
@media (max-width: 768px)
.shopping-container
top: 6.5rem
left: 0.375rem
right: 0.375rem
bottom: 0.375rem
padding: 1.5rem
.shopping-header
flex-direction: column
align-items: stretch
text-align: center
padding-bottom: 1rem
h1
justify-content: center
margin-bottom: 1rem
font-size: 2rem
.header-actions
justify-content: center
.add-item-form
.form-group
flex-direction: column
input
min-width: auto
width: 100%
.shopping-item
padding: 1.25rem
.item-content .item-main
flex-direction: column
align-items: flex-start
gap: 0.75rem
.item-amount
margin-left: 0
align-self: flex-start
.item-actions
flex-direction: column
@media (max-width: 480px)
.shopping-container
top: 6rem
left: 0.25rem
right: 0.25rem
bottom: 0.25rem
padding: 1rem
.shopping-item
flex-direction: column
align-items: stretch
gap: 1.25rem
padding: 1rem
.check-btn
align-self: flex-start
.item-actions
flex-direction: row
justify-content: flex-end
gap: 0.5rem
.btn
padding: 0.75rem 1rem
font-size: 0.85rem
&.btn-small
padding: 0.5rem 0.75rem

View File

@@ -0,0 +1,546 @@
// Import individual page styles
@import './Shopping.sass'
// Common page container
.page-container
padding: 2rem
min-height: calc(100vh - 6rem)
overflow-y: auto
// Calendar Styles
.calendar-container
max-width: 1200px
margin: 0 auto
.calendar-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
flex-wrap: wrap
gap: 1rem
h1
font-size: 2rem
font-weight: 700
color: #1e293b
margin: 0
.calendar-nav
display: flex
align-items: center
gap: 1rem
background: rgba(255, 255, 255, 0.3)
padding: 0.75rem 1.5rem
border-radius: 16px
backdrop-filter: blur(20px)
border: 1px solid rgba(255, 255, 255, 0.4)
h2
font-size: 1.25rem
font-weight: 600
color: #1e293b
margin: 0
min-width: 200px
text-align: center
.nav-btn, .add-event-btn
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.2))
border: 1px solid rgba(255, 255, 255, 0.5)
border-radius: 12px
padding: 0.75rem 1rem
color: #475569
cursor: pointer
transition: all 0.3s ease
backdrop-filter: blur(20px)
font-weight: 500
&:hover
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.4))
transform: translateY(-2px)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1)
.add-event-btn
display: flex
align-items: center
gap: 0.5rem
font-size: 0.875rem
.calendar-grid
background: rgba(255, 255, 255, 0.25)
border-radius: 24px
padding: 2rem
margin-bottom: 2rem
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1)
.calendar-days-header
display: grid
grid-template-columns: repeat(7, 1fr)
gap: 1rem
margin-bottom: 1rem
.day-header
text-align: center
font-weight: 600
color: #475569
padding: 0.75rem
.calendar-days
display: grid
grid-template-columns: repeat(7, 1fr)
gap: 1rem
.calendar-day
aspect-ratio: 1
display: flex
align-items: center
justify-content: center
border-radius: 12px
cursor: pointer
font-weight: 500
transition: all 0.3s ease
&.empty
cursor: default
&.active
background: rgba(255, 255, 255, 0.2)
color: #1e293b
&:hover
background: rgba(255, 255, 255, 0.4)
transform: scale(1.05)
&.today
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(37, 99, 235, 0.3))
color: white
font-weight: 700
&.selected
background: linear-gradient(135deg, rgba(168, 85, 247, 0.3), rgba(147, 51, 234, 0.3))
color: white
// Notes Styles
.notes-container
max-width: 1200px
margin: 0 auto
.notes-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
h1
font-size: 2rem
font-weight: 700
color: #1e293b
margin: 0
.add-note-btn
display: flex
align-items: center
gap: 0.5rem
background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(124, 58, 237, 0.3))
border: 1px solid rgba(255, 255, 255, 0.4)
border-radius: 12px
padding: 0.75rem 1.5rem
color: white
cursor: pointer
font-weight: 500
transition: all 0.3s ease
&:hover
background: linear-gradient(135deg, rgba(139, 92, 246, 0.4), rgba(124, 58, 237, 0.4))
transform: translateY(-2px)
.note-editor
background: rgba(255, 255, 255, 0.25)
border-radius: 20px
padding: 2rem
margin-bottom: 2rem
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
.editor-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 1rem
gap: 1rem
.title-input
flex: 1
padding: 0.75rem 1rem
border: 1px solid rgba(255, 255, 255, 0.4)
border-radius: 12px
background: rgba(255, 255, 255, 0.3)
color: #1e293b
font-size: 1.125rem
font-weight: 600
&:focus
outline: none
border-color: rgba(59, 130, 246, 0.5)
.color-picker
display: flex
gap: 0.5rem
.color-option
width: 32px
height: 32px
border-radius: 50%
border: 2px solid transparent
cursor: pointer
transition: all 0.3s ease
&.selected
border-color: rgba(255, 255, 255, 0.8)
transform: scale(1.1)
.content-input
width: 100%
padding: 1rem
border: 1px solid rgba(255, 255, 255, 0.4)
border-radius: 12px
background: rgba(255, 255, 255, 0.3)
color: #1e293b
font-size: 1rem
line-height: 1.6
resize: vertical
margin-bottom: 1rem
&:focus
outline: none
border-color: rgba(59, 130, 246, 0.5)
.editor-actions
display: flex
gap: 1rem
.save-btn, .cancel-btn
display: flex
align-items: center
gap: 0.5rem
padding: 0.75rem 1.5rem
border-radius: 12px
cursor: pointer
font-weight: 500
transition: all 0.3s ease
.save-btn
background: linear-gradient(135deg, rgba(16, 185, 129, 0.3), rgba(5, 150, 105, 0.3))
border: 1px solid rgba(255, 255, 255, 0.4)
color: white
&:hover
background: linear-gradient(135deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.4))
.cancel-btn
background: rgba(255, 255, 255, 0.3)
border: 1px solid rgba(255, 255, 255, 0.4)
color: #475569
&:hover
background: rgba(255, 255, 255, 0.4)
.notes-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr))
gap: 1.5rem
.note-card
background: rgba(255, 255, 255, 0.25)
border-radius: 20px
padding: 1.5rem
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
border-left: 4px solid #3b82f6
transition: all 0.3s ease
&:hover
transform: translateY(-4px)
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15)
.note-header
display: flex
justify-content: space-between
align-items: flex-start
margin-bottom: 1rem
h3
font-size: 1.125rem
font-weight: 600
color: #1e293b
margin: 0
.note-actions
display: flex
gap: 0.5rem
button
background: transparent
border: none
color: #64748b
cursor: pointer
padding: 0.25rem
border-radius: 6px
transition: all 0.3s ease
&:hover
background: rgba(100, 116, 139, 0.1)
color: #475569
.note-content
margin-bottom: 1rem
p
color: #475569
line-height: 1.6
margin-bottom: 0.5rem
&:last-child
margin-bottom: 0
.note-footer
border-top: 1px solid rgba(255, 255, 255, 0.2)
padding-top: 1rem
.note-date
font-size: 0.875rem
color: #64748b
// Search Styles
.search-container
max-width: 800px
margin: 0 auto
.search-header
margin-bottom: 2rem
h1
font-size: 2rem
font-weight: 700
color: #1e293b
margin-bottom: 1.5rem
.search-input-container
position: relative
background: rgba(255, 255, 255, 0.25)
border-radius: 20px
padding: 1.5rem
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
.search-icon
position: absolute
left: 2.5rem
top: 50%
transform: translateY(-50%)
color: #64748b
font-size: 1.25rem
.search-input
width: 100%
padding: 1rem 1rem 1rem 3rem
border: none
background: transparent
color: #1e293b
font-size: 1.125rem
outline: none
&::placeholder
color: rgba(30, 41, 59, 0.6)
.search-suggestions
display: grid
grid-template-columns: 1fr 1fr
gap: 2rem
.recent-searches, .quick-actions
background: rgba(255, 255, 255, 0.25)
border-radius: 20px
padding: 1.5rem
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
h3
font-size: 1.125rem
font-weight: 600
color: #1e293b
margin-bottom: 1rem
.recent-list
display: flex
flex-direction: column
gap: 0.75rem
.recent-item
display: flex
align-items: center
gap: 0.75rem
padding: 0.75rem 1rem
background: rgba(255, 255, 255, 0.3)
border: 1px solid rgba(255, 255, 255, 0.4)
border-radius: 12px
color: #475569
cursor: pointer
transition: all 0.3s ease
&:hover
background: rgba(255, 255, 255, 0.4)
transform: translateX(4px)
.actions-grid
display: grid
grid-template-columns: 1fr 1fr
gap: 1rem
.quick-action
display: flex
flex-direction: column
align-items: center
gap: 0.5rem
padding: 1rem
background: rgba(255, 255, 255, 0.3)
border: 1px solid rgba(255, 255, 255, 0.4)
border-radius: 12px
color: #475569
cursor: pointer
transition: all 0.3s ease
text-align: center
&:hover
background: rgba(255, 255, 255, 0.4)
transform: translateY(-2px)
svg
font-size: 1.5rem
span
font-size: 0.875rem
font-weight: 500
.search-results
.results-header
margin-bottom: 1.5rem
h3
font-size: 1.125rem
font-weight: 600
color: #1e293b
.results-list
display: flex
flex-direction: column
gap: 1rem
.result-item
display: flex
align-items: flex-start
gap: 1rem
padding: 1.5rem
background: rgba(255, 255, 255, 0.25)
border-radius: 16px
backdrop-filter: blur(40px)
border: 1px solid rgba(255, 255, 255, 0.3)
cursor: pointer
transition: all 0.3s ease
&:hover
transform: translateY(-2px)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1)
.result-icon
width: 48px
height: 48px
border-radius: 12px
display: flex
align-items: center
justify-content: center
color: white
font-size: 1.25rem
.result-content
flex: 1
.result-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 0.5rem
h4
font-size: 1.125rem
font-weight: 600
color: #1e293b
margin: 0
.result-type
font-size: 0.875rem
font-weight: 500
.result-description
color: #475569
line-height: 1.5
margin-bottom: 0.5rem
.result-date
font-size: 0.875rem
color: #64748b
.no-results
text-align: center
padding: 3rem
color: #64748b
svg
margin-bottom: 1rem
opacity: 0.5
h3
font-size: 1.25rem
font-weight: 600
margin-bottom: 0.5rem
p
font-size: 1rem
// Responsive Design
@media (max-width: 768px)
.page-container
padding: 1.5rem
.calendar-header, .shopping-header, .notes-header
flex-direction: column
align-items: flex-start
gap: 1rem
.search-suggestions
grid-template-columns: 1fr
.actions-grid
grid-template-columns: 1fr
.notes-grid
grid-template-columns: 1fr
.editor-header
flex-direction: column
align-items: stretch
@media (max-width: 480px)
.page-container
padding: 1rem
.calendar-days
gap: 0.5rem
.input-group
flex-direction: column
.result-item
padding: 1rem

View File

@@ -0,0 +1,54 @@
import requestUtil from '../utils/RequestUtil';
class CalendarService {
constructor() {
this.endpoint = '/api/calendar';
}
// Get all calendar users
async getUsers() {
return requestUtil.get(`${this.endpoint}/users`);
}
// Get calendar events for a specific month
async getEventsForMonth(year, month, user = null, type = 'family') {
let url = `${this.endpoint}/events/${year}/${month}?type=${type}`;
if (user && type === 'individual') {
url += `&user=${encodeURIComponent(user)}`;
}
return requestUtil.get(url);
}
// Create a new calendar event
async createEvent(event) {
return requestUtil.post(`${this.endpoint}/events`, event);
}
// Update a calendar event
async updateEvent(eventId, event) {
return requestUtil.put(`${this.endpoint}/events/${eventId}`, event);
}
// Delete a calendar event
async deleteEvent(eventId, user) {
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
}
// Get contact birthdays for a specific month
async getContactBirthdaysForMonth(year, month, user) {
let url = `${this.endpoint}/birthdays/${year}/${month}`;
if (user) {
url += `?user=${encodeURIComponent(user)}`;
}
return requestUtil.get(url);
}
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
}
}
// Create and export a singleton instance
const calendarService = new CalendarService();
export default calendarService;

View File

@@ -0,0 +1,51 @@
import requestUtil from '../utils/RequestUtil';
class ShoppingService {
constructor() {
this.endpoint = '/api/shopping';
}
// Get all shopping items
async getItems() {
return requestUtil.get(this.endpoint);
}
// Get a specific shopping item
async getItem(id) {
return requestUtil.get(`${this.endpoint}/${id}`);
}
// Create a new shopping item
async createItem(item) {
return requestUtil.post(this.endpoint, item);
}
// Update a shopping item
async updateItem(id, item) {
return requestUtil.put(`${this.endpoint}/${id}`, item);
}
// Toggle checked status of an item
async toggleItem(id) {
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
}
// Delete a shopping item
async deleteItem(id) {
return requestUtil.delete(`${this.endpoint}/${id}`);
}
// Delete all checked items
async deleteCheckedItems() {
return requestUtil.delete(`${this.endpoint}/checked/all`);
}
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
}
}
// Create and export a singleton instance
const shoppingService = new ShoppingService();
export default shoppingService;

View File

@@ -0,0 +1,135 @@
/**
* Utility functions for triggering the input overlay
*/
/**
* Show the input overlay with specified options
* @param {Object} options - Configuration options
* @param {HTMLElement} options.targetElement - The target input element (optional)
* @param {string} options.initialValue - Initial text value (optional)
* @param {string} options.preferredMode - 'keyboard' or 'drawing' (optional)
*/
export const showInputOverlay = (options = {}) => {
// Try using Electron API first
if (window.api && window.api.showInputOverlay) {
window.api.showInputOverlay(options)
return
}
// Fallback to custom DOM event
const event = new CustomEvent('show-input-overlay', { detail: options })
document.dispatchEvent(event)
}
/**
* Hide the input overlay
*/
export const hideInputOverlay = () => {
// Try using Electron API first
if (window.api && window.api.hideInputOverlay) {
window.api.hideInputOverlay()
return
}
// Fallback to custom DOM event
const event = new CustomEvent('hide-input-overlay')
document.dispatchEvent(event)
}
/**
* Setup automatic input overlay for input elements
* Call this to automatically show the overlay when input elements are focused
*/
export const setupAutoInputOverlay = () => {
const handleFocus = (event) => {
const element = event.target
// Only handle text inputs, textareas, and contenteditable elements
if (
element.tagName === 'INPUT' &&
['text', 'search', 'email', 'url', 'password'].includes(element.type)
) {
showInputOverlay({
targetElement: element,
initialValue: element.value || '',
preferredMode: 'keyboard'
})
} else if (element.tagName === 'TEXTAREA') {
showInputOverlay({
targetElement: element,
initialValue: element.value || '',
preferredMode: 'keyboard'
})
} else if (element.contentEditable === 'true') {
showInputOverlay({
targetElement: element,
initialValue: element.textContent || '',
preferredMode: 'keyboard'
})
}
}
// Add event listener for focus events
document.addEventListener('focusin', handleFocus, true)
// Return cleanup function
return () => {
document.removeEventListener('focusin', handleFocus, true)
}
}
/**
* Listen for input overlay submissions
* @param {Function} callback - Callback function to handle submitted values
*/
export const onInputOverlaySubmit = (callback) => {
const handleSubmit = (event) => {
callback(event.detail)
}
document.addEventListener('input-overlay-submit', handleSubmit)
// Return cleanup function
return () => {
document.removeEventListener('input-overlay-submit', handleSubmit)
}
}
/**
* Focus trap helper for accessibility
*/
export const setupFocusTrap = (container) => {
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
if (e.key === 'Escape') {
hideInputOverlay()
}
}
container.addEventListener('keydown', handleKeyDown)
// Focus first element
if (firstElement) {
firstElement.focus()
}
return () => {
container.removeEventListener('keydown', handleKeyDown)
}
}

View File

@@ -0,0 +1,86 @@
class RequestUtil {
constructor() {
this.baseURL = this.getBaseURL();
}
getBaseURL() {
// In production, use the static endpoint, otherwise use proxy
if (process.env.NODE_ENV === 'production') {
return 'https://static.endpoint.com';
}
return ''; // Uses proxy in development
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
const config = {
...defaultOptions,
...options
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}
// GET request
async get(endpoint) {
return this.request(endpoint, {
method: 'GET'
});
}
// POST request
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// PUT request
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
// PATCH request
async patch(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
// DELETE request
async delete(endpoint, data = {}) {
return this.request(endpoint, {
method: 'DELETE',
body: JSON.stringify(data)
});
}
}
// Create and export a singleton instance
const requestUtil = new RequestUtil();
export default requestUtil;

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
version: '3.8'
services:
server:
build:
context: ./server
dockerfile: Dockerfile
container_name: openwall-server
ports:
- "3001:3001"
volumes:
- server-data:/app/data
environment:
- NODE_ENV=production
- DATABASE_PATH=/app/data/database.sqlite
restart: unless-stopped
networks:
- openwall-network
mobile-calendar:
build:
context: ./mobile-calendar
dockerfile: Dockerfile
container_name: openwall-mobile-calendar
ports:
- "8080:80"
depends_on:
- server
restart: unless-stopped
networks:
- openwall-network
mobile-shopping:
build:
context: ./mobile-shopping
dockerfile: Dockerfile
container_name: openwall-mobile-shopping
ports:
- "8081:80"
depends_on:
- server
restart: unless-stopped
networks:
- openwall-network
volumes:
server-data:
driver: local
networks:
openwall-network:
driver: bridge

View File

@@ -0,0 +1,17 @@
node_modules
dist
build
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode
coverage

24
mobile-calendar/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,35 @@
# Use Node.js official image for building
FROM node:18-alpine as builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install pnpm globally
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Use nginx for serving the built application
FROM nginx:alpine
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenWall Calendar</title>
<!-- PWA Meta Tags -->
<meta name="description" content="Mobile calendar app for OpenWall Smart Home Dashboard">
<meta name="theme-color" content="#3b82f6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="OpenWall Calendar">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Additional PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="OpenWall Calendar">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend server
location /api/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -0,0 +1,30 @@
{
"name": "mobile-calendar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"sass": "^1.89.2",
"vite": "^7.0.4",
"vite-plugin-pwa": "^1.0.1"
}
}

4789
mobile-calendar/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -0,0 +1,167 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<!-- Background gradient -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
</linearGradient>
<!-- Calendar body gradient -->
<linearGradient id="calendarGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
</linearGradient>
<!-- Header gradient -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:1" />
</linearGradient>
<!-- Today highlight gradient -->
<radialGradient id="todayGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
</radialGradient>
<!-- Drop shadow -->
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="4" dy="8" stdDeviation="6" flood-color="#1e293b" flood-opacity="0.3"/>
</filter>
<!-- Inner shadow -->
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="4" result="offset-blur"/>
<feFlood flood-color="#334155" flood-opacity="0.15"/>
<feComposite in2="offset-blur" operator="in"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode/>
</feMerge>
</filter>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="240" fill="url(#bgGradient)" filter="url(#dropShadow)"/>
<!-- Calendar container -->
<rect x="96" y="80" width="320" height="352" rx="24" ry="24"
fill="url(#calendarGradient)"
stroke="#e2e8f0"
stroke-width="4"
filter="url(#innerShadow)"/>
<!-- Calendar header -->
<rect x="96" y="80" width="320" height="80" rx="24" ry="24"
fill="url(#headerGradient)"/>
<rect x="96" y="136" width="320" height="24"
fill="url(#headerGradient)"/>
<!-- Binding rings -->
<circle cx="160" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
<circle cx="256" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
<circle cx="352" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
<!-- Binding ring holes -->
<circle cx="160" cy="64" r="6" fill="#334155"/>
<circle cx="256" cy="64" r="6" fill="#334155"/>
<circle cx="352" cy="64" r="6" fill="#334155"/>
<!-- Month/Year text area -->
<rect x="120" y="100" width="272" height="40" rx="8" ry="8" fill="#ffffff" opacity="0.9"/>
<!-- Calendar grid -->
<g stroke="#cbd5e1" stroke-width="1" opacity="0.6">
<!-- Vertical lines -->
<line x1="141" y1="160" x2="141" y2="416"/>
<line x1="186" y1="160" x2="186" y2="416"/>
<line x1="231" y1="160" x2="231" y2="416"/>
<line x1="276" y1="160" x2="276" y2="416"/>
<line x1="321" y1="160" x2="321" y2="416"/>
<line x1="366" y1="160" x2="366" y2="416"/>
<!-- Horizontal lines -->
<line x1="96" y1="196" x2="416" y2="196"/>
<line x1="96" y1="232" x2="416" y2="232"/>
<line x1="96" y1="268" x2="416" y2="268"/>
<line x1="96" y1="304" x2="416" y2="304"/>
<line x1="96" y1="340" x2="416" y2="340"/>
<line x1="96" y1="376" x2="416" y2="376"/>
</g>
<!-- Day of week labels area -->
<rect x="96" y="160" width="320" height="36" fill="#f8fafc" opacity="0.8"/>
<!-- Sample dates -->
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
<!-- Week days (abbreviated) -->
<text x="118" y="182" fill="#475569" font-size="12">M</text>
<text x="163" y="182" fill="#475569" font-size="12">T</text>
<text x="208" y="182" fill="#475569" font-size="12">W</text>
<text x="253" y="182" fill="#475569" font-size="12">T</text>
<text x="298" y="182" fill="#475569" font-size="12">F</text>
<text x="343" y="182" fill="#475569" font-size="12">S</text>
<text x="388" y="182" fill="#475569" font-size="12">S</text>
<!-- Sample date numbers -->
<text x="118" y="218">1</text>
<text x="163" y="218">2</text>
<text x="208" y="218">3</text>
<text x="253" y="218">4</text>
<text x="298" y="218">5</text>
<text x="343" y="218">6</text>
<text x="388" y="218">7</text>
<text x="118" y="254">8</text>
<text x="163" y="254">9</text>
<text x="208" y="254">10</text>
<text x="253" y="254">11</text>
<text x="298" y="254">12</text>
<text x="343" y="254">13</text>
<text x="388" y="254">14</text>
<text x="118" y="290">15</text>
<text x="163" y="290">16</text>
<text x="208" y="290">17</text>
</g>
<!-- Today highlight (day 18) -->
<circle cx="253" cy="285" r="18" fill="url(#todayGradient)" opacity="0.9"/>
<text x="253" y="291" fill="white" font-family="Arial, sans-serif" font-size="16" font-weight="600" text-anchor="middle">18</text>
<!-- Additional date numbers -->
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
<text x="298" y="290">19</text>
<text x="343" y="290">20</text>
<text x="388" y="290">21</text>
<text x="118" y="326">22</text>
<text x="163" y="326">23</text>
<text x="208" y="326">24</text>
<text x="253" y="326">25</text>
<text x="298" y="326">26</text>
<text x="343" y="326">27</text>
<text x="388" y="326">28</text>
<text x="118" y="362">29</text>
<text x="163" y="362">30</text>
<text x="208" y="362">31</text>
</g>
<!-- Event indicators -->
<circle cx="163" cy="244" r="3" fill="#10b981"/>
<circle cx="298" cy="244" r="3" fill="#f59e0b"/>
<circle cx="343" cy="280" r="3" fill="#ef4444"/>
<circle cx="118" cy="316" r="3" fill="#8b5cf6"/>
<!-- Small OpenWall branding element -->
<g transform="translate(360, 380)" opacity="0.4">
<circle cx="16" cy="16" r="12" fill="#3b82f6"/>
<rect x="10" y="10" width="12" height="12" rx="2" fill="white" opacity="0.8"/>
<rect x="12" y="12" width="8" height="2" fill="#3b82f6"/>
<circle cx="14" cy="16" r="1" fill="#3b82f6"/>
<circle cx="18" cy="16" r="1" fill="#3b82f6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

613
mobile-calendar/src/App.jsx Normal file
View File

@@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import {
FaPlus,
FaTrash,
FaEdit,
FaCalendarAlt,
FaUsers,
FaUser,
FaChevronLeft,
FaChevronRight,
FaSave,
FaTimes,
FaBirthdayCake,
FaGift
} from 'react-icons/fa';
import calendarService from './services/CalendarService';
import './App.sass';
const App = () => {
const [events, setEvents] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const [viewType, setViewType] = useState('family'); // 'family' or 'individual'
const [selectedUser, setSelectedUser] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
const [newEvent, setNewEvent] = useState({
user: '',
date: '',
text: ''
});
useEffect(() => {
loadUsers();
loadEvents();
}, [currentDate, viewType, selectedUser]);
const loadUsers = async () => {
try {
const userData = await calendarService.getUsers();
setUsers(userData);
if (userData.length > 0 && !selectedUser) {
setSelectedUser(userData[0].name);
setNewEvent(prev => ({ ...prev, user: userData[0].name }));
}
} catch (err) {
setError('Fehler beim Laden der Benutzer');
console.error('Error loading users:', err);
}
};
const loadEvents = async () => {
try {
setLoading(true);
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const eventData = await calendarService.getEventsForMonth(
year,
month,
selectedUser,
viewType
);
setEvents(eventData);
setError(null);
} catch (err) {
setError('Fehler beim Laden der Termine');
console.error('Error loading events:', err);
} finally {
setLoading(false);
}
};
const handleAddEvent = async (e) => {
e.preventDefault();
if (!newEvent.text.trim() || !newEvent.date || !newEvent.user) return;
try {
const createdEvent = await calendarService.createEvent(newEvent);
setEvents([...events, createdEvent]);
setNewEvent({
user: selectedUser || users[0]?.name || '',
date: '',
text: ''
});
setShowAddForm(false);
} catch (err) {
setError('Fehler beim Erstellen des Termins');
console.error('Error creating event:', err);
}
};
const handleUpdateEvent = async (eventId, updatedData) => {
try {
const updatedEvent = await calendarService.updateEvent(eventId, updatedData);
setEvents(events.map(event =>
event.id === eventId ? updatedEvent : event
));
setEditingEvent(null);
} catch (err) {
console.error('Error updating event:', err);
// Show more specific error messages based on the error
if (err.message && err.message.includes('belong to you')) {
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht bearbeitet werden');
} else if (err.message && err.message.includes('not found')) {
setError('Termin nicht gefunden');
} else if (err.message && err.message.includes('uid already exists')) {
setError('Fehler beim Aktualisieren: Termin-ID bereits vorhanden');
} else {
setError('Fehler beim Aktualisieren des Termins');
}
}
};
const handleDeleteEvent = async (eventId, user) => {
try {
// Additional protection: don't allow deleting birthday events
if (eventId && eventId.includes('birthday-')) {
setError('Geburtstage können nicht über die Kalender-App gelöscht werden');
return;
}
await calendarService.deleteEvent(eventId, user);
setEvents(events.filter(event => event.id !== eventId));
} catch (err) {
console.error('Error deleting event:', err);
// Show more specific error messages based on the error
if (err.message && err.message.includes('belong to you')) {
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht gelöscht werden');
} else if (err.message && err.message.includes('not found')) {
setError('Termin nicht gefunden oder bereits gelöscht');
} else if (err.message && err.message.includes('Birthday events')) {
setError('Geburtstage können nicht gelöscht werden');
} else {
setError('Fehler beim Löschen des Termins');
}
}
};
const navigateMonth = (direction) => {
const newDate = new Date(currentDate);
newDate.setMonth(newDate.getMonth() + direction);
setCurrentDate(newDate);
};
const formatDate = (dateString) => {
// Parse the date string ensuring we don't have timezone issues
const dateParts = dateString.split('-');
const date = new Date(parseInt(dateParts[0]), parseInt(dateParts[1]) - 1, parseInt(dateParts[2]));
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset time for comparison
// Create a date object for the event date for comparison
const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
return {
dayNumber: date.getDate(),
dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }),
monthYear: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }),
isToday: eventDate.getTime() === today.getTime(),
isThisMonth: date.getMonth() === currentDate.getMonth() && date.getFullYear() === currentDate.getFullYear()
};
};
const getCurrentMonthYear = () => {
return currentDate.toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric'
});
};
const groupEventsByDate = (events) => {
// First, ensure events are properly sorted by date
const sortedEvents = [...events].sort((a, b) => {
// Compare dates as strings to avoid timezone issues
if (a.date !== b.date) {
return a.date.localeCompare(b.date);
}
// If dates are the same, sort by type (birthdays first)
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
// Then sort by event text for consistent ordering
return (a.text || '').localeCompare(b.text || '');
});
const grouped = {};
sortedEvents.forEach(event => {
const date = event.date;
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
// Sort dates chronologically using string comparison for YYYY-MM-DD format
const sortedDates = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
const result = {};
sortedDates.forEach(date => {
// Events within each date are already sorted from the initial sort
result[date] = grouped[date];
});
return result;
};
const groupedEvents = groupEventsByDate(events);
const getUserColor = (userName, eventType) => {
// Different color scheme for birthdays
if (eventType === 'birthday') {
return '#ff6b9d'; // Pink for birthdays
}
const colors = [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#06b6d4', // cyan
'#ec4899', // pink
'#84cc16' // lime
];
const userIndex = users.findIndex(user => user.name === userName);
return colors[userIndex % colors.length];
};
if (loading && events.length === 0) {
return (
<div className="app">
<div className="loading">
<FaCalendarAlt className="loading-icon" />
<p>Kalender wird geladen...</p>
</div>
</div>
);
}
return (
<div className="app calendar-app">
<header className="header">
<div className="header-content">
<h1>
<FaCalendarAlt />
Kalender
</h1>
<div className="month-navigation">
<button
className="btn btn-icon"
onClick={() => navigateMonth(-1)}
>
<FaChevronLeft />
</button>
<h2 className="current-month">{getCurrentMonthYear()}</h2>
<button
className="btn btn-icon"
onClick={() => navigateMonth(1)}
>
<FaChevronRight />
</button>
</div>
</div>
<div className="view-controls">
<div className="view-type-toggle">
<button
className={`btn ${viewType === 'family' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setViewType('family')}
>
<FaUsers />
Familie
</button>
<button
className={`btn ${viewType === 'individual' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setViewType('individual')}
>
<FaUser />
Einzeln
</button>
</div>
{viewType === 'individual' && (
<select
className="user-select"
value={selectedUser || ''}
onChange={(e) => setSelectedUser(e.target.value)}
>
{users.map(user => (
<option key={user.id} value={user.name}>
{user.name}
</option>
))}
</select>
)}
</div>
</header>
<main className="main">
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{showAddForm && (
<div className="add-form-overlay">
<form className="add-form" onSubmit={handleAddEvent}>
<h3>Neuer Termin</h3>
<div className="form-group">
<label>Benutzer</label>
<select
value={newEvent.user}
onChange={(e) => setNewEvent({ ...newEvent, user: e.target.value })}
required
>
<option value="">Benutzer auswählen</option>
{users.map(user => (
<option key={user.id} value={user.name}>
{user.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Datum</label>
<input
type="date"
value={newEvent.date}
onChange={(e) => setNewEvent({ ...newEvent, date: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>Beschreibung</label>
<input
type="text"
placeholder="Termin beschreiben..."
value={newEvent.text}
onChange={(e) => setNewEvent({ ...newEvent, text: e.target.value })}
autoFocus
required
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
<FaTimes />
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
<FaSave />
Speichern
</button>
</div>
</form>
</div>
)}
<div className="calendar-content">
{Object.keys(groupedEvents).length === 0 ? (
<div className="empty-state">
<FaCalendarAlt />
<p>Keine Termine in diesem Monat</p>
<small>
{viewType === 'family'
? 'Füge einen neuen Termin hinzu für alle Familienmitglieder'
: `Keine Termine für ${selectedUser} gefunden`
}
</small>
</div>
) : (
<div className="events-list">
{Object.entries(groupedEvents).map(([date, dayEvents]) => {
const dateInfo = formatDate(date);
return (
<div key={date} className={`day-section ${dateInfo.isToday ? 'today' : ''} ${!dateInfo.isThisMonth ? 'other-month' : ''}`}>
<div className="day-header">
<div className="day-info">
<div className="day-number">{dateInfo.dayNumber}</div>
<div className="day-details">
<div className="day-name">{dateInfo.dayName}</div>
{!dateInfo.isThisMonth && (
<div className="month-indicator">{dateInfo.monthYear}</div>
)}
</div>
</div>
<div className="event-count">
<span className="count-number">{dayEvents.length}</span>
<span className="count-label">Termin{dayEvents.length !== 1 ? 'e' : ''}</span>
</div>
</div>
<div className="day-events">
{dayEvents.map(event => (
<CalendarEvent
key={event.id}
event={event}
onUpdate={handleUpdateEvent}
onDelete={handleDeleteEvent}
editingEvent={editingEvent}
setEditingEvent={setEditingEvent}
userColor={getUserColor(event.user, event.type)}
showUser={viewType === 'family'}
users={users}
viewType={viewType}
selectedUser={selectedUser}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</main>
<div className="fab-container">
<button
className="fab"
onClick={() => setShowAddForm(true)}
>
<FaPlus />
</button>
</div>
</div>
);
};
// Calendar Event Component
const CalendarEvent = ({
event,
onUpdate,
onDelete,
editingEvent,
setEditingEvent,
userColor,
showUser,
users,
viewType,
selectedUser
}) => {
const [editData, setEditData] = useState({
user: event.user,
date: event.date,
text: event.text
});
const handleEdit = () => {
// Don't allow editing of birthday events (they come from external calendar)
if (event.type === 'birthday') {
return;
}
// In individual view, only allow editing events that belong to the selected user
if (viewType === 'individual' && event.user !== selectedUser) {
return;
}
setEditingEvent(event.id);
setEditData({
user: event.user,
date: event.date,
text: event.text
});
};
const handleSave = () => {
onUpdate(event.id, editData);
};
const handleCancel = () => {
setEditingEvent(null);
setEditData({
user: event.user,
date: event.date,
text: event.text
});
};
const handleDelete = () => {
// Don't allow deleting of birthday events (they come from external calendar)
if (event.type === 'birthday') {
return;
}
// In individual view, only allow deleting events that belong to the selected user
if (viewType === 'individual' && event.user !== selectedUser) {
return;
}
// Always pass the event's original user for deletion (important for family view)
onDelete(event.id, event.user);
};
const isEditing = editingEvent === event.id;
const isBirthday = event.type === 'birthday';
// Determine if this event can be edited/deleted
const canEdit = !isBirthday && (viewType === 'family' || event.user === selectedUser);
const canDelete = !isBirthday && (viewType === 'family' || event.user === selectedUser);
return (
<div className={`calendar-event ${isBirthday ? 'birthday-event' : ''}`} style={{ borderLeftColor: userColor }}>
{isEditing ? (
<div className="edit-form">
<div className="form-group">
<select
value={editData.user}
onChange={(e) => setEditData({ ...editData, user: e.target.value })}
>
{users.map(user => (
<option key={user.id} value={user.name}>
{user.name}
</option>
))}
</select>
</div>
<div className="form-group">
<input
type="date"
value={editData.date}
onChange={(e) => setEditData({ ...editData, date: e.target.value })}
/>
</div>
<div className="form-group">
<input
type="text"
value={editData.text}
onChange={(e) => setEditData({ ...editData, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
</div>
<div className="edit-actions">
<button className="btn btn-primary btn-small" onClick={handleSave}>
<FaSave />
</button>
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
<FaTimes />
</button>
</div>
</div>
) : (
<>
<div className="event-content">
<div className="event-text">
{isBirthday && (
<span className="birthday-icon">
<FaBirthdayCake />
</span>
)}
{isBirthday && event.contactName ? event.contactName : event.text}
{isBirthday && (
<span className="birthday-label">Geburtstag</span>
)}
</div>
{showUser && (
<div className="event-user" style={{ color: userColor }}>
{event.user}
{isBirthday && <span className="event-source"> (Kontakte)</span>}
{!canEdit && !isBirthday && <span className="read-only-indicator"> (nur lesen)</span>}
</div>
)}
</div>
{canEdit && (
<div className="event-actions">
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
<FaEdit />
</button>
{canDelete && (
<button
className="btn btn-danger btn-small"
onClick={handleDelete}
>
<FaTrash />
</button>
)}
</div>
)}
{isBirthday && (
<div className="event-actions">
<div className="birthday-indicator">
<FaGift className="gift-icon" />
</div>
</div>
)}
{!canEdit && !isBirthday && (
<div className="event-actions">
<div className="read-only-indicator">
<span className="read-only-text">👁</span>
</div>
</div>
)}
</>
)}
</div>
);
};
export default App;

1000
mobile-calendar/src/App.sass Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,556 @@
// Mobile Shopping App Styles
// Clean, simple design without glassmorphism
:root {
--primary-color: #007AFF;
--secondary-color: #5856D6;
--success-color: #34C759;
--danger-color: #FF3B30;
--warning-color: #FF9500;
--background-color: #f8f9fa;
--surface-color: #ffffff;
--border-color: #e1e5e9;
--text-primary: #1d1d1f;
--text-secondary: #6e6e73;
--text-muted: #8e8e93;
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15);
--border-radius: 12px;
--border-radius-small: 8px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: 100vw;
overflow-x: hidden;
}
// Header
.header {
background: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-light);
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 600px;
margin: 0 auto;
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
svg {
color: var(--primary-color);
font-size: 1.25rem;
}
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
}
}
// Main Content
.main {
flex: 1;
padding: var(--spacing-md);
max-width: 600px;
margin: 0 auto;
width: 100%;
padding-bottom: 100px; // Space for FAB
}
// Error Message
.error-message {
background: var(--danger-color);
color: white;
padding: var(--spacing-md);
border-radius: var(--border-radius-small);
margin-bottom: var(--spacing-md);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
button {
background: none;
border: none;
color: white;
font-size: 1.25rem;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
// Loading State
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
color: var(--text-secondary);
.loading-icon {
font-size: 3rem;
color: var(--primary-color);
margin-bottom: var(--spacing-md);
animation: pulse 2s infinite;
}
p {
font-size: 1.1rem;
font-weight: 500;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
// Shopping Content
.shopping-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.shopping-section {
h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
padding: 0 var(--spacing-xs);
}
}
.items-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--text-muted);
text-align: center;
svg {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
p {
font-size: 1.1rem;
font-weight: 500;
}
}
// Shopping Item
.shopping-item {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--spacing-md);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
transition: all 0.2s ease;
box-shadow: var(--shadow-light);
&:hover {
box-shadow: var(--shadow-medium);
transform: translateY(-1px);
}
&.checked {
opacity: 0.7;
background: #f8f9fa;
.item-name {
text-decoration: line-through;
color: var(--text-muted);
}
.check-btn {
background: var(--success-color);
color: white;
&:hover {
background: #28a745;
}
}
}
.check-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--surface-color);
color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
margin-top: 2px;
&:hover {
border-color: var(--success-color);
background: rgba(52, 199, 89, 0.1);
}
svg {
font-size: 0.875rem;
}
}
.item-content {
flex: 1;
min-width: 0;
.item-main {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
.item-name {
font-weight: 500;
color: var(--text-primary);
font-size: 1rem;
flex: 1;
word-break: break-word;
}
.item-amount {
background: var(--primary-color);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: var(--spacing-sm);
flex-shrink: 0;
}
}
.item-meta {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: 0.875rem;
color: var(--text-secondary);
.deletion-timer {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--warning-color);
font-weight: 500;
svg {
font-size: 0.75rem;
}
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
input {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 1rem;
background: var(--surface-color);
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
}
.edit-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
}
}
.item-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
}
// Buttons
.btn {
border: none;
border-radius: var(--border-radius-small);
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
transition: all 0.2s ease;
text-decoration: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-primary {
background: var(--primary-color);
color: white;
&:hover:not(:disabled) {
background: #0056b3;
transform: translateY(-1px);
}
}
&.btn-secondary {
background: var(--border-color);
color: var(--text-primary);
&:hover:not(:disabled) {
background: #d1d5db;
}
}
&.btn-danger {
background: var(--danger-color);
color: white;
&:hover:not(:disabled) {
background: #dc3545;
}
}
&.btn-small {
padding: var(--spacing-xs);
font-size: 0.75rem;
width: 32px;
height: 32px;
svg {
font-size: 0.875rem;
}
}
svg {
font-size: 1rem;
}
}
// Add Form Overlay
.add-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-md);
.add-form {
background: var(--surface-color);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-medium);
h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
input {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 1rem;
background: var(--surface-color);
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
}
}
.form-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
}
}
// Floating Action Button
.fab-container {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
z-index: 100;
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: var(--shadow-medium);
transition: all 0.2s ease;
&:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
}
}
// Responsive Design
@media (max-width: 480px) {
.main {
padding: var(--spacing-sm);
}
.header {
padding: var(--spacing-xs) var(--spacing-sm);
.header-content h1 {
font-size: 1.25rem;
}
}
.shopping-item {
padding: var(--spacing-sm);
.item-content .item-main {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
.item-amount {
margin-left: 0;
}
}
.item-actions {
flex-direction: column;
}
}
.add-form-overlay {
padding: var(--spacing-sm);
.add-form {
padding: var(--spacing-md);
}
}
.fab-container {
bottom: var(--spacing-md);
right: var(--spacing-md);
.fab {
width: 48px;
height: 48px;
font-size: 1.125rem;
}
}
}

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,54 @@
import requestUtil from '../utils/RequestUtil';
class CalendarService {
constructor() {
this.endpoint = '/api/calendar';
}
// Get all calendar users
async getUsers() {
return requestUtil.get(`${this.endpoint}/users`);
}
// Get calendar events for a specific month
async getEventsForMonth(year, month, user = null, type = 'family') {
let url = `${this.endpoint}/events/${year}/${month}?type=${type}`;
if (user && type === 'individual') {
url += `&user=${encodeURIComponent(user)}`;
}
return requestUtil.get(url);
}
// Create a new calendar event
async createEvent(event) {
return requestUtil.post(`${this.endpoint}/events`, event);
}
// Update a calendar event
async updateEvent(eventId, event) {
return requestUtil.put(`${this.endpoint}/events/${eventId}`, event);
}
// Delete a calendar event
async deleteEvent(eventId, user) {
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
}
// Get contact birthdays for a specific month
async getContactBirthdaysForMonth(year, month, user) {
let url = `${this.endpoint}/birthdays/${year}/${month}`;
if (user) {
url += `?user=${encodeURIComponent(user)}`;
}
return requestUtil.get(url);
}
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
}
}
// Create and export a singleton instance
const calendarService = new CalendarService();
export default calendarService;

View File

@@ -0,0 +1,51 @@
import requestUtil from '../utils/RequestUtil';
class ShoppingService {
constructor() {
this.endpoint = '/api/shopping';
}
// Get all shopping items
async getItems() {
return requestUtil.get(this.endpoint);
}
// Get a specific shopping item
async getItem(id) {
return requestUtil.get(`${this.endpoint}/${id}`);
}
// Create a new shopping item
async createItem(item) {
return requestUtil.post(this.endpoint, item);
}
// Update a shopping item
async updateItem(id, item) {
return requestUtil.put(`${this.endpoint}/${id}`, item);
}
// Toggle checked status of an item
async toggleItem(id) {
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
}
// Delete a shopping item
async deleteItem(id) {
return requestUtil.delete(`${this.endpoint}/${id}`);
}
// Delete all checked items
async deleteCheckedItems() {
return requestUtil.delete(`${this.endpoint}/checked/all`);
}
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
}
}
// Create and export a singleton instance
const shoppingService = new ShoppingService();
export default shoppingService;

View File

@@ -0,0 +1,91 @@
class RequestUtil {
constructor() {
this.baseURL = this.getBaseURL();
}
getBaseURL() {
// In production, use the static endpoint, otherwise use proxy
if (process.env.NODE_ENV === 'production') {
return 'https://static.endpoint.com';
}
return ''; // Uses proxy in development
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
const config = {
...defaultOptions,
...options
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}
// GET request
async get(endpoint) {
return this.request(endpoint, {
method: 'GET'
});
}
// POST request
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// PUT request
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
// PATCH request
async patch(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
// DELETE request
async delete(endpoint, data = null) {
const options = {
method: 'DELETE'
};
if (data) {
options.body = JSON.stringify(data);
}
return this.request(endpoint, options);
}
}
// Create and export a singleton instance
const requestUtil = new RequestUtil();
export default requestUtil;

View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'calendar-icon.svg'],
manifest: {
name: 'OpenWall Calendar',
short_name: 'Calendar',
description: 'Mobile calendar app for OpenWall Smart Home Dashboard',
theme_color: '#3b82f6',
background_color: '#f8f9fa',
display: 'standalone',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
})
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})

View File

@@ -0,0 +1,17 @@
node_modules
dist
build
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode
coverage

24
mobile-shopping/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,35 @@
# Use Node.js official image for building
FROM node:18-alpine as builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install pnpm globally
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Use nginx for serving the built application
FROM nginx:alpine
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shopping List Mobile</title>
<!-- PWA Meta Tags -->
<meta name="description" content="Mobile shopping list app with auto-sync">
<meta name="theme-color" content="#007AFF">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="ShoppingList">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Additional PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="ShoppingList">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend server
location /api/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -0,0 +1,30 @@
{
"name": "mobile-shopping",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"sass": "^1.89.2",
"vite": "^7.0.4",
"vite-plugin-pwa": "^1.0.1"
}
}

4789
mobile-shopping/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

421
mobile-shopping/src/App.jsx Normal file
View File

@@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react';
import { FaPlus, FaTrash, FaCheck, FaEdit, FaShoppingCart, FaClock } from 'react-icons/fa';
import shoppingService from './services/ShoppingService';
import './App.sass';
const App = () => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [initialLoad, setInitialLoad] = useState(true);
const [error, setError] = useState(null);
const [newItem, setNewItem] = useState({ name: '', amount: '1' });
const [editingItem, setEditingItem] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [showInstallPrompt, setShowInstallPrompt] = useState(false);
// Load shopping items on component mount
useEffect(() => {
loadItems();
// Set up auto-refresh every 5 seconds
const interval = setInterval(() => {
loadItems();
}, 5000);
// PWA install prompt handling
const handleBeforeInstallPrompt = (e) => {
e.preventDefault();
setDeferredPrompt(e);
setShowInstallPrompt(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// Cleanup interval and event listener on component unmount
return () => {
clearInterval(interval);
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const loadItems = async () => {
try {
if (initialLoad) {
setLoading(true);
}
const data = await shoppingService.getItems();
setItems(data);
setError(null);
} catch (err) {
setError('Failed to load shopping items');
console.error('Error loading items:', err);
} finally {
if (initialLoad) {
setLoading(false);
setInitialLoad(false);
}
}
};
const handleAddItem = async (e) => {
e.preventDefault();
if (!newItem.name.trim()) return;
try {
const createdItem = await shoppingService.createItem({
name: newItem.name.trim(),
amount: newItem.amount.trim() || '1'
});
setItems([createdItem, ...items]);
setNewItem({ name: '', amount: '1' });
setShowAddForm(false);
} catch (err) {
setError('Failed to add item');
console.error('Error adding item:', err);
}
};
const handleToggleItem = async (id) => {
try {
const updatedItem = await shoppingService.toggleItem(id);
setItems(items.map(item =>
item.id === id ? updatedItem : item
));
} catch (err) {
setError('Failed to update item');
console.error('Error toggling item:', err);
}
};
const handleDeleteItem = async (id) => {
try {
await shoppingService.deleteItem(id);
setItems(items.filter(item => item.id !== id));
} catch (err) {
setError('Failed to delete item');
console.error('Error deleting item:', err);
}
};
const handleUpdateItem = async (id, updatedData) => {
try {
const updatedItem = await shoppingService.updateItem(id, updatedData);
setItems(items.map(item =>
item.id === id ? updatedItem : item
));
setEditingItem(null);
} catch (err) {
setError('Failed to update item');
console.error('Error updating item:', err);
}
};
const handleDeleteChecked = async () => {
try {
await shoppingService.deleteCheckedItems();
setItems(items.filter(item => !item.checked));
} catch (err) {
setError('Failed to delete checked items');
console.error('Error deleting checked items:', err);
}
};
const handleInstallApp = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null);
setShowInstallPrompt(false);
}
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTimeUntilDeletion = (checkedAt) => {
if (!checkedAt) return null;
const twoHoursLater = new Date(new Date(checkedAt).getTime() + 2 * 60 * 60 * 1000);
const now = new Date();
const timeDiff = twoHoursLater - now;
if (timeDiff <= 0) return 'Wird gelöscht...';
const minutes = Math.floor(timeDiff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours > 0) {
return `${hours}h ${remainingMinutes}m`;
}
return `${minutes}m`;
};
const uncheckedItems = items.filter(item => !item.checked);
const checkedItems = items.filter(item => item.checked);
if (loading && initialLoad) {
return (
<div className="app">
<div className="loading">
<FaShoppingCart className="loading-icon" />
<p>Einkaufsliste wird geladen...</p>
</div>
</div>
);
}
return (
<div className="app">
<header className="header">
<div className="header-content">
<h1>
<FaShoppingCart />
Einkaufsliste
</h1>
<div className="header-actions">
{checkedItems.length > 0 && (
<button
className="btn btn-danger"
onClick={handleDeleteChecked}
>
<FaTrash />
{checkedItems.length}
</button>
)}
</div>
</div>
</header>
<main className="main">
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{showInstallPrompt && (
<div className="install-prompt">
<p>Diese App auf dem Homescreen installieren?</p>
<div className="install-actions">
<button className="btn btn-primary" onClick={handleInstallApp}>
Installieren
</button>
<button className="btn btn-secondary" onClick={() => setShowInstallPrompt(false)}>
Später
</button>
</div>
</div>
)}
{showAddForm && (
<div className="add-form-overlay">
<form className="add-form" onSubmit={handleAddItem}>
<h3>Neuer Artikel</h3>
<div className="form-group">
<input
type="text"
placeholder="Artikelname..."
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
autoFocus
/>
<input
type="text"
placeholder="Menge"
value={newItem.amount}
onChange={(e) => setNewItem({ ...newItem, amount: e.target.value })}
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Hinzufügen
</button>
</div>
</form>
</div>
)}
<div className="shopping-content">
{/* Unchecked Items */}
<section className="shopping-section">
<h2>Zu kaufen ({uncheckedItems.length})</h2>
<div className="items-list">
{uncheckedItems.length === 0 ? (
<div className="empty-state">
<FaShoppingCart />
<p>Keine Artikel auf der Einkaufsliste</p>
</div>
) : (
uncheckedItems.map(item => (
<ShoppingItem
key={item.id}
item={item}
onToggle={handleToggleItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
editingItem={editingItem}
setEditingItem={setEditingItem}
formatDate={formatDate}
/>
))
)}
</div>
</section>
{/* Checked Items */}
{checkedItems.length > 0 && (
<section className="shopping-section">
<h2>Erledigt ({checkedItems.length})</h2>
<div className="items-list">
{checkedItems.map(item => (
<ShoppingItem
key={item.id}
item={item}
onToggle={handleToggleItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
editingItem={editingItem}
setEditingItem={setEditingItem}
formatDate={formatDate}
getTimeUntilDeletion={getTimeUntilDeletion}
/>
))}
</div>
</section>
)}
</div>
</main>
<div className="fab-container">
<button
className="fab"
onClick={() => setShowAddForm(true)}
>
<FaPlus />
</button>
</div>
</div>
);
};
// Shopping Item Component
const ShoppingItem = ({
item,
onToggle,
onDelete,
onUpdate,
editingItem,
setEditingItem,
formatDate,
getTimeUntilDeletion
}) => {
const [editData, setEditData] = useState({ name: item.name, amount: item.amount });
const handleEdit = () => {
setEditingItem(item.id);
setEditData({ name: item.name, amount: item.amount });
};
const handleSave = () => {
onUpdate(item.id, editData);
};
const handleCancel = () => {
setEditingItem(null);
setEditData({ name: item.name, amount: item.amount });
};
const isEditing = editingItem === item.id;
return (
<div className={`shopping-item ${item.checked ? 'checked' : ''}`}>
<button
className="check-btn"
onClick={() => onToggle(item.id)}
>
<FaCheck />
</button>
<div className="item-content">
{isEditing ? (
<div className="edit-form">
<input
type="text"
value={editData.name}
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
<input
type="text"
value={editData.amount}
onChange={(e) => setEditData({ ...editData, amount: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
/>
<div className="edit-actions">
<button className="btn btn-primary btn-small" onClick={handleSave}>
<FaCheck />
</button>
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
×
</button>
</div>
</div>
) : (
<>
<div className="item-main">
<span className="item-name">{item.name}</span>
<span className="item-amount">{item.amount}</span>
</div>
<div className="item-meta">
<span className="item-date">
{formatDate(item.date)}
</span>
{item.checked && item.checkedAt && getTimeUntilDeletion && (
<span className="deletion-timer">
<FaClock />
{getTimeUntilDeletion(item.checkedAt)}
</span>
)}
</div>
</>
)}
</div>
<div className="item-actions">
{!isEditing && (
<>
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
<FaEdit />
</button>
<button className="btn btn-danger btn-small" onClick={() => onDelete(item.id)}>
<FaTrash />
</button>
</>
)}
</div>
</div>
);
};
export default App;

View File

@@ -0,0 +1,513 @@
// Mobile Shopping App Styles
// Clean, simple design without glassmorphism
:root
--primary-color: #007AFF
--secondary-color: #5856D6
--success-color: #34C759
--danger-color: #FF3B30
--warning-color: #FF9500
--background-color: #f8f9fa
--surface-color: #ffffff
--border-color: #e1e5e9
--text-primary: #1d1d1f
--text-secondary: #6e6e73
--text-muted: #8e8e93
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1)
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15)
--border-radius: 12px
--border-radius-small: 8px
--spacing-xs: 4px
--spacing-sm: 8px
--spacing-md: 16px
--spacing-lg: 24px
--spacing-xl: 32px
*
margin: 0
padding: 0
box-sizing: border-box
body
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif
background-color: var(--background-color)
color: var(--text-primary)
line-height: 1.5
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
.app
min-height: 100vh
display: flex
flex-direction: column
max-width: 100vw
overflow-x: hidden
// Header
.header
background: var(--surface-color)
border-bottom: 1px solid var(--border-color)
padding: var(--spacing-sm) var(--spacing-md)
position: sticky
top: 0
z-index: 100
box-shadow: var(--shadow-light)
.header-content
display: flex
align-items: center
justify-content: space-between
max-width: 600px
margin: 0 auto
h1
font-size: 1.5rem
font-weight: 600
color: var(--text-primary)
display: flex
align-items: center
gap: var(--spacing-sm)
svg
color: var(--primary-color)
font-size: 1.25rem
.header-actions
display: flex
align-items: center
gap: var(--spacing-sm)
// Main Content
.main
flex: 1
padding: var(--spacing-md)
max-width: 600px
margin: 0 auto
width: 100%
padding-bottom: 100px // Space for FAB
// Error Message
.error-message
background: var(--danger-color)
color: white
padding: var(--spacing-md)
border-radius: var(--border-radius-small)
margin-bottom: var(--spacing-md)
display: flex
align-items: center
justify-content: space-between
font-weight: 500
button
background: none
border: none
color: white
font-size: 1.25rem
cursor: pointer
padding: 0
width: 24px
height: 24px
border-radius: 50%
display: flex
align-items: center
justify-content: center
&:hover
background: rgba(255, 255, 255, 0.1)
// Install Prompt
.install-prompt
background: var(--primary-color)
color: white
padding: var(--spacing-md)
border-radius: var(--border-radius-small)
margin-bottom: var(--spacing-md)
text-align: center
p
margin-bottom: var(--spacing-md)
font-weight: 500
.install-actions
display: flex
gap: var(--spacing-sm)
justify-content: center
.btn
color: var(--primary-color)
background: white
border: 1px solid white
&:hover
background: #f0f0f0
&.btn-secondary
background: transparent
color: white
border: 1px solid rgba(255, 255, 255, 0.3)
&:hover
background: rgba(255, 255, 255, 0.1)
// Loading State
.loading
display: flex
flex-direction: column
align-items: center
justify-content: center
height: 60vh
color: var(--text-secondary)
.loading-icon
font-size: 3rem
color: var(--primary-color)
margin-bottom: var(--spacing-md)
animation: pulse 2s infinite
p
font-size: 1.1rem
font-weight: 500
@keyframes pulse
0%, 100%
opacity: 1
50%
opacity: 0.5
// Shopping Content
.shopping-content
display: flex
flex-direction: column
gap: var(--spacing-xl)
.shopping-section
h2
font-size: 1.25rem
font-weight: 600
color: var(--text-primary)
margin-bottom: var(--spacing-md)
padding: 0 var(--spacing-xs)
.items-list
display: flex
flex-direction: column
gap: var(--spacing-sm)
// Empty State
.empty-state
display: flex
flex-direction: column
align-items: center
justify-content: center
padding: var(--spacing-xl)
color: var(--text-muted)
text-align: center
svg
font-size: 3rem
margin-bottom: var(--spacing-md)
opacity: 0.5
p
font-size: 1.1rem
font-weight: 500
// Shopping Item
.shopping-item
background: var(--surface-color)
border: 1px solid var(--border-color)
border-radius: var(--border-radius)
padding: var(--spacing-md)
display: flex
align-items: flex-start
gap: var(--spacing-md)
transition: all 0.2s ease
box-shadow: var(--shadow-light)
&:hover
box-shadow: var(--shadow-medium)
transform: translateY(-1px)
&.checked
opacity: 0.7
background: #f8f9fa
.item-name
text-decoration: line-through
color: var(--text-muted)
.check-btn
background: var(--success-color)
color: white
&:hover
background: #28a745
.check-btn
width: 32px
height: 32px
border-radius: 50%
border: 2px solid var(--border-color)
background: var(--surface-color)
color: transparent
cursor: pointer
display: flex
align-items: center
justify-content: center
transition: all 0.2s ease
flex-shrink: 0
margin-top: 2px
&:hover
border-color: var(--success-color)
background: rgba(52, 199, 89, 0.1)
svg
font-size: 0.875rem
.item-content
flex: 1
min-width: 0
.item-main
display: flex
align-items: center
justify-content: space-between
margin-bottom: var(--spacing-xs)
.item-name
font-weight: 500
color: var(--text-primary)
font-size: 1rem
flex: 1
word-break: break-word
.item-amount
background: var(--primary-color)
color: white
padding: 2px 8px
border-radius: 12px
font-size: 0.75rem
font-weight: 600
margin-left: var(--spacing-sm)
flex-shrink: 0
.item-meta
display: flex
align-items: center
gap: var(--spacing-md)
font-size: 0.875rem
color: var(--text-secondary)
.deletion-timer
display: flex
align-items: center
gap: var(--spacing-xs)
color: var(--warning-color)
font-weight: 500
svg
font-size: 0.75rem
.edit-form
display: flex
flex-direction: column
gap: var(--spacing-sm)
input
width: 100%
padding: var(--spacing-sm)
border: 1px solid var(--border-color)
border-radius: var(--border-radius-small)
font-size: 1rem
background: var(--surface-color)
&:focus
outline: none
border-color: var(--primary-color)
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1)
.edit-actions
display: flex
gap: var(--spacing-sm)
margin-top: var(--spacing-xs)
.item-actions
display: flex
gap: var(--spacing-xs)
flex-shrink: 0
// Buttons
.btn
border: none
border-radius: var(--border-radius-small)
padding: var(--spacing-sm) var(--spacing-md)
font-size: 0.875rem
font-weight: 500
cursor: pointer
display: flex
align-items: center
justify-content: center
gap: var(--spacing-xs)
transition: all 0.2s ease
text-decoration: none
&:disabled
opacity: 0.5
cursor: not-allowed
&.btn-primary
background: var(--primary-color)
color: white
&:hover:not(:disabled)
background: #0056b3
transform: translateY(-1px)
&.btn-secondary
background: var(--border-color)
color: var(--text-primary)
&:hover:not(:disabled)
background: #d1d5db
&.btn-danger
background: var(--danger-color)
color: white
&:hover:not(:disabled)
background: #dc3545
&.btn-small
padding: var(--spacing-xs)
font-size: 0.75rem
width: 32px
height: 32px
svg
font-size: 0.875rem
svg
font-size: 1rem
// Add Form Overlay
.add-form-overlay
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background: rgba(0, 0, 0, 0.5)
display: flex
align-items: center
justify-content: center
z-index: 1000
padding: var(--spacing-md)
.add-form
background: var(--surface-color)
border-radius: var(--border-radius)
padding: var(--spacing-lg)
width: 100%
max-width: 400px
box-shadow: var(--shadow-medium)
h3
font-size: 1.25rem
font-weight: 600
margin-bottom: var(--spacing-md)
color: var(--text-primary)
.form-group
display: flex
flex-direction: column
gap: var(--spacing-sm)
margin-bottom: var(--spacing-lg)
input
width: 100%
padding: var(--spacing-md)
border: 1px solid var(--border-color)
border-radius: var(--border-radius-small)
font-size: 1rem
background: var(--surface-color)
&:focus
outline: none
border-color: var(--primary-color)
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1)
.form-actions
display: flex
gap: var(--spacing-sm)
justify-content: flex-end
// Floating Action Button
.fab-container
position: fixed
bottom: var(--spacing-lg)
right: var(--spacing-lg)
z-index: 100
.fab
width: 56px
height: 56px
border-radius: 50%
background: var(--primary-color)
color: white
border: none
cursor: pointer
display: flex
align-items: center
justify-content: center
font-size: 1.25rem
box-shadow: var(--shadow-medium)
transition: all 0.2s ease
&:hover
background: #0056b3
transform: translateY(-2px)
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2)
&:active
transform: translateY(0)
// Responsive Design
@media (max-width: 480px)
.main
padding: var(--spacing-sm)
.header
padding: var(--spacing-xs) var(--spacing-sm)
.header-content h1
font-size: 1.25rem
.shopping-item
padding: var(--spacing-sm)
.item-content .item-main
flex-direction: column
align-items: flex-start
gap: var(--spacing-xs)
.item-amount
margin-left: 0
.item-actions
flex-direction: column
.add-form-overlay
padding: var(--spacing-sm)
.add-form
padding: var(--spacing-md)
.fab-container
bottom: var(--spacing-md)
right: var(--spacing-md)
.fab
width: 48px
height: 48px
font-size: 1.125rem

View File

@@ -0,0 +1,556 @@
// Mobile Shopping App Styles
// Clean, simple design without glassmorphism
:root {
--primary-color: #007AFF;
--secondary-color: #5856D6;
--success-color: #34C759;
--danger-color: #FF3B30;
--warning-color: #FF9500;
--background-color: #f8f9fa;
--surface-color: #ffffff;
--border-color: #e1e5e9;
--text-primary: #1d1d1f;
--text-secondary: #6e6e73;
--text-muted: #8e8e93;
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15);
--border-radius: 12px;
--border-radius-small: 8px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: 100vw;
overflow-x: hidden;
}
// Header
.header {
background: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-light);
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 600px;
margin: 0 auto;
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
svg {
color: var(--primary-color);
font-size: 1.25rem;
}
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
}
}
// Main Content
.main {
flex: 1;
padding: var(--spacing-md);
max-width: 600px;
margin: 0 auto;
width: 100%;
padding-bottom: 100px; // Space for FAB
}
// Error Message
.error-message {
background: var(--danger-color);
color: white;
padding: var(--spacing-md);
border-radius: var(--border-radius-small);
margin-bottom: var(--spacing-md);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
button {
background: none;
border: none;
color: white;
font-size: 1.25rem;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
// Loading State
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
color: var(--text-secondary);
.loading-icon {
font-size: 3rem;
color: var(--primary-color);
margin-bottom: var(--spacing-md);
animation: pulse 2s infinite;
}
p {
font-size: 1.1rem;
font-weight: 500;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
// Shopping Content
.shopping-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.shopping-section {
h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
padding: 0 var(--spacing-xs);
}
}
.items-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--text-muted);
text-align: center;
svg {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
p {
font-size: 1.1rem;
font-weight: 500;
}
}
// Shopping Item
.shopping-item {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--spacing-md);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
transition: all 0.2s ease;
box-shadow: var(--shadow-light);
&:hover {
box-shadow: var(--shadow-medium);
transform: translateY(-1px);
}
&.checked {
opacity: 0.7;
background: #f8f9fa;
.item-name {
text-decoration: line-through;
color: var(--text-muted);
}
.check-btn {
background: var(--success-color);
color: white;
&:hover {
background: #28a745;
}
}
}
.check-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--surface-color);
color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
margin-top: 2px;
&:hover {
border-color: var(--success-color);
background: rgba(52, 199, 89, 0.1);
}
svg {
font-size: 0.875rem;
}
}
.item-content {
flex: 1;
min-width: 0;
.item-main {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
.item-name {
font-weight: 500;
color: var(--text-primary);
font-size: 1rem;
flex: 1;
word-break: break-word;
}
.item-amount {
background: var(--primary-color);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: var(--spacing-sm);
flex-shrink: 0;
}
}
.item-meta {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: 0.875rem;
color: var(--text-secondary);
.deletion-timer {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--warning-color);
font-weight: 500;
svg {
font-size: 0.75rem;
}
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
input {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 1rem;
background: var(--surface-color);
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
}
.edit-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
}
}
.item-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
}
// Buttons
.btn {
border: none;
border-radius: var(--border-radius-small);
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
transition: all 0.2s ease;
text-decoration: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-primary {
background: var(--primary-color);
color: white;
&:hover:not(:disabled) {
background: #0056b3;
transform: translateY(-1px);
}
}
&.btn-secondary {
background: var(--border-color);
color: var(--text-primary);
&:hover:not(:disabled) {
background: #d1d5db;
}
}
&.btn-danger {
background: var(--danger-color);
color: white;
&:hover:not(:disabled) {
background: #dc3545;
}
}
&.btn-small {
padding: var(--spacing-xs);
font-size: 0.75rem;
width: 32px;
height: 32px;
svg {
font-size: 0.875rem;
}
}
svg {
font-size: 1rem;
}
}
// Add Form Overlay
.add-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-md);
.add-form {
background: var(--surface-color);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-medium);
h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
input {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 1rem;
background: var(--surface-color);
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
}
}
.form-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
}
}
// Floating Action Button
.fab-container {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
z-index: 100;
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: var(--shadow-medium);
transition: all 0.2s ease;
&:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
}
}
// Responsive Design
@media (max-width: 480px) {
.main {
padding: var(--spacing-sm);
}
.header {
padding: var(--spacing-xs) var(--spacing-sm);
.header-content h1 {
font-size: 1.25rem;
}
}
.shopping-item {
padding: var(--spacing-sm);
.item-content .item-main {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
.item-amount {
margin-left: 0;
}
}
.item-actions {
flex-direction: column;
}
}
.add-form-overlay {
padding: var(--spacing-sm);
.add-form {
padding: var(--spacing-md);
}
}
.fab-container {
bottom: var(--spacing-md);
right: var(--spacing-md);
.fab {
width: 48px;
height: 48px;
font-size: 1.125rem;
}
}
}

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,51 @@
import requestUtil from '../utils/RequestUtil';
class ShoppingService {
constructor() {
this.endpoint = '/api/shopping';
}
// Get all shopping items
async getItems() {
return requestUtil.get(this.endpoint);
}
// Get a specific shopping item
async getItem(id) {
return requestUtil.get(`${this.endpoint}/${id}`);
}
// Create a new shopping item
async createItem(item) {
return requestUtil.post(this.endpoint, item);
}
// Update a shopping item
async updateItem(id, item) {
return requestUtil.put(`${this.endpoint}/${id}`, item);
}
// Toggle checked status of an item
async toggleItem(id) {
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
}
// Delete a shopping item
async deleteItem(id) {
return requestUtil.delete(`${this.endpoint}/${id}`);
}
// Delete all checked items
async deleteCheckedItems() {
return requestUtil.delete(`${this.endpoint}/checked/all`);
}
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
}
}
// Create and export a singleton instance
const shoppingService = new ShoppingService();
export default shoppingService;

View File

@@ -0,0 +1,85 @@
class RequestUtil {
constructor() {
this.baseURL = this.getBaseURL();
}
getBaseURL() {
// In production, use the static endpoint, otherwise use proxy
if (process.env.NODE_ENV === 'production') {
return 'https://static.endpoint.com';
}
return ''; // Uses proxy in development
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
const config = {
...defaultOptions,
...options
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}
// GET request
async get(endpoint) {
return this.request(endpoint, {
method: 'GET'
});
}
// POST request
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// PUT request
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
// PATCH request
async patch(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
// DELETE request
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE'
});
}
}
// Create and export a singleton instance
const requestUtil = new RequestUtil();
export default requestUtil;

View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'Shopping List Mobile',
short_name: 'ShoppingList',
description: 'Mobile shopping list app with auto-sync',
theme_color: '#007AFF',
background_color: '#f8f9fa',
display: 'standalone',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
})
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})

485
os/build-iso.sh Executable file
View File

@@ -0,0 +1,485 @@
#!/bin/bash
# OpenWall ISO Builder
# Creates a custom Debian ISO with OpenWall Dashboard pre-installed
# Uses Docker to build the ISO in a clean environment
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
ISO_NAME="openwall-dashboard"
ISO_VERSION="1.0.0"
DEBIAN_VERSION="bookworm"
BUILD_DIR="$(pwd)/iso-build"
OUTPUT_DIR="$(pwd)/dist"
DOCKER_IMAGE="openwall-iso-builder"
echo -e "${BLUE}OpenWall ISO Builder${NC}"
echo -e "${BLUE}===================${NC}"
# Check if Docker is installed and running
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
if ! docker info &> /dev/null; then
echo -e "${RED}Docker is not running. Please start Docker first.${NC}"
exit 1
fi
# Clean previous builds
echo -e "${YELLOW}Cleaning previous builds...${NC}"
rm -rf "$BUILD_DIR" "$OUTPUT_DIR"
mkdir -p "$BUILD_DIR" "$OUTPUT_DIR"
# Create Dockerfile for ISO building
echo -e "${YELLOW}Creating Docker build environment...${NC}"
cat > "$BUILD_DIR/Dockerfile" << EOF
FROM debian:${DEBIAN_VERSION}
# Install required packages for ISO building
RUN apt-get update && apt-get install -y \\
debootstrap \\
squashfs-tools \\
xorriso \\
isolinux \\
syslinux-efi \\
grub-pc-bin \\
grub-efi-amd64-bin \\
grub-efi-ia32-bin \\
mtools \\
dosfstools \\
curl \\
wget \\
git \\
rsync \\
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /build
# Copy build scripts
COPY build-iso.sh /build/
COPY install.sh /build/
COPY . /build/openwall/
RUN chmod +x /build/build-iso.sh
# Build the ISO
CMD ["/build/build-iso.sh"]
EOF
# Create the main ISO build script
cat > "$BUILD_DIR/build-iso.sh" << 'EOF'
#!/bin/bash
set -e
# Configuration
ISO_NAME="openwall-dashboard"
ISO_VERSION="1.0.0"
DEBIAN_VERSION="bookworm"
WORK_DIR="/build"
CHROOT_DIR="${WORK_DIR}/chroot"
ISO_DIR="${WORK_DIR}/iso"
OUTPUT_ISO="${WORK_DIR}/${ISO_NAME}-${ISO_VERSION}.iso"
echo "Building OpenWall Dashboard ISO..."
# Create directories
mkdir -p "${CHROOT_DIR}" "${ISO_DIR}"
# Bootstrap Debian base system
echo "Bootstrapping Debian base system..."
debootstrap --arch=amd64 --variant=minbase "${DEBIAN_VERSION}" "${CHROOT_DIR}" http://deb.debian.org/debian/ 2>/dev/null
# Mount necessary filesystems
echo "Mounting virtual filesystems..."
mkdir -p "${CHROOT_DIR}"/{dev,dev/pts,proc,sys}
mount --bind /dev "${CHROOT_DIR}/dev"
mount --bind /dev/pts "${CHROOT_DIR}/dev/pts"
mount --bind /proc "${CHROOT_DIR}/proc"
mount --bind /sys "${CHROOT_DIR}/sys"
# Create cleanup function
cleanup() {
echo "Cleaning up mounts..."
umount -lf "${CHROOT_DIR}/dev/pts" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
# Additional cleanup - remove any leftover mount points
sync
sleep 1
}
trap cleanup EXIT INT TERM
# Copy OpenWall files into chroot
echo "Copying OpenWall files..."
mkdir -p "${CHROOT_DIR}/opt/openwall-installer"
cp -r /build/openwall/* "${CHROOT_DIR}/opt/openwall-installer/"
cp /build/install.sh "${CHROOT_DIR}/opt/openwall-installer/"
# Configure chroot environment
cat > "${CHROOT_DIR}/etc/apt/sources.list" << SOURCES
deb http://deb.debian.org/debian/ ${DEBIAN_VERSION} main non-free-firmware
deb-src http://deb.debian.org/debian/ ${DEBIAN_VERSION} main non-free-firmware
deb http://security.debian.org/debian-security ${DEBIAN_VERSION}-security main non-free-firmware
deb-src http://security.debian.org/debian-security ${DEBIAN_VERSION}-security main non-free-firmware
deb http://deb.debian.org/debian/ ${DEBIAN_VERSION}-updates main non-free-firmware
deb-src http://deb.debian.org/debian/ ${DEBIAN_VERSION}-updates main non-free-firmware
SOURCES
# Configure hostname
echo "openwall-dashboard" > "${CHROOT_DIR}/etc/hostname"
# Create chroot setup script
cat > "${CHROOT_DIR}/setup-system.sh" << 'SETUP'
#!/bin/bash
set -e
export DEBIAN_FRONTEND=noninteractive
export LC_ALL=C
echo "Setting up OpenWall Dashboard system..."
# Update package lists
apt-get update
# Install essential packages
apt-get install -y \
linux-image-amd64 \
live-boot \
systemd-sysv \
locales \
keyboard-configuration \
console-setup \
sudo \
network-manager \
openssh-server \
curl \
wget \
git \
nano \
htop \
firmware-linux-free \
firmware-linux-nonfree
# Configure locales
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
locale-gen
echo "LANG=en_US.UTF-8" > /etc/default/locale
# Create openwall user
useradd -m -s /bin/bash -G sudo openwall
echo "openwall:openwall" | chpasswd
echo "root:openwall" | chpasswd
# Configure sudo
echo "openwall ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/openwall
# Configure automatic login
mkdir -p /etc/systemd/system/getty@tty1.service.d
cat > /etc/systemd/system/getty@tty1.service.d/override.conf << AUTOLOGIN
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin openwall --noclear %I \$TERM
AUTOLOGIN
# Create OpenWall installation service
cat > /etc/systemd/system/openwall-install.service << SERVICE
[Unit]
Description=OpenWall Dashboard Installation
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=/opt/openwall-installer/install.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
SERVICE
# Enable the installation service
systemctl enable openwall-install.service
# Create post-install script for user
mkdir -p /home/openwall
cat > /home/openwall/welcome.sh << WELCOME
#!/bin/bash
echo "=================================================="
echo " Welcome to OpenWall Dashboard Live System!"
echo "=================================================="
echo ""
echo "This system will automatically install and configure"
echo "the OpenWall Dashboard on first boot."
echo ""
echo "Default credentials:"
echo " Username: openwall"
echo " Password: openwall"
echo ""
echo "The dashboard will start automatically after installation."
echo ""
echo "To reinstall manually, run:"
echo " sudo /opt/openwall-installer/install.sh"
echo ""
echo "=================================================="
echo ""
WELCOME
chmod +x /home/openwall/welcome.sh
chown openwall:openwall /home/openwall/welcome.sh
# Add welcome message to bashrc
echo "/home/openwall/welcome.sh" >> /home/openwall/.bashrc
# Configure network
systemctl enable NetworkManager
# Clean up
apt-get clean
rm -rf /var/lib/apt/lists/*
rm -f /setup-system.sh
echo "System setup completed!"
SETUP
chmod +x "${CHROOT_DIR}/setup-system.sh"
# Run setup in chroot
echo "Running system setup in chroot..."
chroot "${CHROOT_DIR}" /setup-system.sh
# Clean up mounts before creating squashfs
echo "Cleaning up mounts before filesystem creation..."
# Disable trap temporarily
trap - EXIT INT TERM
umount -lf "${CHROOT_DIR}/dev/pts" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
sync
sleep 2
# Create live boot configuration
echo "Setting up live boot configuration..."
mkdir -p "${ISO_DIR}/live"
# Create filesystem image
echo "Creating filesystem image (this may take a while)..."
mksquashfs "${CHROOT_DIR}" "${ISO_DIR}/live/filesystem.squashfs" \
-comp xz \
-e boot \
-e proc \
-e sys \
-e dev \
-e tmp \
-e var/tmp \
-e var/cache \
-e var/log \
-no-progress \
-quiet
# Copy kernel and initrd
cp "${CHROOT_DIR}/boot/vmlinuz-"* "${ISO_DIR}/live/vmlinuz"
cp "${CHROOT_DIR}/boot/initrd.img-"* "${ISO_DIR}/live/initrd"
# Create GRUB configuration
mkdir -p "${ISO_DIR}/boot/grub"
cat > "${ISO_DIR}/boot/grub/grub.cfg" << GRUB
set timeout=10
set default=0
menuentry "OpenWall Dashboard Live" {
linux /live/vmlinuz boot=live components quiet splash
initrd /live/initrd
}
menuentry "OpenWall Dashboard Live (Safe Mode)" {
linux /live/vmlinuz boot=live components nomodeset
initrd /live/initrd
}
GRUB
# Create isolinux configuration
mkdir -p "${ISO_DIR}/isolinux"
cp /usr/lib/ISOLINUX/isolinux.bin "${ISO_DIR}/isolinux/"
cp /usr/lib/syslinux/modules/bios/menu.c32 "${ISO_DIR}/isolinux/"
cp /usr/lib/syslinux/modules/bios/libutil.c32 "${ISO_DIR}/isolinux/"
cp /usr/lib/syslinux/modules/bios/ldlinux.c32 "${ISO_DIR}/isolinux/"
cat > "${ISO_DIR}/isolinux/isolinux.cfg" << ISOLINUX
UI menu.c32
PROMPT 0
TIMEOUT 100
MENU TITLE OpenWall Dashboard Live
LABEL live
MENU LABEL OpenWall Dashboard Live
KERNEL /live/vmlinuz
APPEND initrd=/live/initrd boot=live components quiet splash
LABEL safe
MENU LABEL OpenWall Dashboard Live (Safe Mode)
KERNEL /live/vmlinuz
APPEND initrd=/live/initrd boot=live components nomodeset
ISOLINUX
# Create EFI boot configuration
mkdir -p "${ISO_DIR}/EFI/boot"
grub-mkstandalone \
--format=x86_64-efi \
--output="${ISO_DIR}/EFI/boot/bootx64.efi" \
--locales="" \
--fonts="" \
"boot/grub/grub.cfg=${ISO_DIR}/boot/grub/grub.cfg"
# Create the ISO
echo "Creating ISO image..."
xorriso -as mkisofs \
-iso-level 3 \
-full-iso9660-filenames \
-volid "OpenWall Dashboard" \
-eltorito-boot isolinux/isolinux.bin \
-eltorito-catalog isolinux/boot.cat \
-no-emul-boot \
-boot-load-size 4 \
-boot-info-table \
--eltorito-alt-boot \
-e EFI/boot/bootx64.efi \
-no-emul-boot \
-append_partition 2 0xef "${ISO_DIR}/EFI/boot/bootx64.efi" \
-output "${OUTPUT_ISO}" \
-graft-points \
"${ISO_DIR}" 2>/dev/null
echo "ISO created: ${OUTPUT_ISO}"
# Final cleanup
final_cleanup() {
umount -lf "${CHROOT_DIR}/dev/pts" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
}
final_cleanup
EOF
chmod +x "$BUILD_DIR/build-iso.sh"
# Copy project files
echo -e "${YELLOW}Copying project files...${NC}"
cp -r "$(dirname "$0")/../dashboard" "$BUILD_DIR/"
cp -r "$(dirname "$0")/../server" "$BUILD_DIR/"
cp "$(dirname "$0")/install.sh" "$BUILD_DIR/"
# Create README for the ISO
cat > "$BUILD_DIR/README.md" << 'EOF'
# OpenWall Dashboard Live ISO
This ISO contains a complete Debian-based live system with the OpenWall Dashboard pre-configured.
## Features
- **Live Boot**: Boots directly from USB/DVD without installation
- **Auto-Installation**: Automatically installs and configures OpenWall Dashboard on first boot
- **Touch Support**: Optimized for touch displays
- **9:16 Display**: Configured for portrait orientation displays
- **Wayland Session**: Modern display server with touch support
- **Kiosk Mode**: Runs in fullscreen kiosk mode
## Boot Options
- **OpenWall Dashboard Live**: Normal boot with all features
- **OpenWall Dashboard Live (Safe Mode)**: Boot with minimal graphics drivers
## Default Credentials
- **Username**: openwall
- **Password**: openwall
## Usage
1. Flash the ISO to a USB drive or burn to DVD
2. Boot from the USB/DVD
3. The system will automatically start and install the dashboard
4. The dashboard will be available in fullscreen mode
## Manual Installation
If you need to reinstall or configure manually:
```bash
sudo /opt/openwall-installer/install.sh
```
## Network Configuration
The system uses NetworkManager for network configuration. Connect to WiFi or Ethernet as needed.
## System Information
- **Base OS**: Debian Bookworm
- **Display Server**: Wayland (Sway compositor)
- **Application**: Electron-based dashboard
- **Backend**: Node.js Express server with SQLite
EOF
# Build the Docker image and create ISO
echo -e "${YELLOW}Building Docker image...${NC}"
docker build -t "$DOCKER_IMAGE" "$BUILD_DIR"
echo -e "${YELLOW}Creating ISO (this may take a while)...${NC}"
docker run --rm --privileged \
-v "$OUTPUT_DIR:/output" \
"$DOCKER_IMAGE" \
bash -c "
/build/build-iso.sh &&
cp /build/*.iso /output/
"
# Check if ISO was created successfully
if [ -f "$OUTPUT_DIR/${ISO_NAME}-${ISO_VERSION}.iso" ]; then
echo -e "${GREEN}ISO created successfully!${NC}"
echo -e "${GREEN}========================${NC}"
echo ""
echo -e "${BLUE}ISO Information:${NC}"
echo -e "• File: ${OUTPUT_DIR}/${ISO_NAME}-${ISO_VERSION}.iso"
echo -e "• Size: $(du -h "$OUTPUT_DIR/${ISO_NAME}-${ISO_VERSION}.iso" | cut -f1)"
echo ""
echo -e "${YELLOW}Usage:${NC}"
echo -e "1. Flash to USB: ${BLUE}dd if=${OUTPUT_DIR}/${ISO_NAME}-${ISO_VERSION}.iso of=/dev/sdX bs=4M status=progress${NC}"
echo -e "2. Or use tools like Balena Etcher, Rufus, etc."
echo -e "3. Boot from USB and follow the on-screen instructions"
echo ""
echo -e "${YELLOW}Features:${NC}"
echo -e "• Live boot with auto-installation"
echo -e "• Touch display support (9:16 ratio)"
echo -e "• Wayland session with Sway compositor"
echo -e "• Kiosk mode dashboard"
echo -e "• Default user: openwall/openwall"
else
echo -e "${RED}ISO creation failed!${NC}"
exit 1
fi
# Clean up Docker image
echo -e "${YELLOW}Cleaning up...${NC}"
docker rmi "$DOCKER_IMAGE" 2>/dev/null || true
echo -e "${GREEN}Build completed successfully!${NC}"

422
os/install.sh Normal file
View File

@@ -0,0 +1,422 @@
#!/bin/bash
# OpenWall Dashboard Installer for Debian
# Installs and configures the dashboard app for touch displays with 9:16 ratio
# Supports Wayland session with auto-start
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
APP_USER="openwall"
APP_DIR="/opt/openwall"
DISPLAY_WIDTH="1080"
DISPLAY_HEIGHT="1920"
SERVICE_NAME="openwall-dashboard"
echo -e "${BLUE}OpenWall Dashboard Installer${NC}"
echo -e "${BLUE}==============================${NC}"
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}This script must be run as root${NC}"
exit 1
fi
# Update system
echo -e "${YELLOW}Updating system packages...${NC}"
apt update && apt upgrade -y
# Install required packages
echo -e "${YELLOW}Installing required packages...${NC}"
apt install -y \
curl \
wget \
git \
build-essential \
python3 \
python3-pip \
nodejs \
npm \
wayland-protocols \
wayland-utils \
weston \
sway \
swaylock \
swayidle \
xwayland \
libdrm2 \
libinput-tools \
udev \
systemd \
xinput \
x11-xserver-utils \
mesa-utils \
vulkan-tools \
libgl1-mesa-dri \
libgles2-mesa \
libegl1-mesa \
firefox-esr \
chromium \
fonts-liberation \
fonts-dejavu-core \
unclutter \
xdotool
# Install Node.js LTS
echo -e "${YELLOW}Installing Node.js LTS...${NC}"
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt install -y nodejs
# Install pnpm
echo -e "${YELLOW}Installing pnpm...${NC}"
npm install -g pnpm
# Create application user
echo -e "${YELLOW}Creating application user...${NC}"
if ! id "$APP_USER" &>/dev/null; then
useradd -m -s /bin/bash "$APP_USER"
usermod -aG video,input,audio "$APP_USER"
fi
# Create application directory
echo -e "${YELLOW}Setting up application directory...${NC}"
mkdir -p "$APP_DIR"
chown "$APP_USER:$APP_USER" "$APP_DIR"
# Copy application files
echo -e "${YELLOW}Installing application files...${NC}"
if [ -d "$(dirname "$0")/../dashboard" ]; then
cp -r "$(dirname "$0")/../dashboard/"* "$APP_DIR/"
cp -r "$(dirname "$0")/../server" "$APP_DIR/"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
else
echo -e "${RED}Dashboard directory not found. Please run this script from the OpenWall project root.${NC}"
exit 1
fi
# Install application dependencies
echo -e "${YELLOW}Installing application dependencies...${NC}"
cd "$APP_DIR"
sudo -u "$APP_USER" pnpm install
# Install server dependencies
cd "$APP_DIR/server"
sudo -u "$APP_USER" npm install
# Create Wayland session configuration
echo -e "${YELLOW}Configuring Wayland session...${NC}"
cat > /usr/share/wayland-sessions/openwall.desktop << EOF
[Desktop Entry]
Name=OpenWall Dashboard
Comment=OpenWall Touch Dashboard Session
Exec=/opt/openwall/start-session.sh
Type=Application
DesktopNames=openwall
EOF
# Create session start script
cat > "$APP_DIR/start-session.sh" << 'EOF'
#!/bin/bash
# OpenWall Dashboard Session Starter
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export XDG_SESSION_TYPE=wayland
export XDG_CURRENT_DESKTOP=openwall
export QT_QPA_PLATFORM=wayland
export GDK_BACKEND=wayland
export MOZ_ENABLE_WAYLAND=1
# Start Wayland compositor (Sway)
exec sway --config /opt/openwall/sway-config
EOF
chmod +x "$APP_DIR/start-session.sh"
# Create Sway configuration for dashboard
cat > "$APP_DIR/sway-config" << EOF
# OpenWall Dashboard Sway Configuration
# Optimized for touch displays with 9:16 ratio
# Set display configuration
output * {
mode ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}@60Hz
scale 1
transform normal
bg #000000 solid_color
}
# Input configuration for touch
input type:touch {
events enabled
tap enabled
natural_scroll enabled
dwt disabled
}
# Input configuration for touchpad
input type:touchpad {
events enabled
tap enabled
natural_scroll enabled
dwt enabled
accel_profile adaptive
pointer_accel 0.3
}
# Disable window decorations and gaps
default_border none
default_floating_border none
gaps inner 0
gaps outer 0
# Hide cursor after 3 seconds
seat * hide_cursor 3000
# Disable screen timeout
exec swayidle timeout 0 '' before-sleep 'true'
# Start the dashboard application
exec /opt/openwall/start-dashboard.sh
EOF
# Create dashboard start script
cat > "$APP_DIR/start-dashboard.sh" << 'EOF'
#!/bin/bash
# Start the backend server
cd /opt/openwall/server
node index.js &
# Wait for server to start
sleep 3
# Start the dashboard in fullscreen
cd /opt/openwall
pnpm run electron -- --kiosk --touch-events --disable-pinch --overscroll-history-navigation=0 --disable-features=TranslateUI
EOF
chmod +x "$APP_DIR/start-dashboard.sh"
# Update Electron main process for kiosk mode
cat > "$APP_DIR/src/main/index.js" << 'EOF'
import { app, shell, BrowserWindow, screen } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
function createWindow() {
// Get the primary display
const primaryDisplay = screen.getPrimaryDisplay()
const { width, height } = primaryDisplay.workAreaSize
// Create the browser window in kiosk mode for touch displays
const mainWindow = new BrowserWindow({
width: width,
height: height,
show: false,
fullscreen: true,
kiosk: true,
frame: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: false,
contextIsolation: true,
webSecurity: false
}
})
// Disable zoom and context menu for touch interface
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && (input.key === '+' || input.key === '-' || input.key === '=')) {
event.preventDefault()
}
})
mainWindow.webContents.on('context-menu', (event) => {
event.preventDefault()
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
mainWindow.setFullScreen(true)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// Load the app
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.openwall.dashboard')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
EOF
# Create systemd service for auto-start
echo -e "${YELLOW}Creating systemd service...${NC}"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit]
Description=OpenWall Dashboard Service
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
Environment=HOME=/home/${APP_USER}
Environment=XDG_RUNTIME_DIR=/run/user/1001
ExecStart=${APP_DIR}/start-dashboard.sh
Restart=always
RestartSec=10
[Install]
WantedBy=graphical-session.target
EOF
# Create udev rules for touch input
echo -e "${YELLOW}Configuring touch input...${NC}"
cat > /etc/udev/rules.d/99-openwall-touch.rules << 'EOF'
# OpenWall touch input configuration
SUBSYSTEM=="input", ATTRS{name}=="*touch*", ENV{LIBINPUT_CALIBRATION_MATRIX}="1 0 0 0 1 0"
SUBSYSTEM=="input", ATTRS{name}=="*Touch*", ENV{LIBINPUT_CALIBRATION_MATRIX}="1 0 0 0 1 0"
EOF
# Configure X11 display settings (fallback)
echo -e "${YELLOW}Configuring display settings...${NC}"
cat > /etc/X11/xorg.conf.d/99-openwall-display.conf << EOF
Section "Monitor"
Identifier "HDMI-1"
Option "PreferredMode" "${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}"
Option "Position" "0 0"
Option "Rotate" "normal"
EndSection
Section "Screen"
Identifier "Default Screen"
Monitor "HDMI-1"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}"
EndSubSection
EndSection
EOF
# Create auto-login configuration
echo -e "${YELLOW}Configuring auto-login...${NC}"
mkdir -p /etc/systemd/system/getty@tty1.service.d
cat > /etc/systemd/system/getty@tty1.service.d/override.conf << EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin ${APP_USER} --noclear %I \$TERM
EOF
# Configure user session to start Wayland
echo -e "${YELLOW}Configuring user session...${NC}"
mkdir -p "/home/${APP_USER}/.config/systemd/user"
cat > "/home/${APP_USER}/.config/systemd/user/openwall-session.service" << EOF
[Unit]
Description=OpenWall Dashboard Session
After=graphical-session.target
[Service]
Type=exec
ExecStart=/opt/openwall/start-session.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
EOF
# Set up user environment
cat > "/home/${APP_USER}/.profile" << 'EOF'
# OpenWall Dashboard Environment
export XDG_SESSION_TYPE=wayland
export XDG_CURRENT_DESKTOP=openwall
export QT_QPA_PLATFORM=wayland
export GDK_BACKEND=wayland
export MOZ_ENABLE_WAYLAND=1
# Auto-start Wayland session on login
if [ -z "$DISPLAY" ] && [ "$XDG_VTNR" = "1" ]; then
exec /opt/openwall/start-session.sh
fi
EOF
chown -R "${APP_USER}:${APP_USER}" "/home/${APP_USER}"
# Enable services
echo -e "${YELLOW}Enabling services...${NC}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
# Enable user service
sudo -u "$APP_USER" systemctl --user enable openwall-session.service
# Build the application
echo -e "${YELLOW}Building application...${NC}"
cd "$APP_DIR"
sudo -u "$APP_USER" pnpm run build
# Set file permissions
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
chmod +x "$APP_DIR"/*.sh
# Reload udev rules
udevadm control --reload-rules
udevadm trigger
echo -e "${GREEN}Installation completed successfully!${NC}"
echo -e "${GREEN}==============================${NC}"
echo ""
echo -e "${BLUE}Installation Summary:${NC}"
echo -e "• Application installed to: ${APP_DIR}"
echo -e "• User created: ${APP_USER}"
echo -e "• Display configured for: ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT} (9:16 ratio)"
echo -e "• Wayland session: openwall"
echo -e "• Service: ${SERVICE_NAME}"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo -e "1. Reboot the system: ${BLUE}reboot${NC}"
echo -e "2. The dashboard will start automatically on boot"
echo -e "3. Access the dashboard at the configured display"
echo ""
echo -e "${YELLOW}Manual control:${NC}"
echo -e "• Start service: ${BLUE}systemctl start ${SERVICE_NAME}${NC}"
echo -e "• Stop service: ${BLUE}systemctl stop ${SERVICE_NAME}${NC}"
echo -e "• View logs: ${BLUE}journalctl -u ${SERVICE_NAME} -f${NC}"
echo ""
echo -e "${GREEN}Reboot now to start the OpenWall Dashboard!${NC}"

12
server/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
*.log
.DS_Store
.vscode

30
server/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Use Node.js official image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install yarn globally
RUN npm install -g yarn
# Install dependencies
RUN yarn install
# Copy source code
COPY . .
# Create directory for SQLite database
RUN mkdir -p /app/data
# Expose port
EXPOSE 3001
# Set environment variables
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/database.sqlite
# Start the application
CMD ["yarn", "start"]

65
server/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Shopping List Server
A simple Express.js server with SQLite database for managing shopping list items.
## Features
- CRUD operations for shopping list items
- Automatic cleanup of checked items after 2 hours
- SQLite database with Sequelize ORM
- CORS enabled for frontend integration
- RESTful API endpoints
## Database Schema
Each shopping item has:
- `id`: Unique identifier (auto-increment)
- `name`: Item name (required)
- `amount`: Quantity/amount (default: "1")
- `checked`: Boolean status (default: false)
- `checkedAt`: Timestamp when item was checked
- `date`: Creation date
- `createdAt`: Auto-generated creation timestamp
- `updatedAt`: Auto-generated update timestamp
## API Endpoints
### Shopping Items
- `GET /api/shopping` - Get all shopping items
- `GET /api/shopping/:id` - Get a specific shopping item
- `POST /api/shopping` - Create a new shopping item
- `PUT /api/shopping/:id` - Update a shopping item
- `PATCH /api/shopping/:id/toggle` - Toggle checked status
- `DELETE /api/shopping/:id` - Delete a shopping item
- `DELETE /api/shopping/checked/all` - Delete all checked items
### Health Check
- `GET /api/health` - Server health check
## Installation
1. Install dependencies:
```bash
pnpm install
```
2. Start the development server:
```bash
pnpm dev
```
3. Or start the production server:
```bash
pnpm start
```
The server will run on `http://localhost:3001` by default.
## Environment Variables
- `PORT`: Server port (default: 3001)
- `NODE_ENV`: Environment mode (development/production)
## Auto-cleanup
Checked items are automatically deleted after 2 hours. The cleanup runs every 30 minutes.

View File

@@ -0,0 +1,82 @@
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const { sequelize, cleanupCheckedItems } = require('./models');
const shoppingRoutes = require('./routes/shopping');
const calendarRoutes = require('./routes/calendar');
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/shopping', shoppingRoutes);
app.use('/api/calendar', calendarRoutes);
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'OK',
message: 'OpenWall Server is running',
timestamp: new Date().toISOString(),
features: ['shopping', 'calendar']
});
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// 404 handler
app.use('*name', (req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Initialize database and start server
const startServer = async () => {
try {
// Test database connection
await sequelize.authenticate();
console.log('Database connection established successfully.');
// Sync database (create tables if they don't exist)
await sequelize.sync();
console.log('Database synchronized successfully.');
// Run initial cleanup
await cleanupCheckedItems();
// Start the server
app.listen(PORT, () => {
console.log(`OpenWall Server is running on port ${PORT}`);
console.log(`Health check available at: http://localhost:${PORT}/api/health`);
console.log(`Shopping API available at: http://localhost:${PORT}/api/shopping`);
console.log(`Calendar API available at: http://localhost:${PORT}/api/calendar`);
});
} catch (error) {
console.error('Unable to start server:', error);
process.exit(1);
}
};
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');
await sequelize.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\nShutting down gracefully...');
await sequelize.close();
process.exit(0);
});
startServer();

View File

@@ -0,0 +1,41 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const CalendarUser = sequelize.define('CalendarUser', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
nextcloudUrl: {
type: DataTypes.STRING,
allowNull: false
},
username: {
type: DataTypes.STRING,
allowNull: false
},
password: {
type: DataTypes.STRING,
allowNull: false
},
calendarName: {
type: DataTypes.STRING,
allowNull: true // Will default to personal calendar if not specified
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
tableName: 'calendar_users',
timestamps: true
});
return CalendarUser;
};

View File

@@ -0,0 +1,50 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const ShoppingItem = sequelize.define('ShoppingItem', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: true,
},
},
amount: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: '1',
},
checked: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
checkedAt: {
type: DataTypes.DATE,
allowNull: true,
},
date: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
}, {
timestamps: true,
hooks: {
beforeUpdate: (item, options) => {
// Set checkedAt timestamp when item is checked
if (item.checked && !item.previous('checked')) {
item.checkedAt = new Date();
} else if (!item.checked) {
item.checkedAt = null;
}
},
},
});
return ShoppingItem;
};

44
server/models/index.js Normal file
View File

@@ -0,0 +1,44 @@
const { Sequelize } = require('sequelize');
const path = require('path');
// Initialize Sequelize with SQLite
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: path.join(__dirname, '..', 'database.sqlite'),
logging: process.env.NODE_ENV === 'development' ? console.log : false
});
// Import and initialize models
const createShoppingItem = require('./ShoppingItem');
const createCalendarUser = require('./CalendarUser');
const ShoppingItem = createShoppingItem(sequelize);
const CalendarUser = createCalendarUser(sequelize);
// Function to clean up checked shopping items older than 24 hours
const cleanupCheckedItems = async () => {
try {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const deletedCount = await ShoppingItem.destroy({
where: {
checked: true,
updatedAt: {
[Sequelize.Op.lt]: oneDayAgo
}
}
});
if (deletedCount > 0) {
console.log(`Cleaned up ${deletedCount} checked shopping items older than 24 hours`);
}
} catch (error) {
console.error('Error during cleanup:', error);
}
};
module.exports = {
sequelize,
ShoppingItem,
CalendarUser,
cleanupCheckedItems
};

24
server/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

975
server/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,975 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
cors:
specifier: ^2.8.5
version: 2.8.5
dotenv:
specifier: ^17.2.0
version: 17.2.0
express:
specifier: ^5.1.0
version: 5.1.0
sequelize:
specifier: ^6.37.7
version: 6.37.7
devDependencies:
nodemon:
specifier: ^3.1.10
version: 3.1.10
packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.0.14':
resolution: {integrity: sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==}
'@types/validator@13.15.2':
resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
body-parser@2.2.0:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
content-disposition@1.0.0:
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
engines: {node: '>= 0.6'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dotenv@17.2.0:
resolution: {integrity: sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==}
engines: {node: '>=12'}
dottie@2.0.6:
resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
express@5.1.0:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
finalhandler@2.1.0:
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
engines: {node: '>= 0.8'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
inflection@1.13.4:
resolution: {integrity: sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==}
engines: {'0': node >= 0.4.0}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@3.0.1:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
nodemon@3.1.10:
resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==}
engines: {node: '>=10'}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-to-regexp@8.2.0:
resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
engines: {node: '>=16'}
pg-connection-string@2.9.1:
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
raw-body@3.0.0:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
retry-as-promised@7.1.1:
resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
sequelize-pool@7.1.0:
resolution: {integrity: sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==}
engines: {node: '>= 10.0.0'}
sequelize@6.37.7:
resolution: {integrity: sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==}
engines: {node: '>=10.0.0'}
peerDependencies:
ibm_db: '*'
mariadb: '*'
mysql2: '*'
oracledb: '*'
pg: '*'
pg-hstore: '*'
snowflake-sdk: '*'
sqlite3: '*'
tedious: '*'
peerDependenciesMeta:
ibm_db:
optional: true
mariadb:
optional: true
mysql2:
optional: true
oracledb:
optional: true
pg:
optional: true
pg-hstore:
optional: true
snowflake-sdk:
optional: true
sqlite3:
optional: true
tedious:
optional: true
serve-static@2.2.0:
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
engines: {node: '>= 18'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
toposort-class@1.0.1:
resolution: {integrity: sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==}
touch@3.1.1:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
validator@13.15.15:
resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
engines: {node: '>= 0.10'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
wkx@0.5.0:
resolution: {integrity: sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
snapshots:
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
'@types/ms@2.1.0': {}
'@types/node@24.0.14':
dependencies:
undici-types: 7.8.0
'@types/validator@13.15.2': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.1
negotiator: 1.0.0
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
body-parser@2.2.0:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.1(supports-color@5.5.0)
http-errors: 2.0.0
iconv-lite: 0.6.3
on-finished: 2.4.1
qs: 6.14.0
raw-body: 3.0.0
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
braces@3.0.3:
dependencies:
fill-range: 7.1.1
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
concat-map@0.0.1: {}
content-disposition@1.0.0:
dependencies:
safe-buffer: 5.2.1
content-type@1.0.5: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
debug@4.4.1(supports-color@5.5.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
depd@2.0.0: {}
dotenv@17.2.0: {}
dottie@2.0.6: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
ee-first@1.1.1: {}
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
escape-html@1.0.3: {}
etag@1.8.1: {}
express@5.1.0:
dependencies:
accepts: 2.0.0
body-parser: 2.2.0
content-disposition: 1.0.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.1(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.0
fresh: 2.0.0
http-errors: 2.0.0
merge-descriptors: 2.0.0
mime-types: 3.0.1
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.0
serve-static: 2.2.0
statuses: 2.0.2
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
finalhandler@2.1.0:
dependencies:
debug: 4.4.1(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
forwarded@0.2.0: {}
fresh@2.0.0: {}
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
gopd@1.2.0: {}
has-flag@3.0.0: {}
has-symbols@1.1.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
http-errors@2.0.0:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
ignore-by-default@1.0.1: {}
inflection@1.13.4: {}
inherits@2.0.4: {}
ipaddr.js@1.9.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
is-promise@4.0.0: {}
lodash@4.17.21: {}
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
merge-descriptors@2.0.0: {}
mime-db@1.54.0: {}
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
ms@2.1.3: {}
negotiator@1.0.0: {}
nodemon@3.1.10:
dependencies:
chokidar: 3.6.0
debug: 4.4.1(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.7.2
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
undefsafe: 2.0.5
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
parseurl@1.3.3: {}
path-to-regexp@8.2.0: {}
pg-connection-string@2.9.1: {}
picomatch@2.3.1: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
pstree.remy@1.1.8: {}
qs@6.14.0:
dependencies:
side-channel: 1.1.0
range-parser@1.2.1: {}
raw-body@3.0.0:
dependencies:
bytes: 3.1.2
http-errors: 2.0.0
iconv-lite: 0.6.3
unpipe: 1.0.0
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
retry-as-promised@7.1.1: {}
router@2.2.0:
dependencies:
debug: 4.4.1(supports-color@5.5.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.2.0
transitivePeerDependencies:
- supports-color
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
semver@7.7.2: {}
send@1.2.0:
dependencies:
debug: 4.4.1(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.0
mime-types: 3.0.1
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
sequelize-pool@7.1.0: {}
sequelize@6.37.7:
dependencies:
'@types/debug': 4.1.12
'@types/validator': 13.15.2
debug: 4.4.1(supports-color@5.5.0)
dottie: 2.0.6
inflection: 1.13.4
lodash: 4.17.21
moment: 2.30.1
moment-timezone: 0.5.48
pg-connection-string: 2.9.1
retry-as-promised: 7.1.1
semver: 7.7.2
sequelize-pool: 7.1.0
toposort-class: 1.0.1
uuid: 8.3.2
validator: 13.15.15
wkx: 0.5.0
transitivePeerDependencies:
- supports-color
serve-static@2.2.0:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.0
transitivePeerDependencies:
- supports-color
setprototypeof@1.2.0: {}
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.0
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.2
statuses@2.0.1: {}
statuses@2.0.2: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
toidentifier@1.0.1: {}
toposort-class@1.0.1: {}
touch@3.1.1: {}
type-is@2.0.1:
dependencies:
content-type: 1.0.5
media-typer: 1.1.0
mime-types: 3.0.1
undefsafe@2.0.5: {}
undici-types@7.8.0: {}
unpipe@1.0.0: {}
uuid@8.3.2: {}
validator@13.15.15: {}
vary@1.1.2: {}
wkx@0.5.0:
dependencies:
'@types/node': 24.0.14
wrappy@1.0.2: {}

403
server/routes/calendar.js Normal file
View File

@@ -0,0 +1,403 @@
const express = require('express');
const router = express.Router();
const { CalendarUser } = require('../models');
const CalendarService = require('../services/CalendarService');
// Get all calendar users
router.get('/users', async (req, res) => {
try {
const users = await CalendarUser.findAll({
where: { isActive: true },
attributes: ['id', 'name', 'nextcloudUrl', 'username', 'calendarName', 'isActive']
});
res.json(users);
} catch (error) {
console.error('Error fetching calendar users:', error);
res.status(500).json({ error: 'Failed to fetch calendar users' });
}
});
// Add a new calendar user
router.post('/users', async (req, res) => {
try {
const { name, nextcloudUrl, username, password, calendarName } = req.body;
if (!name || !nextcloudUrl || !username || !password) {
return res.status(400).json({
error: 'Missing required fields: name, nextcloudUrl, username, password'
});
}
// Test connection before saving
const testConfig = { name, nextcloudUrl, username, password, calendarName };
const connectionTest = await CalendarService.testConnection(testConfig);
if (!connectionTest) {
return res.status(400).json({
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
});
}
const user = await CalendarUser.create({
name,
nextcloudUrl,
username,
password,
calendarName
});
// Return user without password
const { password: _, ...userWithoutPassword } = user.toJSON();
res.status(201).json(userWithoutPassword);
} catch (error) {
console.error('Error creating calendar user:', error);
if (error.name === 'SequelizeUniqueConstraintError') {
res.status(400).json({ error: 'User name already exists' });
} else {
res.status(500).json({ error: 'Failed to create calendar user' });
}
}
});
// Update a calendar user
router.put('/users/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, nextcloudUrl, username, password, calendarName, isActive } = req.body;
const user = await CalendarUser.findByPk(id);
if (!user) {
return res.status(404).json({ error: 'Calendar user not found' });
}
// Test connection if credentials are being updated
if (nextcloudUrl || username || password) {
const testConfig = {
name: name || user.name,
nextcloudUrl: nextcloudUrl || user.nextcloudUrl,
username: username || user.username,
password: password || user.password,
calendarName: calendarName || user.calendarName
};
const connectionTest = await CalendarService.testConnection(testConfig);
if (!connectionTest) {
return res.status(400).json({
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
});
}
}
await user.update({
...(name && { name }),
...(nextcloudUrl && { nextcloudUrl }),
...(username && { username }),
...(password && { password }),
...(calendarName !== undefined && { calendarName }),
...(isActive !== undefined && { isActive })
});
// Return user without password
const { password: _, ...userWithoutPassword } = user.toJSON();
res.json(userWithoutPassword);
} catch (error) {
console.error('Error updating calendar user:', error);
res.status(500).json({ error: 'Failed to update calendar user' });
}
});
// Delete a calendar user
router.delete('/users/:id', async (req, res) => {
try {
const { id } = req.params;
const user = await CalendarUser.findByPk(id);
if (!user) {
return res.status(404).json({ error: 'Calendar user not found' });
}
await user.destroy();
res.json({ message: 'Calendar user deleted successfully' });
} catch (error) {
console.error('Error deleting calendar user:', error);
res.status(500).json({ error: 'Failed to delete calendar user' });
}
});
// Get calendar events for a specific month
// GET /api/calendar/events/:year/:month?user=username&type=family|individual
router.get('/events/:year/:month', async (req, res) => {
try {
const { year, month } = req.params;
const { user, type } = req.query;
// Validate year and month
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
return res.status(400).json({
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
});
}
if (type === 'family') {
// Get events from all active users
const users = await CalendarUser.findAll({
where: { isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (users.length === 0) {
return res.json([]);
}
// Convert Sequelize instances to plain objects
const userConfigs = users.map(user => user.toJSON());
const events = await CalendarService.getFamilyEventsForMonth(userConfigs, year, month);
res.json(events);
} else {
// Get events for a specific user
if (!user) {
return res.status(400).json({
error: 'User parameter required for individual calendar view'
});
}
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (!userConfig) {
return res.status(404).json({ error: 'User not found or inactive' });
}
// Convert Sequelize instance to plain object
const userConfigPlain = userConfig.toJSON();
const events = await CalendarService.getEventsForMonth(userConfigPlain, year, month);
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfigPlain, year, month);
// Combine regular events and birthday events
const allEvents = [...events, ...birthdayEvents];
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
allEvents.sort((a, b) => {
// First, compare dates as strings to avoid timezone issues
if (a.date !== b.date) {
return a.date.localeCompare(b.date);
}
// If dates are the same, sort by type (birthdays first)
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
// Then sort by event text for consistent ordering
return (a.text || '').localeCompare(b.text || '');
});
res.json(allEvents);
}
} catch (error) {
console.error('Error retrieving calendar events:', error.message);
res.status(500).json({ error: 'Failed to retrieve calendar events' });
}
});
// Create a new calendar event
// POST /api/calendar/events
router.post('/events', async (req, res) => {
try {
const { user, date, text } = req.body;
if (!user || !date || !text) {
return res.status(400).json({
error: 'Missing required fields: user, date, text'
});
}
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({
error: 'Invalid date format. Use YYYY-MM-DD.'
});
}
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (!userConfig) {
return res.status(404).json({ error: 'User not found or inactive' });
}
const event = await CalendarService.createEvent(userConfig.toJSON(), date, text);
res.status(201).json(event);
} catch (error) {
console.error('Error creating calendar event:', error.message);
res.status(500).json({ error: 'Failed to create calendar event' });
}
});
// Update a calendar event
// PUT /api/calendar/events/:eventId
router.put('/events/:eventId', async (req, res) => {
try {
const { eventId } = req.params;
const { user, date, text } = req.body;
if (!user || !date || !text) {
return res.status(400).json({
error: 'Missing required fields: user, date, text'
});
}
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({
error: 'Invalid date format. Use YYYY-MM-DD.'
});
}
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (!userConfig) {
return res.status(404).json({ error: 'User not found or inactive' });
}
const event = await CalendarService.updateEvent(userConfig.toJSON(), eventId, date, text);
res.json(event);
} catch (error) {
console.error('Error updating calendar event:', error);
if (error.message === 'Event not found') {
res.status(404).json({ error: 'Event not found' });
} else if (error.message.includes('You can only edit events that belong to you')) {
res.status(403).json({ error: 'You can only edit events that belong to you' });
} else if (error.message.includes('Permission denied')) {
res.status(403).json({ error: error.message });
} else if (error.message.includes('CalDAV authentication failed')) {
res.status(401).json({ error: 'Calendar authentication failed' });
} else {
res.status(500).json({ error: 'Failed to update calendar event' });
}
}
});
// Delete a calendar event
// DELETE /api/calendar/events/:eventId
router.delete('/events/:eventId', async (req, res) => {
try {
const { eventId } = req.params;
const { user } = req.body;
if (!user) {
return res.status(400).json({
error: 'Missing required field: user'
});
}
// Check if this is a birthday event (birthday events should not be deletable)
if (eventId && eventId.includes('birthday-')) {
return res.status(403).json({
error: 'Birthday events cannot be deleted from the calendar interface'
});
}
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (!userConfig) {
return res.status(404).json({ error: 'User not found or inactive' });
}
await CalendarService.deleteEvent(userConfig.toJSON(), eventId);
res.json({ message: 'Event deleted successfully' });
} catch (error) {
console.error('Error deleting calendar event:', error);
if (error.message === 'Event not found') {
res.status(404).json({
error: 'Event not found. This event may belong to a different user or have already been deleted.'
});
} else if (error.message.includes('You can only delete events that belong to you')) {
res.status(403).json({ error: 'You can only delete events that belong to you' });
} else if (error.message === 'Birthday events cannot be deleted from the calendar interface') {
res.status(403).json({ error: error.message });
} else if (error.message.includes('Permission denied') || error.message.includes('authentication failed')) {
res.status(403).json({ error: 'Permission denied. You may not have access to delete this event.' });
} else {
res.status(500).json({ error: 'Failed to delete calendar event' });
}
}
});
// Test Nextcloud connection for a user
router.post('/users/:id/test-connection', async (req, res) => {
try {
const { id } = req.params;
const user = await CalendarUser.findByPk(id);
if (!user) {
return res.status(404).json({ error: 'Calendar user not found' });
}
const connectionTest = await CalendarService.testConnection({
name: user.name,
nextcloudUrl: user.nextcloudUrl,
username: user.username,
password: user.password,
calendarName: user.calendarName
});
res.json({
success: connectionTest,
message: connectionTest ? 'Connection successful' : 'Connection failed'
});
} catch (error) {
console.error('Error testing connection:', error);
res.status(500).json({ error: 'Failed to test connection' });
}
});
// Get contact birthdays for a specific month
// GET /api/calendar/birthdays/:year/:month?user=username
router.get('/birthdays/:year/:month', async (req, res) => {
try {
const { year, month } = req.params;
const { user } = req.query;
// Validate year and month
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
return res.status(400).json({
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
});
}
if (!user) {
return res.status(400).json({
error: 'User parameter required for birthday calendar access'
});
}
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
});
if (!userConfig) {
return res.status(404).json({ error: 'User not found or inactive' });
}
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfig.toJSON(), year, month);
res.json(birthdayEvents);
} catch (error) {
console.error('Error retrieving contact birthdays:', error.message);
res.status(500).json({ error: 'Failed to retrieve contact birthdays' });
}
});
module.exports = router;

146
server/routes/shopping.js Normal file
View File

@@ -0,0 +1,146 @@
const express = require('express');
const { ShoppingItem } = require('../models');
const { Op } = require('sequelize');
const router = express.Router();
// Get all shopping items
router.get('/', async (req, res) => {
try {
const items = await ShoppingItem.findAll({
order: [['createdAt', 'DESC']],
});
res.json(items);
} catch (error) {
console.error('Error fetching shopping items:', error);
res.status(500).json({ error: 'Failed to fetch shopping items' });
}
});
// Get a specific shopping item
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const item = await ShoppingItem.findByPk(id);
if (!item) {
return res.status(404).json({ error: 'Shopping item not found' });
}
res.json(item);
} catch (error) {
console.error('Error fetching shopping item:', error);
res.status(500).json({ error: 'Failed to fetch shopping item' });
}
});
// Create a new shopping item
router.post('/', async (req, res) => {
try {
const { name, amount, date } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const item = await ShoppingItem.create({
name,
amount: amount || '1',
date: date || new Date(),
});
res.status(201).json(item);
} catch (error) {
console.error('Error creating shopping item:', error);
res.status(500).json({ error: 'Failed to create shopping item' });
}
});
// Update a shopping item
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, amount, checked, date } = req.body;
const item = await ShoppingItem.findByPk(id);
if (!item) {
return res.status(404).json({ error: 'Shopping item not found' });
}
// Update fields if provided
if (name !== undefined) item.name = name;
if (amount !== undefined) item.amount = amount;
if (checked !== undefined) item.checked = checked;
if (date !== undefined) item.date = date;
await item.save();
res.json(item);
} catch (error) {
console.error('Error updating shopping item:', error);
res.status(500).json({ error: 'Failed to update shopping item' });
}
});
// Toggle checked status of a shopping item
router.patch('/:id/toggle', async (req, res) => {
try {
const { id } = req.params;
const item = await ShoppingItem.findByPk(id);
if (!item) {
return res.status(404).json({ error: 'Shopping item not found' });
}
item.checked = !item.checked;
await item.save();
res.json(item);
} catch (error) {
console.error('Error toggling shopping item:', error);
res.status(500).json({ error: 'Failed to toggle shopping item' });
}
});
// Delete a shopping item
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const item = await ShoppingItem.findByPk(id);
if (!item) {
return res.status(404).json({ error: 'Shopping item not found' });
}
await item.destroy();
res.json({ message: 'Shopping item deleted successfully' });
} catch (error) {
console.error('Error deleting shopping item:', error);
res.status(500).json({ error: 'Failed to delete shopping item' });
}
});
// Delete all checked items
router.delete('/checked/all', async (req, res) => {
try {
const deletedCount = await ShoppingItem.destroy({
where: {
checked: true,
},
});
res.json({
message: `Deleted ${deletedCount} checked items`,
deletedCount
});
} catch (error) {
console.error('Error deleting checked items:', error);
res.status(500).json({ error: 'Failed to delete checked items' });
}
});
module.exports = router;

View File

@@ -0,0 +1,970 @@
// Calendar Service for Nextcloud CalDAV integration
// This is a basic implementation that can be enhanced with the @nextcloud/cdav-library
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const { URL } = require('url');
class CalendarService {
/**
* Create a calendar event
* @param {Object} userConfig - Nextcloud user configuration
* @param {string} date - Date in YYYY-MM-DD format
* @param {string} text - Event description
* @returns {Promise<Object>} Created event
*/
async createEvent(userConfig, date, text) {
try {
// Validate input parameters
if (!userConfig) {
throw new Error('User configuration is required');
}
if (!userConfig.name) {
throw new Error('User name is required');
}
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error('Valid date in YYYY-MM-DD format is required');
}
if (!text || text.trim().length === 0) {
throw new Error('Event text is required');
}
// Generate event ID and structure
const eventId = crypto.randomUUID();
const event = {
id: eventId,
date: date,
text: text.trim(),
user: userConfig.name,
summary: text.trim(),
dtstart: this._formatDateForCalDAV(date),
dtend: this._formatDateForCalDAV(date),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Create event via CalDAV
const caldavEvent = await this._createCalDAVEvent(userConfig, event);
return caldavEvent;
} catch (error) {
throw new Error(`Failed to create calendar event: ${error.message}`);
}
}
/**
* Get calendar events for a specific month
* @param {Object} userConfig - Nextcloud user configuration
* @param {string} year - Year (YYYY)
* @param {string} month - Month (MM)
* @returns {Promise<Array>} Array of events
*/
async getEventsForMonth(userConfig, year, month) {
try {
// Validate input parameters
if (!userConfig || !userConfig.name) {
throw new Error('Valid user configuration is required');
}
if (!year || !/^\d{4}$/.test(year)) {
throw new Error('Valid year in YYYY format is required');
}
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
throw new Error('Valid month in MM format is required');
}
// Fetch events from CalDAV
const events = await this._fetchCalDAVEvents(userConfig, year, month);
return events;
} catch (error) {
throw new Error(`Failed to retrieve calendar events: ${error.message}`);
}
}
/**
* Get contact birthdays for a specific month
* @param {Object} userConfig - Nextcloud user configuration
* @param {string} year - Year (YYYY)
* @param {string} month - Month (MM)
* @returns {Promise<Array>} Array of birthday events
*/
async getContactBirthdaysForMonth(userConfig, year, month) {
try {
// Validate input parameters
if (!userConfig || !userConfig.name) {
console.warn('Valid user configuration is required for birthday fetch');
return [];
}
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
console.warn(`Missing required connection details for ${userConfig.name} birthday calendar`);
return [];
}
if (!year || !/^\d{4}$/.test(year)) {
throw new Error('Valid year in YYYY format is required');
}
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
throw new Error('Valid month in MM format is required');
}
// Create a separate config for the contact_birthdays calendar
const birthdayConfig = {
name: userConfig.name,
nextcloudUrl: userConfig.nextcloudUrl,
username: userConfig.username,
password: userConfig.password,
calendarName: 'contact_birthdays'
};
try {
// Fetch birthday events from the contact_birthdays calendar
const birthdayEvents = await this._fetchCalDAVEvents(birthdayConfig, year, month);
// Mark these events as birthday events and add additional properties
const processedBirthdayEvents = birthdayEvents.map(event => ({
...event,
id: `birthday-${event.id}`, // Prefix birthday event IDs to make them identifiable
type: 'birthday',
isBirthday: true,
source: 'contact_birthdays',
// Extract contact name from birthday text if possible
contactName: this._extractContactNameFromBirthday(event.text || event.summary)
}));
return processedBirthdayEvents;
} catch (error) {
// If contact_birthdays calendar doesn't exist or is inaccessible, return empty array
console.warn(`Could not fetch contact birthdays for ${userConfig.name}: ${error.message}`);
return [];
}
} catch (error) {
console.error(`Failed to retrieve contact birthdays: ${error.message}`);
return []; // Return empty array instead of throwing to not break other calendar functionality
}
}
/**
* Get calendar events for all users (family view)
* @param {Array} userConfigs - Array of user configurations
* @param {string} year - Year (YYYY)
* @param {string} month - Month (MM)
* @returns {Promise<Array>} Array of events from all users
*/
async getFamilyEventsForMonth(userConfigs, year, month) {
try {
const allEvents = [];
for (const userConfig of userConfigs) {
const userEvents = await this.getEventsForMonth(userConfig, year, month);
allEvents.push(...userEvents);
}
// Fetch contact birthdays only from the first user to avoid duplicates
// Birthday calendars are typically shared/synchronized across users
if (userConfigs.length > 0) {
const birthdayEvents = await this.getContactBirthdaysForMonth(userConfigs[0], year, month);
// Filter out duplicate birthday events and merge with regular events that might be on the same day
const seenBirthdays = new Set();
const uniqueBirthdayEvents = birthdayEvents.filter(event => {
const birthdayKey = `${event.date}-${(event.text || '').trim()}-${(event.contactName || '').trim()}`;
if (!seenBirthdays.has(birthdayKey)) {
seenBirthdays.add(birthdayKey);
return true;
}
return false;
}).map(event => ({
...event,
// Use the first user's name for birthday events or mark as shared
user: userConfigs[0].name,
isSharedBirthday: true
}));
allEvents.push(...uniqueBirthdayEvents);
}
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
allEvents.sort((a, b) => {
// First, compare dates as strings to avoid timezone issues
if (a.date !== b.date) {
return a.date.localeCompare(b.date);
}
// If dates are the same, sort by type (birthdays first)
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
// Then sort by event text for consistent ordering
return (a.text || '').localeCompare(b.text || '');
});
return allEvents;
} catch (error) {
console.error('Error retrieving family calendar events:', error);
throw new Error('Failed to retrieve family calendar events');
}
}
/**
* Update a calendar event
* @param {Object} userConfig - Nextcloud user configuration
* @param {string} eventId - Event ID
* @param {string} date - New date
* @param {string} text - New text
* @returns {Promise<Object>} Updated event
*/
async updateEvent(userConfig, eventId, date, text) {
try {
// Build updated event object
const event = {
id: eventId,
date: date,
text: text,
user: userConfig.name,
summary: text,
dtstart: this._formatDateForCalDAV(date),
dtend: this._formatDateForCalDAV(date),
updatedAt: new Date().toISOString()
};
// Update event via CalDAV - let the server handle existence validation
const updatedEvent = await this._updateCalDAVEvent(userConfig, event);
return updatedEvent;
} catch (error) {
throw error;
}
}
/**
* Delete a calendar event
* @param {Object} userConfig - Nextcloud user configuration
* @param {string} eventId - Event ID
* @returns {Promise<boolean>} Success status
*/
async deleteEvent(userConfig, eventId) {
try {
// Check if this is a birthday event that shouldn't be deleted
if (eventId && eventId.includes('birthday-')) {
console.warn(`Blocked attempt to delete birthday event ${eventId}`);
throw new Error('Birthday events cannot be deleted from the calendar interface');
}
// Simply attempt the deletion - let CalDAV server handle existence validation
// The previous approach of checking multiple months was too complex and unreliable
await this._deleteCalDAVEvent(userConfig, eventId);
return true;
} catch (error) {
console.error(`Failed to delete event ${eventId} for user ${userConfig.name}: ${error.message}`);
throw error;
}
}
/**
* Test connection to Nextcloud instance
* @param {Object} userConfig - Nextcloud user configuration
* @returns {Promise<boolean>} Connection status
*/
async testConnection(userConfig) {
try {
// Validate user configuration
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
return false;
}
// Basic HTTP test to verify Nextcloud instance is reachable
const url = new URL(userConfig.nextcloudUrl);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: '/status.php',
method: 'GET',
timeout: 5000,
headers: {
'User-Agent': 'OpenWall/1.0'
}
};
const protocol = url.protocol === 'https:' ? https : http;
const result = await new Promise((resolve) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
try {
const status = JSON.parse(data);
const isNextcloud = status.productname && status.productname.toLowerCase().includes('nextcloud');
resolve(isNextcloud);
} catch (e) {
resolve(false);
}
});
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
// If basic connection works, test CalDAV endpoint
if (result) {
const caldavResult = await this._testCalDAVConnection(userConfig);
return caldavResult;
}
return result;
} catch (error) {
return false;
}
}
/**
* Format date for CalDAV (YYYYMMDD format for all-day events)
* @private
*/
_formatDateForCalDAV(dateString) {
const date = new Date(dateString);
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* Test CalDAV connection by trying to access the calendar endpoint
* @private
*/
async _testCalDAVConnection(userConfig) {
try {
// Try to access the CalDAV principal endpoint
const caldavUrl = this._buildCalDAVUrl(userConfig);
const url = new URL(caldavUrl);
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'PROPFIND',
timeout: 10000,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '0',
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
}
};
const protocol = url.protocol === 'https:' ? https : http;
const body = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:resourcetype/>
<D:displayname/>
<C:calendar-description/>
</D:prop>
</D:propfind>`;
return new Promise((resolve) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(true);
} else {
resolve(false);
}
});
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.write(body);
req.end();
});
} catch (error) {
return false;
}
}
/**
* Build CalDAV URL for a user
* @private
*/
_buildCalDAVUrl(userConfig) {
try {
// Validate userConfig
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
throw new Error('Invalid user configuration: missing required fields');
}
// Remove trailing slash from nextcloudUrl if present
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
// Build CalDAV principal URL - this is the standard Nextcloud CalDAV path
const calendarName = userConfig.calendarName || 'personal';
return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`;
} catch (error) {
throw new Error(`Failed to build CalDAV URL: ${error.message}`);
}
}
/**
* Build CalDAV principals URL for a user
* @private
*/
_buildCalDAVPrincipalsUrl(userConfig) {
try {
// Validate userConfig
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
throw new Error('Invalid user configuration: missing required fields');
}
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`;
} catch (error) {
throw new Error(`Failed to build CalDAV principals URL: ${error.message}`);
}
}
/**
* Create a CalDAV event
* @private
*/
async _createCalDAVEvent(userConfig, event) {
try {
const caldavUrl = this._buildCalDAVUrl(userConfig);
const eventUrl = `${caldavUrl}${event.id}.ics`;
const url = new URL(eventUrl);
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
const icsContent = this._generateICSEvent(event);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'PUT',
timeout: 10000,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
'If-None-Match': '*' // Prevent overwriting existing events
}
};
const protocol = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(event);
} else if (res.statusCode === 401) {
reject(new Error('CalDAV authentication failed'));
} else if (res.statusCode === 412) {
reject(new Error('CalDAV event already exists'));
} else {
reject(new Error(`CalDAV event creation failed with status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`CalDAV request failed: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('CalDAV request timeout'));
});
req.write(icsContent);
req.end();
});
} catch (error) {
throw error;
}
}
/**
* Fetch CalDAV events
* @private
*/
async _fetchCalDAVEvents(userConfig, year, month) {
try {
const caldavUrl = this._buildCalDAVUrl(userConfig);
const url = new URL(caldavUrl);
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
// Build date range for the month
const startDate = new Date(parseInt(year), parseInt(month) - 1, 1);
const endDate = new Date(parseInt(year), parseInt(month), 0);
const startDateStr = startDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T000000Z';
const endDateStr = endDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T235959Z';
const reportBody = `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="${startDateStr}" end="${endDateStr}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>`;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'REPORT',
timeout: 10000,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Buffer.byteLength(reportBody, 'utf8'),
'Depth': '1',
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
}
};
const protocol = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const events = this._parseCalDAVResponse(data, userConfig, year, month);
resolve(events);
} catch (parseError) {
reject(parseError);
}
} else if (res.statusCode === 401) {
reject(new Error('CalDAV authentication failed'));
} else {
reject(new Error(`CalDAV event fetch failed with status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`CalDAV request failed: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('CalDAV request timeout'));
});
req.write(reportBody);
req.end();
});
} catch (error) {
throw error;
}
}
/**
* Parse CalDAV response and extract events
* @private
*/
_parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) {
try {
// This is a basic parser - in a real implementation, you'd use a proper XML parser
const events = [];
// Look for calendar-data with different possible namespace prefixes
const eventMatches = xmlData.match(/<(?:C:|cal:)?calendar-data[^>]*>([\s\S]*?)<\/(?:C:|cal:)?calendar-data>/gi);
if (eventMatches) {
eventMatches.forEach((match, index) => {
try {
// Extract the VCALENDAR content - handle CDATA and escaped content
let icsContent = match
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') // Remove CDATA wrapper
.replace(/<[^>]*>/g, '') // Remove XML tags
.replace(/&lt;/g, '<') // Decode HTML entities
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.trim();
const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth);
if (event) {
events.push(event);
}
} catch (eventError) {
// Skip individual events that fail to parse
}
});
}
return events;
} catch (error) {
throw new Error(`Failed to parse CalDAV response: ${error.message}`);
}
}
/**
* Parse ICS event content
* @private
*/
_parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) {
try {
const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line);
const event = {
user: userConfig.name,
source: 'caldav'
};
for (const line of lines) {
if (line.startsWith('UID:')) {
event.id = line.substring(4);
} else if (line.startsWith('SUMMARY:')) {
event.text = line.substring(8);
event.summary = event.text;
} else if (line.startsWith('DTSTART')) {
const dateValue = line.split(':')[1];
event.dtstart = dateValue;
// Parse date more robustly
if (dateValue && dateValue.length >= 8) {
// Handle different DTSTART formats: YYYYMMDD, YYYYMMDDTHHMMSSZ, etc.
const dateOnly = dateValue.substring(0, 8);
if (/^\d{8}$/.test(dateOnly)) {
const year = parseInt(dateOnly.substring(0, 4));
const month = parseInt(dateOnly.substring(4, 6));
const day = parseInt(dateOnly.substring(6, 8));
// Validate month and day are reasonable (don't restrict year for recurring events)
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
// Create date and validate it's correct
const testDate = new Date(year, month - 1, day);
if (!isNaN(testDate.getTime()) &&
testDate.getMonth() === (month - 1) &&
testDate.getDate() === day) {
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
event.originalYear = year;
event.originalDate = event.date;
} else {
// If date validation fails, try with current year
const currentYear = new Date().getFullYear();
const fallbackDate = new Date(currentYear, month - 1, day);
if (!isNaN(fallbackDate.getTime())) {
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
event.originalYear = year;
event.isFallback = true;
}
}
}
}
}
} else if (line.startsWith('DTEND')) {
const dateValue = line.split(':')[1];
event.dtend = dateValue;
// If we don't have a start date yet, try to get it from end date
if (!event.date && dateValue && dateValue.length >= 8) {
const dateOnly = dateValue.substring(0, 8);
if (/^\d{8}$/.test(dateOnly)) {
const year = parseInt(dateOnly.substring(0, 4));
const month = parseInt(dateOnly.substring(4, 6));
const day = parseInt(dateOnly.substring(6, 8));
// Validate month and day are reasonable (don't restrict year for recurring events)
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
// Create date and validate it's correct
const testDate = new Date(year, month - 1, day);
if (!isNaN(testDate.getTime()) &&
testDate.getMonth() === (month - 1) &&
testDate.getDate() === day) {
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
event.originalYear = year;
event.originalDate = event.date;
} else {
// If date validation fails, try with current year
const currentYear = new Date().getFullYear();
const fallbackDate = new Date(currentYear, month - 1, day);
if (!isNaN(fallbackDate.getTime())) {
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
event.originalYear = year;
event.isFallback = true;
}
}
}
}
}
} else if (line.startsWith('CREATED:')) {
event.createdAt = line.substring(8);
} else if (line.startsWith('LAST-MODIFIED:')) {
event.updatedAt = line.substring(14);
}
}
// Normalize date to the requested year/month for proper grouping
if (event.date && requestedYear && requestedMonth) {
const eventDate = new Date(event.date);
// Validate that the date parsed correctly
if (!isNaN(eventDate.getTime())) {
const eventDay = eventDate.getDate();
// Validate day is reasonable for the month
if (eventDay >= 1 && eventDay <= 31) {
// Always use the requested year for proper grouping, regardless of original event year
const normalizedDate = `${requestedYear}-${requestedMonth.padStart(2, '0')}-${eventDay.toString().padStart(2, '0')}`;
// Validate the normalized date is valid
const testNormalizedDate = new Date(normalizedDate);
if (!isNaN(testNormalizedDate.getTime())) {
event.date = normalizedDate;
event.isNormalized = true;
}
}
}
}
// Only return events with required fields and valid dates
if (event.id && event.text && event.date) {
return event;
} else {
return null;
}
} catch (error) {
return null;
}
}
/**
* Update a calendar event
* @private
*/
async _updateCalDAVEvent(userConfig, event) {
try {
const caldavUrl = this._buildCalDAVUrl(userConfig);
const eventUrl = `${caldavUrl}${event.id}.ics`;
const url = new URL(eventUrl);
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
const icsContent = this._generateICSEvent(event);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'PUT',
timeout: 10000,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
'If-Match': '*' // Allow overwriting existing events
}
};
const protocol = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(event);
} else if (res.statusCode === 401) {
reject(new Error('CalDAV authentication failed'));
} else if (res.statusCode === 404) {
reject(new Error('Event not found'));
} else if (res.statusCode === 400 && data.includes('uid already exists')) {
// If UID conflict, try to delete and recreate
this._deleteCalDAVEvent(userConfig, event.id)
.then(() => this._createCalDAVEvent(userConfig, event))
.then(resolve)
.catch(reject);
return;
} else {
reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`CalDAV request failed: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('CalDAV request timeout'));
});
req.write(icsContent);
req.end();
});
} catch (error) {
throw error;
}
}
/**
* Delete a CalDAV event
* @private
*/
async _deleteCalDAVEvent(userConfig, eventId) {
try {
const caldavUrl = this._buildCalDAVUrl(userConfig);
const eventUrl = `${caldavUrl}${eventId}.ics`;
const url = new URL(eventUrl);
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'DELETE',
timeout: 10000,
headers: {
'Authorization': `Basic ${auth}`,
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
}
};
const protocol = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(true);
} else if (res.statusCode === 401) {
reject(new Error('CalDAV authentication failed'));
} else if (res.statusCode === 404) {
reject(new Error('Event not found'));
} else {
reject(new Error(`CalDAV event deletion failed with status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`CalDAV request failed: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('CalDAV request timeout'));
});
req.end();
});
} catch (error) {
throw error;
}
}
/**
* Generate ICS event content
* @private
*/
_generateICSEvent(event) {
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const created = event.createdAt ? new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
const modified = event.updatedAt ? new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//OpenWall//Calendar//EN
BEGIN:VEVENT
UID:${event.id}
DTSTART;VALUE=DATE:${event.dtstart}
DTEND;VALUE=DATE:${event.dtend}
SUMMARY:${event.summary}
DESCRIPTION:${event.text}
CREATED:${created}
LAST-MODIFIED:${modified}
DTSTAMP:${now}
END:VEVENT
END:VCALENDAR`;
}
/**
* Extract contact name from birthday event text
* @private
*/
_extractContactNameFromBirthday(text) {
if (!text) return null;
// Common birthday text patterns in different languages
const patterns = [
/^(.+?)(?:'s|s)?\s*(?:Birthday|Geburtstag|birthday|geburtstag)$/i,
/^(?:Birthday|Geburtstag)\s*[:-]?\s*(.+)$/i,
/^(.+?)\s*\(.*\)$/i, // Remove parentheses content
/^(.+?)(?:\s*-\s*Birthday)?$/i
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]) {
return match[1].trim();
}
}
// If no pattern matches, return the original text (minus common birthday words)
return text.replace(/\b(Birthday|Geburtstag|birthday|geburtstag)\b/gi, '').trim() || text;
}
}
module.exports = new CalendarService();