Compare commits
17 Commits
v0.1.1
...
feature-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 681f892d63 | |||
| 012db524cb | |||
| 77349f2b90 | |||
| f95d1f3028 | |||
| a27bd118e5 | |||
| de9cbb14d0 | |||
| bf96fb9763 | |||
| f2af73ac69 | |||
| 1195159c3d | |||
| 4e234d075f | |||
| a0b1efd242 | |||
| e808aa8a37 | |||
| 4a9f6b6266 | |||
| 592ccf0a92 | |||
| f8a1f2cbdd | |||
| 1064c724f7 | |||
| 9d98966a8e |
35
.gitea/workflows/deploy.yml
Normal 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"
|
||||
79
README.md
@@ -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.
|
||||
|
||||
|
||||
58
index.html
@@ -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>
|
||||
|
||||
BIN
public/brew_favicons/brew_1024.png
Normal file
|
After Width: | Height: | Size: 796 KiB |
BIN
public/brew_favicons/brew_64.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
BIN
public/icon-1024.png
Normal file
|
After Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 545 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 545 KiB After Width: | Height: | Size: 164 KiB |
@@ -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);
|
||||
|
||||
113
server/index.js
@@ -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,
|
||||
|
||||
28
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
275
src/App.jsx
@@ -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"
|
||||
>
|
||||
×
|
||||
</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)} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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
@@ -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>
|
||||
);
|
||||
}
|
||||
69
src/components/SyncIndicator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||