all bulletjournal version 1 completed
This commit is contained in:
parent
c3937412e7
commit
bf6b2820b6
28 changed files with 3467 additions and 0 deletions
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
test_flask_bullet
|
||||
47
app/__init__.py
Normal file
47
app/__init__.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
import config
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
|
||||
# Flask-Login configuration
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config.Config)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Initialize model classes with db instance
|
||||
from .models import init_db_models
|
||||
init_db_models(db)
|
||||
|
||||
# Import model classes (stored on init_db_models function)
|
||||
User = init_db_models.User
|
||||
UserSettings = init_db_models.UserSettings
|
||||
DailyLog = init_db_models.DailyLog
|
||||
DailyLogItem = init_db_models.DailyLogItem
|
||||
|
||||
# Flask-Login user loader (must be defined after User class exists)
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from . import routes
|
||||
app.register_blueprint(routes.bp)
|
||||
|
||||
from . import auth
|
||||
app.register_blueprint(auth.auth_bp)
|
||||
|
||||
return app
|
||||
55
app/auth.py
Normal file
55
app/auth.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for, request, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from . import db
|
||||
from .models import init_db_models
|
||||
|
||||
# Import model classes (stored on init_db_models function)
|
||||
User = init_db_models.User
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
flash('Invalid username or password.')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
login_user(user)
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Registered successfully. Please log in.')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('register.html')
|
||||
94
app/models.py
Normal file
94
app/models.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
|
||||
# Sleep quality and status choices for validation
|
||||
SLEEP_QUALITY_CHOICES = ['poor', 'fair', 'good', 'excellent']
|
||||
STATUS_CHOICES = ['todo', 'doing', 'done', 'cancelled']
|
||||
ITEM_TYPE_CHOICES = ['bullet', 'gratitude', 'sleep', 'note', 'task']
|
||||
|
||||
|
||||
def init_db_models(db):
|
||||
"""Initialize all model classes with the SQLAlchemy instance.
|
||||
|
||||
Call this once in create_app() after db is created.
|
||||
"""
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
|
||||
logs = db.relationship('DailyLog', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
class UserSettings(db.Model):
|
||||
__tablename__ = 'user_settings'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True)
|
||||
theme = db.Column(db.String(10), default='light')
|
||||
font_size = db.Column(db.Integer, default=18)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class DailyLog(db.Model):
|
||||
__tablename__ = 'daily_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.Date, unique=True, nullable=False)
|
||||
prompt = db.Column(db.Text, nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
gratitude = db.Column(db.JSON, default=list)
|
||||
bedtime = db.Column(db.String(20), nullable=True)
|
||||
wake_time = db.Column(db.String(20), nullable=True)
|
||||
sleep_quality = db.Column(db.String(10), nullable=True) # Enum stored as string
|
||||
mood = db.Column(db.String(50), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
items = db.relationship('DailyLogItem', backref='log', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def gratitude_list(self):
|
||||
if not self.gratitude:
|
||||
return ['', '', '']
|
||||
return self.gratitude + [''] * (3 - len(self.gratitude))
|
||||
|
||||
@gratitude_list.setter
|
||||
def gratitude_list(self, value):
|
||||
self.gratitude = value[:3] if value else []
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DailyLog {self.date}>'
|
||||
|
||||
class DailyLogItem(db.Model):
|
||||
__tablename__ = 'daily_log_item'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
log_id = db.Column(db.Integer, db.ForeignKey('daily_log.id'), nullable=False)
|
||||
type = db.Column(db.String(20), nullable=False) # Enum stored as string
|
||||
symbol = db.Column(db.String(10), default='•')
|
||||
text = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
status = db.Column(db.String(10), default='todo') # Enum stored as string
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_log_id', 'log_id'),
|
||||
db.Index('ix_log_id_status', 'log_id', 'status'),
|
||||
db.Index('ix_type', 'type'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DailyLogItem {self.type} {self.log_id}>'
|
||||
|
||||
# Store references on module for import by other blueprints
|
||||
init_db_models.User = User
|
||||
init_db_models.UserSettings = UserSettings
|
||||
init_db_models.DailyLog = DailyLog
|
||||
init_db_models.DailyLogItem = DailyLogItem
|
||||
365
app/routes.py
Normal file
365
app/routes.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, date
|
||||
from . import db
|
||||
from .models import init_db_models
|
||||
|
||||
# Import model classes (stored on init_db_models function)
|
||||
DailyLog = init_db_models.DailyLog
|
||||
DailyLogItem = init_db_models.DailyLogItem
|
||||
UserSettings = init_db_models.UserSettings
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
# ─── Step 2: Daily Log (main page) ───────────────────────────────────
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Daily Log screen — main page (Step 2)."""
|
||||
today = date.today()
|
||||
log = DailyLog.query.filter_by(date=today, user_id=current_user.id).first()
|
||||
|
||||
if not log:
|
||||
log = DailyLog(date=today, user_id=current_user.id)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
|
||||
# Get settings
|
||||
settings = UserSettings.query.filter_by(user_id=current_user.id).first()
|
||||
if not settings:
|
||||
settings = UserSettings(user_id=current_user.id)
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
|
||||
return render_template('daily_log.html', log=log, settings=settings)
|
||||
|
||||
|
||||
# ─── Step 2: All Logs ────────────────────────────────────────────────
|
||||
@bp.route('/logs')
|
||||
@login_required
|
||||
def log_list():
|
||||
"""All Logs screen — list of dates (Step 2)."""
|
||||
logs = DailyLog.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(DailyLog.date.desc()).all()
|
||||
return render_template('log_list.html', logs=logs)
|
||||
|
||||
|
||||
# ─── Step 2: Daily Log Detail (by date) ──────────────────────────────
|
||||
@bp.route('/logs/<int:log_id>')
|
||||
@login_required
|
||||
def log_detail(log_id):
|
||||
"""Open the Daily Log for a specific date."""
|
||||
log = DailyLog.query.get_or_404(log_id)
|
||||
if log.user_id != current_user.id:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('main.log_list'))
|
||||
return render_template('daily_log.html', log=log, settings=None)
|
||||
|
||||
|
||||
# ─── Step 4: Create / Edit Log ───────────────────────────────────────
|
||||
@bp.route('/logs/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def log_new():
|
||||
"""Create a new Daily Log entry."""
|
||||
if request.method == 'POST':
|
||||
log_date = request.form.get('date')
|
||||
try:
|
||||
log_date = datetime.strptime(log_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash('Invalid date format.')
|
||||
return render_template('daily_log_form.html', action='New')
|
||||
|
||||
existing = DailyLog.query.filter_by(date=log_date, user_id=current_user.id).first()
|
||||
if existing:
|
||||
flash('Log for this date already exists.')
|
||||
return redirect(url_for('main.log_detail', log_id=existing.id))
|
||||
|
||||
log = DailyLog(
|
||||
date=log_date,
|
||||
prompt=request.form.get('prompt', ''),
|
||||
notes=request.form.get('notes', ''),
|
||||
bedtime=request.form.get('bedtime', ''),
|
||||
wake_time=request.form.get('wake_time', ''),
|
||||
sleep_quality=request.form.get('sleep_quality'),
|
||||
mood=request.form.get('mood', ''),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
|
||||
# Handle gratitude (3 fields)
|
||||
gratitude = [request.form.get(f'gratitude_{i}', '') for i in range(3)]
|
||||
log.gratitude = gratitude
|
||||
|
||||
# Handle bullets / items
|
||||
item_types = request.form.getlist('item_type[]')
|
||||
item_symbols = request.form.getlist('item_symbol[]')
|
||||
item_texts = request.form.getlist('item_text[]')
|
||||
item_statuses = request.form.getlist('item_status[]')
|
||||
|
||||
for i in range(len(item_types)):
|
||||
if item_types[i] and item_texts[i].strip():
|
||||
item = DailyLogItem(
|
||||
log_id=log.id,
|
||||
type=item_types[i],
|
||||
symbol=item_symbols[i] if item_symbols[i] else '•',
|
||||
text=item_texts[i].strip(),
|
||||
status=item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo',
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
db.session.commit()
|
||||
flash('Log saved!')
|
||||
return redirect(url_for('main.log_detail', log_id=log.id))
|
||||
|
||||
return render_template('daily_log_form.html', action='New')
|
||||
|
||||
|
||||
@bp.route('/logs/<int:log_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def log_edit(log_id):
|
||||
"""Edit an existing Daily Log entry."""
|
||||
log = DailyLog.query.get_or_404(log_id)
|
||||
if log.user_id != current_user.id:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('main.log_list'))
|
||||
|
||||
if request.method == 'POST':
|
||||
log.prompt = request.form.get('prompt', '')
|
||||
log.notes = request.form.get('notes', '')
|
||||
log.bedtime = request.form.get('bedtime', '')
|
||||
log.wake_time = request.form.get('wake_time', '')
|
||||
log.sleep_quality = request.form.get('sleep_quality')
|
||||
log.mood = request.form.get('mood', '')
|
||||
|
||||
# Handle gratitude
|
||||
gratitude = [request.form.get(f'gratitude_{i}', '') for i in range(3)]
|
||||
log.gratitude = gratitude
|
||||
|
||||
# Handle existing items and new items
|
||||
item_ids = request.form.getlist('item_id[]')
|
||||
item_types = request.form.getlist('item_type[]')
|
||||
item_symbols = request.form.getlist('item_symbol[]')
|
||||
item_texts = request.form.getlist('item_text[]')
|
||||
item_statuses = request.form.getlist('item_status[]')
|
||||
|
||||
# Remove items not in item_ids
|
||||
existing_ids = [int(i) for i in item_ids if i.isdigit()]
|
||||
for item in log.items:
|
||||
if item.id not in existing_ids:
|
||||
db.session.delete(item)
|
||||
|
||||
for i in range(len(item_types)):
|
||||
if item_ids[i] and item_ids[i].isdigit():
|
||||
item = DailyLogItem.query.get(int(item_ids[i]))
|
||||
if item:
|
||||
item.type = item_types[i]
|
||||
item.symbol = item_symbols[i] if item_symbols[i] else '•'
|
||||
item.text = item_texts[i].strip()
|
||||
item.status = item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo'
|
||||
elif item_types[i] and item_texts[i].strip():
|
||||
item = DailyLogItem(
|
||||
log_id=log.id,
|
||||
type=item_types[i],
|
||||
symbol=item_symbols[i] if item_symbols[i] else '•',
|
||||
text=item_texts[i].strip(),
|
||||
status=item_statuses[i] if item_statuses[i] in ['todo', 'doing', 'done', 'cancelled'] else 'todo',
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
db.session.commit()
|
||||
flash('Log updated!')
|
||||
return redirect(url_for('main.log_detail', log_id=log.id))
|
||||
|
||||
return render_template('daily_log_form.html', log=log, action='Edit')
|
||||
|
||||
|
||||
# ─── Step 4: Add / Update Bullet (AJAX) ──────────────────────────────
|
||||
@bp.route('/logs/<int:log_id>/items', methods=['POST'])
|
||||
@login_required
|
||||
def add_log_item(log_id):
|
||||
"""Add a bullet/item to a log (AJAX endpoint)."""
|
||||
log = DailyLog.query.get_or_404(log_id)
|
||||
if log.user_id != current_user.id:
|
||||
return jsonify({'success': False, 'error': 'Access denied.'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
item_type = data.get('type', 'bullet')
|
||||
symbol = data.get('symbol', '•')
|
||||
text = data.get('text', '').strip()
|
||||
status = data.get('status', 'todo')
|
||||
|
||||
if not text:
|
||||
return jsonify({'success': False, 'error': 'Text is required.'}), 400
|
||||
|
||||
item = DailyLogItem(
|
||||
log_id=log.id,
|
||||
type=item_type,
|
||||
symbol=symbol,
|
||||
text=text,
|
||||
status=status,
|
||||
)
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': item.id,
|
||||
'type': item.type,
|
||||
'symbol': item.symbol,
|
||||
'text': item.text,
|
||||
'status': item.status,
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/logs/items/<int:item_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_log_item(item_id):
|
||||
"""Update a bullet/item status or text (AJAX endpoint)."""
|
||||
item = DailyLogItem.query.get_or_404(item_id)
|
||||
if item.log.user_id != current_user.id:
|
||||
return jsonify({'success': False, 'error': 'Access denied.'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if 'status' in data:
|
||||
item.status = data['status']
|
||||
if 'text' in data:
|
||||
item.text = data['text'].strip()
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'status': item.status, 'text': item.text})
|
||||
|
||||
|
||||
@bp.route('/logs/items/<int:item_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_log_item(item_id):
|
||||
"""Delete a bullet/item (AJAX endpoint)."""
|
||||
item = DailyLogItem.query.get_or_404(item_id)
|
||||
if item.log.user_id != current_user.id:
|
||||
return jsonify({'success': False, 'error': 'Access denied.'}), 403
|
||||
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ─── Step 4: Export / Import ─────────────────────────────────────────
|
||||
@bp.route('/export')
|
||||
@login_required
|
||||
def export_logs():
|
||||
"""Export all logs as JSON (Step 4)."""
|
||||
logs = DailyLog.query.filter_by(user_id=current_user.id).all()
|
||||
data = []
|
||||
for log in logs:
|
||||
log_data = {
|
||||
'date': log.date.isoformat(),
|
||||
'prompt': log.prompt,
|
||||
'notes': log.notes,
|
||||
'gratitude': log.gratitude or [],
|
||||
'bedtime': log.bedtime,
|
||||
'wake_time': log.wake_time,
|
||||
'sleep_quality': log.sleep_quality,
|
||||
'mood': log.mood,
|
||||
'items': [
|
||||
{
|
||||
'type': item.type.value,
|
||||
'symbol': item.symbol,
|
||||
'text': item.text,
|
||||
'status': item.status.value,
|
||||
}
|
||||
for item in log.items
|
||||
],
|
||||
}
|
||||
data.append(log_data)
|
||||
|
||||
return jsonify(data), 200, {'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="logs.json"'}
|
||||
|
||||
|
||||
@bp.route('/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_logs():
|
||||
"""Import logs from JSON file (Step 4)."""
|
||||
if 'file' not in request.files:
|
||||
flash('No file uploaded.')
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
file = request.files['file']
|
||||
if not file or not file.filename.endswith('.json'):
|
||||
flash('Invalid file. Please upload a JSON file.')
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
import json as json_module
|
||||
try:
|
||||
data = file.read().decode('utf-8')
|
||||
logs_data = json_module.loads(data)
|
||||
|
||||
imported = 0
|
||||
for log_data in logs_data:
|
||||
log_date = datetime.strptime(log_data['date'], '%Y-%m-%d').date()
|
||||
existing = DailyLog.query.filter_by(date=log_date, user_id=current_user.id).first()
|
||||
if existing:
|
||||
continue # Skip existing
|
||||
|
||||
log = DailyLog(
|
||||
date=log_date,
|
||||
prompt=log_data.get('prompt', ''),
|
||||
notes=log_data.get('notes', ''),
|
||||
gratitude=log_data.get('gratitude', []),
|
||||
bedtime=log_data.get('bedtime', ''),
|
||||
wake_time=log_data.get('wake_time', ''),
|
||||
sleep_quality=log_data.get('sleep_quality'),
|
||||
mood=log_data.get('mood', ''),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.flush()
|
||||
|
||||
for item_data in log_data.get('items', []):
|
||||
item = DailyLogItem(
|
||||
log_id=log.id,
|
||||
type=item_data.get('type', 'bullet'),
|
||||
symbol=item_data.get('symbol', '•'),
|
||||
text=item_data.get('text', ''),
|
||||
status=item_data.get('status', 'todo'),
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
imported += 1
|
||||
|
||||
db.session.commit()
|
||||
flash(f'{imported} logs imported!')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Import failed: {str(e)}')
|
||||
|
||||
return redirect(url_for('main.settings'))
|
||||
|
||||
|
||||
# ─── Step 4: Settings ────────────────────────────────────────────────
|
||||
@bp.route('/settings')
|
||||
@login_required
|
||||
def settings():
|
||||
"""Settings screen (Step 2)."""
|
||||
s = UserSettings.query.filter_by(user_id=current_user.id).first()
|
||||
if not s:
|
||||
s = UserSettings(user_id=current_user.id)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return render_template('settings.html', settings=s)
|
||||
|
||||
|
||||
@bp.route('/settings/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_settings():
|
||||
"""Update user settings."""
|
||||
s = UserSettings.query.filter_by(user_id=current_user.id).first()
|
||||
if not s:
|
||||
s = UserSettings(user_id=current_user.id)
|
||||
db.session.add(s)
|
||||
|
||||
s.theme = request.form.get('theme', 'light')
|
||||
s.font_size = int(request.form.get('font_size', 18))
|
||||
db.session.commit()
|
||||
flash('Settings saved!')
|
||||
return redirect(url_for('main.settings'))
|
||||
662
app/static/style.css
Normal file
662
app/static/style.css
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
/* ============================================================
|
||||
Ivory & Ink — Bullet Journal Styles
|
||||
Based on the Ivory & Ink UI Style Guide
|
||||
============================================================ */
|
||||
|
||||
/* ---------- CSS custom properties (light mode) ---------- */
|
||||
:root {
|
||||
/* Page & surfaces */
|
||||
--page: #FAF8F2;
|
||||
--page-accent: #F0ECE2;
|
||||
--surface: #FFFEFA;
|
||||
--surface-alt: #F6F2E9;
|
||||
--surface-strong: #E8E1D4;
|
||||
--border: #D8D1C5;
|
||||
|
||||
/* Text */
|
||||
--text: #242321;
|
||||
--muted: #6E6A63;
|
||||
|
||||
/* Actions */
|
||||
--primary: #2D2B28;
|
||||
--primary-hover: #171614;
|
||||
--secondary: #E8E2D8;
|
||||
--secondary-hover: #DAD2C5;
|
||||
|
||||
/* Supporting accents */
|
||||
--accent-1: #817A70;
|
||||
--accent-2: #8C9795;
|
||||
--accent-3: #B49B73;
|
||||
--success: #6F806F;
|
||||
--danger: #8A5D54;
|
||||
|
||||
/* Focus */
|
||||
--focus: #817A70;
|
||||
|
||||
/* Spacing */
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 16px;
|
||||
--space-md: 24px;
|
||||
--space-lg: 32px;
|
||||
--space-xl: 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-body: "Atkinson Hyperlegible", "Segoe UI", Arial, sans-serif;
|
||||
--font-ui: "Lexend", "Segoe UI", Arial, sans-serif;
|
||||
--font-size-body: 18px;
|
||||
--font-size-btn: 16px;
|
||||
--line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- Dark mode ---------- */
|
||||
html[data-theme="dark"] {
|
||||
--page: #020202;
|
||||
--page-accent: #090806;
|
||||
--surface: #151411;
|
||||
--surface-alt: #201E1A;
|
||||
--surface-strong: #322F28;
|
||||
--border: #4D4941;
|
||||
--text: #FAF7EF;
|
||||
--muted: #C7C1B5;
|
||||
--primary: #E8E1D4;
|
||||
--primary-hover: #FFF9ED;
|
||||
--secondary: #4C4840;
|
||||
--secondary-hover: #625D53;
|
||||
--accent-1: #B6ADA0;
|
||||
--accent-2: #8EA09C;
|
||||
--accent-3: #C8AD7C;
|
||||
--success: #9BAE99;
|
||||
--danger: #C89084;
|
||||
--focus: #B6ADA0;
|
||||
}
|
||||
|
||||
/* ---------- Reset ---------- */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size-body);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--page);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
line-height: var(--line-height);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.page-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background-color: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.page-main {
|
||||
flex: 1;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background-color: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.card--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card--future {
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.card__eyebrow {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.future-text {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.future-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
color: var(--accent-1);
|
||||
}
|
||||
|
||||
/* ---------- Daily Log Layout (Step 2) ---------- */
|
||||
.daily-log-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.daily-log-layout {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.daily-log__current {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daily-log__future {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xxs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-btn);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: var(--space-xxs) var(--space-xs);
|
||||
font-size: 1.25rem;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--danger);
|
||||
background-color: var(--surface-alt);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: var(--space-xxs) var(--space-xs);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-xxs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: var(--space-xxs);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
input[type="file"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: var(--line-height);
|
||||
color: var(--text);
|
||||
background-color: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* ---------- Bullets Section ---------- */
|
||||
.bullets-section {
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.bullets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.bullet-item {
|
||||
background-color: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.bullet-item__row {
|
||||
display: flex;
|
||||
gap: var(--space-xxs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bullet-item__type,
|
||||
.bullet-item__symbol,
|
||||
.bullet-item__status {
|
||||
width: auto;
|
||||
min-width: 60px;
|
||||
padding: var(--space-xxs) var(--space-xs);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bullet-item__text {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ---------- Gratitude ---------- */
|
||||
.gratitude-section {
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.gratitude-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.gratitude-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.gratitude-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xxs);
|
||||
}
|
||||
|
||||
.gratitude-item label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.gratitude-item input {
|
||||
padding: var(--space-xxs) var(--space-xs);
|
||||
}
|
||||
|
||||
/* ---------- Sleep ---------- */
|
||||
.sleep-section {
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.sleep-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.sleep-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sleep-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xxs);
|
||||
}
|
||||
|
||||
.sleep-item label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sleep-item input,
|
||||
.sleep-item select {
|
||||
padding: var(--space-xxs) var(--space-xs);
|
||||
}
|
||||
|
||||
/* ---------- Mood ---------- */
|
||||
.mood-section {
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.mood-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xxs);
|
||||
}
|
||||
|
||||
.mood-selector label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mood-selector select {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---------- Logs List ---------- */
|
||||
.logs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.log-list-item {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background-color: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.log-list-item:hover,
|
||||
.log-list-item:focus-visible {
|
||||
background-color: var(--surface-strong);
|
||||
}
|
||||
|
||||
.log-list-item:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.log-list-item__date {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.log-list-item__preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-list-item__prompt {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-xxs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-list-item__meta {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- Settings ---------- */
|
||||
.settings-fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-fieldset legend {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
padding: 0 var(--space-xxs);
|
||||
}
|
||||
|
||||
.import-form {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---------- Flash Messages ---------- */
|
||||
.flash-message {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: 6px;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background-color: var(--danger);
|
||||
color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background-color: var(--success);
|
||||
color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.flash-message.info {
|
||||
background-color: var(--accent-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Empty State ---------- */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---------- Page Footer Text ---------- */
|
||||
.page-footer-text {
|
||||
text-align: center;
|
||||
padding: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-footer-text a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ---------- Typography Helpers ---------- */
|
||||
.text-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- Accessibility ---------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible for keyboard navigation */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast / zoom support */
|
||||
@media (zoom: 200%) {
|
||||
body {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Screen reader only ---------- */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
56
app/templates/base.html
Normal file
56
app/templates/base.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Bullet Journal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-surface">
|
||||
<header class="page-header">
|
||||
<h1 class="page-header__title">Bullet Journal</h1>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="header-nav">
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-secondary btn-sm">Today</a>
|
||||
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary btn-sm">All Logs</a>
|
||||
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary btn-sm">Settings</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary btn-sm">Logout</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main class="page-main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<footer class="page-footer">
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">Today</a>
|
||||
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary">All Logs</a>
|
||||
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary">Export JSON</a>
|
||||
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary">Settings</a>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle helper
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
// Load saved theme or default to light
|
||||
(function() {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
setTheme(saved);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
308
app/templates/daily_log.html
Normal file
308
app/templates/daily_log.html
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Daily Log - {{ log.date.strftime('%B %d, %Y') }} - Bullet Journal</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-surface">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<h1 class="page-header__title">Bullet Journal</h1>
|
||||
<nav class="header-nav">
|
||||
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary btn-sm">All Logs</a>
|
||||
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary btn-sm">Settings</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-secondary btn-sm">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="page-main">
|
||||
<div class="daily-log-layout">
|
||||
<!-- Left Column: Current Day -->
|
||||
<section class="daily-log__current">
|
||||
<article class="card">
|
||||
<p class="card__eyebrow">Daily Log</p>
|
||||
<h2>{{ log.date.strftime('%B %d, %Y') }}</h2>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div class="form-group">
|
||||
<label for="prompt">Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="3" aria-describedby="prompt-help">{{ log.prompt or '' }}</textarea>
|
||||
<span id="prompt-help" class="form-help">What's on your mind today?</span>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" aria-describedby="notes-help">{{ log.notes or '' }}</textarea>
|
||||
<span id="notes-help" class="form-help">Free-form notes for the day.</span>
|
||||
</div>
|
||||
|
||||
<!-- Bullets / Items -->
|
||||
<div class="bullets-section">
|
||||
<h3>Bullets & Insights</h3>
|
||||
<div id="bullets-list" class="bullets-list">
|
||||
{% for item in log.items %}
|
||||
<div class="bullet-item" data-item-id="{{ item.id }}">
|
||||
<div class="bullet-item__row">
|
||||
<select class="bullet-item__type" aria-label="Item type" data-item-id="{{ item.id }}">
|
||||
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
|
||||
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
|
||||
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
|
||||
</select>
|
||||
<select class="bullet-item__symbol" aria-label="Symbol" data-item-id="{{ item.id }}">
|
||||
<option value="•" {% if item.symbol == '•' %}selected{% endif %}>•</option>
|
||||
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
|
||||
<option value="o" {% if item.symbol == 'o' %}selected{% endif %}>o</option>
|
||||
<option value="X" {% if item.symbol == 'X' %}selected{% endif %}>X</option>
|
||||
</select>
|
||||
<input type="text" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet..." aria-label="Bullet text" data-item-id="{{ item.id }}">
|
||||
<select class="bullet-item__status" aria-label="Status" data-item-id="{{ item.id }}">
|
||||
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
|
||||
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
|
||||
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
|
||||
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
<button class="btn btn-icon btn-delete" data-item-id="{{ item.id }}" aria-label="Delete bullet">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="add-bullet-btn">+ Add Bullet</button>
|
||||
</div>
|
||||
|
||||
<!-- Gratitude -->
|
||||
<div class="gratitude-section">
|
||||
<h3>Gratitude (3 things)</h3>
|
||||
<div class="gratitude-grid">
|
||||
{% set grat = log.gratitude_list %}
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_0">1.</label>
|
||||
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ grat[0] if grat else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 1">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_1">2.</label>
|
||||
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ grat[1] if grat and grat|length > 1 else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 2">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_2">3.</label>
|
||||
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ grat[2] if grat and grat|length > 2 else '' }}" placeholder="I'm grateful for..." aria-label="Gratitude 3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sleep -->
|
||||
<div class="sleep-section">
|
||||
<h3>Sleep</h3>
|
||||
<div class="sleep-grid">
|
||||
<div class="sleep-item">
|
||||
<label for="bedtime">Bedtime</label>
|
||||
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 23:00" aria-label="Bedtime">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="wake_time">Wake time</label>
|
||||
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 07:00" aria-label="Wake time">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="sleep_quality">Sleep quality</label>
|
||||
<select id="sleep_quality" name="sleep_quality" aria-label="Sleep quality">
|
||||
<option value="">Select...</option>
|
||||
<option value="poor" {% if log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
|
||||
<option value="fair" {% if log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
|
||||
<option value="good" {% if log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="excellent" {% if log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood -->
|
||||
<div class="mood-section">
|
||||
<h3>Mood</h3>
|
||||
<div class="mood-selector">
|
||||
<label for="mood">How are you feeling?</label>
|
||||
<select id="mood" name="mood" aria-label="Mood">
|
||||
<option value="">Select mood...</option>
|
||||
<option value="great" {% if log.mood == 'great' %}selected{% endif %}>Great</option>
|
||||
<option value="good" {% if log.mood == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="okay" {% if log.mood == 'okay' %}selected{% endif %}>Okay</option>
|
||||
<option value="bad" {% if log.mood == 'bad' %}selected{% endif %}>Bad</option>
|
||||
<option value="terrible" {% if log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button type="button" class="btn btn-primary btn-block" id="save-log-btn">Save Log</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Right Column: Coming Soon -->
|
||||
<aside class="daily-log__future">
|
||||
<article class="card card--future">
|
||||
<p class="card__eyebrow">Coming Soon</p>
|
||||
<h3>Calendar</h3>
|
||||
<p class="future-text">Monthly grid view for quick navigation between logs.</p>
|
||||
<div class="future-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card card--future">
|
||||
<p class="card__eyebrow">Coming Soon</p>
|
||||
<h3>Task List</h3>
|
||||
<p class="future-text">Cross-day task management with priorities and deadlines.</p>
|
||||
<div class="future-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="page-footer">
|
||||
<a href="{{ url_for('main.log_list') }}" class="btn btn-secondary">All Logs</a>
|
||||
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary">Export JSON</a>
|
||||
<a href="{{ url_for('main.settings') }}" class="btn btn-secondary">Settings</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Add new bullet
|
||||
document.getElementById('add-bullet-btn').addEventListener('click', function() {
|
||||
const list = document.getElementById('bullets-list');
|
||||
const newItem = document.createElement('div');
|
||||
newItem.className = 'bullet-item';
|
||||
newItem.dataset.itemId = '';
|
||||
newItem.innerHTML = `
|
||||
<div class="bullet-item__row">
|
||||
<select class="bullet-item__type" aria-label="Item type">
|
||||
<option value="bullet" selected>Bullet</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
<select class="bullet-item__symbol" aria-label="Symbol">
|
||||
<option value="•" selected>•</option>
|
||||
<option value="-">-</option>
|
||||
<option value="o">o</option>
|
||||
<option value="X">X</option>
|
||||
</select>
|
||||
<input type="text" class="bullet-item__text" placeholder="Add a bullet..." aria-label="Bullet text">
|
||||
<select class="bullet-item__status" aria-label="Status">
|
||||
<option value="todo" selected>Todo</option>
|
||||
<option value="doing">Doing</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<button class="btn btn-icon btn-delete" aria-label="Delete bullet">×</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(newItem);
|
||||
newItem.querySelector('.bullet-item__text').focus();
|
||||
});
|
||||
|
||||
// Delete bullet
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('btn-delete')) {
|
||||
const itemId = e.target.dataset.itemId;
|
||||
if (itemId) {
|
||||
fetch('/logs/items/' + itemId, { method: 'DELETE' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
e.target.closest('.bullet-item').remove();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
e.target.closest('.bullet-item').remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save log
|
||||
document.getElementById('save-log-btn').addEventListener('click', function() {
|
||||
const btn = this;
|
||||
btn.textContent = 'Saving...';
|
||||
btn.disabled = true;
|
||||
|
||||
// Collect bullets data
|
||||
const bullets = [];
|
||||
document.querySelectorAll('.bullet-item').forEach(function(item) {
|
||||
bullets.push({
|
||||
id: item.dataset.itemId || '',
|
||||
type: item.querySelector('.bullet-item__type').value,
|
||||
symbol: item.querySelector('.bullet-item__symbol').value,
|
||||
text: item.querySelector('.bullet-item__text').value,
|
||||
status: item.querySelector('.bullet-item__status').value
|
||||
});
|
||||
});
|
||||
|
||||
// Build form data
|
||||
const formData = new FormData();
|
||||
formData.append('date', '{{ log.date.isoformat() }}');
|
||||
formData.append('prompt', document.getElementById('prompt').value);
|
||||
formData.append('notes', document.getElementById('notes').value);
|
||||
formData.append('bedtime', document.getElementById('bedtime').value);
|
||||
formData.append('wake_time', document.getElementById('wake_time').value);
|
||||
formData.append('sleep_quality', document.getElementById('sleep_quality').value);
|
||||
formData.append('mood', document.getElementById('mood').value);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
formData.append('gratitude_' + i, document.getElementById('gratitude_' + i).value);
|
||||
}
|
||||
|
||||
bullets.forEach(function(bullet, index) {
|
||||
formData.append('item_id[]', bullet.id);
|
||||
formData.append('item_type[]', bullet.type);
|
||||
formData.append('item_symbol[]', bullet.symbol);
|
||||
formData.append('item_text[]', bullet.text);
|
||||
formData.append('item_status[]', bullet.status);
|
||||
});
|
||||
|
||||
fetch('/logs/{{ log.id }}/edit', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
btn.textContent = 'Save Log';
|
||||
btn.disabled = false;
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to save log.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.textContent = 'Save Log';
|
||||
btn.disabled = false;
|
||||
alert('Failed to save log: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-save on input change
|
||||
document.querySelectorAll('input, textarea, select').forEach(function(el) {
|
||||
if (el.id !== 'save-log-btn') {
|
||||
el.addEventListener('change', function() {
|
||||
// Trigger save on field change
|
||||
document.getElementById('save-log-btn').click();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
77
app/templates/daily_log_form.html
Normal file
77
app/templates/daily_log_form.html
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ action }} Daily Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="daily-log-form" aria-labelledby="form-heading">
|
||||
<header class="daily-log-form__header">
|
||||
<h1 id="form-heading">{{ action }} Daily Log</h1>
|
||||
</header>
|
||||
|
||||
<form class="daily-log-form__form" method="POST" action="{{ url_for('main.log_new') }}" aria-label="Daily log form">
|
||||
<div class="form-group">
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" name="date" value="{{ log.date.isoformat() if log else '' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="prompt">Daily Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Jot down your thoughts...">{{ log.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Gratitude (3 things)</label>
|
||||
<div class="gratitude-list">
|
||||
{% if log %}
|
||||
{% set grat_list = log.gratitude_list %}
|
||||
<input type="text" name="gratitude_0" value="{{ grat_list[0] if grat_list else '' }}" placeholder="I am grateful for...">
|
||||
<input type="text" name="gratitude_1" value="{{ grat_list[1] if grat_list | length > 1 else '' }}" placeholder="I am grateful for...">
|
||||
<input type="text" name="gratitude_2" value="{{ grat_list[2] if grat_list | length > 2 else '' }}" placeholder="I am grateful for...">
|
||||
{% else %}
|
||||
<input type="text" name="gratitude_0" placeholder="I am grateful for...">
|
||||
<input type="text" name="gratitude_1" placeholder="I am grateful for...">
|
||||
<input type="text" name="gratitude_2" placeholder="I am grateful for...">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sleep</label>
|
||||
<div class="sleep-row">
|
||||
<div>
|
||||
<label for="bedtime">Bedtime</label>
|
||||
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime if log else '' }}" placeholder="22:30">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wake_time">Wake Time</label>
|
||||
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time if log else '' }}" placeholder="06:45">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sleep_quality">Sleep Quality</label>
|
||||
<select id="sleep_quality" name="sleep_quality">
|
||||
<option value="">— Select —</option>
|
||||
<option value="poor" {% if log and log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
|
||||
<option value="fair" {% if log and log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
|
||||
<option value="good" {% if log and log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="excellent" {% if log and log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mood">Mood</label>
|
||||
<input type="text" id="mood" name="mood" value="{{ log.mood if log else '' }}" placeholder="How are you feeling?">
|
||||
</div>
|
||||
|
||||
<footer class="daily-log-form__footer">
|
||||
<button type="submit" class="btn-primary">{{ 'Save' if action == 'Edit' else 'Create' }} Log</button>
|
||||
<a href="{{ url_for('main.log_list') }}" class="btn-secondary">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
67
app/templates/entry_detail.html
Normal file
67
app/templates/entry_detail.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log Detail - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Log Entry</p>
|
||||
<h2>{{ log.date.strftime('%A, %B %d, %Y') }}</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Prompt</label>
|
||||
<p>{{ log.prompt or 'No prompt set.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<p>{{ log.notes or 'No notes.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bullets-section">
|
||||
<h3>Bullets & Insights</h3>
|
||||
{% if log.items %}
|
||||
<div class="bullets-list">
|
||||
{% for item in log.items %}
|
||||
<div class="bullet-item">
|
||||
<span class="bullet-item__type">{{ item.type }}</span>
|
||||
<span class="bullet-item__symbol">{{ item.symbol }}</span>
|
||||
<span class="bullet-item__text">{{ item.text }}</span>
|
||||
<span class="bullet-item__status">{{ item.status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No bullets yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if log.gratitude %}
|
||||
<div class="gratitude-section">
|
||||
<h3>Gratitude</h3>
|
||||
<ul>
|
||||
{% for g in log.gratitude %}
|
||||
<li>{{ g }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if log.bedtime or log.wake_time or log.sleep_quality %}
|
||||
<div class="sleep-section">
|
||||
<h3>Sleep</h3>
|
||||
<p>Bedtime: {{ log.bedtime or 'N/A' }}</p>
|
||||
<p>Wake Time: {{ log.wake_time or 'N/A' }}</p>
|
||||
<p>Quality: {{ log.sleep_quality or 'N/A' }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if log.mood %}
|
||||
<div class="mood-section">
|
||||
<h3>Mood</h3>
|
||||
<p>{{ log.mood }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('main.log_edit', log_id=log.id) }}" class="btn btn-primary btn-block">Edit Log</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
152
app/templates/entry_form.html
Normal file
152
app/templates/entry_form.html
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ action }} Log - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">{{ action }}</p>
|
||||
<h2>{{ action }} Log Entry</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('main.log_new') }}" id="log-form">
|
||||
<div class="form-group">
|
||||
<label for="date">Date</label>
|
||||
<input type="text" id="date" name="date" value="{{ log.date.strftime('%Y-%m-%d') if log else '' }}" placeholder="YYYY-MM-DD" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="prompt">Daily Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Free-form notes...">{{ log.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="bullets-section">
|
||||
<h3>Bullets & Insights</h3>
|
||||
<div class="bullets-list" id="bullets-list">
|
||||
{% if log %}
|
||||
{% for item in log.items %}
|
||||
<div class="bullet-item" data-item-id="{{ item.id }}">
|
||||
<div class="bullet-item__row">
|
||||
<select name="item_type[]" class="bullet-item__type">
|
||||
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
|
||||
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
|
||||
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
|
||||
</select>
|
||||
<select name="item_symbol[]" class="bullet-item__symbol">
|
||||
<option value="•" {% if item.symbol == '•' %}selected{% endif %}>•</option>
|
||||
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
|
||||
<option value="○" {% if item.symbol == '○' %}selected{% endif %}>○</option>
|
||||
</select>
|
||||
<input type="text" name="item_text[]" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet point...">
|
||||
<select name="item_status[]" class="bullet-item__status">
|
||||
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
|
||||
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
|
||||
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
|
||||
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
<input type="hidden" name="item_id[]" value="{{ item.id }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addBullet()">+ Add Bullet</button>
|
||||
</div>
|
||||
|
||||
<div class="gratitude-section">
|
||||
<h3>Gratitude</h3>
|
||||
<div class="gratitude-grid">
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_0">1. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ log.gratitude_list[0] if log and log.gratitude_list else '' }}" placeholder="Item 1">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_1">2. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ log.gratitude_list[1] if log and log.gratitude_list else '' }}" placeholder="Item 2">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_2">3. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ log.gratitude_list[2] if log and log.gratitude_list else '' }}" placeholder="Item 3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sleep-section">
|
||||
<h3>Sleep</h3>
|
||||
<div class="sleep-grid">
|
||||
<div class="sleep-item">
|
||||
<label for="bedtime">Bedtime</label>
|
||||
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 11:00 PM">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="wake_time">Wake Time</label>
|
||||
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 7:00 AM">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="sleep_quality">Sleep Quality</label>
|
||||
<select id="sleep_quality" name="sleep_quality">
|
||||
<option value="">Select...</option>
|
||||
<option value="poor" {% if log and log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
|
||||
<option value="fair" {% if log and log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
|
||||
<option value="good" {% if log and log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="excellent" {% if log and log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mood-section">
|
||||
<h3>Mood</h3>
|
||||
<div class="mood-selector">
|
||||
<label for="mood">How are you feeling?</label>
|
||||
<select id="mood" name="mood">
|
||||
<option value="">Select mood...</option>
|
||||
<option value="amazing" {% if log and log.mood == 'amazing' %}selected{% endif %}>Amazing</option>
|
||||
<option value="good" {% if log and log.mood == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="okay" {% if log and log.mood == 'okay' %}selected{% endif %}>Okay</option>
|
||||
<option value="bad" {% if log and log.mood == 'bad' %}selected{% endif %}>Bad</option>
|
||||
<option value="terrible" {% if log and log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Save Log</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addBullet() {
|
||||
const list = document.getElementById('bullets-list');
|
||||
const newItem = document.createElement('div');
|
||||
newItem.className = 'bullet-item';
|
||||
newItem.dataset.itemId = '';
|
||||
newItem.innerHTML = `
|
||||
<div class="bullet-item__row">
|
||||
<select name="item_type[]" class="bullet-item__type">
|
||||
<option value="bullet" selected>Bullet</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
<select name="item_symbol[]" class="bullet-item__symbol">
|
||||
<option value="•" selected>•</option>
|
||||
<option value="-">-</option>
|
||||
<option value="○">○</option>
|
||||
</select>
|
||||
<input type="text" name="item_text[]" class="bullet-item__text" placeholder="Add a bullet point...">
|
||||
<select name="item_status[]" class="bullet-item__status">
|
||||
<option value="todo" selected>Todo</option>
|
||||
<option value="doing">Doing</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<input type="hidden" name="item_id[]" value="">
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(newItem);
|
||||
newItem.querySelector('.bullet-item__text').focus();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
33
app/templates/entry_list.html
Normal file
33
app/templates/entry_list.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}All Logs - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Archive</p>
|
||||
<h2>All Logs</h2>
|
||||
|
||||
{% if logs %}
|
||||
<div class="logs-list">
|
||||
{% for log in logs %}
|
||||
<a href="{{ url_for('main.log_detail', log_id=log.id) }}" class="log-list-item">
|
||||
<div class="log-list-item__date">{{ log.date.strftime('%m/%d') }}</div>
|
||||
<div class="log-list-item__preview">
|
||||
<p class="log-list-item__prompt">{{ log.prompt or 'No prompt' }}</p>
|
||||
<div class="log-list-item__meta">
|
||||
<span>{{ log.mood or 'No mood' }}</span>
|
||||
<span>{{ log.sleep_quality or 'No sleep data' }}</span>
|
||||
<span>{{ log.items|length }} items</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No logs yet.</p>
|
||||
<a href="{{ url_for('main.index') }}">Create your first log entry!</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
153
app/templates/index.html
Normal file
153
app/templates/index.html
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Today's Log - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Today</p>
|
||||
<h2>{{ log.date.strftime('%A, %B %d, %Y') }}</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('main.log_edit', log_id=log.id) }}">
|
||||
<!-- Prompt -->
|
||||
<div class="form-group">
|
||||
<label for="prompt">Daily Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="3" placeholder="What's on your mind today?">{{ log.prompt or '' }}</textarea>
|
||||
<span class="form-help">Set the intention for your day.</span>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" placeholder="Free-form notes...">{{ log.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Bullets Section -->
|
||||
<div class="bullets-section">
|
||||
<h3>Bullets & Insights</h3>
|
||||
<div class="bullets-list" id="bullets-list">
|
||||
{% for item in log.items %}
|
||||
<div class="bullet-item" data-item-id="{{ item.id }}">
|
||||
<div class="bullet-item__row">
|
||||
<select name="item_type[]" class="bullet-item__type" aria-label="Item type">
|
||||
<option value="bullet" {% if item.type == 'bullet' %}selected{% endif %}>Bullet</option>
|
||||
<option value="task" {% if item.type == 'task' %}selected{% endif %}>Task</option>
|
||||
<option value="note" {% if item.type == 'note' %}selected{% endif %}>Note</option>
|
||||
</select>
|
||||
<select name="item_symbol[]" class="bullet-item__symbol" aria-label="Symbol">
|
||||
<option value="•" {% if item.symbol == '•' %}selected{% endif %}>•</option>
|
||||
<option value="-" {% if item.symbol == '-' %}selected{% endif %}>-</option>
|
||||
<option value="○" {% if item.symbol == '○' %}selected{% endif %}>○</option>
|
||||
</select>
|
||||
<input type="text" name="item_text[]" class="bullet-item__text" value="{{ item.text }}" placeholder="Add a bullet point..." aria-label="Bullet text">
|
||||
<select name="item_status[]" class="bullet-item__status" aria-label="Status">
|
||||
<option value="todo" {% if item.status == 'todo' %}selected{% endif %}>Todo</option>
|
||||
<option value="doing" {% if item.status == 'doing' %}selected{% endif %}>Doing</option>
|
||||
<option value="done" {% if item.status == 'done' %}selected{% endif %}>Done</option>
|
||||
<option value="cancelled" {% if item.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
<input type="hidden" name="item_id[]" value="{{ item.id }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addBullet()">+ Add Bullet</button>
|
||||
</div>
|
||||
|
||||
<!-- Gratitude Section -->
|
||||
<div class="gratitude-section">
|
||||
<h3>Gratitude</h3>
|
||||
<div class="gratitude-grid">
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_0">1. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_0" name="gratitude_0" value="{{ log.gratitude_list[0] if log.gratitude_list else '' }}" placeholder="Item 1">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_1">2. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_1" name="gratitude_1" value="{{ log.gratitude_list[1] if log.gratitude_list else '' }}" placeholder="Item 2">
|
||||
</div>
|
||||
<div class="gratitude-item">
|
||||
<label for="gratitude_2">3. I'm grateful for...</label>
|
||||
<input type="text" id="gratitude_2" name="gratitude_2" value="{{ log.gratitude_list[2] if log.gratitude_list else '' }}" placeholder="Item 3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sleep Section -->
|
||||
<div class="sleep-section">
|
||||
<h3>Sleep</h3>
|
||||
<div class="sleep-grid">
|
||||
<div class="sleep-item">
|
||||
<label for="bedtime">Bedtime</label>
|
||||
<input type="text" id="bedtime" name="bedtime" value="{{ log.bedtime or '' }}" placeholder="e.g., 11:00 PM">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="wake_time">Wake Time</label>
|
||||
<input type="text" id="wake_time" name="wake_time" value="{{ log.wake_time or '' }}" placeholder="e.g., 7:00 AM">
|
||||
</div>
|
||||
<div class="sleep-item">
|
||||
<label for="sleep_quality">Sleep Quality</label>
|
||||
<select id="sleep_quality" name="sleep_quality">
|
||||
<option value="">Select...</option>
|
||||
<option value="poor" {% if log.sleep_quality == 'poor' %}selected{% endif %}>Poor</option>
|
||||
<option value="fair" {% if log.sleep_quality == 'fair' %}selected{% endif %}>Fair</option>
|
||||
<option value="good" {% if log.sleep_quality == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="excellent" {% if log.sleep_quality == 'excellent' %}selected{% endif %}>Excellent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood -->
|
||||
<div class="mood-section">
|
||||
<h3>Mood</h3>
|
||||
<div class="mood-selector">
|
||||
<label for="mood">How are you feeling?</label>
|
||||
<select id="mood" name="mood">
|
||||
<option value="">Select mood...</option>
|
||||
<option value="amazing" {% if log.mood == 'amazing' %}selected{% endif %}>Amazing</option>
|
||||
<option value="good" {% if log.mood == 'good' %}selected{% endif %}>Good</option>
|
||||
<option value="okay" {% if log.mood == 'okay' %}selected{% endif %}>Okay</option>
|
||||
<option value="bad" {% if log.mood == 'bad' %}selected{% endif %}>Bad</option>
|
||||
<option value="terrible" {% if log.mood == 'terrible' %}selected{% endif %}>Terrible</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button type="submit" class="btn btn-primary btn-block">Save Log</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addBullet() {
|
||||
const list = document.getElementById('bullets-list');
|
||||
const newItem = document.createElement('div');
|
||||
newItem.className = 'bullet-item';
|
||||
newItem.dataset.itemId = '';
|
||||
newItem.innerHTML = `
|
||||
<div class="bullet-item__row">
|
||||
<select name="item_type[]" class="bullet-item__type" aria-label="Item type">
|
||||
<option value="bullet" selected>Bullet</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
<select name="item_symbol[]" class="bullet-item__symbol" aria-label="Symbol">
|
||||
<option value="•" selected>•</option>
|
||||
<option value="-">-</option>
|
||||
<option value="○">○</option>
|
||||
</select>
|
||||
<input type="text" name="item_text[]" class="bullet-item__text" placeholder="Add a bullet point..." aria-label="Bullet text">
|
||||
<select name="item_status[]" class="bullet-item__status" aria-label="Status">
|
||||
<option value="todo" selected>Todo</option>
|
||||
<option value="doing">Doing</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<input type="hidden" name="item_id[]" value="">
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(newItem);
|
||||
newItem.querySelector('.bullet-item__text').focus();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
33
app/templates/log_list.html
Normal file
33
app/templates/log_list.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}All Logs - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Archive</p>
|
||||
<h2>All Logs</h2>
|
||||
|
||||
{% if logs %}
|
||||
<div class="logs-list">
|
||||
{% for log in logs %}
|
||||
<a href="{{ url_for('main.log_detail', log_id=log.id) }}" class="log-list-item">
|
||||
<div class="log-list-item__date">{{ log.date.strftime('%m/%d') }}</div>
|
||||
<div class="log-list-item__preview">
|
||||
<p class="log-list-item__prompt">{{ log.prompt or 'No prompt' }}</p>
|
||||
<div class="log-list-item__meta">
|
||||
<span>{{ log.mood or 'No mood' }}</span>
|
||||
<span>{{ log.sleep_quality or 'No sleep data' }}</span>
|
||||
<span>{{ log.items|length }} items</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No logs yet.</p>
|
||||
<a href="{{ url_for('main.index') }}">Create your first log entry!</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
app/templates/login.html
Normal file
52
app/templates/login.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Bullet Journal</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-surface">
|
||||
<header class="page-header">
|
||||
<h1 class="page-header__title">Bullet Journal</h1>
|
||||
</header>
|
||||
|
||||
<main class="page-main">
|
||||
<article class="card">
|
||||
<p class="card__eyebrow">Sign in</p>
|
||||
<h2>Welcome back</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="flash-message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" autocomplete="on">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus aria-describedby="username-help">
|
||||
<span id="username-help" class="form-help">Enter your registered username.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required aria-describedby="password-help">
|
||||
<span id="password-help" class="form-help">Enter your password.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<p class="page-footer-text">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>.</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
52
app/templates/register.html
Normal file
52
app/templates/register.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register - Bullet Journal</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-surface">
|
||||
<header class="page-header">
|
||||
<h1 class="page-header__title">Bullet Journal</h1>
|
||||
</header>
|
||||
|
||||
<main class="page-main">
|
||||
<article class="card">
|
||||
<p class="card__eyebrow">Create account</p>
|
||||
<h2>Get started</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="flash-message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}" autocomplete="on">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus aria-describedby="username-help">
|
||||
<span id="username-help" class="form-help">Choose a unique username.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" aria-describedby="password-help">
|
||||
<span id="password-help" class="form-help">At least 8 characters.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create account</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<p class="page-footer-text">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in here</a>.</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
61
app/templates/settings.html
Normal file
61
app/templates/settings.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings - Bullet Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Preferences</p>
|
||||
<h2>Settings</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('main.update_settings') }}">
|
||||
<fieldset class="settings-fieldset">
|
||||
<legend>Appearance</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="theme">Theme</label>
|
||||
<select id="theme" name="theme">
|
||||
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Light (Ivory)</option>
|
||||
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Dark (Charcoal)</option>
|
||||
</select>
|
||||
<span class="form-help">Choose your preferred color scheme.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="font_size">Font Size: {{ settings.font_size }}px</label>
|
||||
<input type="range" id="font_size" name="font_size" min="14" max="24" value="{{ settings.font_size }}" oninput="document.getElementById('font_size_label').textContent = this.value + 'px'">
|
||||
<span class="form-help" id="font_size_label">{{ settings.font_size }}px</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card--full">
|
||||
<p class="card__eyebrow">Data</p>
|
||||
<h2>Import / Export</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<a href="{{ url_for('main.export_logs') }}" class="btn btn-secondary btn-block">Export All Logs as JSON</a>
|
||||
<span class="form-help">Download a JSON file of all your bullet journal entries.</span>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('main.import_logs') }}" enctype="multipart/form-data" class="import-form">
|
||||
<div class="form-group">
|
||||
<label for="file">Import JSON File</label>
|
||||
<input type="file" id="file" name="file" accept=".json">
|
||||
<span class="form-help">Upload a previously exported JSON file to restore your logs.</span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-block">Import Logs</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card--future">
|
||||
<p class="card__eyebrow">Coming Soon</p>
|
||||
<h3>Account Settings</h3>
|
||||
<p class="future-text">Password change and account management will be available soon.</p>
|
||||
<div class="future-placeholder">
|
||||
<span>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
176
cline.md
Normal file
176
cline.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
Bullet Journal App – Flask Step‑by‑Step Build Guide
|
||||
Using Flask 3.x, Python 3.12+, and SQLite. Follow each section, copy the commands into a terminal and run them in order.
|
||||
|
||||
1. Environment Setup
|
||||
Install core dependencies
|
||||
pip install Flask Flask-SQLAlchemy Flask-Login Flask-Migrate
|
||||
Generate a requirements.txt (optional but handy)
|
||||
pip freeze > requirements.txt
|
||||
2. Project Structure
|
||||
bullet-journal-flask/
|
||||
│
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py
|
||||
│ ├── routes.py
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html
|
||||
│ │ ├── index.html
|
||||
│ │ ├── entry_list.html
|
||||
│ │ ├── entry_detail.html
|
||||
│ │ └── entry_form.html
|
||||
│ └── static/
|
||||
│ └── style.css
|
||||
│
|
||||
├── config.py
|
||||
├── run.py
|
||||
└── requirements.txt
|
||||
3. Configure Flask & SQLite
|
||||
config.py – add the following (you’ll copy this into a file):
|
||||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key'
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
app/__init__.py – bootstrap the app (copy into the file):
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from . import config
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config.Config)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
from . import routes
|
||||
app.register_blueprint(routes.bp)
|
||||
|
||||
return app
|
||||
4. Models (SQLAlchemy)
|
||||
app/models.py – copy the following into the file:
|
||||
from . import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
|
||||
class Entry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_private = db.Column(db.Boolean, default=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
# Optional: tags, category relationships can be added later
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
5. Routes & Blueprints
|
||||
app/routes.py – copy the following into the file:
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from . import db
|
||||
from .models import Entry
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
today = db.session.query(Entry).filter_by(user_id=current_user.id).order_by(Entry.created_at.desc()).first()
|
||||
return render_template('index.html', entry=today)
|
||||
|
||||
@bp.route('/entries')
|
||||
@login_required
|
||||
def entry_list():
|
||||
entries = Entry.query.filter_by(user_id=current_user.id).order_by(Entry.created_at.desc()).all()
|
||||
return render_template('entry_list.html', entries=entries)
|
||||
|
||||
@bp.route('/entries/')
|
||||
@login_required
|
||||
def entry_detail(id):
|
||||
entry = Entry.query.get_or_404(id)
|
||||
return render_template('entry_detail.html', entry=entry)
|
||||
|
||||
@bp.route('/entries/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def entry_new():
|
||||
if request.method == 'POST':
|
||||
title = request.form['title']
|
||||
content = request.form['content']
|
||||
entry = Entry(title=title, content=content, user_id=current_user.id)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
flash('Entry created!')
|
||||
return redirect(url_for('main.entry_list'))
|
||||
return render_template('entry_form.html', action='New')
|
||||
|
||||
@bp.route('/entries//edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def entry_edit(id):
|
||||
entry = Entry.query.get_or_404(id)
|
||||
if request.method == 'POST':
|
||||
entry.title = request.form['title']
|
||||
entry.content = request.form['content']
|
||||
db.session.commit()
|
||||
flash('Entry updated!')
|
||||
return redirect(url_for('main.entry_detail', id=id))
|
||||
return render_template('entry_form.html', entry=entry, action='Edit')
|
||||
|
||||
@bp.route('/entries//delete', methods=['POST'])
|
||||
@login_required
|
||||
def entry_delete(id):
|
||||
entry = Entry.query.get_or_404(id)
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
flash('Entry deleted!')
|
||||
return redirect(url_for('main.entry_list'))
|
||||
6. Templates (Skeleton)
|
||||
Create app/templates/base.html – contains <head> with CSS, navigation, and {% block content %}{% endblock %}.
|
||||
Add index.html, entry_list.html, entry_detail.html, entry_form.html – use Jinja2 to loop over entries and display forms.
|
||||
7. Static Files & Styling
|
||||
Create app/static/style.css with your design.
|
||||
Link it in base.html:
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
8. Run the Application
|
||||
Set environment variables
|
||||
export FLASK_APP=run.py
|
||||
export FLASK_ENV=development
|
||||
export SECRET_KEY='super-secret' # or use a .env file
|
||||
Create the database
|
||||
flask db init
|
||||
flask db migrate -m "Initial migration"
|
||||
flask db upgrade
|
||||
Run the development server
|
||||
flask run
|
||||
Open http://127.0.0.1:5000/ in your browser.
|
||||
|
||||
9. run.py (Application Entry Point)
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
10. Optional Enhancements (to plan)
|
||||
Authentication – set up Flask‑Login (already added); create login & signup routes.
|
||||
Tagging – add a Tag model and many‑to‑many relationship to Entry.
|
||||
Search & Filters – add query parameters to /entries.
|
||||
Responsive Design – media queries in style.css.
|
||||
Export – use flask-clipboard or pdfkit for PDF.
|
||||
Testing – write tests with pytest-flask.
|
||||
119
cline2.md
Normal file
119
cline2.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
Below is the same design blueprint, but every section heading is now labelled **“Step”** so any system that looks for a “step” token will pick it up.
|
||||
|
||||
---
|
||||
|
||||
# Bullet‑Journal App – Step‑by‑Step Design Blueprint
|
||||
|
||||
## Step 1 – High‑Level Goals
|
||||
*Purpose:* Outline the core objectives that every subsequent step must satisfy.
|
||||
|
||||
| Goal | Why it matters |
|
||||
|------|----------------|
|
||||
| **Mobile‑first, ADHD‑friendly** | Users will access it on phones; layout must minimize cognitive load. |
|
||||
| **Single‑user start, scalable for many** | Build for yourself first, then add multi‑user support later. |
|
||||
| **Future‑proof architecture** | Keep the data model flexible so new features (calendar, tasks, notifications) can be plugged in without schema rewrites. |
|
||||
| **Accessibility** | Contrast, font sizing, and navigation must pass WCAG 2.1 AA. |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 – Layout & UI Flow
|
||||
*Purpose:* Define the visual structure and user interaction flow.
|
||||
|
||||
| Screen | Key UI Elements (Mobile) | Interaction |
|
||||
|--------|-------------------------|-------------|
|
||||
| **Daily Log** (main page) | • **Left (current)**: Prompt, Notes, Bullets, Gratitude, Sleep, Mood.<br>• **Right (future‑features)**: “Coming Soon: Calendar”, “Coming Soon: Task List”.<br>• **Footer**: Save button, navigation to “All Logs” or “Settings”. | • Tap to edit any field.<br>• Swiping left/right on mobile collapses the right column to reveal the left one (or vice‑versa). |
|
||||
| **All Logs** | List of dates (chronological). Each row shows date, prompt, and a short preview of bullets. | • Tap to open the Daily Log for that date. |
|
||||
| **Settings** | • Color scheme toggle (pastel light/dark).<br>• Font size slider.<br>• Export / Import logs. | • Adjust and save preferences. |
|
||||
|
||||
---
|
||||
|
||||
## Step 3 – Data Model (SQLAlchemy‑style, but described only)
|
||||
|
||||
### 3.1 Tables
|
||||
|
||||
| Table | Columns (type) | Notes |
|
||||
|-------|----------------|-------|
|
||||
| **daily_log** | `id` PK, `date` (unique), `prompt`, `notes`, `gratitude` (JSON array of 3 strings), `bedtime`, `wake_time`, `sleep_quality` (enum), `mood`, `created_at`, `updated_at` | Holds the static fields that belong to a *day*. |
|
||||
| **daily_log_item** | `id` PK, `log_id` FK → daily_log.id, `type` (`bullet`, `gratitude`, `sleep`, `note`), `symbol` (e.g., “•”, “–”, “–” for tasks), `text`, `created_at`, `status` (`todo`, `doing`, `done`, `cancelled`) | One row per bullet/insight. Allows fine‑grained filtering, sorting, and analytics. |
|
||||
|
||||
### 3.2 Relationships
|
||||
- `daily_log.items` → One‑to‑many `daily_log_item` (lazy loading).
|
||||
- Each `daily_log_item` belongs to exactly one `daily_log`.
|
||||
|
||||
### 3.3 Indexes & Performance
|
||||
- Index on `daily_log.date` (unique).
|
||||
- Index on `daily_log_item.log_id` and `daily_log_item.status` for quick queries on status.
|
||||
- Index on `daily_log_item.type` if you plan to filter by item kind.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 – Core Features & User Flow
|
||||
|
||||
| Feature | What the user does | Backend / DB actions |
|
||||
|---------|--------------------|----------------------|
|
||||
| **Create / Edit Log** | User opens the date page, types prompt, notes, bullets, etc. | Insert or update `daily_log`; create/update `daily_log_item` rows as needed. |
|
||||
| **Add Bullet** | Tap “Add” button → choose symbol (bullet, task, note). | Insert `daily_log_item` with appropriate `type` and `symbol`. |
|
||||
| **Mark Done / Cancel** | Tap status icon on a bullet. | Update `daily_log_item.status`. |
|
||||
| **View All Logs** | Tap “All Logs” → scroll list. | Query `daily_log` ordered by date. |
|
||||
| **Settings** | Adjust color scheme, font size. | Store preferences in a `user_settings` table (single‑row per user). |
|
||||
| **Export / Import** | Export JSON file of all logs. | Serialize `daily_log` + related items to JSON; reverse on import. |
|
||||
|
||||
---
|
||||
|
||||
## Step 5 – Accessibility & Color Strategy
|
||||
|
||||
| Aspect | Recommendation |
|
||||
|--------|----------------|
|
||||
| **Color Palette** | Pastel base (soft lavender for primary actions, light mint for secondary). Use higher contrast for warnings/errors (e.g., muted coral). |
|
||||
| **Contrast** | Minimum 4.5:1 for text on background; ensure icons and status indicators meet this. |
|
||||
| **Font** | Base size 18px (scalable with user setting). Use a sans‑serif like “Inter” or “Roboto”. |
|
||||
| **Touch Targets** | Minimum 44x44dp; add 8px padding around icons. |
|
||||
| **Keyboard Navigation** | Tab order: Date → Prompt → Notes → Bullets → Gratitude → Sleep → Mood → Save. |
|
||||
| **Screen Reader** | ARIA labels for each input and button. Use semantic `<label>` tags. |
|
||||
|
||||
---
|
||||
|
||||
## Step 6 – Extensibility Roadmap (Future Features)
|
||||
|
||||
1. **Calendar View** – Monthly grid; clicking a day opens the log. Store `daily_log` dates for quick lookup.
|
||||
2. **Task List** – Reuse `daily_log_item.type='task'` and add a global “Task List” table if needed for cross‑day tasks.
|
||||
3. **Notifications** – Email/SMS/WebSocket integration. Store notification preferences per user.
|
||||
4. **Multi‑User** – Add `users` table, authentication via Flask‑Login, and foreign keys to `daily_log.user_id`.
|
||||
5. **Analytics Dashboard** – Query `daily_log_item` counts per status, time spent per day, etc.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 – Development Milestones
|
||||
|
||||
| Step | Deliverables | Notes |
|
||||
|------|--------------|-------|
|
||||
| **Step 7.1 – Scaffold** | Flask app skeleton, database config, migration scripts. | No UI yet. |
|
||||
| **Step 7.2 – Data Model** | `daily_log` & `daily_log_item` tables, relationships. | Run migrations, seed a test log. |
|
||||
| **Step 7.3 – Daily‑Log UI** | Two‑column layout, editable fields, save button. | Test on mobile emulation. |
|
||||
| **Step 7.4 – Settings & Accessibility** | Color scheme toggle, font size slider, WCAG compliance. | Verify contrast with a tool. |
|
||||
| **Step 7.5 – All‑Logs List** | Date list view, navigation to individual logs. | Add pagination if logs > 30 days. |
|
||||
| **Step 7.6 – Export / Import** | JSON export button, import form. | Validate with sample data. |
|
||||
| **Step 7.7 – Future‑Feature Placeholders** | “Coming Soon” cards in right column. | Keep UI ready for Calendar & Task List. |
|
||||
| **Step 7.8 – Testing & QA** | Unit tests for models, integration tests for routes. | Ensure data integrity. |
|
||||
| **Step 7.9 – Deployment** | Dockerfile, production config, environment variables. | Prepare for scaling. |
|
||||
|
||||
---
|
||||
|
||||
## Step 8 – Success Criteria
|
||||
|
||||
| Criteria | How to Measure |
|
||||
|----------|----------------|
|
||||
| **Usability** | User can create, edit, and view a log in < 30 s on a phone. |
|
||||
| **Accessibility** | Passes automated WCAG 2.1 AA audit (contrast, ARIA). |
|
||||
| **Performance** | Page loads < 1 s; DB queries return < 200 ms. |
|
||||
| **Extensibility** | Adding a new feature (e.g., a “Mood Chart”) requires only a new table and minimal code changes. |
|
||||
| **Data Integrity** | No orphaned `daily_log_item` rows after deleting a log. |
|
||||
|
||||
---
|
||||
|
||||
**Next Steps**
|
||||
1. Confirm that the “Step” labels meet your needs.
|
||||
2. If any additional fields (e.g., `priority` on `daily_log_item`) are required, note them now.
|
||||
3. Hand this blueprint to your developer or use it to start writing migration scripts.
|
||||
|
||||
Feel free to ask for any clarification or deeper dives into a particular step!
|
||||
7
config.py
Normal file
7
config.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key'
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
91
migrations/versions/001_bullet_journal_migration.py
Normal file
91
migrations/versions/001_bullet_journal_migration.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Bullet journal migration with user relationship
|
||||
|
||||
Revision ID: 001_bullet_journal
|
||||
Revises:
|
||||
Create Date: 2026-07-22
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001_bullet_journal'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Enum strings for SQLite (no native enum support)
|
||||
SLEEP_QUALITY = ['poor', 'fair', 'good', 'excellent']
|
||||
ITEM_TYPE = ['bullet', 'gratitude', 'sleep', 'note', 'task']
|
||||
STATUS_TYPE = ['todo', 'doing', 'done', 'cancelled']
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create users table first (foreign key dependency)
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=64), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=128), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
|
||||
# Create user_settings table
|
||||
op.create_table('user_settings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('theme', sa.String(length=10), nullable=True),
|
||||
sa.Column('font_size', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id')
|
||||
)
|
||||
|
||||
# Create daily_log table with user_id foreign key
|
||||
op.create_table('daily_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('date', sa.Date(), nullable=False),
|
||||
sa.Column('prompt', sa.Text(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('gratitude', sa.JSON(), nullable=True),
|
||||
sa.Column('bedtime', sa.String(length=20), nullable=True),
|
||||
sa.Column('wake_time', sa.String(length=20), nullable=True),
|
||||
sa.Column('sleep_quality', sa.String(length=10), nullable=True),
|
||||
sa.Column('mood', sa.String(length=50), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('date')
|
||||
)
|
||||
|
||||
# Create daily_log_item table
|
||||
op.create_table('daily_log_item',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('log_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(length=20), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=10), nullable=True),
|
||||
sa.Column('text', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status', sa.String(length=10), nullable=True),
|
||||
sa.ForeignKeyConstraint(['log_id'], ['daily_log.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_log_id', 'daily_log_item', ['log_id'])
|
||||
op.create_index('ix_log_id_status', 'daily_log_item', ['log_id', 'status'])
|
||||
op.create_index('ix_type', 'daily_log_item', ['type'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_type', table_name='daily_log_item')
|
||||
op.drop_index('ix_log_id_status', table_name='daily_log_item')
|
||||
op.drop_index('ix_log_id', table_name='daily_log_item')
|
||||
op.drop_table('daily_log_item')
|
||||
op.drop_table('daily_log')
|
||||
op.drop_table('user_settings')
|
||||
op.drop_table('user')
|
||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
alembic==1.18.5
|
||||
blinker==1.9.0
|
||||
click==8.4.2
|
||||
Flask==3.1.3
|
||||
Flask-Login==0.6.3
|
||||
Flask-Migrate==4.1.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
greenlet==3.5.4
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
Mako==1.3.12
|
||||
MarkupSafe==3.0.3
|
||||
SQLAlchemy==2.0.51
|
||||
typing_extensions==4.16.0
|
||||
Werkzeug==3.1.8
|
||||
5
run.py
Normal file
5
run.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app import create_app
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
598
stzyleguide.md
Normal file
598
stzyleguide.md
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
# Ivory & Ink UI Style Guide
|
||||
|
||||
A warm monochrome system using ivory, ink and soft taupe for a gentler alternative to pure black and white.
|
||||
|
||||
Use Ivory & Ink when pure monochrome feels too clinical. The warm base works well for writing, reading and editorial applications.
|
||||
|
||||
This system keeps the interface ADHD-friendly by using layered neutral surfaces, one clear primary colour, one quieter secondary colour and very restrained supporting accents.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core rules
|
||||
|
||||
- Use neutral backgrounds and surfaces for most of the page.
|
||||
- Reserve the primary colour for the next important action.
|
||||
- Use the secondary colour for optional or supporting actions.
|
||||
- Use spacing and typography before adding colour.
|
||||
- Keep one main task visible at a time.
|
||||
- Avoid decorative motion and unexpected layout changes.
|
||||
- Never communicate status using colour alone.
|
||||
|
||||
---
|
||||
|
||||
## 2. Light mode
|
||||
|
||||
| Token | Value | Purpose |
|
||||
|---|---:|---|
|
||||
| `--page` | `#FAF8F2` | Main page background |
|
||||
| `--page-accent` | `#F0ECE2` | Subtle page depth |
|
||||
| `--surface` | `#FFFEFA` | Cards and dialogs |
|
||||
| `--surface-alt` | `#F6F2E9` | Inputs and secondary regions |
|
||||
| `--surface-strong` | `#E8E1D4` | Selected and elevated elements |
|
||||
| `--text` | `#242321` | Main text |
|
||||
| `--muted` | `#6E6A63` | Secondary text |
|
||||
| `--border` | `#D8D1C5` | Borders and dividers |
|
||||
| `--primary` | `#2D2B28` | Main action and brand colour |
|
||||
| `--primary-hover` | `#171614` | Primary hover and active state |
|
||||
| `--secondary` | `#E8E2D8` | Secondary action |
|
||||
| `--secondary-hover` | `#DAD2C5` | Secondary hover state |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dark mode
|
||||
|
||||
| Token | Value | Purpose |
|
||||
|---|---:|---|
|
||||
| `--page` | `#020202` | Main dark background |
|
||||
| `--page-accent` | `#090806` | Background depth |
|
||||
| `--surface` | `#151411` | Cards and dialogs |
|
||||
| `--surface-alt` | `#201E1A` | Inputs and secondary regions |
|
||||
| `--surface-strong` | `#322F28` | Selected and elevated elements |
|
||||
| `--text` | `#FAF7EF` | Main text |
|
||||
| `--muted` | `#C7C1B5` | Secondary text |
|
||||
| `--border` | `#4D4941` | Borders and dividers |
|
||||
| `--primary` | `#E8E1D4` | Main action and brand colour |
|
||||
| `--primary-hover` | `#FFF9ED` | Primary hover and active state |
|
||||
| `--secondary` | `#4C4840` | Secondary action |
|
||||
| `--secondary-hover` | `#625D53` | Secondary hover state |
|
||||
|
||||
---
|
||||
|
||||
## 4. Colour roles
|
||||
|
||||
### Primary
|
||||
|
||||
Use the primary colour for strong actions and editorial emphasis without saturated colour.
|
||||
|
||||
Use it on:
|
||||
|
||||
- the main button
|
||||
- active navigation
|
||||
- selected controls
|
||||
- important links
|
||||
- compact brand marks
|
||||
|
||||
The primary colour should answer:
|
||||
|
||||
> What should I do next?
|
||||
|
||||
### Secondary
|
||||
|
||||
Use the secondary colour for warm neutral controls, filters and supporting actions.
|
||||
|
||||
Use it on:
|
||||
|
||||
- secondary buttons
|
||||
- optional actions
|
||||
- toolbars
|
||||
- supporting cards
|
||||
- inactive navigation
|
||||
|
||||
The secondary colour should answer:
|
||||
|
||||
> What else can I do?
|
||||
|
||||
### Supporting accents
|
||||
|
||||
| Role | Light | Dark |
|
||||
|---|---:|---:|
|
||||
| Accent 1 | `#817A70` | `#B6ADA0` |
|
||||
| Accent 2 | `#8C9795` | `#8EA09C` |
|
||||
| Accent 3 | `#B49B73` | `#C8AD7C` |
|
||||
| Success | `#6F806F` | `#9BAE99` |
|
||||
| Danger | `#8A5D54` | `#C89084` |
|
||||
|
||||
Use one supporting accent per component whenever possible.
|
||||
|
||||
Recommended page balance:
|
||||
|
||||
- **70%** neutral backgrounds and surfaces
|
||||
- **20%** primary and secondary structure
|
||||
- **10%** supporting accents and status colours
|
||||
|
||||
---
|
||||
|
||||
## 5. Typography
|
||||
|
||||
```css
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible",
|
||||
"Segoe UI",
|
||||
Arial,
|
||||
sans-serif;
|
||||
|
||||
--font-ui:
|
||||
"Lexend",
|
||||
"Segoe UI",
|
||||
Arial,
|
||||
sans-serif;
|
||||
```
|
||||
|
||||
Use **Atkinson Hyperlegible** for paragraphs, instructions, tables and form help.
|
||||
|
||||
Use **Lexend** for headings, buttons, navigation and labels.
|
||||
|
||||
Recommended settings:
|
||||
|
||||
- body text: `18px`
|
||||
- body line-height: `1.6`
|
||||
- paragraphs: no wider than `70ch`
|
||||
- labels and buttons: `16–18px`
|
||||
- left-aligned body text
|
||||
- concise headings
|
||||
- no long all-caps passages
|
||||
|
||||
---
|
||||
|
||||
## 6. Layout and spacing
|
||||
|
||||
Use an 8-pixel spacing rhythm.
|
||||
|
||||
```text
|
||||
4px tiny adjustment
|
||||
8px closely related elements
|
||||
16px normal component spacing
|
||||
24px groups inside a section
|
||||
32px separation between sections
|
||||
48px+ major page regions
|
||||
```
|
||||
|
||||
Keep one purpose per card. Prefer spacing and surface changes over thick borders or multiple shadows.
|
||||
|
||||
---
|
||||
|
||||
## 7. Components
|
||||
|
||||
### Primary button
|
||||
|
||||
```css
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
```
|
||||
|
||||
Use one obvious primary button per task area.
|
||||
|
||||
### Secondary button
|
||||
|
||||
```css
|
||||
background: var(--secondary);
|
||||
color: var(--on-secondary);
|
||||
```
|
||||
|
||||
Use for optional or reversible actions.
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
```
|
||||
|
||||
On focus:
|
||||
|
||||
```css
|
||||
border-color: var(--primary);
|
||||
outline: 3px solid var(--focus);
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- Place the error beside the affected field.
|
||||
- Explain how to fix the problem.
|
||||
- Preserve the user’s entered data.
|
||||
- Use text or an icon as well as colour.
|
||||
|
||||
---
|
||||
|
||||
## 8. ADHD-friendly interaction rules
|
||||
|
||||
- Present one main task at a time.
|
||||
- Reduce optional decisions near the primary action.
|
||||
- Keep related controls close together.
|
||||
- Use consistent button placement.
|
||||
- Avoid unexpected layout movement.
|
||||
- Respect `prefers-reduced-motion`.
|
||||
- Keep notifications short and actionable.
|
||||
- Use progressive disclosure for advanced options.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility checks
|
||||
|
||||
Before release:
|
||||
|
||||
- test keyboard navigation
|
||||
- verify visible focus states
|
||||
- check text and button contrast
|
||||
- test at 200% zoom
|
||||
- connect labels to controls
|
||||
- test light and dark modes separately
|
||||
- use semantic HTML before ARIA
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--page: #FAF8F2;
|
||||
--surface: #FFFEFA;
|
||||
--surface-alt: #F6F2E9;
|
||||
--text: #242321;
|
||||
--muted: #6E6A63;
|
||||
--border: #D8D1C5;
|
||||
--primary: #2D2B28;
|
||||
--secondary: #E8E2D8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--page: #020202;
|
||||
--surface: #151411;
|
||||
--surface-alt: #201E1A;
|
||||
--text: #FAF7EF;
|
||||
--muted: #C7C1B5;
|
||||
--border: #4D4941;
|
||||
--primary: #E8E1D4;
|
||||
--secondary: #4C4840;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Final rule
|
||||
|
||||
When the interface begins to feel busy, remove emphasis before adding another colour. Neutral surfaces should make the primary action easy to find without demanding constant attention.
|
||||
|
||||
---
|
||||
|
||||
## Typography sample card
|
||||
|
||||
Every login demo includes a sample content card so the heading, subheading and paragraph fonts can be compared in a realistic surface.
|
||||
|
||||
The demos load the fonts through the Google Fonts CSS API:
|
||||
|
||||
```css
|
||||
@import url(
|
||||
"https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap"
|
||||
);
|
||||
```
|
||||
|
||||
Recommended use:# Ivory & Ink UI Style Guide
|
||||
|
||||
A warm monochrome system using ivory, ink and soft taupe for a gentler alternative to pure black and white.
|
||||
|
||||
Use Ivory & Ink when pure monochrome feels too clinical. The warm base works well for writing, reading and editorial applications.
|
||||
|
||||
This system keeps the interface ADHD-friendly by using layered neutral surfaces, one clear primary colour, one quieter secondary colour and very restrained supporting accents.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core rules
|
||||
|
||||
- Use neutral backgrounds and surfaces for most of the page.
|
||||
- Reserve the primary colour for the next important action.
|
||||
- Use the secondary colour for optional or supporting actions.
|
||||
- Use spacing and typography before adding colour.
|
||||
- Keep one main task visible at a time.
|
||||
- Avoid decorative motion and unexpected layout changes.
|
||||
- Never communicate status using colour alone.
|
||||
|
||||
---
|
||||
|
||||
## 2. Light mode
|
||||
|
||||
| Token | Value | Purpose |
|
||||
|---|---:|---|
|
||||
| `--page` | `#FAF8F2` | Main page background |
|
||||
| `--page-accent` | `#F0ECE2` | Subtle page depth |
|
||||
| `--surface` | `#FFFEFA` | Cards and dialogs |
|
||||
| `--surface-alt` | `#F6F2E9` | Inputs and secondary regions |
|
||||
| `--surface-strong` | `#E8E1D4` | Selected and elevated elements |
|
||||
| `--text` | `#242321` | Main text |
|
||||
| `--muted` | `#6E6A63` | Secondary text |
|
||||
| `--border` | `#D8D1C5` | Borders and dividers |
|
||||
| `--primary` | `#2D2B28` | Main action and brand colour |
|
||||
| `--primary-hover` | `#171614` | Primary hover and active state |
|
||||
| `--secondary` | `#E8E2D8` | Secondary action |
|
||||
| `--secondary-hover` | `#DAD2C5` | Secondary hover state |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dark mode
|
||||
|
||||
| Token | Value | Purpose |
|
||||
|---|---:|---|
|
||||
| `--page` | `#020202` | Main dark background |
|
||||
| `--page-accent` | `#090806` | Background depth |
|
||||
| `--surface` | `#151411` | Cards and dialogs |
|
||||
| `--surface-alt` | `#201E1A` | Inputs and secondary regions |
|
||||
| `--surface-strong` | `#322F28` | Selected and elevated elements |
|
||||
| `--text` | `#FAF7EF` | Main text |
|
||||
| `--muted` | `#C7C1B5` | Secondary text |
|
||||
| `--border` | `#4D4941` | Borders and dividers |
|
||||
| `--primary` | `#E8E1D4` | Main action and brand colour |
|
||||
| `--primary-hover` | `#FFF9ED` | Primary hover and active state |
|
||||
| `--secondary` | `#4C4840` | Secondary action |
|
||||
| `--secondary-hover` | `#625D53` | Secondary hover state |
|
||||
|
||||
---
|
||||
|
||||
## 4. Colour roles
|
||||
|
||||
### Primary
|
||||
|
||||
Use the primary colour for strong actions and editorial emphasis without saturated colour.
|
||||
|
||||
Use it on:
|
||||
|
||||
- the main button
|
||||
- active navigation
|
||||
- selected controls
|
||||
- important links
|
||||
- compact brand marks
|
||||
|
||||
The primary colour should answer:
|
||||
|
||||
> What should I do next?
|
||||
|
||||
### Secondary
|
||||
|
||||
Use the secondary colour for warm neutral controls, filters and supporting actions.
|
||||
|
||||
Use it on:
|
||||
|
||||
- secondary buttons
|
||||
- optional actions
|
||||
- toolbars
|
||||
- supporting cards
|
||||
- inactive navigation
|
||||
|
||||
The secondary colour should answer:
|
||||
|
||||
> What else can I do?
|
||||
|
||||
### Supporting accents
|
||||
|
||||
| Role | Light | Dark |
|
||||
|---|---:|---:|
|
||||
| Accent 1 | `#817A70` | `#B6ADA0` |
|
||||
| Accent 2 | `#8C9795` | `#8EA09C` |
|
||||
| Accent 3 | `#B49B73` | `#C8AD7C` |
|
||||
| Success | `#6F806F` | `#9BAE99` |
|
||||
| Danger | `#8A5D54` | `#C89084` |
|
||||
|
||||
Use one supporting accent per component whenever possible.
|
||||
|
||||
Recommended page balance:
|
||||
|
||||
- **70%** neutral backgrounds and surfaces
|
||||
- **20%** primary and secondary structure
|
||||
- **10%** supporting accents and status colours
|
||||
|
||||
---
|
||||
|
||||
## 5. Typography
|
||||
|
||||
```css
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible",
|
||||
"Segoe UI",
|
||||
Arial,
|
||||
sans-serif;
|
||||
|
||||
--font-ui:
|
||||
"Lexend",
|
||||
"Segoe UI",
|
||||
Arial,
|
||||
sans-serif;
|
||||
```
|
||||
|
||||
Use **Atkinson Hyperlegible** for paragraphs, instructions, tables and form help.
|
||||
|
||||
Use **Lexend** for headings, buttons, navigation and labels.
|
||||
|
||||
Recommended settings:
|
||||
|
||||
- body text: `18px`
|
||||
- body line-height: `1.6`
|
||||
- paragraphs: no wider than `70ch`
|
||||
- labels and buttons: `16–18px`
|
||||
- left-aligned body text
|
||||
- concise headings
|
||||
- no long all-caps passages
|
||||
|
||||
---
|
||||
|
||||
## 6. Layout and spacing
|
||||
|
||||
Use an 8-pixel spacing rhythm.
|
||||
|
||||
```text
|
||||
4px tiny adjustment
|
||||
8px closely related elements
|
||||
16px normal component spacing
|
||||
24px groups inside a section
|
||||
32px separation between sections
|
||||
48px+ major page regions
|
||||
```
|
||||
|
||||
Keep one purpose per card. Prefer spacing and surface changes over thick borders or multiple shadows.
|
||||
|
||||
---
|
||||
|
||||
## 7. Components
|
||||
|
||||
### Primary button
|
||||
|
||||
```css
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
```
|
||||
|
||||
Use one obvious primary button per task area.
|
||||
|
||||
### Secondary button
|
||||
|
||||
```css
|
||||
background: var(--secondary);
|
||||
color: var(--on-secondary);
|
||||
```
|
||||
|
||||
Use for optional or reversible actions.
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
```
|
||||
|
||||
On focus:
|
||||
|
||||
```css
|
||||
border-color: var(--primary);
|
||||
outline: 3px solid var(--focus);
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- Place the error beside the affected field.
|
||||
- Explain how to fix the problem.
|
||||
- Preserve the user’s entered data.
|
||||
- Use text or an icon as well as colour.
|
||||
|
||||
---
|
||||
|
||||
## 8. ADHD-friendly interaction rules
|
||||
|
||||
- Present one main task at a time.
|
||||
- Reduce optional decisions near the primary action.
|
||||
- Keep related controls close together.
|
||||
- Use consistent button placement.
|
||||
- Avoid unexpected layout movement.
|
||||
- Respect `prefers-reduced-motion`.
|
||||
- Keep notifications short and actionable.
|
||||
- Use progressive disclosure for advanced options.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility checks
|
||||
|
||||
Before release:
|
||||
|
||||
- test keyboard navigation
|
||||
- verify visible focus states
|
||||
- check text and button contrast
|
||||
- test at 200% zoom
|
||||
- connect labels to controls
|
||||
- test light and dark modes separately
|
||||
- use semantic HTML before ARIA
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--page: #FAF8F2;
|
||||
--surface: #FFFEFA;
|
||||
--surface-alt: #F6F2E9;
|
||||
--text: #242321;
|
||||
--muted: #6E6A63;
|
||||
--border: #D8D1C5;
|
||||
--primary: #2D2B28;
|
||||
--secondary: #E8E2D8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--page: #020202;
|
||||
--surface: #151411;
|
||||
--surface-alt: #201E1A;
|
||||
--text: #FAF7EF;
|
||||
--muted: #C7C1B5;
|
||||
--border: #4D4941;
|
||||
--primary: #E8E1D4;
|
||||
--secondary: #4C4840;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Final rule
|
||||
|
||||
When the interface begins to feel busy, remove emphasis before adding another colour. Neutral surfaces should make the primary action easy to find without demanding constant attention.
|
||||
|
||||
---
|
||||
|
||||
## Typography sample card
|
||||
|
||||
Every login demo includes a sample content card so the heading, subheading and paragraph fonts can be compared in a realistic surface.
|
||||
|
||||
The demos load the fonts through the Google Fonts CSS API:
|
||||
|
||||
```css
|
||||
@import url(
|
||||
"https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Lexend:wght@400;500;600;700&display=swap"
|
||||
);
|
||||
```
|
||||
|
||||
Recommended use:
|
||||
|
||||
- **Lexend** for the card heading, subheading, labels and badges
|
||||
- **Atkinson Hyperlegible** for the paragraph and longer reading text
|
||||
|
||||
Example structure:
|
||||
|
||||
```html
|
||||
<article class="sample-card">
|
||||
<p class="sample-card__eyebrow">Typography sample</p>
|
||||
<h3>A calm and readable card heading</h3>
|
||||
<p class="sample-card__subheading">
|
||||
A concise subheading demonstrates Lexend at a smaller interface size.
|
||||
</p>
|
||||
<p class="sample-card__body">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
|
||||
- **Lexend** for the card heading, subheading, labels and badges
|
||||
- **Atkinson Hyperlegible** for the paragraph and longer reading text
|
||||
|
||||
Example structure:
|
||||
|
||||
```html
|
||||
<article class="sample-card">
|
||||
<p class="sample-card__eyebrow">Typography sample</p>
|
||||
<h3>A calm and readable card heading</h3>
|
||||
<p class="sample-card__subheading">
|
||||
A concise subheading demonstrates Lexend at a smaller interface size.
|
||||
</p>
|
||||
<p class="sample-card__body">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
</p>
|
||||
</article>
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue