1
0

Create mobile calender service

This commit is contained in:
2025-07-18 11:49:28 +02:00
parent 53e2b15351
commit 3608750616
16 changed files with 6749 additions and 0 deletions

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,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="de">
<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>OpenWall Kalender</title>
<!-- PWA Meta Tags -->
<meta name="description" content="Mobile Kalender-App für OpenWall Smart Home Dashboard">
<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="OpenWall Kalender">
<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 Kalender">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

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: 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

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

@@ -0,0 +1,475 @@
import React, { useState, useEffect } from 'react';
import {
FaPlus,
FaTrash,
FaEdit,
FaCalendarAlt,
FaUsers,
FaUser,
FaChevronLeft,
FaChevronRight,
FaSave,
FaTimes
} 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) {
setError('Fehler beim Aktualisieren des Termins');
console.error('Error updating event:', err);
}
};
const handleDeleteEvent = async (eventId, user) => {
try {
await calendarService.deleteEvent(eventId, user);
setEvents(events.filter(event => event.id !== eventId));
} catch (err) {
setError('Fehler beim Löschen des Termins');
console.error('Error deleting event:', err);
}
};
const navigateMonth = (direction) => {
const newDate = new Date(currentDate);
newDate.setMonth(newDate.getMonth() + direction);
setCurrentDate(newDate);
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit'
});
};
const getCurrentMonthYear = () => {
return currentDate.toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric'
});
};
const groupEventsByDate = (events) => {
const grouped = {};
events.forEach(event => {
const date = event.date;
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
// Sort dates
const sortedDates = Object.keys(grouped).sort();
const result = {};
sortedDates.forEach(date => {
result[date] = grouped[date];
});
return result;
};
const groupedEvents = groupEventsByDate(events);
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 && 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]) => (
<div key={date} className="day-section">
<div className="day-header">
<h3>{formatDate(date)}</h3>
<span className="event-count">{dayEvents.length} Termin{dayEvents.length !== 1 ? 'e' : ''}</span>
</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)}
showUser={viewType === 'family'}
users={users}
/>
))}
</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
}) => {
const [editData, setEditData] = useState({
user: event.user,
date: event.date,
text: event.text
});
const handleEdit = () => {
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 isEditing = editingEvent === event.id;
return (
<div className="calendar-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">{event.text}</div>
{showUser && (
<div className="event-user" style={{ color: userColor }}>
{event.user}
</div>
)}
</div>
<div className="event-actions">
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
<FaEdit />
</button>
<button
className="btn btn-danger btn-small"
onClick={() => onDelete(event.id, event.user)}
>
<FaTrash />
</button>
</div>
</>
)}
</div>
);
};
export default App;

View File

@@ -0,0 +1,559 @@
// Mobile Calendar App Styles
// Clean, beautiful design for calendar events
: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
&.calendar-app
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
.main
background: transparent
// 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
flex-direction: column
gap: var(--spacing-md)
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)
justify-content: center
svg
color: var(--primary-color)
font-size: 1.25rem
.month-navigation
display: flex
align-items: center
justify-content: center
gap: var(--spacing-md)
.current-month
font-size: 1.125rem
font-weight: 600
color: var(--text-primary)
margin: 0
min-width: 140px
text-align: center
.btn-icon
width: 40px
height: 40px
border-radius: 50%
display: flex
align-items: center
justify-content: center
background: var(--background-color)
border: 1px solid var(--border-color)
&:hover
background: var(--primary-color)
color: white
border-color: var(--primary-color)
.view-controls
display: flex
align-items: center
justify-content: space-between
gap: var(--spacing-md)
padding-top: var(--spacing-sm)
border-top: 1px solid var(--border-color)
.view-type-toggle
display: flex
gap: var(--spacing-xs)
background: var(--background-color)
padding: 4px
border-radius: var(--border-radius-small)
.btn
padding: var(--spacing-xs) var(--spacing-md)
border-radius: var(--border-radius-small)
font-size: 0.875rem
.user-select
padding: var(--spacing-sm)
border: 1px solid var(--border-color)
border-radius: var(--border-radius-small)
background: var(--surface-color)
font-size: 0.875rem
min-width: 120px
&:focus
outline: none
border-color: var(--primary-color)
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1)
// 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
// Calendar Content
.calendar-content
display: flex
flex-direction: column
gap: var(--spacing-lg)
// Empty State
.empty-state
display: flex
flex-direction: column
align-items: center
justify-content: center
padding: var(--spacing-xl)
background: var(--surface-color)
border-radius: var(--border-radius)
box-shadow: var(--shadow-light)
text-align: center
svg
font-size: 3rem
margin-bottom: var(--spacing-md)
color: var(--primary-color)
opacity: 0.5
p
font-size: 1.1rem
font-weight: 600
color: var(--text-primary)
margin-bottom: var(--spacing-sm)
small
color: var(--text-secondary)
line-height: 1.4
// Events List
.events-list
display: flex
flex-direction: column
gap: var(--spacing-lg)
.day-section
background: var(--surface-color)
border-radius: var(--border-radius)
box-shadow: var(--shadow-light)
overflow: hidden
.day-header
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%)
color: white
padding: var(--spacing-md)
display: flex
align-items: center
justify-content: space-between
h3
font-size: 1.125rem
font-weight: 600
margin: 0
.event-count
background: rgba(255, 255, 255, 0.2)
padding: 4px 8px
border-radius: 12px
font-size: 0.75rem
font-weight: 500
.day-events
padding: var(--spacing-sm)
display: flex
flex-direction: column
gap: var(--spacing-sm)
// Calendar Event
.calendar-event
background: var(--surface-color)
border: 1px solid var(--border-color)
border-left: 4px solid var(--primary-color)
border-radius: var(--border-radius-small)
padding: var(--spacing-md)
display: flex
align-items: flex-start
justify-content: space-between
gap: var(--spacing-md)
transition: all 0.2s ease
&:hover
box-shadow: var(--shadow-medium)
transform: translateY(-1px)
.event-content
flex: 1
min-width: 0
.event-text
font-weight: 500
color: var(--text-primary)
font-size: 1rem
margin-bottom: var(--spacing-xs)
word-break: break-word
.event-user
font-size: 0.875rem
font-weight: 600
opacity: 0.8
.event-actions
display: flex
gap: var(--spacing-xs)
flex-shrink: 0
.edit-form
width: 100%
display: flex
flex-direction: column
gap: var(--spacing-sm)
.form-group
display: flex
flex-direction: column
label
font-size: 0.875rem
font-weight: 500
color: var(--text-secondary)
margin-bottom: var(--spacing-xs)
input, select
width: 100%
padding: var(--spacing-sm)
border: 1px solid var(--border-color)
border-radius: var(--border-radius-small)
font-size: 0.875rem
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)
justify-content: flex-end
margin-top: var(--spacing-sm)
// 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)
max-height: 80vh
overflow-y: auto
h3
font-size: 1.25rem
font-weight: 600
margin-bottom: var(--spacing-md)
color: var(--text-primary)
text-align: center
.form-group
display: flex
flex-direction: column
margin-bottom: var(--spacing-md)
label
font-size: 0.875rem
font-weight: 500
color: var(--text-secondary)
margin-bottom: var(--spacing-xs)
input, select
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
margin-top: var(--spacing-lg)
// 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
gap: var(--spacing-sm)
h1
font-size: 1.25rem
.month-navigation
.current-month
font-size: 1rem
min-width: 120px
.btn-icon
width: 36px
height: 36px
.view-controls
flex-direction: column
gap: var(--spacing-sm)
align-items: stretch
.view-type-toggle
justify-content: center
.user-select
width: 100%
.day-header
padding: var(--spacing-sm)
h3
font-size: 1rem
.calendar-event
padding: var(--spacing-sm)
flex-direction: column
align-items: stretch
.event-actions
justify-content: flex-end
margin-top: var(--spacing-sm)
.add-form-overlay
padding: var(--spacing-sm)
.add-form
padding: var(--spacing-md)
max-height: 90vh
.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,45 @@
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 });
}
// 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', '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
}
}
}
})