pwa ops
This commit is contained in:
@@ -202,3 +202,7 @@ When deploying the Brew application to a production environment, follow these gu
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **HTTPS Required for PWAs**: For security reasons, web browsers will only install Progressive Web Apps (PWAs) and register Service Workers when served over a secure connection (`HTTPS`). Make sure to set up an SSL certificate (e.g., via Let's Encrypt / Certbot) for your production deployment domain. Local development on `localhost` or `127.0.0.1` is exempt and will work over HTTP.
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#2C1810" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>brew</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Brew Journal",
|
||||
"short_name": "Brew",
|
||||
"description": "Log and track your coffee brew recipes offline-first.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FAF6F1",
|
||||
"theme_color": "#2C1810",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
||||
80
public/sw.js
Normal file
80
public/sw.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const CACHE_NAME = 'brew-journal-v1';
|
||||
|
||||
// Static assets to cache immediately
|
||||
const PRECACHE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/favicon.svg',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png'
|
||||
];
|
||||
|
||||
// Install Event
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(PRECACHE_ASSETS);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate Event
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys.map((key) => {
|
||||
if (key !== CACHE_NAME) {
|
||||
return caches.delete(key);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch Event
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
// Bypass API requests - always network-only for backend API calls
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(fetch(e.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for static assets
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
// Fetch new version in background to update cache
|
||||
fetch(e.request).then((networkResponse) => {
|
||||
if (networkResponse.status === 200) {
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(e.request, networkResponse);
|
||||
});
|
||||
}
|
||||
}).catch(() => {/* Ignore network errors offline */});
|
||||
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Network fallback
|
||||
return fetch(e.request).then((networkResponse) => {
|
||||
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(e.request, responseToCache);
|
||||
});
|
||||
|
||||
return networkResponse;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
37
server/db.js
37
server/db.js
@@ -7,6 +7,7 @@ const pool = new Pool({
|
||||
|
||||
const initDb = async () => {
|
||||
try {
|
||||
// 1. Users table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -16,6 +17,42 @@ const initDb = async () => {
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 2. Beans table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS beans (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
roastery VARCHAR(255),
|
||||
tasting_notes TEXT,
|
||||
roast_type VARCHAR(50),
|
||||
roast_date VARCHAR(50),
|
||||
image TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
`);
|
||||
|
||||
// 3. Brew logs table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS brew_logs (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
bean_id VARCHAR(50) REFERENCES beans(id) ON DELETE CASCADE,
|
||||
method VARCHAR(50),
|
||||
grind VARCHAR(100),
|
||||
water_temp VARCHAR(50),
|
||||
ratio VARCHAR(50),
|
||||
yield VARCHAR(50),
|
||||
time VARCHAR(50),
|
||||
notes TEXT,
|
||||
rating INTEGER,
|
||||
created_at BIGINT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
`);
|
||||
console.log('Database initialized');
|
||||
} catch (err) {
|
||||
console.error('Error initializing database', err);
|
||||
|
||||
137
server/index.js
137
server/index.js
@@ -129,6 +129,143 @@ app.post('/api/verify-token', authenticateToken, (req, res) => {
|
||||
res.json({ valid: true, user: req.user });
|
||||
});
|
||||
|
||||
// Sync data (beans and brew logs)
|
||||
app.post('/api/sync', authenticateToken, async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { lastSyncedAt, beans = [], brewLogs = [] } = req.body;
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. Process incoming beans
|
||||
for (const bean of beans) {
|
||||
await client.query(`
|
||||
INSERT INTO beans (id, user_id, name, roastery, tasting_notes, roast_type, roast_date, image, updated_at, is_deleted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
roastery = EXCLUDED.roastery,
|
||||
tasting_notes = EXCLUDED.tasting_notes,
|
||||
roast_type = EXCLUDED.roast_type,
|
||||
roast_date = EXCLUDED.roast_date,
|
||||
image = EXCLUDED.image,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
is_deleted = EXCLUDED.is_deleted
|
||||
WHERE EXCLUDED.updated_at > beans.updated_at OR beans.user_id IS NULL
|
||||
`, [
|
||||
bean.id,
|
||||
userId,
|
||||
bean.name || '',
|
||||
bean.roastery || '',
|
||||
bean.tastingNotes || '',
|
||||
bean.roastType || '',
|
||||
bean.roastDate || '',
|
||||
bean.image || '',
|
||||
bean.updatedAt ? new Date(bean.updatedAt) : new Date(),
|
||||
bean.isDeleted || false
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. Process incoming brew logs
|
||||
for (const log of brewLogs) {
|
||||
await client.query(`
|
||||
INSERT INTO brew_logs (id, user_id, bean_id, method, grind, water_temp, ratio, yield, time, notes, rating, created_at, updated_at, is_deleted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
bean_id = EXCLUDED.bean_id,
|
||||
method = EXCLUDED.method,
|
||||
grind = EXCLUDED.grind,
|
||||
water_temp = EXCLUDED.water_temp,
|
||||
ratio = EXCLUDED.ratio,
|
||||
yield = EXCLUDED.yield,
|
||||
time = EXCLUDED.time,
|
||||
notes = EXCLUDED.notes,
|
||||
rating = EXCLUDED.rating,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
is_deleted = EXCLUDED.is_deleted
|
||||
WHERE EXCLUDED.updated_at > brew_logs.updated_at OR brew_logs.user_id IS NULL
|
||||
`, [
|
||||
log.id,
|
||||
userId,
|
||||
log.beanId,
|
||||
log.method || '',
|
||||
log.grind || '',
|
||||
log.waterTemp || '',
|
||||
log.ratio || '',
|
||||
log.yield || '',
|
||||
log.time || '',
|
||||
log.notes || '',
|
||||
log.rating || 0,
|
||||
log.createdAt ? BigInt(log.createdAt) : BigInt(Date.now()),
|
||||
log.updatedAt ? new Date(log.updatedAt) : new Date(),
|
||||
log.isDeleted || false
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Fetch server changes since lastSyncedAt
|
||||
let beansQuery = `SELECT * FROM beans WHERE user_id = $1`;
|
||||
let logsQuery = `SELECT * FROM brew_logs WHERE user_id = $1`;
|
||||
const params = [userId];
|
||||
|
||||
if (lastSyncedAt) {
|
||||
beansQuery += ` AND updated_at > $2`;
|
||||
logsQuery += ` AND updated_at > $2`;
|
||||
params.push(new Date(lastSyncedAt));
|
||||
}
|
||||
|
||||
const serverBeans = await client.query(beansQuery, params);
|
||||
const serverLogs = await client.query(logsQuery, params);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Map DB fields back to camelCase expected by the frontend
|
||||
const mappedBeans = serverBeans.rows.map(b => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
roastery: b.roastery,
|
||||
tastingNotes: b.tasting_notes,
|
||||
roastType: b.roast_type,
|
||||
roastDate: b.roast_date,
|
||||
image: b.image,
|
||||
updatedAt: b.updated_at.toISOString(),
|
||||
isDeleted: b.is_deleted
|
||||
}));
|
||||
|
||||
const mappedLogs = serverLogs.rows.map(l => ({
|
||||
id: l.id,
|
||||
beanId: l.bean_id,
|
||||
method: l.method,
|
||||
grind: l.grind,
|
||||
waterTemp: l.water_temp,
|
||||
ratio: l.ratio,
|
||||
yield: l.yield,
|
||||
time: l.time,
|
||||
notes: l.notes,
|
||||
rating: l.rating,
|
||||
createdAt: Number(l.created_at),
|
||||
updatedAt: l.updated_at.toISOString(),
|
||||
isDeleted: l.is_deleted
|
||||
}));
|
||||
|
||||
res.json({
|
||||
serverTime,
|
||||
beans: mappedBeans,
|
||||
brewLogs: mappedLogs
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Sync failed', err);
|
||||
res.status(500).json({ error: 'Sync failed' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
211
src/App.jsx
211
src/App.jsx
@@ -3,14 +3,47 @@ import { AuthContext } from "./AuthContext";
|
||||
import Login from "./Login";
|
||||
import Register from "./Register";
|
||||
|
||||
// ─── Storage helpers ───
|
||||
// ─── Storage helpers with Browser/localStorage Fallback ───
|
||||
const STORAGE_KEY = "coffee-logbook-data";
|
||||
const defaultData = { beans: [], brewLogs: [] };
|
||||
const defaultData = { beans: [], brewLogs: [], lastSyncedAt: null };
|
||||
|
||||
const storage = {
|
||||
get: async (key) => {
|
||||
try {
|
||||
if (window.storage && typeof window.storage.get === "function") {
|
||||
return await window.storage.get(key);
|
||||
}
|
||||
const val = localStorage.getItem(key);
|
||||
return val ? { value: val } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set: async (key, val) => {
|
||||
try {
|
||||
if (window.storage && typeof window.storage.set === "function") {
|
||||
await window.storage.set(key, val);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, val);
|
||||
} catch (e) {
|
||||
console.error("Storage set failed:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await window.storage.get(STORAGE_KEY);
|
||||
return result ? JSON.parse(result.value) : defaultData;
|
||||
const result = await storage.get(STORAGE_KEY);
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result.value);
|
||||
return {
|
||||
beans: parsed.beans || [],
|
||||
brewLogs: parsed.brewLogs || [],
|
||||
lastSyncedAt: parsed.lastSyncedAt || null
|
||||
};
|
||||
}
|
||||
return defaultData;
|
||||
} catch {
|
||||
return defaultData;
|
||||
}
|
||||
@@ -18,7 +51,7 @@ async function loadData() {
|
||||
|
||||
async function saveData(data) {
|
||||
try {
|
||||
await window.storage.set(STORAGE_KEY, JSON.stringify(data));
|
||||
await storage.set(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.error("Save failed:", e);
|
||||
}
|
||||
@@ -209,6 +242,7 @@ const styles = `
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
@keyframes pulse { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
@@ -727,14 +761,131 @@ export default function CoffeeLogbook() {
|
||||
const [brewFilter, setBrewFilter] = useState("all");
|
||||
const [editingBean, setEditingBean] = useState(null);
|
||||
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
// Load local data on mount
|
||||
useEffect(() => {
|
||||
loadData().then(setData);
|
||||
}, []);
|
||||
|
||||
// Sync logic
|
||||
const syncData = useCallback(async (currentData = data) => {
|
||||
if (!token || !currentData) return;
|
||||
if (!navigator.onLine) {
|
||||
setIsOnline(false);
|
||||
return;
|
||||
}
|
||||
setIsOnline(true);
|
||||
setSyncing(true);
|
||||
|
||||
try {
|
||||
const { beans = [], brewLogs = [], lastSyncedAt = null } = currentData;
|
||||
|
||||
const res = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lastSyncedAt,
|
||||
beans,
|
||||
brewLogs
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Sync failed');
|
||||
}
|
||||
|
||||
const responseData = await res.json();
|
||||
const { serverTime, beans: serverBeans, brewLogs: serverLogs } = responseData;
|
||||
|
||||
// Merge server beans
|
||||
const mergedBeans = [...beans];
|
||||
serverBeans.forEach(sb => {
|
||||
const idx = mergedBeans.findIndex(b => b.id === sb.id);
|
||||
if (idx >= 0) {
|
||||
const localBean = mergedBeans[idx];
|
||||
if (new Date(sb.updatedAt) > new Date(localBean.updatedAt || 0)) {
|
||||
mergedBeans[idx] = sb;
|
||||
}
|
||||
} else {
|
||||
mergedBeans.push(sb);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge server logs
|
||||
const mergedLogs = [...brewLogs];
|
||||
serverLogs.forEach(sl => {
|
||||
const idx = mergedLogs.findIndex(l => l.id === sl.id);
|
||||
if (idx >= 0) {
|
||||
const localLog = mergedLogs[idx];
|
||||
if (new Date(sl.updatedAt) > new Date(localLog.updatedAt || 0)) {
|
||||
mergedLogs[idx] = sl;
|
||||
}
|
||||
} else {
|
||||
mergedLogs.push(sl);
|
||||
}
|
||||
});
|
||||
|
||||
// Purge soft-deleted items that are successfully synced to the database
|
||||
const finalBeans = mergedBeans.filter(b => !(b.isDeleted && new Date(b.updatedAt) <= new Date(serverTime)));
|
||||
const finalLogs = mergedLogs.filter(l => !(l.isDeleted && new Date(l.updatedAt) <= new Date(serverTime)));
|
||||
|
||||
const nextData = {
|
||||
beans: finalBeans,
|
||||
brewLogs: finalLogs,
|
||||
lastSyncedAt: serverTime
|
||||
};
|
||||
|
||||
setData(nextData);
|
||||
await saveData(nextData);
|
||||
} catch (err) {
|
||||
console.error('Failed to sync data:', err);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [token, data]);
|
||||
|
||||
// Periodic and connectivity event-driven sync triggers
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
syncData();
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setIsOnline(false);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Initial sync once token and data are loaded
|
||||
if (token && data) {
|
||||
syncData();
|
||||
}
|
||||
|
||||
// Sync every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (token && data) {
|
||||
syncData();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [token, data === null, syncData]);
|
||||
|
||||
const persist = useCallback(async (newData) => {
|
||||
setData(newData);
|
||||
await saveData(newData);
|
||||
}, []);
|
||||
syncData(newData);
|
||||
}, [syncData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -788,33 +939,45 @@ export default function CoffeeLogbook() {
|
||||
|
||||
if (!data) return <div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}><div style={{ textAlign: "center", color: "var(--text3)" }}><div style={{ fontSize: 32 }}>☕</div><div style={{ marginTop: 8 }}>Loading…</div></div></div>;
|
||||
|
||||
const { beans, brewLogs } = data;
|
||||
// Filter out soft-deleted items for UI rendering
|
||||
const allBeans = data.beans || [];
|
||||
const allLogs = data.brewLogs || [];
|
||||
|
||||
const beans = allBeans.filter(b => !b.isDeleted);
|
||||
const brewLogs = allLogs.filter(l => !l.isDeleted);
|
||||
|
||||
const addBean = (form) => {
|
||||
const bean = { id: uid(), ...form };
|
||||
persist({ ...data, beans: [...beans, bean] });
|
||||
const now = new Date().toISOString();
|
||||
const bean = { id: uid(), ...form, updatedAt: now, isDeleted: false };
|
||||
persist({ ...data, beans: [...allBeans, bean] });
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const updateBean = (form) => {
|
||||
persist({ ...data, beans: beans.map(b => b.id === editingBean.id ? { ...b, ...form } : b) });
|
||||
const now = new Date().toISOString();
|
||||
const nextBeans = allBeans.map(b => b.id === editingBean.id ? { ...b, ...form, updatedAt: now } : b);
|
||||
persist({ ...data, beans: nextBeans });
|
||||
setModal(null);
|
||||
setEditingBean(null);
|
||||
if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form });
|
||||
if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form, updatedAt: now });
|
||||
};
|
||||
|
||||
const deleteBean = (id) => {
|
||||
persist({ ...data, beans: beans.filter(b => b.id !== id), brewLogs: brewLogs.filter(l => l.beanId !== id) });
|
||||
const now = new Date().toISOString();
|
||||
const nextBeans = allBeans.map(b => b.id === id ? { ...b, isDeleted: true, updatedAt: now } : b);
|
||||
const nextLogs = allLogs.map(l => l.beanId === id ? { ...l, isDeleted: true, updatedAt: now } : l);
|
||||
persist({ ...data, beans: nextBeans, brewLogs: nextLogs });
|
||||
setSelectedBean(null);
|
||||
};
|
||||
|
||||
const addBrew = (form) => {
|
||||
const log = { id: uid(), createdAt: Date.now(), ...form };
|
||||
persist({ ...data, brewLogs: [...brewLogs, log] });
|
||||
const now = new Date().toISOString();
|
||||
const log = { id: uid(), createdAt: Date.now(), ...form, updatedAt: now, isDeleted: false };
|
||||
persist({ ...data, brewLogs: [...allLogs, log] });
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const getBeanName = (id) => beans.find(b => b.id === id)?.name || "Unknown Bean";
|
||||
const getBeanName = (id) => allBeans.find(b => b.id === id)?.name || "Unknown Bean";
|
||||
|
||||
const filteredLogs = (brewFilter === "all" ? brewLogs : brewLogs.filter(l => l.method === brewFilter))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
@@ -833,6 +996,24 @@ export default function CoffeeLogbook() {
|
||||
<h1>Brew Journal</h1>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "11px",
|
||||
fontWeight: "500",
|
||||
color: isOnline ? "#4A7C59" : "#B44040",
|
||||
background: isOnline ? "rgba(74,124,89,0.08)" : "rgba(180,64,64,0.08)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "12px",
|
||||
transition: "all 0.3s"
|
||||
}}
|
||||
title={isOnline ? "All local logs are synchronized with PostgreSQL database" : "No internet connection. Saved locally."}
|
||||
>
|
||||
<span style={{ fontSize: "10px", animation: syncing ? "pulse 1.5s infinite" : "none" }}>●</span>
|
||||
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: "12px", color: "var(--text2)" }}>{user?.username}</span>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
|
||||
@@ -11,3 +11,12 @@ createRoot(document.getElementById('root')).render(
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('Service Worker registered', reg))
|
||||
.catch(err => console.error('Service Worker registration failed', err));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user