Create mobile calender service
This commit is contained in:
24
mobile-calendar/.gitignore
vendored
Normal file
24
mobile-calendar/.gitignore
vendored
Normal 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?
|
29
mobile-calendar/eslint.config.js
Normal file
29
mobile-calendar/eslint.config.js
Normal 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_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
25
mobile-calendar/index.html
Normal file
25
mobile-calendar/index.html
Normal 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>
|
30
mobile-calendar/package.json
Normal file
30
mobile-calendar/package.json
Normal 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
4789
mobile-calendar/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile-calendar/public/apple-touch-icon.png
Normal file
BIN
mobile-calendar/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
mobile-calendar/public/pwa-192x192.png
Normal file
BIN
mobile-calendar/public/pwa-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
mobile-calendar/public/pwa-512x512.png
Normal file
BIN
mobile-calendar/public/pwa-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
475
mobile-calendar/src/App.jsx
Normal file
475
mobile-calendar/src/App.jsx
Normal 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;
|
559
mobile-calendar/src/App.sass
Normal file
559
mobile-calendar/src/App.sass
Normal 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
|
556
mobile-calendar/src/App.scss
Normal file
556
mobile-calendar/src/App.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
mobile-calendar/src/main.jsx
Normal file
22
mobile-calendar/src/main.jsx
Normal 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>,
|
||||||
|
)
|
45
mobile-calendar/src/services/CalendarService.js
Normal file
45
mobile-calendar/src/services/CalendarService.js
Normal 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;
|
51
mobile-calendar/src/services/ShoppingService.js
Normal file
51
mobile-calendar/src/services/ShoppingService.js
Normal 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;
|
91
mobile-calendar/src/utils/RequestUtil.js
Normal file
91
mobile-calendar/src/utils/RequestUtil.js
Normal 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;
|
53
mobile-calendar/vite.config.js
Normal file
53
mobile-calendar/vite.config.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user