Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 188d7bd2f3 | |||
| a2d7b6f0b2 | |||
| 60d1e75177 | |||
| 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"
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
<mail@adityagupta.dev>.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aditya Gupta
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
105
README.md
@@ -1,10 +1,44 @@
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
The codebase is split into two main sections:
|
||||
|
||||
- **Frontend**: A React application located in the root directory, built with [Vite](https://vite.dev/). Key files include:
|
||||
- [package.json](file:///home/sortedcord/Projects/brew/package.json) (dependencies and scripts)
|
||||
- [src/App.jsx](file:///home/sortedcord/Projects/brew/src/App.jsx) (routing and layout)
|
||||
@@ -14,64 +48,71 @@ 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:
|
||||
|
||||
- **Node.js** (v18.x or higher recommended)
|
||||
- **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
|
||||
```
|
||||
|
||||
The PostgreSQL service should start automatically. If not, start it with:
|
||||
|
||||
```bash
|
||||
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
|
||||
sudo -i -u postgres psql
|
||||
```
|
||||
*(On macOS/Windows, open your terminal/command prompt and run `psql postgres` or use a graphical tool like PgAdmin).*
|
||||
|
||||
_(On macOS/Windows, open your terminal/command prompt and run `psql postgres` or use a graphical tool like PgAdmin)._
|
||||
|
||||
2. Create a database user with a secure password:
|
||||
|
||||
```sql
|
||||
CREATE USER brew_user WITH PASSWORD 'your_secure_password';
|
||||
```
|
||||
|
||||
3. Create the database and set its owner to `brew_user`. Setting the owner guarantees that the user has full table creation privileges on the default `public` schema:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE brew OWNER brew_user;
|
||||
```
|
||||
|
||||
4. Exit the PostgreSQL shell:
|
||||
|
||||
```sql
|
||||
\q
|
||||
```
|
||||
@@ -79,6 +120,7 @@ Download and run the interactive installer from the [Official PostgreSQL Downloa
|
||||
> [!TIP]
|
||||
> **Troubleshooting "Permission denied for schema public" (PostgreSQL 15+):**
|
||||
> If you already created the database without setting `brew_user` as the owner and encounter this error, log in as the superuser (`postgres`), connect to the `brew` database, and explicitly grant the schema privileges:
|
||||
>
|
||||
> ```sql
|
||||
> \c brew
|
||||
> GRANT ALL ON SCHEMA public TO brew_user;
|
||||
@@ -87,78 +129,85 @@ 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
|
||||
cd server
|
||||
```
|
||||
|
||||
2. Create a [server/.env](file:///home/sortedcord/Projects/brew/server/.env) file by copying the template [server/.env.example](file:///home/sortedcord/Projects/brew/server/.env.example):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Open [server/.env](file:///home/sortedcord/Projects/brew/server/.env) and configure the environment variables:
|
||||
|
||||
```env
|
||||
PORT=5000
|
||||
DATABASE_URL=postgresql://brew_user:your_secure_password@localhost:5432/brew
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
```
|
||||
*Replace `your_secure_password` with the password you set during the database setup.*
|
||||
|
||||
_Replace `your_secure_password` with the password you set during the database setup._
|
||||
|
||||
4. Install dependencies and start the backend server:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
The backend should start and display:
|
||||
|
||||
```text
|
||||
Database initialized
|
||||
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
|
||||
cd /home/sortedcord/Projects/brew
|
||||
```
|
||||
|
||||
2. Install frontend dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the Vite development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
### 1. Database (PostgreSQL)
|
||||
|
||||
- **Restricted Access**: Do not use superuser accounts (such as `postgres`) for backend application connections. Instead, use a restricted role with standard read/write permissions.
|
||||
- **Managed Databases**: Use a managed database service (e.g., Supabase, Neon, AWS RDS, GCP Cloud SQL) to leverage automated backups, scaling, and high-availability.
|
||||
- **SSL Connection**: Enforce encrypted database connections by appending SSL options to the connection string (e.g., `?sslmode=require`).
|
||||
|
||||
### 2. Backend Server Setup
|
||||
|
||||
- **Environment Variables**: In your production environment, set the following environment variables:
|
||||
- `NODE_ENV=production`
|
||||
- `PORT=8080` (or whichever port is provided by your host)
|
||||
- `DATABASE_URL=postgresql://<db_user>:<db_password>@<db_host>:<db_port>/<db_name>?sslmode=require`
|
||||
- `JWT_SECRET=your_long_random_production_secret` (generate a secure 32-byte key using `openssl rand -base64 32`)
|
||||
- **Process Management**: Use a process manager like **PM2** to run the backend node process, keep it alive, and handle automatic clustering or restarts:
|
||||
|
||||
```bash
|
||||
# Install PM2 globally
|
||||
npm install -g pm2
|
||||
@@ -166,20 +215,27 @@ When deploying the Brew application to a production environment, follow these gu
|
||||
# Start the backend server
|
||||
pm2 start server/index.js --name "brew-backend"
|
||||
```
|
||||
|
||||
- **Restrict CORS**: In [server/index.js](file:///home/sortedcord/Projects/brew/server/index.js), configure the `cors` middleware to only accept requests from your frontend production domain:
|
||||
|
||||
```javascript
|
||||
app.use(cors({ origin: 'https://yourfrontenddomain.com' }));
|
||||
app.use(cors({ origin: "https://yourfrontenddomain.com" }));
|
||||
```
|
||||
|
||||
### 3. Frontend Build & Hosting
|
||||
|
||||
- **Build the static bundle**: Run the build script in the root directory to generate optimized production assets:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This generates static HTML, CSS, and JS files in the `dist/` directory.
|
||||
|
||||
- **Hosting**:
|
||||
- Deploy the static files from the `dist/` directory to static hosting platforms like **Vercel**, **Netlify**, **Cloudflare Pages**, or **AWS S3/CloudFront**.
|
||||
- Alternatively, if using a VPS, serve the `dist/` directory using **Nginx** and proxy API traffic:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
@@ -205,4 +261,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.
|
||||
|
||||
|
||||
BIN
docs/assets/screenshot.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
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 |
@@ -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,
|
||||
@@ -189,7 +207,8 @@ app.post('/api/sync', authenticateToken, async (req, res) => {
|
||||
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
|
||||
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,
|
||||
userId,
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
83
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,6 +16,7 @@ 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
|
||||
@@ -71,12 +73,14 @@ 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);
|
||||
|
||||
useEffect(() => { loadData().then(setData); }, []);
|
||||
useEffect(() => { setBrewSearchQuery(""); }, [view]);
|
||||
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
@@ -177,7 +181,37 @@ 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 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 = { pourover: 0, espresso: 0, coldbrew: 0 };
|
||||
brewLogs.forEach(l => { if (methodCounts[l.method] !== undefined) methodCounts[l.method]++; });
|
||||
|
||||
@@ -196,13 +230,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 */}
|
||||
@@ -297,6 +325,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 +362,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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
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";
|
||||
|
||||
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 handleClose = (callback) => {
|
||||
setActive(false);
|
||||
setTimeout(() => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,50 +49,45 @@ export default function BrewForm({ beans, onSave, onClose }) {
|
||||
};
|
||||
|
||||
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">
|
||||
{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={() => 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>
|
||||
|
||||
@@ -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,40 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||