17 Commits

Author SHA1 Message Date
681f892d63 feat: Improve preparation method display and filtering 2026-06-07 00:31:38 +05:30
012db524cb docs: Improve readme
All checks were successful
Deploy Brew Application / deploy (push) Successful in 10s
2026-06-06 23:25:59 +05:30
77349f2b90 refactor: Made modal a reusable component
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 22:36:19 +05:30
f95d1f3028 feat: Add search to brew logs
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 22:31:58 +05:30
a27bd118e5 fix: Cross-User Write, strict CORS, rate limiting
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
- IDOR in sync api
- if server was run in prod without jwt secret var then it fell back to inscure string; added startup check
- restrict query requests to vite origin
- use `express-rate-limit`. 100 requests per 15-minute window for a client
2026-06-06 22:14:53 +05:30
de9cbb14d0 feat: Add modal dragHandlers and prevent background scroll
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 20:04:13 +05:30
bf96fb9763 feat: Prevent website behaviors in PWA
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
Merge #1
Reviewed-on: sortedcord/brew#1
2026-06-06 19:44:49 +05:30
f2af73ac69 feat: prevent pull to refresh on phones
- disable y overscroll
2026-06-06 19:36:22 +05:30
1195159c3d feat: try disabling pinch to zoom in on phones 2026-06-06 19:31:53 +05:30
4e234d075f feat: Improved sync editor
All checks were successful
Deploy Brew Application / deploy (push) Successful in 10s
2026-06-06 19:18:30 +05:30
a0b1efd242 fix: prevent shallow clone and get full history with tags
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 18:10:30 +05:30
e808aa8a37 fix: getGitVersion incorrectly stating dirty builds in production
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 18:06:50 +05:30
4a9f6b6266 chore: Fix pm2 process duplication and form icons
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 15:42:52 +05:30
592ccf0a92 docs: Update logos
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 15:35:05 +05:30
f8a1f2cbdd fix: branch name
All checks were successful
Deploy Brew Application / deploy (push) Successful in 17s
2026-06-06 14:08:54 +05:30
1064c724f7 feat: setup CD 2026-06-06 14:07:50 +05:30
9d98966a8e feat: improve profile page 2026-06-06 12:31:27 +05:30
29 changed files with 1139 additions and 392 deletions

View File

@@ -0,0 +1,35 @@
name: Deploy Brew Application
on:
push:
branches:
- master
jobs:
deploy:
runs-on: production
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Frontend
env:
CI: "true"
run: |
npm install
rm -rf dist/
npm run build
- name: Deploy Frontend Files
run: |
rm -rf /var/www/brew/dist/
cp -r dist/ /var/www/brew/
- name: Restart Backend with PM2
run: |
cd server
npm install
pm2 restart brew-backend || pm2 start index.js --name "brew-backend"

View File

@@ -1,6 +1,52 @@
# Brew Application
<p align="center">
<img src="public/brew_favicons/brew_1024.png" alt="Brew Journal Logo" width="256" height="256" />
</p>
Welcome to the **Brew** application! This project features a React + Vite frontend and a Node.js Express + PostgreSQL backend.
<h1 align="center">Brew Journal</h1>
<p align="center">
A beautiful, offline-first coffee logbook and brewing companion designed to track, refine, and perfect your daily coffee rituals.
</p>
<p align="center">
A fully functioning instance of the application is available at <a href="https://brew.adityagupta.dev">brew.adityagupta.dev</a>.
</p>
<p align="center">
<a href="https://react.dev/"><img src="https://img.shields.io/badge/React-19.0-61DAFB?style=flat-square&logo=react&logoColor=black" alt="React" /></a>
<a href="https://vite.dev/"><img src="https://img.shields.io/badge/Vite-8.0-646CFF?style=flat-square&logo=vite&logoColor=white" alt="Vite" /></a>
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/Tailwind_CSS-4.0-38B2AC?style=flat-square&logo=tailwind-css&logoColor=white" alt="TailwindCSS" /></a>
<a href="https://expressjs.com/"><img src="https://img.shields.io/badge/Express-5.2-000000?style=flat-square&logo=express&logoColor=white" alt="Express" /></a>
<a href="https://www.postgresql.org/"><img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL" /></a>
<img src="https://img.shields.io/badge/PWA-Ready-orange?style=flat-square&logo=progressive-web-apps&logoColor=white" alt="PWA Ready" />
<img src="https://img.shields.io/badge/License-ISC-blue?style=flat-square" alt="License" />
</p>
## Screenshots
<p align="center">
<!-- Place your app screenshots here once available -->
<em>Screenshots coming soon!</em>
</p>
<!--
<p align="center">
<img src="path/to/dashboard.png" alt="Brew Journal Dashboard" width="800" />
</p>
<p align="center">
<img src="path/to/mobile_dashboard.png" alt="Mobile Dashboard View" width="280" style="margin-right: 10px;" />
<img src="path/to/brew_form.png" alt="Brew Logging View" width="280" />
</p>
-->
## Features
- **Coffee Bean Inventory & Recipes**: Keep track of your coffee beans, including roasters, roast levels, origins, processing methods, and personal tasting notes. Save custom brewing parameters for every bean.
- **Detailed Brew Logging**: Record every brewing session with precise details: brew method (Pour Over, Espresso, Cold Brew, etc.), grind size, water temperature, brew ratio, and brew time. Grade your cups with detailed taste notes and reviews.
- **Offline-First Sync Engine**: Fully functional offline using local browser storage (`localStorage`). Automatically merges and syncs to a PostgreSQL database via the Express backend when connectivity is restored, using a conflict-resolution system.
- **Mobile & PWA Ready**: Designed with a sleek, mobile-first responsive layout. Features a custom install prompt modal (including iOS specific guidelines) so you can run it as a standalone app.
- **Modern Dark & Light Mode**: Clean, warm, custom-tailored interface matching the espresso color palette. Persistent theme switching based on user preference.
- **Secure User Accounts**: Personal user profiles backed by JSON Web Tokens (JWT) and `bcrypt` password hashing, plus backend API rate-limiting.
## Architecture Overview
@@ -14,8 +60,6 @@ The codebase is split into two main sections:
- [server/index.js](file:///home/sortedcord/Projects/brew/server/index.js) (Express application setup, routes, and authentication middleware)
- [server/db.js](file:///home/sortedcord/Projects/brew/server/db.js) (PostgreSQL connection and table initialization)
---
## Prerequisites
Make sure you have the following installed on your system:
@@ -23,15 +67,15 @@ Make sure you have the following installed on your system:
- **npm** (v9.x or higher)
- **PostgreSQL** (v14 or higher)
---
## Getting Started
## 1. Database Setup (PostgreSQL)
### 1. Database Setup (PostgreSQL)
You need a running PostgreSQL database instance. Follow the steps below based on your operating system:
### Installing PostgreSQL
#### Installing PostgreSQL
#### Linux (Debian/Ubuntu)
##### Linux (Debian/Ubuntu)
```bash
sudo apt update
sudo apt install postgresql postgresql-contrib
@@ -42,18 +86,16 @@ sudo systemctl start postgresql
sudo systemctl enable postgresql
```
#### macOS (using Homebrew)
##### macOS (using Homebrew)
```bash
brew install postgresql
brew services start postgresql
```
#### Windows
##### Windows
Download and run the interactive installer from the [Official PostgreSQL Downloads page](https://www.postgresql.org/download/windows/).
---
### Creating the Database and User
##### Creating the Database and User
1. Log into the PostgreSQL interactive terminal as the superuser `postgres`:
```bash
@@ -87,9 +129,7 @@ Download and run the interactive installer from the [Official PostgreSQL Downloa
> [!NOTE]
> Database tables (such as `users`) are initialized automatically when you start the backend server, as defined in [server/db.js](file:///home/sortedcord/Projects/brew/server/db.js).
---
## 2. Backend Setup
### 2. Backend Setup
1. Navigate to the backend directory:
```bash
@@ -120,9 +160,7 @@ Download and run the interactive installer from the [Official PostgreSQL Downloa
Server running on port 5000
```
---
## 3. Frontend Setup
### 3. Frontend Setup
1. Open a new terminal window/tab and navigate to the project root directory:
```bash
@@ -141,8 +179,6 @@ Download and run the interactive installer from the [Official PostgreSQL Downloa
4. Open your browser and navigate to the local URL printed in the console (usually `http://localhost:5173`).
---
## Production Deployment
When deploying the Brew application to a production environment, follow these guidelines for security, reliability, and performance:
@@ -205,4 +241,3 @@ 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.

View File

@@ -1,28 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<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>
<script>
(function() {
try {
const theme = localStorage.getItem('brew-theme') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/brew_favicons/brew_64.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2C1810" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Brew Journal</title>
<script>
(function () {
try {
const theme = localStorage.getItem('brew-theme') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) { }
})();
// Prevent pinch-to-zoom gesture on iOS devices
document.addEventListener('gesturestart', function (e) {
e.preventDefault();
});
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/icon-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

@@ -50,9 +50,15 @@ const initDb = async () => {
rating INTEGER,
created_at BIGINT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
is_deleted BOOLEAN DEFAULT FALSE,
recipe_data JSONB DEFAULT '{}'
)
`);
// Ensure the recipe_data column exists on older db setups
await pool.query(`
ALTER TABLE brew_logs ADD COLUMN IF NOT EXISTS recipe_data JSONB DEFAULT '{}'
`);
console.log('Database initialized');
} catch (err) {
console.error('Error initializing database', err);

View File

@@ -12,12 +12,29 @@ const jwt = require('jsonwebtoken');
const pool = require('./db');
require('dotenv').config();
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your_jwt_secret_here')) {
console.error("FATAL: JWT_SECRET environment variable is not securely set in production!");
process.exit(1);
}
const rateLimit = require('express-rate-limit');
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'http://localhost:5173';
app.use(cors({
origin: allowedOrigin
}));
app.use(express.json());
// Rate limiter for authentication routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
message: { error: 'Too many authentication attempts. Please try again later.' }
});
// Request logger middleware
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
@@ -44,7 +61,7 @@ const authenticateToken = (req, res, next) => {
};
// Registration
app.post('/api/register', async (req, res) => {
app.post('/api/register', authLimiter, async (req, res) => {
try {
const { username, email, password } = req.body;
@@ -76,7 +93,7 @@ app.post('/api/register', async (req, res) => {
});
// Login
app.post('/api/login', async (req, res) => {
app.post('/api/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
@@ -156,7 +173,8 @@ app.post('/api/sync', authenticateToken, async (req, res) => {
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
WHERE (EXCLUDED.updated_at > beans.updated_at OR beans.user_id IS NULL)
AND (beans.user_id = EXCLUDED.user_id OR beans.user_id IS NULL)
`, [
bean.id,
userId,
@@ -173,9 +191,16 @@ app.post('/api/sync', authenticateToken, async (req, res) => {
// 2. Process incoming brew logs
for (const log of brewLogs) {
const { id, beanId, method, notes, rating, createdAt, updatedAt, isDeleted, ...recipeFields } = log;
const grind = log.grindSize || log.grind || '';
const waterTemp = log.waterTemp || '';
const ratio = log.brewRatio || log.ratio || '';
const yieldVal = log.yield || '';
const time = log.brewTime || log.time || '';
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)
INSERT INTO brew_logs (id, user_id, bean_id, method, grind, water_temp, ratio, yield, time, notes, rating, created_at, updated_at, is_deleted, recipe_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
bean_id = EXCLUDED.bean_id,
method = EXCLUDED.method,
@@ -188,23 +213,26 @@ app.post('/api/sync', authenticateToken, async (req, res) => {
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
is_deleted = EXCLUDED.is_deleted,
recipe_data = EXCLUDED.recipe_data
WHERE (EXCLUDED.updated_at > brew_logs.updated_at OR brew_logs.user_id IS NULL)
AND (brew_logs.user_id = EXCLUDED.user_id OR brew_logs.user_id IS NULL)
`, [
log.id,
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
beanId,
method || '',
grind,
waterTemp,
ratio,
yieldVal,
time,
notes || '',
rating || 0,
createdAt ? BigInt(createdAt) : BigInt(Date.now()),
updatedAt ? new Date(updatedAt) : new Date(),
isDeleted || false,
JSON.stringify(recipeFields)
]);
}
@@ -237,21 +265,34 @@ app.post('/api/sync', authenticateToken, async (req, res) => {
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
}));
const mappedLogs = serverLogs.rows.map(l => {
const baseLog = {
id: l.id,
beanId: l.bean_id,
method: l.method,
notes: l.notes,
rating: l.rating,
createdAt: Number(l.created_at),
updatedAt: l.updated_at.toISOString(),
isDeleted: l.is_deleted
};
const recipeData = l.recipe_data || {};
// Fallback for old database rows that don't have recipe_data populated
const fallback = {};
if (!recipeData.grindSize && l.grind) fallback.grindSize = l.grind;
if (!recipeData.waterTemp && l.water_temp) fallback.waterTemp = l.water_temp;
if (!recipeData.brewRatio && l.ratio) fallback.brewRatio = l.ratio;
if (!recipeData.yield && l.yield) fallback.yield = l.yield;
if (!recipeData.brewTime && l.time) fallback.brewTime = l.time;
return {
...baseLog,
...fallback,
...recipeData
};
});
res.json({
serverTime,

View File

@@ -13,6 +13,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3",
"pg": "^8.21.0"
}
@@ -333,6 +334,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -496,6 +515,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -16,6 +16,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3",
"pg": "^8.21.0"
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useContext, useRef } from "react";
import { AuthContext } from "./AuthContext";
import { Search } from "lucide-react";
// Import pages/views
import Login from "./pages/Login";
@@ -15,10 +16,11 @@ import BrewCard from "./components/BrewCard";
import BottomNav from "./components/BottomNav";
import IosPromptModal from "./components/IosPromptModal";
import UpdatePrompt from "./components/UpdatePrompt";
import SyncIndicator from "./components/SyncIndicator";
// Import constants
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS } from "./constants";
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS, getRoastAgingInfo } from "./constants";
// ─── Storage helpers ───
const STORAGE_KEY = "coffee-logbook-data";
@@ -60,6 +62,121 @@ const LoadingScreen = () => (
</div>
);
function MethodIcon({ method, className }) {
const props = {
className: className || "w-8 h-8",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.5",
strokeLinecap: "round",
strokeLinejoin: "round"
};
switch (method) {
case "pourover":
return (
<svg {...props}>
<path d="M5 5h14l-4 7H9l-4-7z" />
<path d="M7 12h10" />
<path d="M9.5 12L7.5 19.5h9l-2-7.5" />
<path d="M10 2.5a.5.5 0 01.5-.5h3a.5.5 0 01.5.5" />
</svg>
);
case "espresso":
return (
<svg {...props}>
<path d="M5 8h10v5c0 2.5-2 4.5-5 4.5S5 15.5 5 13V8z" />
<path d="M15 10.5h6v1.5h-6z" />
<path d="M3 18.5c4.5 2 13.5 2 18 0" />
<path d="M10 17.5v2" />
</svg>
);
case "coldbrew":
return (
<svg {...props}>
<path d="M7 4h10l-1.5 16.5c0 1-1.5 1.5-3.5 1.5s-3.5-.5-3.5-1.5L7 4z" />
<path d="M9.5 12h3v3h-3z" />
<path d="M11.5 8h3v3h-3z" />
<path d="M12 2l-2 4" />
</svg>
);
case "aeropress":
return (
<svg {...props}>
<path d="M8 8h8v12H8z" />
<path d="M6 20h12" />
<path d="M10 8V3h4v5" />
<path d="M9 3h6" />
<path d="M9 7.5h6" />
</svg>
);
case "frenchpress":
return (
<svg {...props}>
<path d="M8 6h8v14c0 1-1 2-3 2h-2c-2 0-3-1-3-2V6z" />
<path d="M7 6h10" />
<circle cx="12" cy="2.5" r="1.5" />
<path d="M12 4v8" />
<path d="M9 12h6" />
<path d="M8 8H5v10h3" />
</svg>
);
case "chemex":
return (
<svg {...props}>
<path d="M8 4h8c-1 3.5-1.5 5-1.5 8s.5 4.5 1.5 8H8c1-3.5 1.5-5 1.5-8s-.5-4.5-1.5-8z" />
<path d="M9.5 10.5h5" />
<path d="M9.5 12.5h5" />
<circle cx="12" cy="11.5" r="1.2" fill="currentColor" />
</svg>
);
case "mokapot":
return (
<svg {...props}>
<path d="M9 5h6l-1 7H10z" />
<path d="M10 12.5h4l1.2 7H8.8z" />
<path d="M9 5l3-2 3 2" />
<circle cx="12" cy="3" r="1" />
<path d="M9 6.2l-2 1.5 2 1" />
<path d="M14 7.5h3.5v7H14" />
</svg>
);
case "v60":
return (
<svg {...props}>
<path d="M6 5h12l-4.5 7h-3z" />
<path d="M8 12h8" />
<path d="M9.5 12L8.5 19h7l-1-7" />
<path d="M16.5 5a2 2 0 010 4" />
</svg>
);
case "syphon":
return (
<svg {...props}>
<path d="M9.5 4h5v5h-5z" />
<path d="M12 9v4" />
<circle cx="12" cy="16" r="3.2" />
<path d="M7 21h10" />
<path d="M8.5 16H6v5h3v-2" />
</svg>
);
case "dripbrew":
return (
<svg {...props}>
<path d="M6 21h12" />
<path d="M7 21V5c0-1 6-1 6 0v2" />
<path d="M8.5 5h8v3h-8z" />
<path d="M9.5 12h5v7h-5z" />
<path d="M14.5 14h2v3h-2" />
<path d="M11.5 8v2.5" />
</svg>
);
default:
return <span></span>;
}
}
// ─── Main App ───
export default function CoffeeLogbook() {
const { token, user, loading, logout } = useContext(AuthContext);
@@ -71,12 +188,19 @@ export default function CoffeeLogbook() {
const [selectedBean, setSelectedBean] = useState(null);
const [brewFilter, setBrewFilter] = useState("all");
const [editingBean, setEditingBean] = useState(null);
const [brewSearchQuery, setBrewSearchQuery] = useState("");
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [syncing, setSyncing] = useState(false);
const [showSyncedStatus, setShowSyncedStatus] = useState(false);
const [prepActivePage, setPrepActivePage] = useState(0);
useEffect(() => { loadData().then(setData); }, []);
useEffect(() => {
setBrewSearchQuery("");
setPrepActivePage(0);
}, [view]);
const dataRef = useRef(data);
dataRef.current = data;
@@ -177,8 +301,39 @@ export default function CoffeeLogbook() {
const addBrew = (form) => { persist({ ...data, brewLogs: [...allLogs, { id: uid(), createdAt: Date.now(), ...form, updatedAt: new Date().toISOString(), isDeleted: false }] }); setModal(null); };
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);
const methodCounts = { pourover: 0, espresso: 0, coldbrew: 0 };
const filteredLogs = (brewFilter === "all" ? brewLogs : brewLogs.filter(l => l.method === brewFilter))
.filter(log => {
if (!brewSearchQuery) return true;
const dateObj = new Date(log.createdAt);
const weekday = dateObj.toLocaleDateString("en-US", { weekday: "long" }).toLowerCase();
const month = dateObj.toLocaleDateString("en-US", { month: "long" }).toLowerCase();
const monthShort = dateObj.toLocaleDateString("en-US", { month: "short" }).toLowerCase();
const dateNum = dateObj.getDate().toString();
const year = dateObj.getFullYear().toString();
const searchableText = [
getBeanName(log.beanId).toLowerCase(),
METHOD_LABELS[log.method]?.toLowerCase() || "",
log.recipeDetails?.toLowerCase() || "",
log.tasteNotes?.toLowerCase() || "",
log.notes?.toLowerCase() || "",
log.grindSize?.toLowerCase() || log.grind?.toLowerCase() || "",
log.waterTemp?.toLowerCase() || "",
log.brewRatio?.toLowerCase() || log.ratio?.toLowerCase() || "",
log.brewTime?.toLowerCase() || log.time?.toLowerCase() || "",
weekday,
month,
monthShort,
dateNum,
year
].join(" ");
const queryWords = brewSearchQuery.trim().toLowerCase().split(/\s+/);
return queryWords.every(word => searchableText.includes(word));
})
.sort((a, b) => b.createdAt - a.createdAt);
const methodCounts = {};
METHODS.forEach(m => methodCounts[m] = 0);
brewLogs.forEach(l => { if (methodCounts[l.method] !== undefined) methodCounts[l.method]++; });
const filterPillCls = (active) => `px-3.5 py-1.5 rounded-full border text-xs font-medium whitespace-nowrap cursor-pointer transition-all ${active ? "bg-[#2C1810] text-[#FAF6F1] border-[#2C1810] dark:bg-[#FAF6F1] dark:text-[#2C1810] dark:border-[#FAF6F1]" : "bg-white border-[#E8DFD3] text-[#6B5744] dark:bg-[#22120B] dark:border-[#3B2217] dark:text-[#C8B9A6]"}`;
@@ -196,13 +351,7 @@ export default function CoffeeLogbook() {
<div className="text-[10px] text-[#9C8B7A] dark:text-[#C8B9A6] font-semibold tracking-[1.5px] uppercase">{pageSubtitles[view] || "Coffee Logbook"}</div>
<h1 className="font-serif text-[21px] font-semibold tracking-tight text-[#2C1810] dark:text-[#FAF6F1] mt-0.5">{pageTitles[view] || "Brew Journal"}</h1>
</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1 text-[11px] font-medium px-2.5 py-1 rounded-full transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}
title={isOnline ? "Synchronized" : "Offline"}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
</div>
)}
<SyncIndicator isOnline={isOnline} syncing={syncing} showSyncedStatus={showSyncedStatus} />
</div>
{/* Content */}
@@ -212,6 +361,53 @@ export default function CoffeeLogbook() {
{/* ── Dashboard ── */}
{view === "dashboard" && (
<div className="animate-page-enter">
{/* Preparation Methods */}
<div className="text-[13px] font-semibold text-[#6B5744] dark:text-[#C8B9A6] uppercase tracking-widest mb-3">Preparation Methods</div>
<div
className="flex overflow-x-auto pb-4 scrollbar-none snap-x snap-mandatory -mx-5"
onScroll={(e) => {
const scrollLeft = e.target.scrollLeft;
const width = e.target.clientWidth;
const page = Math.round(scrollLeft / width);
setPrepActivePage(page);
}}
>
{(() => {
const chunks = [];
for (let i = 0; i < METHODS.length; i += 8) {
chunks.push(METHODS.slice(i, i + 8));
}
return chunks.map((pageMethods, pageIdx) => (
<div key={pageIdx} className="w-full flex-shrink-0 snap-start grid grid-cols-4 gap-x-4 gap-y-5 px-5">
{pageMethods.map(m => (
<div key={m}
className="flex flex-col items-center cursor-pointer group"
onClick={() => { setBrewFilter(m); setView("brews"); }}>
<div className="w-full aspect-square flex items-center justify-center rounded-2xl bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] transition-all group-hover:scale-[1.03] group-active:scale-[0.98] shadow-[0_1px_3px_rgba(44,24,16,0.04)] dark:shadow-none"
style={{ borderTop: `3px solid ${METHOD_COLORS[m]}` }}>
<MethodIcon method={m} className="w-9 h-9 text-[#6B5744] dark:text-[#C8B9A6] group-hover:text-[#2C1810] dark:group-hover:text-[#FAF6F1] transition-colors" />
</div>
<div className="text-[10px] text-[#9C8B7A] dark:text-[#C8B9A6] uppercase tracking-wider mt-2 text-center font-medium group-hover:text-[#2C1810] dark:group-hover:text-[#FAF6F1] transition-colors">
{METHOD_LABELS[m]}
</div>
</div>
))}
</div>
));
})()}
</div>
{(() => {
const pagesCount = Math.ceil(METHODS.length / 8);
return pagesCount > 1 ? (
<div className="flex justify-center gap-1.5 mt-1 mb-6">
{Array.from({ length: pagesCount }).map((_, idx) => (
<div key={idx} className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${prepActivePage === idx ? "bg-[#2C1810] dark:bg-[#FAF6F1] w-3" : "bg-[#E8DFD3] dark:bg-[#3B2217]"}`} />
))}
</div>
) : <div className="mb-6" />;
})()}
{/* Statistics */}
<div className="flex gap-2 mb-5">
{[{ num: beans.length, label: "Beans" }, { num: brewLogs.length, label: "Brews" }, { num: new Set(brewLogs.map(l => l.beanId)).size, label: "Tried" }].map(s => (
<div key={s.label} className="flex-1 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-3.5 text-center transition-colors duration-200">
@@ -220,16 +416,8 @@ export default function CoffeeLogbook() {
</div>
))}
</div>
<div className="text-[13px] font-semibold text-[#6B5744] dark:text-[#C8B9A6] uppercase tracking-widest mb-3">By Method</div>
<div className="flex gap-2 mb-6">
{METHODS.map(m => (
<div key={m} className="flex-1 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-3 text-center transition-colors duration-200" style={{ borderTop: `3px solid ${METHOD_COLORS[m]}` }}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>
<div className="font-serif text-xl font-bold text-[#2C1810] dark:text-[#FAF6F1]">{methodCounts[m]}</div>
<div className="text-[10px] text-[#9C8B7A] dark:text-[#C8B9A6] uppercase tracking-widest mt-0.5">{METHOD_LABELS[m]}</div>
</div>
))}
</div>
{/* Recent Brews */}
<div className="text-[13px] font-semibold text-[#6B5744] dark:text-[#C8B9A6] uppercase tracking-widest mb-3">Recent Brews</div>
{brewLogs.length === 0 ? (
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
@@ -276,6 +464,14 @@ export default function CoffeeLogbook() {
<div className="flex gap-2 mt-2.5 flex-wrap">
{bean.roastType && <span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${roastTagCls}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] dark:bg-[#2C1810] text-[#6B5744] dark:text-[#C8B9A6] font-medium">Roasted {bean.roastDate}</span>}
{(() => {
const ageInfo = getRoastAgingInfo(bean.roastDate);
return ageInfo ? (
<span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${ageInfo.style}`}>
{ageInfo.label}
</span>
) : null;
})()}
</div>
</div>
);
@@ -297,6 +493,33 @@ export default function CoffeeLogbook() {
{/* ── Brew Logs ── */}
{view === "brews" && (
<div className="animate-page-enter">
{/* Search Bar */}
<div className="mb-4 relative">
<input
type="text"
value={brewSearchQuery}
onChange={(e) => setBrewSearchQuery(e.target.value)}
placeholder="Search brews (e.g. June, espresso, floral...)"
className="w-full pl-10 pr-10 py-3.5 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-xs placeholder-[#9C8B7A] dark:placeholder-[#C8B9A6] text-[#2C1810] dark:text-[#FAF6F1] shadow-[0_1px_3px_rgba(44,24,16,0.04)] dark:shadow-none focus:outline-none focus:border-[#8B6914] dark:focus:border-[#D4A325] transition-all"
/>
{/* Search Icon */}
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[#9C8B7A] dark:text-[#C8B9A6] flex items-center pointer-events-none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="opacity-70">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
{/* Clear button */}
{brewSearchQuery && (
<button
onClick={() => setBrewSearchQuery("")}
className="absolute right-3.5 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] border-none bg-transparent cursor-pointer text-base font-bold transition-colors"
>
&times;
</button>
)}
</div>
<div className="flex gap-1.5 mb-4 overflow-x-auto pb-1">
<button className={filterPillCls(brewFilter === "all")} onClick={() => setBrewFilter("all")}>All</button>
{METHODS.map(m => (
@@ -307,9 +530,15 @@ export default function CoffeeLogbook() {
</div>
{filteredLogs.length === 0 ? (
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">📋</div>
<h3 className="text-base mb-1.5 font-serif text-[#6B5744] dark:text-[#FAF6F1]">No logs {brewFilter !== "all" ? `for ${METHOD_LABELS[brewFilter]}` : "yet"}</h3>
<p className="text-[13px] leading-relaxed">Start brewing and log your recipes here.</p>
<div className="flex justify-center mb-3 text-[#9C8B7A] dark:text-[#C8B9A6] opacity-60">
{brewSearchQuery ? <Search size={36} strokeWidth={2} /> : <span className="text-4xl">📋</span>}
</div>
<h3 className="text-base mb-1.5 font-serif text-[#6B5744] dark:text-[#FAF6F1]">
{brewSearchQuery ? "No search results" : `No logs ${brewFilter !== "all" ? `for ${METHOD_LABELS[brewFilter]}` : "yet"}`}
</h3>
<p className="text-[13px] leading-relaxed">
{brewSearchQuery ? "Try refining your search terms or filters." : "Start brewing and log your recipes here."}
</p>
</div>
) : filteredLogs.map(log => (
<BrewCard key={log.id} log={log} beanName={getBeanName(log.beanId)} />

View File

@@ -1,4 +1,5 @@
import BrewCard from "./BrewCard";
import { getRoastAgingInfo } from "../constants";
export default function BeanDetail({ bean, logs, onBack, onEdit, onDelete }) {
const beanLogs = logs.filter(l => l.beanId === bean.id).sort((a, b) => b.createdAt - a.createdAt);
@@ -17,6 +18,14 @@ export default function BeanDetail({ bean, logs, onBack, onEdit, onDelete }) {
<div className="flex gap-2 mt-2.5 flex-wrap">
{bean.roastType && <span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${roastTagCls}`}>{bean.roastType}</span>}
{bean.roastDate && <span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] dark:bg-[#2C1810] text-[#6B5744] dark:text-[#C8B9A6] font-medium">Roasted {bean.roastDate}</span>}
{(() => {
const ageInfo = getRoastAgingInfo(bean.roastDate);
return ageInfo ? (
<span className={`text-[11px] px-2.5 py-1 rounded-full font-medium ${ageInfo.style}`}>
{ageInfo.label}
</span>
) : null;
})()}
<span className="text-[11px] px-2.5 py-1 rounded-full bg-[#F3EDE4] dark:bg-[#2C1810] text-[#6B5744] dark:text-[#C8B9A6] font-medium">{beanLogs.length} brew{beanLogs.length !== 1 ? "s" : ""}</span>
</div>
<div className="flex gap-2 mt-4">

View File

@@ -1,28 +1,12 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { ROAST_TYPES, inputCls, labelCls } from "../constants";
import Modal from "./Modal";
export default function BeanForm({ onSave, onClose, initial }) {
const [form, setForm] = useState(initial || { name: "", roastery: "", roastDate: "", roastType: "", image: "", tastingNotes: "" });
const [active, setActive] = useState(false);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const canSave = form.name.trim().length > 0;
useEffect(() => {
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
}
}, 200);
};
const handleImage = (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -33,50 +17,45 @@ export default function BeanForm({ onSave, onClose, initial }) {
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="mb-4"><label className={labelCls}>Bean Name *</label>
<input className={inputCls} placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Roastery</label>
<input className={inputCls} placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} /></div>
<div className="flex gap-2.5 mb-4">
<div className="flex-1"><label className={labelCls}>Roast Date</label>
<input className={inputCls} type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} /></div>
<div className="flex-1"><label className={labelCls}>Roast Type</label>
<select className={inputCls} value={form.roastType} onChange={e => set("roastType", e.target.value)}>
<option value="">Select</option>
{ROAST_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select></div>
</div>
<div className="mb-4">
<label className={labelCls}>Bean Photo</label>
<div className={`relative border-2 rounded-lg text-center cursor-pointer transition-all overflow-hidden ${form.image ? "border-[#E8DFD3] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
{form.image ? (
<><img src={form.image} alt="Bean" className="w-full h-40 object-cover rounded-lg block" />
<button className="absolute top-2 right-2 w-7 h-7 rounded-full bg-[rgba(44,24,16,0.7)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Tap to upload a photo</div></>)}
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">{initial ? "Edit Bean" : "Add Bean"}</div>
<div className="mb-4"><label className={labelCls}>Bean Name *</label>
<input className={inputCls} placeholder="e.g. Ethiopia Yirgacheffe" value={form.name} onChange={e => set("name", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Roastery</label>
<input className={inputCls} placeholder="e.g. Blue Tokai" value={form.roastery} onChange={e => set("roastery", e.target.value)} /></div>
<div className="flex gap-2.5 mb-4">
<div className="flex-1"><label className={labelCls}>Roast Date</label>
<input className={inputCls} type="date" value={form.roastDate} onChange={e => set("roastDate", e.target.value)} /></div>
<div className="flex-1"><label className={labelCls}>Roast Type</label>
<select className={inputCls} value={form.roastType} onChange={e => set("roastType", e.target.value)}>
<option value="">Select</option>
{ROAST_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select></div>
</div>
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="mb-4"><label className={labelCls}>Tasting Notes</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`} placeholder="e.g. Citrus, dark chocolate, floral…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Flavour profile from the bag or your own impressions</div></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
onClick={() => { if (canSave) handleClose(() => onSave(form)); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</div>
</div>
<div className="mb-4">
<label className={labelCls}>Bean Photo</label>
<div className={`relative border-2 rounded-lg text-center cursor-pointer transition-all overflow-hidden ${form.image ? "border-[#E8DFD3] dark:border-[#3B2217] p-0" : "border-dashed border-[#E8DFD3] dark:border-[#3B2217] p-5 hover:border-[#8B6914] dark:hover:border-[#D4A325]"}`}>
{form.image ? (
<><img src={form.image} alt="Bean" className="w-full h-40 object-cover rounded-lg block" />
<button className="absolute top-2 right-2 w-7 h-7 rounded-full bg-[rgba(44,24,16,0.7)] dark:bg-[rgba(250,246,241,0.8)] text-white dark:text-[#2C1810] border-none text-sm cursor-pointer flex items-center justify-center font-bold"
onClick={(e) => { e.stopPropagation(); set("image", ""); }} type="button">×</button></>
) : (<><div className="text-3xl mb-1.5 opacity-40 dark:opacity-75">📷</div><div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Tap to upload a photo</div></>)}
<input type="file" accept="image/*" onChange={handleImage} className="absolute inset-0 opacity-0 cursor-pointer" />
</div>
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Max 2 MB · JPG, PNG, or WebP</div>
</div>
<div className="mb-4"><label className={labelCls}>Tasting Notes</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`} placeholder="e.g. Citrus, dark chocolate, floral…" value={form.tastingNotes || ""} onChange={e => set("tastingNotes", e.target.value)} />
<div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">Flavour profile from the bag or your own impressions</div></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer mt-2"
style={{ opacity: canSave ? 1 : 0.4, cursor: canSave ? "pointer" : "not-allowed" }} disabled={!canSave}
onClick={() => { if (canSave) close(() => onSave(form)); }}>
{initial ? "Save Changes" : "Add to Library"}
</button>
</>
)}
</Modal>
);
}

View File

@@ -5,7 +5,7 @@ export default function BrewCard({ log, beanName }) {
const allFields = Object.entries(log)
.filter(([k]) => !["id", "beanId", "method", "createdAt", "recipeDetails", "tasteNotes", "updatedAt", "isDeleted"].includes(k))
.filter(([, v]) => v !== "" && v != null);
const fieldLabels = { grindSize: "Grind", waterTemp: "Temp", beanWeight: "Weight", brewRatio: "Ratio", brewTime: "Time", numPours: "Pours", dose: "Dose", yield: "Yield", waterVolume: "Water", steepTime: "Steep" };
const fieldLabels = { grindSize: "Grind", waterTemp: "Temp", beanWeight: "Weight", brewRatio: "Ratio", brewTime: "Time", numPours: "Pours", dose: "Dose", yield: "Yield", waterVolume: "Water", steepTime: "Steep", inverted: "Inverted", filterType: "Filter" };
return (
<div className="relative mb-3">
<div className="brew-method-bar" style={{ background: color }} />

View File

@@ -1,48 +1,85 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { METHODS, METHOD_LABELS, METHOD_ICONS, METHOD_COLORS, inputCls, labelCls } from "../constants";
import Modal from "./Modal";
const parseRatio = (ratioStr) => {
if (!ratioStr) return null;
// Matches "1:15", "1/15", "15"
const match = ratioStr.match(/(?:1\s*[:/]\s*)?(\d+(?:\.\d+)?)/);
if (match) {
const val = parseFloat(match[1]);
return val > 0 ? val : null;
}
return null;
};
export default function BrewForm({ beans, onSave, onClose }) {
const [method, setMethod] = useState("pourover");
const [form, setForm] = useState({ beanId: beans[0]?.id || "" });
const [active, setActive] = useState(false);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
useEffect(() => {
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleFieldChange = (key, value) => {
setForm(prev => {
const next = { ...prev, [key]: value };
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
if (typeof callback === "function") {
callback();
} else {
onClose();
const getNum = (str) => {
if (!str) return null;
const val = parseFloat(str);
return isNaN(val) ? null : val;
};
const ratioKey = next.brewRatio !== undefined ? "brewRatio" : null;
const ratioVal = ratioKey ? parseRatio(next[ratioKey]) : null;
if (ratioVal) {
if (key === "beanWeight" || key === "dose") {
const coffeeVal = getNum(value);
if (coffeeVal) {
const calculatedWater = Math.round(coffeeVal * ratioVal);
if (next.yield !== undefined) next.yield = `${calculatedWater}g`;
if (next.waterVolume !== undefined) next.waterVolume = `${calculatedWater}ml`;
}
} else if (key === "yield" || key === "waterVolume") {
const waterVal = getNum(value);
if (waterVal) {
const calculatedCoffee = Math.round((waterVal / ratioVal) * 10) / 10;
if (next.beanWeight !== undefined) next.beanWeight = `${calculatedCoffee}g`;
if (next.dose !== undefined) next.dose = `${calculatedCoffee}g`;
}
} else if (key === ratioKey) {
const coffeeStr = next.beanWeight || next.dose;
const coffeeVal = getNum(coffeeStr);
if (coffeeVal) {
const calculatedWater = Math.round(coffeeVal * ratioVal);
if (next.yield !== undefined) next.yield = `${calculatedWater}g`;
if (next.waterVolume !== undefined) next.waterVolume = `${calculatedWater}ml`;
}
}
}
}, 200);
return next;
});
};
const handleMethodChange = (m) => {
setMethod(m);
setForm({ beanId: form.beanId }); // Preserve selected bean but reset inputs
};
if (beans.length === 0) {
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">No beans yet</h3>
<p className="text-[13px] leading-relaxed">Add a bean to your library first.</p>
</div>
<button className="w-full py-3.5 border border-[#E8DFD3] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => handleClose()}>Close</button>
</div>
</div>
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="text-center py-12 text-[#9C8B7A] dark:text-[#C8B9A6]">
<div className="text-4xl mb-3 opacity-50 dark:opacity-80">🫘</div>
<h3 className="text-base mb-1.5 text-[#6B5744] dark:text-[#FAF6F1]">No beans yet</h3>
<p className="text-[13px] leading-relaxed">Add a bean to your library first.</p>
</div>
<button className="w-full py-3.5 border border-[#E8DFD3] dark:border-[#3B2217] rounded-xl text-sm font-semibold text-[#6B5744] dark:text-[#C8B9A6] bg-transparent cursor-pointer hover:bg-[#F3EDE4] dark:hover:bg-[#2C1810]" onClick={() => close()}>Close</button>
</>
)}
</Modal>
);
}
@@ -50,70 +87,124 @@ export default function BrewForm({ beans, onSave, onClose }) {
pourover: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. 18 clicks", hint: "Relative to your grinder" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 93°C" },
{ key: "beanWeight", label: "Bean Weight", placeholder: "e.g. 15g" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 15g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:16" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 240ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 3:30" },
{ key: "numPours", label: "# of Pours", placeholder: "e.g. 4", type: "number" },
],
espresso: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. 8" },
{ key: "dose", label: "Dose", placeholder: "e.g. 18g" },
{ key: "yield", label: "Yield", placeholder: "e.g. 36g" },
{ key: "dose", label: "Coffee Dose", placeholder: "e.g. 18g" },
{ key: "brewRatio", label: "Target Ratio", placeholder: "e.g. 1:2" },
{ key: "yield", label: "Yield (Output)", placeholder: "e.g. 36g", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 28s" },
],
coldbrew: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. coarse" },
{ key: "beanWeight", label: "Bean Weight", placeholder: "e.g. 100g" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 700ml" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 100g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:10" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 1000ml", hint: "Calculated from ratio" },
{ key: "steepTime", label: "Steep Time", placeholder: "e.g. 18 hours" },
],
aeropress: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. 12 clicks" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 85°C" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 15g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:15" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 225ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Steep Time", placeholder: "e.g. 2:00" },
{ key: "inverted", label: "Inverted?", placeholder: "e.g. Yes/No" },
],
frenchpress: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. coarse" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 95°C" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 30g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:15" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 450ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Steep Time", placeholder: "e.g. 4:00" },
],
chemex: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. medium-coarse" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 93°C" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 30g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:15" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 450ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 4:00" },
{ key: "filterType", label: "Filter Type", placeholder: "e.g. Chemex Circle" },
],
mokapot: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. fine-medium" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 15g" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 150ml" },
{ key: "brewTime", label: "Extraction Time", placeholder: "e.g. 1:30" },
],
v60: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. medium-fine" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 92°C" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 15g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:16" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 240ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 3:00" },
{ key: "numPours", label: "# of Pours", placeholder: "e.g. 3", type: "number" },
],
syphon: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. medium" },
{ key: "waterTemp", label: "Water Temp", placeholder: "e.g. 94°C" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 20g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:15" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 300ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 2:10" },
],
dripbrew: [
{ key: "grindSize", label: "Grind Size", placeholder: "e.g. medium" },
{ key: "beanWeight", label: "Coffee Weight", placeholder: "e.g. 60g" },
{ key: "brewRatio", label: "Brew Ratio", placeholder: "e.g. 1:16" },
{ key: "waterVolume", label: "Water Volume", placeholder: "e.g. 960ml", hint: "Calculated from ratio" },
{ key: "brewTime", label: "Brew Time", placeholder: "e.g. 6:00" },
],
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[24px] w-full max-w-[480px] max-h-[88vh] overflow-y-auto transition-transform duration-200 ease-in-out px-5 pb-8 ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded mx-auto my-3" />
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="flex gap-1.5 mb-5">
{METHODS.map(m => (
<button key={m}
className={`flex-1 py-3 px-2 border-2 bg-white dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => setMethod(m)}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="mb-4"><label className={labelCls}>Bean *</label>
<select className={inputCls} value={form.beanId} onChange={e => set("beanId", e.target.value)}>
{beans.map(b => <option key={b.id} value={b.id} className="dark:bg-[#150B07]">{b.name}{b.roastery ? `${b.roastery}` : ""}</option>)}
</select></div>
<div className="flex flex-wrap gap-2.5 mb-4">
{fields[method].map(f => (
<div key={f.key} className="flex-1 min-w-[45%]">
<label className={labelCls}>{f.label}</label>
<input className={inputCls} type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => set(f.key, e.target.value)} />
{f.hint && <div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">{f.hint}</div>}
</div>
))}
</div>
<div className="mb-4"><label className={labelCls}>{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`}
placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => set("recipeDetails", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Taste Notes</label>
<input className={inputCls} placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => set("tasteNotes", e.target.value)} /></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
onClick={() => handleClose(() => onSave({ ...form, method }))}>Save Brew Log</button>
</div>
</div>
<Modal onClose={onClose}>
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-5">Log a Brew</div>
<div className="flex gap-1.5 mb-5 overflow-x-auto pb-1">
{METHODS.map(m => (
<button key={m}
className={`flex-1 min-w-[72px] py-3 px-2 border-2 bg-white dark:bg-[#22120B] rounded-xl cursor-pointer text-center text-xs font-semibold transition-all ${method === m ? "border-current font-bold scale-[1.02]" : "border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6]"}`}
style={{ color: method === m ? METHOD_COLORS[m] : undefined }}
onClick={() => handleMethodChange(m)}>
<div className="text-xl mb-1">{METHOD_ICONS[m]}</div>{METHOD_LABELS[m]}
</button>
))}
</div>
<div className="mb-4"><label className={labelCls}>Bean *</label>
<select className={inputCls} value={form.beanId} onChange={e => handleFieldChange("beanId", e.target.value)}>
{beans.map(b => <option key={b.id} value={b.id} className="dark:bg-[#150B07]">{b.name}{b.roastery ? `${b.roastery}` : ""}</option>)}
</select></div>
<div className="flex flex-wrap gap-2.5 mb-4">
{fields[method].map(f => (
<div key={f.key} className="flex-1 min-w-[45%]">
<label className={labelCls}>{f.label}</label>
<input className={inputCls} type={f.type || "text"} placeholder={f.placeholder}
value={form[f.key] || ""} onChange={e => handleFieldChange(f.key, e.target.value)} />
{f.hint && <div className="text-[11px] text-[#9C8B7A] dark:text-[#C8B9A6] mt-1">{f.hint}</div>}
</div>
))}
</div>
<div className="mb-4"><label className={labelCls}>{method === "pourover" ? "Recipe Details" : "Notes"}</label>
<textarea className={`${inputCls} resize-y min-h-[80px]`}
placeholder="Describe your recipe, technique, or anything notable…"
value={form.recipeDetails || ""} onChange={e => handleFieldChange("recipeDetails", e.target.value)} /></div>
<div className="mb-4"><label className={labelCls}>Taste Notes</label>
<input className={inputCls} placeholder="e.g. citrus, chocolate, floral"
value={form.tasteNotes || ""} onChange={e => handleFieldChange("tasteNotes", e.target.value)} /></div>
<button className="w-full py-3.5 border-none rounded-xl bg-[#2C1810] dark:bg-[#FAF6F1] text-[#FAF6F1] dark:text-[#2C1810] text-sm font-semibold cursor-pointer hover:opacity-90 mt-2"
onClick={() => close(() => onSave({ ...form, method }))}>Save Brew Log</button>
</>
)}
</Modal>
);
}

View File

@@ -1,56 +1,33 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
export default function CreateModal({ onClose, onAddBean, onAddBrew }) {
const [active, setActive] = useState(false);
useEffect(() => {
// Trigger transition shortly after mounting
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
onClose();
if (typeof callback === "function") {
callback();
}
}, 200);
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[100] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[28px] w-full max-w-[480px] px-5 pb-10 transition-transform duration-200 ease-in-out ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded mx-auto mt-3 mb-6" />
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-6">What would you like to add?</div>
<div className="flex flex-col gap-3">
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => handleClose(onAddBrew)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0"></div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Record a brewing session with recipe details</div>
</div>
</button>
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => handleClose(onAddBean)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
</div>
</button>
</div>
</div>
</div>
<Modal onClose={onClose} padding="px-5 pb-10">
{(close) => (
<>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-6">What would you like to add?</div>
<div className="flex flex-col gap-3">
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => close(onAddBrew)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0"></div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Log a Brew</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Record a brewing session with recipe details</div>
</div>
</button>
<button
className="flex items-center gap-4 px-5 py-4 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl text-left cursor-pointer hover:shadow-[0_4px_16px_rgba(44,24,16,0.08)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)] transition-all active:scale-[0.98]"
onClick={() => close(onAddBean)}>
<div className="w-12 h-12 rounded-xl bg-[#FAF6F1] dark:bg-[#2C1810] flex items-center justify-center text-2xl flex-shrink-0">🫘</div>
<div>
<div className="font-semibold text-[#2C1810] dark:text-[#FAF6F1] text-sm">Add Bean</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">Add a new coffee bean to your library</div>
</div>
</button>
</div>
</>
)}
</Modal>
);
}

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
export default function IosPromptModal() {
const [show, setShow] = useState(false);
const [active, setActive] = useState(false);
useEffect(() => {
// Detect if user is on iOS or iPadOS
@@ -18,71 +18,64 @@ export default function IosPromptModal() {
if (isIOS && !isStandalone && !isDismissed) {
setShow(true);
const raf = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(raf);
}
}, []);
if (!show) return null;
const handleClose = () => {
setActive(false);
sessionStorage.setItem("ios-prompt-dismissed", "true");
setTimeout(() => {
setShow(false);
}, 200);
setShow(false);
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.5)] z-[200] flex items-end justify-center transition-opacity duration-200 ease-in-out ${active ? "opacity-100" : "opacity-0"}`}
onClick={handleClose}
<Modal
onClose={handleClose}
zIndex="z-[200]"
className="relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] dark:shadow-none"
padding="px-6 pb-8 pt-4"
>
<div
className={`bg-[#FAF6F1] dark:bg-[#150B07] rounded-t-[28px] w-full max-w-[480px] px-6 pb-8 pt-4 relative shadow-[0_-8px_30px_rgba(44,24,16,0.15)] dark:shadow-none transition-transform duration-200 ease-in-out ${active ? "translate-y-0" : "translate-y-full"}`}
onClick={e => e.stopPropagation()}
>
{/* Handle bar */}
<div className="w-9 h-1 bg-[#E8DFD3] dark:bg-[#3B2217] rounded mx-auto mb-6" />
{/* Close Button */}
<button
onClick={handleClose}
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
aria-label="Close"
>
&times;
</button>
{(close) => (
<>
{/* Close Button */}
<button
onClick={() => close()}
className="absolute right-5 top-5 w-8 h-8 flex items-center justify-center rounded-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] text-[#9C8B7A] dark:text-[#C8B9A6] hover:text-[#2C1810] dark:hover:text-[#FAF6F1] transition-colors cursor-pointer text-lg font-semibold"
aria-label="Close"
>
&times;
</button>
{/* Content */}
<div className="flex flex-col items-center text-center">
{/* Brew Logo */}
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl flex items-center justify-center p-3 shadow-sm mb-3">
<img src="/favicon.svg" alt="Brew Logo" className="w-full h-full object-contain" />
{/* Content */}
<div className="flex flex-col items-center text-center">
{/* Brew Logo */}
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
</div>
{/* Brew Text */}
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
{/* Description */}
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
Open this page on safari and then install this app to your homescreen for a better experience.
</p>
{/* Instruction */}
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
Tap
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
</span>
</div>
</div>
{/* Brew Text */}
<div className="font-serif text-2xl font-bold text-[#2C1810] dark:text-[#FAF6F1] mb-3">Brew</div>
{/* Description */}
<p className="text-sm text-[#6B5744] dark:text-[#C8B9A6] leading-relaxed mb-6 px-4">
Open this page on safari and then install this app to your homescreen for a better experience.
</p>
{/* Instruction */}
<div className="w-full bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl p-4 flex items-center justify-center gap-3">
<span className="text-sm text-[#2C1810] dark:text-[#FAF6F1] font-medium flex items-center justify-center flex-wrap">
Tap
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D4A325" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="inline-block mx-1.5 align-middle">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
then <span className="font-bold text-[#8B6914] dark:text-[#D4A325] ml-1">"Add to Home Screen"</span>
</span>
</div>
</div>
</div>
</div>
</>
)}
</Modal>
);
}

101
src/components/Modal.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from "react";
export default function Modal({
onClose,
children,
zIndex = "z-[100]",
maxWidth = "480px",
maxHeight = "88vh",
padding = "px-5 pb-8",
className = ""
}) {
const [active, setActive] = useState(false);
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
useEffect(() => {
// Prevent background scroll when mounted
document.body.style.overflow = "hidden";
// Trigger transition shortly after mounting
const raf = requestAnimationFrame(() => setActive(true));
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, []);
const handleClose = (callback) => {
setActive(false);
setTimeout(() => {
onClose();
if (typeof callback === "function") {
callback();
}
}, 200);
};
const handleDragStart = (clientY) => {
setIsDragging(true);
setStartY(clientY);
};
const handleDragMove = (clientY) => {
if (!isDragging) return;
const deltaY = clientY - startY;
if (deltaY > 0) {
setDragY(deltaY);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragY > 100) {
handleClose();
} else {
setDragY(0);
}
};
return (
<div
className={`fixed inset-0 bg-[rgba(44,24,16,0.4)] flex items-end justify-center transition-opacity duration-200 ease-in-out ${zIndex} ${active ? "opacity-100" : "opacity-0"}`}
onClick={() => handleClose()}
>
<div
className={`bg-brew-bg dark:bg-[#150B07] rounded-t-3xl w-full transition-transform duration-200 ease-in-out ${padding} ${className}`}
onClick={e => e.stopPropagation()}
style={{
maxWidth,
maxHeight,
transform: isDragging
? `translateY(${dragY}px)`
: active
? `translateY(${dragY}px)`
: "translateY(100%)",
transition: isDragging ? "none" : "transform 0.2s ease-out"
}}
>
{/* Grab Handle */}
<div
className="w-full py-4 -mt-2 cursor-grab active:cursor-grabbing flex justify-center select-none"
onTouchStart={(e) => handleDragStart(e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientY)}
onTouchEnd={handleDragEnd}
onMouseDown={(e) => handleDragStart(e.clientY)}
onMouseMove={(e) => {
if (e.buttons === 1) handleDragMove(e.clientY);
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
>
<div className="w-9 h-1 bg-brew-border dark:bg-[#3B2217] rounded" />
</div>
{/* Content */}
{typeof children === "function" ? children(handleClose) : children}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
/**
* SyncIndicator — a minimal coffee-cup icon that communicates sync results.
*
* Success: outlined cup → steam rises → checkmark appears → fades out (~2.4s)
* Offline: outlined cup + subtle "!" warning, stays visible.
*/
export default function SyncIndicator({ isOnline, syncing, showSyncedStatus }) {
const showSuccess = isOnline && showSyncedStatus && !syncing;
const showOffline = !isOnline;
if (!showSuccess && !showOffline) return null;
return (
<div
className={showSuccess ? "sync-cup-success" : undefined}
title={showOffline ? "Offline — data saved locally" : "All data synced"}
>
<svg
viewBox="0 0 24 24"
width="28"
height="28"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* ── Cup body ── */}
<g className="text-[#9C8B7A] dark:text-[#C8B9A6]">
<path
d="M5 9h10v6.5a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V9z"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15 11h1a2 2 0 0 1 0 4h-1"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
{/* ── Steam lines (success only) ── */}
{showSuccess && (
<g className="text-[#9C8B7A] dark:text-[#C8B9A6]" stroke="currentColor" strokeWidth="1.2">
<path className="sync-steam sync-steam-1" d="M8 7 Q8.7 5.5 8 4.5 Q7.5 3.8 8.2 3" />
<path className="sync-steam sync-steam-2" d="M10 6.5 Q10.7 5 10 4 Q9.5 3.3 10.2 2.5" />
<path className="sync-steam sync-steam-3" d="M12 7 Q12.7 5.5 12 4.5 Q11.5 3.8 12.2 3" />
</g>
)}
{/* ── Checkmark (success only) ── */}
{showSuccess && (
<polyline
points="7.5,14 9.5,16 13,12"
className="sync-check text-[#4A7C59] dark:text-[#6CB281]"
stroke="currentColor"
strokeWidth="1.8"
/>
)}
{/* ── Warning indicator (offline only) ── */}
{showOffline && (
<g className="text-[#B44040] dark:text-[#E55B5B]" style={{ opacity: 0.85 }}>
<line x1="10" y1="12" x2="10" y2="15.5" stroke="currentColor" strokeWidth="1.8" />
<circle cx="10" cy="17.2" r="0.8" fill="currentColor" stroke="none" />
</g>
)}
</svg>
</div>
);
}

View File

@@ -37,9 +37,9 @@ export default function UpdatePrompt() {
<div
className={`fixed inset-x-0 bottom-28 z-[150] flex justify-center px-4 transition-all duration-300 ease-out ${active ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="bg-[#2C1810] text-[#FAF6F1] rounded-2xl shadow-[0_8px_32px_rgba(44,24,16,0.35)] flex items-center gap-3 px-4 py-3.5 max-w-[420px] w-full">
<div className="bg-[#2C1810] text-[#FAF6F1] rounded-full shadow-[0_8px_32px_rgba(44,24,16,0.35)] flex items-center gap-3 pl-3 pr-4 py-2.5 max-w-[420px] w-full border border-white/5">
{/* Icon */}
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
<div className="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<RefreshCw size={16} strokeWidth={2.5} className="text-white" />
</div>
@@ -53,13 +53,13 @@ export default function UpdatePrompt() {
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleDismiss}
className="text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer px-1 py-1"
className="text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer px-2 py-2"
>
Later
</button>
<button
onClick={handleUpdate}
className="text-xs font-semibold bg-white text-[#2C1810] px-3 py-1.5 rounded-lg hover:bg-white/90 transition-colors cursor-pointer"
className="text-xs font-semibold bg-white text-[#2C1810] px-4 py-2 rounded-full hover:bg-white/90 transition-colors cursor-pointer"
>
Update
</button>

View File

@@ -1,8 +1,66 @@
export const ROAST_TYPES = ["Light", "Light-Medium", "Medium", "Medium-Dark", "Dark"];
export const METHODS = ["pourover", "espresso", "coldbrew"];
export const METHOD_LABELS = { pourover: "Pour Over", espresso: "Espresso", coldbrew: "Cold Brew" };
export const METHOD_ICONS = { pourover: "☕", espresso: "⚡", coldbrew: "❄️" };
export const METHOD_COLORS = { pourover: "#8B6914", espresso: "#5C3317", coldbrew: "#2F4F6F" };
export const METHODS = ["pourover", "espresso", "coldbrew", "aeropress", "frenchpress", "chemex", "mokapot", "v60", "syphon", "dripbrew"];
export const METHOD_LABELS = {
pourover: "Pour Over",
espresso: "Espresso",
coldbrew: "Cold Brew",
aeropress: "Aeropress",
frenchpress: "French Press",
chemex: "Chemex",
mokapot: "Moka Pot",
v60: "V60",
syphon: "Syphon",
dripbrew: "Drip Brew"
};
export const METHOD_ICONS = {
pourover: "☕",
espresso: "⚡",
coldbrew: "❄️",
aeropress: "🚀",
frenchpress: "🥛",
chemex: "🏺",
mokapot: "🚂",
v60: "☕",
syphon: "🧪",
dripbrew: "💧"
};
export const METHOD_COLORS = {
pourover: "#8B6914",
espresso: "#5C3317",
coldbrew: "#2F4F6F",
aeropress: "#A0522D",
frenchpress: "#4E3629",
chemex: "#8E6B58",
mokapot: "#6E7B8B",
v60: "#A67B5B",
syphon: "#4E6E5D",
dripbrew: "#A89B8C"
};
export const inputCls = "w-full px-3.5 py-3 border border-[#E8DFD3] dark:border-[#3B2217] rounded-lg bg-white dark:bg-[#150B07] text-sm text-[#2C1810] dark:text-[#FAF6F1] transition-colors outline-none focus:border-[#8B6914] dark:focus:border-[#D4A325]";
export const labelCls = "block text-[10px] font-semibold uppercase tracking-wider text-[#6B5744] dark:text-[#C8B9A6] mb-1.5";
export function getRoastAgingInfo(roastDateStr) {
if (!roastDateStr) return null;
const roastDate = new Date(roastDateStr);
if (isNaN(roastDate.getTime())) return null;
const today = new Date();
roastDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - roastDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { status: "Upcoming", days: diffDays, label: "Not roasted yet", style: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300" };
} else if (diffDays <= 5) {
return { status: "Off-gassing", days: diffDays, label: `${diffDays}d old · Off-gassing`, style: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" };
} else if (diffDays <= 21) {
return { status: "Peak Flavor", days: diffDays, label: `${diffDays}d old · Peak Flavor`, style: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300" };
} else if (diffDays <= 45) {
return { status: "Good", days: diffDays, label: `${diffDays}d old · Good`, style: "bg-stone-200 text-stone-800 dark:bg-stone-800/40 dark:text-stone-300" };
} else {
return { status: "Aging", days: diffDays, label: `${diffDays}d old · Aging`, style: "bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300" };
}
}

View File

@@ -43,6 +43,8 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease;
touch-action: pan-x pan-y;
overscroll-behavior-y: contain;
}
html.dark {
@@ -53,6 +55,8 @@
margin: 0;
background-color: #F3EDE4;
transition: background-color 0.2s ease, color 0.2s ease;
touch-action: pan-x pan-y;
overscroll-behavior-y: contain;
}
html.dark body {
@@ -138,4 +142,49 @@
width: 4px;
border-radius: 0 4px 4px 0;
}
/* ── Sync cup indicator ── */
.sync-cup-success {
animation: sync-cup-fade 2.4s ease-in-out forwards;
}
@keyframes sync-cup-fade {
0% { opacity: 0; transform: scale(0.88); }
8% { opacity: 1; transform: scale(1); }
68% { opacity: 1; }
100% { opacity: 0; }
}
.sync-steam {
opacity: 0;
}
.sync-steam-1 { animation: sync-steam-rise 1.4s ease-out 0.1s forwards; }
.sync-steam-2 { animation: sync-steam-rise 1.4s ease-out 0.3s forwards; }
.sync-steam-3 { animation: sync-steam-rise 1.4s ease-out 0.5s forwards; }
@keyframes sync-steam-rise {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.5; }
100% { opacity: 0; transform: translateY(-4px); }
}
.sync-check {
stroke-dasharray: 14;
stroke-dashoffset: 14;
animation: sync-check-draw 0.45s ease-out 0.65s forwards;
}
@keyframes sync-check-draw {
to { stroke-dashoffset: 0; }
}
/* Hide scrollbar utility */
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
}

View File

@@ -24,7 +24,7 @@ export default function Login({ onLoginSuccess }) {
return (
<div className="px-5 pt-12 pb-8 max-w-[480px] mx-auto transition-colors duration-200">
<div className="mb-8 text-center">
<div className="text-5xl mb-3"></div>
<img src="/icon-192.png" alt="Brew Journal" className="w-16 h-16 mx-auto mb-3 rounded-[22%]" />
<h1 className="font-serif text-3xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-1">Brew Journal</h1>
<p className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6]">Sign in to your coffee logbook</p>
</div>

View File

@@ -2,6 +2,7 @@
import { useContext } from "react";
import { AuthContext } from "../AuthContext";
import { useTheme } from "../ThemeContext";
import { User, Mail, Contrast, Coffee, Database, MessageSquare } from "lucide-react";
export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus }) {
const { logout } = useContext(AuthContext);
@@ -18,16 +19,14 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
{/* Avatar & Name */}
<div className="flex flex-col items-center pt-6 pb-8">
<div className="w-20 h-20 rounded-full bg-[#2C1810] dark:bg-[#D4A325] flex items-center justify-center text-3xl text-[#FAF6F1] dark:text-[#2C1810] font-serif font-bold mb-3 shadow-sm">
{user?.username?.[0]?.toUpperCase() || "☕"}
{user?.username?.[0]?.toUpperCase() || <User size={32} strokeWidth={2} />}
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1]">{user?.username}</div>
<div className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">{user?.email}</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1.5 text-[11px] font-medium px-3 py-1.5 rounded-full mt-3 transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing…" : "All data synced") : "Offline — saved locally"}</span>
</div>
)}
<div className={`flex items-center gap-1.5 text-[11px] font-medium px-3 py-1.5 rounded-full mt-3 transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing…" : "All data synced") : "Offline — saved locally"}</span>
</div>
</div>
{/* Account section */}
@@ -35,14 +34,14 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Account</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[#F3EDE4] dark:border-[#3B2217]">
<span className="text-lg">👤</span>
<User size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Username</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">{user?.username}</div>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg"></span>
<Mail size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Email</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">{user?.email}</div>
@@ -51,13 +50,22 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
</div>
</div>
{/* Sign out */}
<div className="mb-6">
<button
onClick={() => logout()}
className="w-full py-3.5 border border-[#B44040] dark:border-[#E55B5B] rounded-2xl text-sm font-semibold text-[#B44040] dark:text-[#E55B5B] bg-transparent cursor-pointer hover:bg-[rgba(180,64,64,0.05)] dark:hover:bg-[rgba(229,91,91,0.05)] transition-all">
Sign Out
</button>
</div>
{/* Appearance Settings section */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Appearance</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] p-4 flex flex-col gap-3 shadow-sm transition-colors duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-lg">🌓</span>
<Contrast size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Theme</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Customize your viewing experience</div>
@@ -72,7 +80,7 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={`py-2 px-3 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
className={`py-2 px-3 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
active
? "bg-[#2C1810] text-[#FAF6F1] dark:bg-[#FAF6F1] dark:text-[#2C1810] shadow-sm"
: "text-[#6B5744] hover:text-[#2C1810] dark:text-[#C8B9A6] dark:hover:text-[#FAF6F1]"
@@ -92,13 +100,13 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-[#F3EDE4] dark:border-[#3B2217]">
<div className="flex items-center gap-3">
<span className="text-lg"></span>
<Coffee size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Brew Journal</div>
</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] font-mono">{__APP_VERSION__}</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg">🗄</span>
<Database size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Storage</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Local + PostgreSQL</div>
@@ -107,12 +115,27 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
</div>
</div>
{/* Sign out */}
<button
onClick={() => logout()}
className="w-full py-3.5 border border-[#B44040] dark:border-[#E55B5B] rounded-2xl text-sm font-semibold text-[#B44040] dark:text-[#E55B5B] bg-transparent cursor-pointer hover:bg-[rgba(180,64,64,0.05)] dark:hover:bg-[rgba(229,91,91,0.05)] transition-all">
Sign Out
</button>
{/* Support section */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Support</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<a
href="https://git.adityagupta.dev/sortedcord/brew/issues/new"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3.5 hover:bg-[#F3EDE4]/50 dark:hover:bg-[#2C1810]/50 transition-colors cursor-pointer no-underline group"
>
<div className="flex items-center gap-3">
<MessageSquare size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Feedback</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Report an issue or request a feature</div>
</div>
</div>
<span className="text-[#9C8B7A] dark:text-[#C8B9A6] text-sm group-hover:translate-x-0.5 transition-transform duration-200"></span>
</a>
</div>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export default function Register({ onRegisterSuccess }) {
return (
<div className="px-5 pt-12 pb-8 max-w-[480px] mx-auto transition-colors duration-200">
<div className="mb-8 text-center">
<div className="text-5xl mb-3">🫘</div>
<img src="/icon-192.png" alt="Brew Journal" className="w-16 h-16 mx-auto mb-3 rounded-[22%]" />
<h1 className="font-serif text-3xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-1">Create Account</h1>
<p className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6]">Start tracking your coffee journey</p>
</div>

View File

@@ -6,12 +6,28 @@ import { execSync } from 'child_process'
// Derive version from git tags; fall back to commit hash
function getGitVersion() {
try {
// If there's a tag pointing at HEAD, use it exactly (e.g. "v1.2.0")
// Otherwise use "v0.0.0-<hash>[-dirty]"
const raw = execSync('git describe --tags --always --dirty', { stdio: ['pipe', 'pipe', 'ignore'] })
// Attempt to fetch tags in case of a shallow clone (e.g. CI/CD)
try {
execSync('git fetch --tags', { stdio: 'ignore' });
} catch (e) {
// Ignore if fetch fails
}
// Only include --dirty locally (development), omit in production to prevent false "dirty" flags
const isProd = process.env.NODE_ENV === 'production' || process.env.CI;
const describeCmd = isProd ? 'git describe --tags --always' : 'git describe --tags --always --dirty';
const raw = execSync(describeCmd, { stdio: ['pipe', 'pipe', 'ignore'] })
.toString()
.trim();
// If it looks like a pure tag (no dashes after the tag part) return as-is
// If it's just a commit hash (no tags found)
if (/^[0-9a-f]{7,}$/.test(raw)) {
return `v0.0.0-${raw}`;
}
// Otherwise, return raw (e.g., v0.1.1-5-g4a9f6b6 or v1.2.0)
// Exact tag (e.g., v1.2.0)
return raw;
} catch {
return 'dev';