Create mobile-shopping app
This commit is contained in:
24
mobile-shopping/.gitignore
vendored
Normal file
24
mobile-shopping/.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-shopping/eslint.config.js
Normal file
29
mobile-shopping/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-shopping/index.html
Normal file
25
mobile-shopping/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shopping List Mobile</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Mobile shopping list app with auto-sync">
|
||||
<meta name="theme-color" content="#007AFF">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="ShoppingList">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<!-- Additional PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="application-name" content="ShoppingList">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
30
mobile-shopping/package.json
Normal file
30
mobile-shopping/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mobile-shopping",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"sass": "^1.89.2",
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
}
|
||||
}
|
4789
mobile-shopping/pnpm-lock.yaml
generated
Normal file
4789
mobile-shopping/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile-shopping/public/apple-touch-icon.png
Normal file
BIN
mobile-shopping/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
mobile-shopping/public/pwa-192x192.png
Normal file
BIN
mobile-shopping/public/pwa-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
mobile-shopping/public/pwa-512x512.png
Normal file
BIN
mobile-shopping/public/pwa-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
421
mobile-shopping/src/App.jsx
Normal file
421
mobile-shopping/src/App.jsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FaPlus, FaTrash, FaCheck, FaEdit, FaShoppingCart, FaClock } from 'react-icons/fa';
|
||||
import shoppingService from './services/ShoppingService';
|
||||
import './App.sass';
|
||||
|
||||
const App = () => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [newItem, setNewItem] = useState({ name: '', amount: '1' });
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [showInstallPrompt, setShowInstallPrompt] = useState(false);
|
||||
|
||||
// Load shopping items on component mount
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
|
||||
// Set up auto-refresh every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadItems();
|
||||
}, 5000);
|
||||
|
||||
// PWA install prompt handling
|
||||
const handleBeforeInstallPrompt = (e) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e);
|
||||
setShowInstallPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
|
||||
// Cleanup interval and event listener on component unmount
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadItems = async () => {
|
||||
try {
|
||||
if (initialLoad) {
|
||||
setLoading(true);
|
||||
}
|
||||
const data = await shoppingService.getItems();
|
||||
setItems(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load shopping items');
|
||||
console.error('Error loading items:', err);
|
||||
} finally {
|
||||
if (initialLoad) {
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddItem = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newItem.name.trim()) return;
|
||||
|
||||
try {
|
||||
const createdItem = await shoppingService.createItem({
|
||||
name: newItem.name.trim(),
|
||||
amount: newItem.amount.trim() || '1'
|
||||
});
|
||||
setItems([createdItem, ...items]);
|
||||
setNewItem({ name: '', amount: '1' });
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Failed to add item');
|
||||
console.error('Error adding item:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleItem = async (id) => {
|
||||
try {
|
||||
const updatedItem = await shoppingService.toggleItem(id);
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? updatedItem : item
|
||||
));
|
||||
} catch (err) {
|
||||
setError('Failed to update item');
|
||||
console.error('Error toggling item:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id) => {
|
||||
try {
|
||||
await shoppingService.deleteItem(id);
|
||||
setItems(items.filter(item => item.id !== id));
|
||||
} catch (err) {
|
||||
setError('Failed to delete item');
|
||||
console.error('Error deleting item:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateItem = async (id, updatedData) => {
|
||||
try {
|
||||
const updatedItem = await shoppingService.updateItem(id, updatedData);
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? updatedItem : item
|
||||
));
|
||||
setEditingItem(null);
|
||||
} catch (err) {
|
||||
setError('Failed to update item');
|
||||
console.error('Error updating item:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChecked = async () => {
|
||||
try {
|
||||
await shoppingService.deleteCheckedItems();
|
||||
setItems(items.filter(item => !item.checked));
|
||||
} catch (err) {
|
||||
setError('Failed to delete checked items');
|
||||
console.error('Error deleting checked items:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallApp = async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setDeferredPrompt(null);
|
||||
setShowInstallPrompt(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeUntilDeletion = (checkedAt) => {
|
||||
if (!checkedAt) return null;
|
||||
const twoHoursLater = new Date(new Date(checkedAt).getTime() + 2 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
const timeDiff = twoHoursLater - now;
|
||||
|
||||
if (timeDiff <= 0) return 'Wird gelöscht...';
|
||||
|
||||
const minutes = Math.floor(timeDiff / (1000 * 60));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const uncheckedItems = items.filter(item => !item.checked);
|
||||
const checkedItems = items.filter(item => item.checked);
|
||||
|
||||
if (loading && initialLoad) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="loading">
|
||||
<FaShoppingCart className="loading-icon" />
|
||||
<p>Einkaufsliste wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<h1>
|
||||
<FaShoppingCart />
|
||||
Einkaufsliste
|
||||
</h1>
|
||||
<div className="header-actions">
|
||||
{checkedItems.length > 0 && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleDeleteChecked}
|
||||
>
|
||||
<FaTrash />
|
||||
{checkedItems.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInstallPrompt && (
|
||||
<div className="install-prompt">
|
||||
<p>Diese App auf dem Homescreen installieren?</p>
|
||||
<div className="install-actions">
|
||||
<button className="btn btn-primary" onClick={handleInstallApp}>
|
||||
Installieren
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowInstallPrompt(false)}>
|
||||
Später
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="add-form-overlay">
|
||||
<form className="add-form" onSubmit={handleAddItem}>
|
||||
<h3>Neuer Artikel</h3>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Artikelname..."
|
||||
value={newItem.name}
|
||||
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Menge"
|
||||
value={newItem.amount}
|
||||
onChange={(e) => setNewItem({ ...newItem, amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shopping-content">
|
||||
{/* Unchecked Items */}
|
||||
<section className="shopping-section">
|
||||
<h2>Zu kaufen ({uncheckedItems.length})</h2>
|
||||
<div className="items-list">
|
||||
{uncheckedItems.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FaShoppingCart />
|
||||
<p>Keine Artikel auf der Einkaufsliste</p>
|
||||
</div>
|
||||
) : (
|
||||
uncheckedItems.map(item => (
|
||||
<ShoppingItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onToggle={handleToggleItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onUpdate={handleUpdateItem}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Checked Items */}
|
||||
{checkedItems.length > 0 && (
|
||||
<section className="shopping-section">
|
||||
<h2>Erledigt ({checkedItems.length})</h2>
|
||||
<div className="items-list">
|
||||
{checkedItems.map(item => (
|
||||
<ShoppingItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onToggle={handleToggleItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onUpdate={handleUpdateItem}
|
||||
editingItem={editingItem}
|
||||
setEditingItem={setEditingItem}
|
||||
formatDate={formatDate}
|
||||
getTimeUntilDeletion={getTimeUntilDeletion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="fab-container">
|
||||
<button
|
||||
className="fab"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Shopping Item Component
|
||||
const ShoppingItem = ({
|
||||
item,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
formatDate,
|
||||
getTimeUntilDeletion
|
||||
}) => {
|
||||
const [editData, setEditData] = useState({ name: item.name, amount: item.amount });
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditingItem(item.id);
|
||||
setEditData({ name: item.name, amount: item.amount });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(item.id, editData);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingItem(null);
|
||||
setEditData({ name: item.name, amount: item.amount });
|
||||
};
|
||||
|
||||
const isEditing = editingItem === item.id;
|
||||
|
||||
return (
|
||||
<div className={`shopping-item ${item.checked ? 'checked' : ''}`}>
|
||||
<button
|
||||
className="check-btn"
|
||||
onClick={() => onToggle(item.id)}
|
||||
>
|
||||
<FaCheck />
|
||||
</button>
|
||||
|
||||
<div className="item-content">
|
||||
{isEditing ? (
|
||||
<div className="edit-form">
|
||||
<input
|
||||
type="text"
|
||||
value={editData.name}
|
||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.amount}
|
||||
onChange={(e) => setEditData({ ...editData, amount: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
/>
|
||||
<div className="edit-actions">
|
||||
<button className="btn btn-primary btn-small" onClick={handleSave}>
|
||||
<FaCheck />
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="item-main">
|
||||
<span className="item-name">{item.name}</span>
|
||||
<span className="item-amount">{item.amount}</span>
|
||||
</div>
|
||||
<div className="item-meta">
|
||||
<span className="item-date">
|
||||
{formatDate(item.date)}
|
||||
</span>
|
||||
{item.checked && item.checkedAt && getTimeUntilDeletion && (
|
||||
<span className="deletion-timer">
|
||||
<FaClock />
|
||||
{getTimeUntilDeletion(item.checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="item-actions">
|
||||
{!isEditing && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button className="btn btn-danger btn-small" onClick={() => onDelete(item.id)}>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
513
mobile-shopping/src/App.sass
Normal file
513
mobile-shopping/src/App.sass
Normal file
@@ -0,0 +1,513 @@
|
||||
// Mobile Shopping App Styles
|
||||
// Clean, simple design without glassmorphism
|
||||
|
||||
:root
|
||||
--primary-color: #007AFF
|
||||
--secondary-color: #5856D6
|
||||
--success-color: #34C759
|
||||
--danger-color: #FF3B30
|
||||
--warning-color: #FF9500
|
||||
--background-color: #f8f9fa
|
||||
--surface-color: #ffffff
|
||||
--border-color: #e1e5e9
|
||||
--text-primary: #1d1d1f
|
||||
--text-secondary: #6e6e73
|
||||
--text-muted: #8e8e93
|
||||
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1)
|
||||
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15)
|
||||
--border-radius: 12px
|
||||
--border-radius-small: 8px
|
||||
--spacing-xs: 4px
|
||||
--spacing-sm: 8px
|
||||
--spacing-md: 16px
|
||||
--spacing-lg: 24px
|
||||
--spacing-xl: 32px
|
||||
|
||||
*
|
||||
margin: 0
|
||||
padding: 0
|
||||
box-sizing: border-box
|
||||
|
||||
body
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif
|
||||
background-color: var(--background-color)
|
||||
color: var(--text-primary)
|
||||
line-height: 1.5
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
|
||||
.app
|
||||
min-height: 100vh
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: 100vw
|
||||
overflow-x: hidden
|
||||
|
||||
// Header
|
||||
.header
|
||||
background: var(--surface-color)
|
||||
border-bottom: 1px solid var(--border-color)
|
||||
padding: var(--spacing-sm) var(--spacing-md)
|
||||
position: sticky
|
||||
top: 0
|
||||
z-index: 100
|
||||
box-shadow: var(--shadow-light)
|
||||
|
||||
.header-content
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
max-width: 600px
|
||||
margin: 0 auto
|
||||
|
||||
h1
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
color: var(--text-primary)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--spacing-sm)
|
||||
|
||||
svg
|
||||
color: var(--primary-color)
|
||||
font-size: 1.25rem
|
||||
|
||||
.header-actions
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--spacing-sm)
|
||||
|
||||
// Main Content
|
||||
.main
|
||||
flex: 1
|
||||
padding: var(--spacing-md)
|
||||
max-width: 600px
|
||||
margin: 0 auto
|
||||
width: 100%
|
||||
padding-bottom: 100px // Space for FAB
|
||||
|
||||
// Error Message
|
||||
.error-message
|
||||
background: var(--danger-color)
|
||||
color: white
|
||||
padding: var(--spacing-md)
|
||||
border-radius: var(--border-radius-small)
|
||||
margin-bottom: var(--spacing-md)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
font-weight: 500
|
||||
|
||||
button
|
||||
background: none
|
||||
border: none
|
||||
color: white
|
||||
font-size: 1.25rem
|
||||
cursor: pointer
|
||||
padding: 0
|
||||
width: 24px
|
||||
height: 24px
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
|
||||
// Install Prompt
|
||||
.install-prompt
|
||||
background: var(--primary-color)
|
||||
color: white
|
||||
padding: var(--spacing-md)
|
||||
border-radius: var(--border-radius-small)
|
||||
margin-bottom: var(--spacing-md)
|
||||
text-align: center
|
||||
|
||||
p
|
||||
margin-bottom: var(--spacing-md)
|
||||
font-weight: 500
|
||||
|
||||
.install-actions
|
||||
display: flex
|
||||
gap: var(--spacing-sm)
|
||||
justify-content: center
|
||||
|
||||
.btn
|
||||
color: var(--primary-color)
|
||||
background: white
|
||||
border: 1px solid white
|
||||
|
||||
&:hover
|
||||
background: #f0f0f0
|
||||
|
||||
&.btn-secondary
|
||||
background: transparent
|
||||
color: white
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
|
||||
// Loading State
|
||||
.loading
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
justify-content: center
|
||||
height: 60vh
|
||||
color: var(--text-secondary)
|
||||
|
||||
.loading-icon
|
||||
font-size: 3rem
|
||||
color: var(--primary-color)
|
||||
margin-bottom: var(--spacing-md)
|
||||
animation: pulse 2s infinite
|
||||
|
||||
p
|
||||
font-size: 1.1rem
|
||||
font-weight: 500
|
||||
|
||||
@keyframes pulse
|
||||
0%, 100%
|
||||
opacity: 1
|
||||
50%
|
||||
opacity: 0.5
|
||||
|
||||
// Shopping Content
|
||||
.shopping-content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: var(--spacing-xl)
|
||||
|
||||
.shopping-section
|
||||
h2
|
||||
font-size: 1.25rem
|
||||
font-weight: 600
|
||||
color: var(--text-primary)
|
||||
margin-bottom: var(--spacing-md)
|
||||
padding: 0 var(--spacing-xs)
|
||||
|
||||
.items-list
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: var(--spacing-sm)
|
||||
|
||||
// Empty State
|
||||
.empty-state
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
justify-content: center
|
||||
padding: var(--spacing-xl)
|
||||
color: var(--text-muted)
|
||||
text-align: center
|
||||
|
||||
svg
|
||||
font-size: 3rem
|
||||
margin-bottom: var(--spacing-md)
|
||||
opacity: 0.5
|
||||
|
||||
p
|
||||
font-size: 1.1rem
|
||||
font-weight: 500
|
||||
|
||||
// Shopping Item
|
||||
.shopping-item
|
||||
background: var(--surface-color)
|
||||
border: 1px solid var(--border-color)
|
||||
border-radius: var(--border-radius)
|
||||
padding: var(--spacing-md)
|
||||
display: flex
|
||||
align-items: flex-start
|
||||
gap: var(--spacing-md)
|
||||
transition: all 0.2s ease
|
||||
box-shadow: var(--shadow-light)
|
||||
|
||||
&:hover
|
||||
box-shadow: var(--shadow-medium)
|
||||
transform: translateY(-1px)
|
||||
|
||||
&.checked
|
||||
opacity: 0.7
|
||||
background: #f8f9fa
|
||||
|
||||
.item-name
|
||||
text-decoration: line-through
|
||||
color: var(--text-muted)
|
||||
|
||||
.check-btn
|
||||
background: var(--success-color)
|
||||
color: white
|
||||
|
||||
&:hover
|
||||
background: #28a745
|
||||
|
||||
.check-btn
|
||||
width: 32px
|
||||
height: 32px
|
||||
border-radius: 50%
|
||||
border: 2px solid var(--border-color)
|
||||
background: var(--surface-color)
|
||||
color: transparent
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
transition: all 0.2s ease
|
||||
flex-shrink: 0
|
||||
margin-top: 2px
|
||||
|
||||
&:hover
|
||||
border-color: var(--success-color)
|
||||
background: rgba(52, 199, 89, 0.1)
|
||||
|
||||
svg
|
||||
font-size: 0.875rem
|
||||
|
||||
.item-content
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.item-main
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
margin-bottom: var(--spacing-xs)
|
||||
|
||||
.item-name
|
||||
font-weight: 500
|
||||
color: var(--text-primary)
|
||||
font-size: 1rem
|
||||
flex: 1
|
||||
word-break: break-word
|
||||
|
||||
.item-amount
|
||||
background: var(--primary-color)
|
||||
color: white
|
||||
padding: 2px 8px
|
||||
border-radius: 12px
|
||||
font-size: 0.75rem
|
||||
font-weight: 600
|
||||
margin-left: var(--spacing-sm)
|
||||
flex-shrink: 0
|
||||
|
||||
.item-meta
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--spacing-md)
|
||||
font-size: 0.875rem
|
||||
color: var(--text-secondary)
|
||||
|
||||
.deletion-timer
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--spacing-xs)
|
||||
color: var(--warning-color)
|
||||
font-weight: 500
|
||||
|
||||
svg
|
||||
font-size: 0.75rem
|
||||
|
||||
.edit-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: var(--spacing-sm)
|
||||
|
||||
input
|
||||
width: 100%
|
||||
padding: var(--spacing-sm)
|
||||
border: 1px solid var(--border-color)
|
||||
border-radius: var(--border-radius-small)
|
||||
font-size: 1rem
|
||||
background: var(--surface-color)
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: var(--primary-color)
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1)
|
||||
|
||||
.edit-actions
|
||||
display: flex
|
||||
gap: var(--spacing-sm)
|
||||
margin-top: var(--spacing-xs)
|
||||
|
||||
.item-actions
|
||||
display: flex
|
||||
gap: var(--spacing-xs)
|
||||
flex-shrink: 0
|
||||
|
||||
// Buttons
|
||||
.btn
|
||||
border: none
|
||||
border-radius: var(--border-radius-small)
|
||||
padding: var(--spacing-sm) var(--spacing-md)
|
||||
font-size: 0.875rem
|
||||
font-weight: 500
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: var(--spacing-xs)
|
||||
transition: all 0.2s ease
|
||||
text-decoration: none
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
|
||||
&.btn-primary
|
||||
background: var(--primary-color)
|
||||
color: white
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: #0056b3
|
||||
transform: translateY(-1px)
|
||||
|
||||
&.btn-secondary
|
||||
background: var(--border-color)
|
||||
color: var(--text-primary)
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: #d1d5db
|
||||
|
||||
&.btn-danger
|
||||
background: var(--danger-color)
|
||||
color: white
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: #dc3545
|
||||
|
||||
&.btn-small
|
||||
padding: var(--spacing-xs)
|
||||
font-size: 0.75rem
|
||||
width: 32px
|
||||
height: 32px
|
||||
|
||||
svg
|
||||
font-size: 0.875rem
|
||||
|
||||
svg
|
||||
font-size: 1rem
|
||||
|
||||
// Add Form Overlay
|
||||
.add-form-overlay
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: rgba(0, 0, 0, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
z-index: 1000
|
||||
padding: var(--spacing-md)
|
||||
|
||||
.add-form
|
||||
background: var(--surface-color)
|
||||
border-radius: var(--border-radius)
|
||||
padding: var(--spacing-lg)
|
||||
width: 100%
|
||||
max-width: 400px
|
||||
box-shadow: var(--shadow-medium)
|
||||
|
||||
h3
|
||||
font-size: 1.25rem
|
||||
font-weight: 600
|
||||
margin-bottom: var(--spacing-md)
|
||||
color: var(--text-primary)
|
||||
|
||||
.form-group
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: var(--spacing-sm)
|
||||
margin-bottom: var(--spacing-lg)
|
||||
|
||||
input
|
||||
width: 100%
|
||||
padding: var(--spacing-md)
|
||||
border: 1px solid var(--border-color)
|
||||
border-radius: var(--border-radius-small)
|
||||
font-size: 1rem
|
||||
background: var(--surface-color)
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: var(--primary-color)
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1)
|
||||
|
||||
.form-actions
|
||||
display: flex
|
||||
gap: var(--spacing-sm)
|
||||
justify-content: flex-end
|
||||
|
||||
// Floating Action Button
|
||||
.fab-container
|
||||
position: fixed
|
||||
bottom: var(--spacing-lg)
|
||||
right: var(--spacing-lg)
|
||||
z-index: 100
|
||||
|
||||
.fab
|
||||
width: 56px
|
||||
height: 56px
|
||||
border-radius: 50%
|
||||
background: var(--primary-color)
|
||||
color: white
|
||||
border: none
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-size: 1.25rem
|
||||
box-shadow: var(--shadow-medium)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
background: #0056b3
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2)
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 480px)
|
||||
.main
|
||||
padding: var(--spacing-sm)
|
||||
|
||||
.header
|
||||
padding: var(--spacing-xs) var(--spacing-sm)
|
||||
|
||||
.header-content h1
|
||||
font-size: 1.25rem
|
||||
|
||||
.shopping-item
|
||||
padding: var(--spacing-sm)
|
||||
|
||||
.item-content .item-main
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: var(--spacing-xs)
|
||||
|
||||
.item-amount
|
||||
margin-left: 0
|
||||
|
||||
.item-actions
|
||||
flex-direction: column
|
||||
|
||||
.add-form-overlay
|
||||
padding: var(--spacing-sm)
|
||||
|
||||
.add-form
|
||||
padding: var(--spacing-md)
|
||||
|
||||
.fab-container
|
||||
bottom: var(--spacing-md)
|
||||
right: var(--spacing-md)
|
||||
|
||||
.fab
|
||||
width: 48px
|
||||
height: 48px
|
||||
font-size: 1.125rem
|
556
mobile-shopping/src/App.scss
Normal file
556
mobile-shopping/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-shopping/src/main.jsx
Normal file
22
mobile-shopping/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>,
|
||||
)
|
51
mobile-shopping/src/services/ShoppingService.js
Normal file
51
mobile-shopping/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;
|
85
mobile-shopping/src/utils/RequestUtil.js
Normal file
85
mobile-shopping/src/utils/RequestUtil.js
Normal file
@@ -0,0 +1,85 @@
|
||||
class RequestUtil {
|
||||
constructor() {
|
||||
this.baseURL = this.getBaseURL();
|
||||
}
|
||||
|
||||
getBaseURL() {
|
||||
// In production, use the static endpoint, otherwise use proxy
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return 'https://static.endpoint.com';
|
||||
}
|
||||
return ''; // Uses proxy in development
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
...defaultOptions,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET request
|
||||
async get(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
// POST request
|
||||
async post(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PUT request
|
||||
async put(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH request
|
||||
async patch(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE request
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const requestUtil = new RequestUtil();
|
||||
export default requestUtil;
|
53
mobile-shopping/vite.config.js
Normal file
53
mobile-shopping/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