first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
/data/
|
||||||
|
*.db
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py ./
|
||||||
|
COPY templates ./templates
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
RUN useradd -m appuser
|
||||||
|
|
||||||
|
# Ensure the mounted volume path is writable by the non-root user.
|
||||||
|
# (docker named volumes are typically owned by root by default)
|
||||||
|
RUN mkdir -p /data && chown -R appuser:appuser /data
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:8000", "server:app", "--workers", "2", "--threads", "4", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Honeymoon trip site (Flask + plain HTML/CSS/JS)
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
- Minimal Flask backend with session auth (credentials from `.env`)
|
||||||
|
- Plain frontend (no framework)
|
||||||
|
- SQLite persistence for:
|
||||||
|
- To-do list
|
||||||
|
- Expenses (JPY/EUR)
|
||||||
|
- Designed to run **only inside Docker network** (behind your nginx/SSL reverse proxy)
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
1. Create `.env` from [`./.env.example`](./.env.example:1)
|
||||||
|
2. Create the external docker network (once):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create misc
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The container exposes port `8000` **only to the `misc` network** (no host port published).
|
||||||
|
|
||||||
|
## Reverse proxy (nginx)
|
||||||
|
|
||||||
|
Point your nginx upstream to `honeymoon:8000` on the `misc` network.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Backend: [`server.py`](server.py:1)
|
||||||
|
- Templates: [`templates/index.html`](templates/index.html:1), [`templates/login.html`](templates/login.html:1)
|
||||||
|
- Static: [`static/app.js`](static/app.js:1), [`static/style.css`](static/style.css:1)
|
||||||
|
- Docker: [`Dockerfile`](Dockerfile:1), [`docker-compose.yml`](docker-compose.yml:1)
|
||||||
2
README.txt
Normal file
2
README.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Versione separata in 3 file: index.html, style.css, app.js.
|
||||||
|
Tieni i 3 file nella stessa cartella oppure carica lo zip completo su Netlify.
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
honeymoon:
|
||||||
|
build: .
|
||||||
|
container_name: honeymoon
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PORT: "8000"
|
||||||
|
DB_PATH: "/data/app.db"
|
||||||
|
AUTH_USERNAME: "${AUTH_USERNAME}"
|
||||||
|
AUTH_PASSWORD: "${AUTH_PASSWORD}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
volumes:
|
||||||
|
- honeymoon_data:/data
|
||||||
|
networks:
|
||||||
|
- misc
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
misc:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
honeymoon_data:
|
||||||
11
index.html
Normal file
11
index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>App viaggio</title><meta name="theme-color" content="#0a1220"><link rel="stylesheet" href="style.css"></head><body>
|
||||||
|
<header><div class="wrap"><div class="brand"></div></div></header>
|
||||||
|
<main class="wrap">
|
||||||
|
<section id="oggi" class="anchor-offset card"><h2 class="section">Oggi</h2><div class="toolbar"><div class="search"><span>🗓️</span><select id="todaySelect"></select></div><div class="actions"><button class="btn ghost" id="prevDay">Indietro</button><button class="btn ghost" id="nextDay">Avanti</button></div></div><div id="todayTop"></div></section>
|
||||||
|
<section id="giorni" class="anchor-offset card"><h2 class="section">Giorni</h2><div class="toolbar"><div class="search"><span>🔎</span><input id="searchInput" placeholder="Cerca giorno o città"></div><div class="search"><span>📍</span><select id="cityFilter"></select></div></div><div id="daysList"></div></section>
|
||||||
|
<section id="hotel" class="anchor-offset card"><h2 class="section">Hotel</h2><div id="hotelList"></div></section>
|
||||||
|
<section id="todo" class="anchor-offset card"><h2 class="section">To do</h2><div class="small">Checklist pre-partenza spuntabile</div><div id="todoList"></div></section>
|
||||||
|
<section id="costi" class="anchor-offset card"><h2 class="section">Costi</h2><div class="small">Cambio di riferimento: 1 EUR = 182.85 JPY · 1.000 JPY ≈ 5.47 EUR</div><div class="cost-inputs"><div class="search"><span>🧾</span><input id="costDesc" placeholder="Descrizione spesa"></div><div class="search"><span>¥</span><input id="costJpy" type="number" min="0" step="1" placeholder="Importo JPY"></div><div class="search"><span>€</span><input id="costEur" type="number" min="0" step="0.01" placeholder="Importo EUR"></div></div><div class="actions" style="margin-top:10px"><button class="btn" id="addCostBtn">Aggiungi spesa</button><button class="btn sec" id="clearCostsBtn">Azzera tutto</button></div><div id="costList"></div><div class="totals"><div class="kpi"><div class="label">Totale JPY</div><div class="value" id="totalJpy">¥0</div></div><div class="kpi"><div class="label">Totale EUR</div><div class="value" id="totalEur">€0,00</div></div></div><div class="notice">Questa versione salva To do e Costi con IndexedDB e prova anche a importare automaticamente i dati già presenti.</div></section>
|
||||||
|
</main>
|
||||||
|
<nav class="footer"><div class="inner"><a class="navbtn" href="#oggi">Oggi</a><a class="navbtn" href="#giorni">Giorni</a><a class="navbtn" href="#hotel">Hotel</a><a class="navbtn" href="#todo">To do</a><a class="navbtn" href="#costi">Costi</a></div></nav>
|
||||||
|
<script src="app.js"></script></body></html>
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
gunicorn==22.0.0
|
||||||
331
server.py
Normal file
331
server.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
abort,
|
||||||
|
g,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
|
||||||
|
secret_key = os.environ.get("SECRET_KEY")
|
||||||
|
if not secret_key:
|
||||||
|
# Safe default for local dev; in production set SECRET_KEY in .env
|
||||||
|
secret_key = secrets.token_urlsafe(32)
|
||||||
|
app.secret_key = secret_key
|
||||||
|
|
||||||
|
app.config["DB_PATH"] = os.environ.get("DB_PATH", "./data/app.db")
|
||||||
|
app.config["AUTH_USERNAME"] = os.environ.get("AUTH_USERNAME", "")
|
||||||
|
app.config["AUTH_PASSWORD"] = os.environ.get("AUTH_PASSWORD", "")
|
||||||
|
|
||||||
|
# Ensure DB directory exists.
|
||||||
|
# - If DB_PATH is a bare filename (no directory), dirname() is "".
|
||||||
|
# - If DB_PATH is absolute (e.g. /data/app.db), dirname() is "/data".
|
||||||
|
db_dir = os.path.dirname(app.config["DB_PATH"]) or "."
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _open_db():
|
||||||
|
g.db = sqlite3.connect(app.config["DB_PATH"])
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _close_db(resp):
|
||||||
|
db = getattr(g, "db", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
db = sqlite3.connect(app.config["DB_PATH"])
|
||||||
|
try:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
jpy REAL NOT NULL,
|
||||||
|
eur REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
def is_authed() -> bool:
|
||||||
|
return bool(session.get("authed"))
|
||||||
|
|
||||||
|
def require_auth() -> None:
|
||||||
|
if not is_authed():
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
if not is_authed():
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login():
|
||||||
|
if is_authed():
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
def login_post():
|
||||||
|
username = (request.form.get("username") or "").strip()
|
||||||
|
password = request.form.get("password") or ""
|
||||||
|
|
||||||
|
if not app.config["AUTH_USERNAME"] or not app.config["AUTH_PASSWORD"]:
|
||||||
|
# Misconfiguration: refuse login if credentials are not set.
|
||||||
|
abort(500)
|
||||||
|
|
||||||
|
if username == app.config["AUTH_USERNAME"] and password == app.config["AUTH_PASSWORD"]:
|
||||||
|
session.clear()
|
||||||
|
session["authed"] = True
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
return render_template("login.html", error="Credenziali non valide")
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# API: TODO
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
@app.get("/api/todos")
|
||||||
|
def api_todos_list():
|
||||||
|
require_auth()
|
||||||
|
rows = g.db.execute(
|
||||||
|
"SELECT id, text, done, created_at FROM todo_items ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"text": r["text"],
|
||||||
|
"done": bool(r["done"]),
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/todos")
|
||||||
|
def api_todos_create():
|
||||||
|
require_auth()
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return jsonify({"error": "text is required"}), 400
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cur = g.db.execute(
|
||||||
|
"INSERT INTO todo_items(text, done, created_at) VALUES(?, 0, ?)",
|
||||||
|
(text, now),
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
return jsonify({"id": cur.lastrowid, "text": text, "done": False, "created_at": now}), 201
|
||||||
|
|
||||||
|
@app.patch("/api/todos/<int:todo_id>")
|
||||||
|
def api_todos_update(todo_id: int):
|
||||||
|
require_auth()
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if "text" in data:
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return jsonify({"error": "text cannot be empty"}), 400
|
||||||
|
fields.append("text = ?")
|
||||||
|
params.append(text)
|
||||||
|
|
||||||
|
if "done" in data:
|
||||||
|
done = 1 if bool(data.get("done")) else 0
|
||||||
|
fields.append("done = ?")
|
||||||
|
params.append(done)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return jsonify({"error": "no fields to update"}), 400
|
||||||
|
|
||||||
|
params.append(todo_id)
|
||||||
|
cur = g.db.execute(
|
||||||
|
f"UPDATE todo_items SET {', '.join(fields)} WHERE id = ?",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.delete("/api/todos/<int:todo_id>")
|
||||||
|
def api_todos_delete(todo_id: int):
|
||||||
|
require_auth()
|
||||||
|
cur = g.db.execute("DELETE FROM todo_items WHERE id = ?", (todo_id,))
|
||||||
|
g.db.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# API: EXPENSES
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
@app.get("/api/expenses")
|
||||||
|
def api_expenses_list():
|
||||||
|
require_auth()
|
||||||
|
rows = g.db.execute(
|
||||||
|
"SELECT id, description, jpy, eur, created_at FROM expenses ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"description": r["description"],
|
||||||
|
"jpy": r["jpy"],
|
||||||
|
"eur": r["eur"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/expenses")
|
||||||
|
def api_expenses_create():
|
||||||
|
require_auth()
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
description = (data.get("description") or "").strip()
|
||||||
|
jpy = data.get("jpy")
|
||||||
|
eur = data.get("eur")
|
||||||
|
|
||||||
|
if not description:
|
||||||
|
return jsonify({"error": "description is required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
jpy_f = float(jpy)
|
||||||
|
eur_f = float(eur)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "jpy and eur must be numbers"}), 400
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cur = g.db.execute(
|
||||||
|
"INSERT INTO expenses(description, jpy, eur, created_at) VALUES(?, ?, ?, ?)",
|
||||||
|
(description, jpy_f, eur_f, now),
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"id": cur.lastrowid,
|
||||||
|
"description": description,
|
||||||
|
"jpy": jpy_f,
|
||||||
|
"eur": eur_f,
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.patch("/api/expenses/<int:expense_id>")
|
||||||
|
def api_expenses_update(expense_id: int):
|
||||||
|
require_auth()
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if "description" in data:
|
||||||
|
description = (data.get("description") or "").strip()
|
||||||
|
if not description:
|
||||||
|
return jsonify({"error": "description cannot be empty"}), 400
|
||||||
|
fields.append("description = ?")
|
||||||
|
params.append(description)
|
||||||
|
|
||||||
|
if "jpy" in data:
|
||||||
|
try:
|
||||||
|
jpy_f = float(data.get("jpy"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "jpy must be a number"}), 400
|
||||||
|
fields.append("jpy = ?")
|
||||||
|
params.append(jpy_f)
|
||||||
|
|
||||||
|
if "eur" in data:
|
||||||
|
try:
|
||||||
|
eur_f = float(data.get("eur"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "eur must be a number"}), 400
|
||||||
|
fields.append("eur = ?")
|
||||||
|
params.append(eur_f)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return jsonify({"error": "no fields to update"}), 400
|
||||||
|
|
||||||
|
params.append(expense_id)
|
||||||
|
cur = g.db.execute(
|
||||||
|
f"UPDATE expenses SET {', '.join(fields)} WHERE id = ?",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.delete("/api/expenses/<int:expense_id>")
|
||||||
|
def api_expenses_delete(expense_id: int):
|
||||||
|
require_auth()
|
||||||
|
cur = g.db.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
|
||||||
|
g.db.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# Errors
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
def _unauthorized(_e):
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")), debug=False)
|
||||||
359
static/app.js
Normal file
359
static/app.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
const EURJPY = 182.85;
|
||||||
|
|
||||||
|
function mapsLink(q) {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`;
|
||||||
|
}
|
||||||
|
function jpyToEur(jpy) {
|
||||||
|
return jpy / EURJPY;
|
||||||
|
}
|
||||||
|
function eurToJpy(eur) {
|
||||||
|
return eur * EURJPY;
|
||||||
|
}
|
||||||
|
function formatJpy(n) {
|
||||||
|
return "¥" + Number(n || 0).toLocaleString("it-IT", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
function formatEur(n) {
|
||||||
|
return new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(n || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal “calendar” dataset (lorem ipsum placeholders)
|
||||||
|
const DAYS = [
|
||||||
|
{
|
||||||
|
id: "d01",
|
||||||
|
dateLabel: "14 giugno",
|
||||||
|
title: "Tokyo · Arrivo",
|
||||||
|
city: "Tokyo",
|
||||||
|
base: "Asakusa",
|
||||||
|
summary: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||||
|
movements: [
|
||||||
|
{ time: "10:30", from: "Narita", to: "Asakusa", mode: "Train", mapsQuery: "Narita Airport to Asakusa" },
|
||||||
|
{ time: "15:00", from: "Asakusa", to: "Shibuya", mode: "Metro", mapsQuery: "Asakusa to Shibuya" },
|
||||||
|
],
|
||||||
|
attractions: [
|
||||||
|
{ name: "Lorem Shrine", note: "Lorem ipsum dolor sit amet.", mapsQuery: "Meiji Jingu" },
|
||||||
|
{ name: "Ipsum Crossing", note: "Sed do eiusmod tempor.", mapsQuery: "Shibuya Crossing" },
|
||||||
|
],
|
||||||
|
lunch: [
|
||||||
|
{ name: "Lorem Ramen", mapsQuery: "Ramen Asakusa" },
|
||||||
|
{ name: "Ipsum Sushi", mapsQuery: "Sushi Asakusa" },
|
||||||
|
],
|
||||||
|
dinner: [
|
||||||
|
{ name: "Dolor Izakaya", mapsQuery: "Izakaya Shinjuku" },
|
||||||
|
{ name: "Sit Tempura", mapsQuery: "Tempura Shinjuku" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d02",
|
||||||
|
dateLabel: "15 giugno",
|
||||||
|
title: "Tokyo · Tradizione",
|
||||||
|
city: "Tokyo",
|
||||||
|
base: "Asakusa",
|
||||||
|
summary: "Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
|
||||||
|
movements: [
|
||||||
|
{ time: "09:00", from: "Asakusa", to: "Senso-ji", mode: "Walk", mapsQuery: "Senso-ji" },
|
||||||
|
{ time: "12:00", from: "Senso-ji", to: "Skytree", mode: "Walk", mapsQuery: "Tokyo Skytree" },
|
||||||
|
],
|
||||||
|
attractions: [
|
||||||
|
{ name: "Senso-ji", note: "Lorem ipsum dolor sit amet.", mapsQuery: "Senso-ji" },
|
||||||
|
{ name: "Tokyo Skytree", note: "Consectetur adipiscing elit.", mapsQuery: "Tokyo Skytree" },
|
||||||
|
],
|
||||||
|
lunch: [
|
||||||
|
{ name: "Lorem Bento", mapsQuery: "Tokyo Solamachi food" },
|
||||||
|
{ name: "Ipsum Udon", mapsQuery: "Udon Asakusa" },
|
||||||
|
],
|
||||||
|
dinner: [
|
||||||
|
{ name: "Dolor Gyoza", mapsQuery: "Gyoza Akihabara" },
|
||||||
|
{ name: "Sit Soba", mapsQuery: "Soba Asakusa" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const elCalendar = document.getElementById("calendar");
|
||||||
|
const elDayView = document.getElementById("dayView");
|
||||||
|
|
||||||
|
function renderCalendar(selectedId) {
|
||||||
|
elCalendar.innerHTML = DAYS.map((d) => {
|
||||||
|
const active = d.id === selectedId ? " is-active" : "";
|
||||||
|
return `
|
||||||
|
<button class="cal-day${active}" data-id="${d.id}" type="button">
|
||||||
|
<div class="cal-date">${d.dateLabel}</div>
|
||||||
|
<div class="cal-title">${d.title}</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
elCalendar.querySelectorAll(".cal-day").forEach((b) => {
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
const id = b.getAttribute("data-id");
|
||||||
|
renderDay(id);
|
||||||
|
renderCalendar(id);
|
||||||
|
location.hash = "#giorno";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDay(dayId) {
|
||||||
|
const day = DAYS.find((d) => d.id === dayId) || DAYS[0];
|
||||||
|
|
||||||
|
const movements = day.movements
|
||||||
|
.map(
|
||||||
|
(m) => `
|
||||||
|
<div class="trow">
|
||||||
|
<div class="t">${m.time}</div>
|
||||||
|
<div>
|
||||||
|
<div class="p">${m.from} → ${m.to}</div>
|
||||||
|
<div class="d">${m.mode} · Lorem ipsum dolor sit amet.</div>
|
||||||
|
</div>
|
||||||
|
<div><a class="btn ghost" target="_blank" href="${mapsLink(m.mapsQuery)}">Maps</a></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const attractions = day.attractions
|
||||||
|
.map(
|
||||||
|
(a) => `
|
||||||
|
<li>
|
||||||
|
<a class="placeLink" target="_blank" href="${mapsLink(a.mapsQuery)}">${a.name}</a>
|
||||||
|
<div class="small">${a.note}</div>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const lunch = day.lunch
|
||||||
|
.map((p) => `<li><a class="placeLink" target="_blank" href="${mapsLink(p.mapsQuery)}">${p.name}</a></li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const dinner = day.dinner
|
||||||
|
.map((p) => `<li><a class="placeLink" target="_blank" href="${mapsLink(p.mapsQuery)}">${p.name}</a></li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
elDayView.innerHTML = `
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="label">Dove siete</div>
|
||||||
|
<div class="value">${day.city} · ${day.base}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="label">Nota</div>
|
||||||
|
<div class="value">${day.summary}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meal">
|
||||||
|
<h4>Spostamenti</h4>
|
||||||
|
<div class="timeline">${movements}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meal">
|
||||||
|
<h4>Attrazioni</h4>
|
||||||
|
<ul>${attractions}</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meal">
|
||||||
|
<h4>Pranzo (suggeriti)</h4>
|
||||||
|
<ul>${lunch}</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meal">
|
||||||
|
<h4>Cena (suggeriti)</h4>
|
||||||
|
<ul>${dinner}</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path, options) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------
|
||||||
|
// TODO UI
|
||||||
|
// -----------------
|
||||||
|
|
||||||
|
const elTodoList = document.getElementById("todoList");
|
||||||
|
const elTodoNewText = document.getElementById("todoNewText");
|
||||||
|
const elTodoAddBtn = document.getElementById("todoAddBtn");
|
||||||
|
|
||||||
|
async function renderTodos() {
|
||||||
|
const todos = await apiFetch("/api/todos");
|
||||||
|
elTodoList.innerHTML = todos.length
|
||||||
|
? todos
|
||||||
|
.map(
|
||||||
|
(t) => `
|
||||||
|
<div class="todo-item">
|
||||||
|
<label class="todo-check">
|
||||||
|
<input type="checkbox" data-id="${t.id}" ${t.done ? "checked" : ""} />
|
||||||
|
<span>${t.text}</span>
|
||||||
|
</label>
|
||||||
|
<div class="todo-actions">
|
||||||
|
<button class="btn ghost todo-edit" data-id="${t.id}" type="button">Modifica</button>
|
||||||
|
<button class="btn ghost todo-del" data-id="${t.id}" type="button">Elimina</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="small" style="margin-top:12px">Nessuna voce. Lorem ipsum.</div>`;
|
||||||
|
|
||||||
|
elTodoList.querySelectorAll("input[type=checkbox]").forEach((cb) => {
|
||||||
|
cb.addEventListener("change", async () => {
|
||||||
|
const id = cb.getAttribute("data-id");
|
||||||
|
await apiFetch(`/api/todos/${id}`, { method: "PATCH", body: JSON.stringify({ done: cb.checked }) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
elTodoList.querySelectorAll(".todo-del").forEach((b) => {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const id = b.getAttribute("data-id");
|
||||||
|
await apiFetch(`/api/todos/${id}`, { method: "DELETE" });
|
||||||
|
await renderTodos();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
elTodoList.querySelectorAll(".todo-edit").forEach((b) => {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const id = b.getAttribute("data-id");
|
||||||
|
const current = b.closest(".todo-item").querySelector(".todo-check span").textContent;
|
||||||
|
const next = prompt("Modifica testo", current);
|
||||||
|
if (next === null) return;
|
||||||
|
await apiFetch(`/api/todos/${id}`, { method: "PATCH", body: JSON.stringify({ text: next }) });
|
||||||
|
await renderTodos();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
elTodoAddBtn.addEventListener("click", async () => {
|
||||||
|
const text = (elTodoNewText.value || "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
await apiFetch("/api/todos", { method: "POST", body: JSON.stringify({ text }) });
|
||||||
|
elTodoNewText.value = "";
|
||||||
|
await renderTodos();
|
||||||
|
});
|
||||||
|
|
||||||
|
elTodoNewText.addEventListener("keydown", async (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
elTodoAddBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------
|
||||||
|
// EXPENSES UI
|
||||||
|
// -----------------
|
||||||
|
|
||||||
|
const elCostList = document.getElementById("costList");
|
||||||
|
const elCostDesc = document.getElementById("costDesc");
|
||||||
|
const elCostJpy = document.getElementById("costJpy");
|
||||||
|
const elCostEur = document.getElementById("costEur");
|
||||||
|
const elAddCostBtn = document.getElementById("addCostBtn");
|
||||||
|
|
||||||
|
async function renderExpenses() {
|
||||||
|
const expenses = await apiFetch("/api/expenses");
|
||||||
|
|
||||||
|
elCostList.innerHTML = expenses.length
|
||||||
|
? expenses
|
||||||
|
.map(
|
||||||
|
(c) => `
|
||||||
|
<div class="cost-row">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div class="p">${c.description}</div>
|
||||||
|
<div class="small">${formatJpy(c.jpy)} · ${formatEur(c.eur)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-actions">
|
||||||
|
<button class="btn ghost cost-edit" data-id="${c.id}" data-desc="${encodeURIComponent(
|
||||||
|
c.description
|
||||||
|
)}" data-jpy="${c.jpy}" data-eur="${c.eur}" type="button">Modifica</button>
|
||||||
|
<button class="btn ghost cost-del" data-id="${c.id}" type="button">Elimina</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="small" style="margin-top:12px">Nessuna spesa inserita. Lorem ipsum.</div>`;
|
||||||
|
|
||||||
|
const totalJpy = expenses.reduce((a, c) => a + Number(c.jpy || 0), 0);
|
||||||
|
const totalEur = expenses.reduce((a, c) => a + Number(c.eur || 0), 0);
|
||||||
|
document.getElementById("totalJpy").textContent = formatJpy(totalJpy);
|
||||||
|
document.getElementById("totalEur").textContent = formatEur(totalEur);
|
||||||
|
|
||||||
|
elCostList.querySelectorAll(".cost-del").forEach((b) => {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const id = b.getAttribute("data-id");
|
||||||
|
await apiFetch(`/api/expenses/${id}`, { method: "DELETE" });
|
||||||
|
await renderExpenses();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
elCostList.querySelectorAll(".cost-edit").forEach((b) => {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const id = b.getAttribute("data-id");
|
||||||
|
const desc = decodeURIComponent(b.getAttribute("data-desc"));
|
||||||
|
const jpy = Number(b.getAttribute("data-jpy"));
|
||||||
|
const eur = Number(b.getAttribute("data-eur"));
|
||||||
|
|
||||||
|
const nextDesc = prompt("Descrizione", desc);
|
||||||
|
if (nextDesc === null) return;
|
||||||
|
const nextJpyRaw = prompt("Importo JPY", String(jpy));
|
||||||
|
if (nextJpyRaw === null) return;
|
||||||
|
const nextJpy = Number(nextJpyRaw);
|
||||||
|
if (!Number.isFinite(nextJpy) || nextJpy < 0) return;
|
||||||
|
const nextEur = jpyToEur(nextJpy);
|
||||||
|
|
||||||
|
await apiFetch(`/api/expenses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ description: nextDesc, jpy: nextJpy, eur: nextEur }),
|
||||||
|
});
|
||||||
|
await renderExpenses();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
elAddCostBtn.addEventListener("click", async () => {
|
||||||
|
const description = (elCostDesc.value || "").trim();
|
||||||
|
const jpyRaw = elCostJpy.value;
|
||||||
|
const eurRaw = elCostEur.value;
|
||||||
|
|
||||||
|
if (!description) return;
|
||||||
|
|
||||||
|
const jpyVal = Number(jpyRaw);
|
||||||
|
const eurVal = Number(eurRaw);
|
||||||
|
|
||||||
|
let jpy = 0;
|
||||||
|
let eur = 0;
|
||||||
|
|
||||||
|
if (jpyVal > 0) {
|
||||||
|
jpy = jpyVal;
|
||||||
|
eur = jpyToEur(jpyVal);
|
||||||
|
} else if (eurVal > 0) {
|
||||||
|
eur = eurVal;
|
||||||
|
jpy = eurToJpy(eurVal);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch("/api/expenses", { method: "POST", body: JSON.stringify({ description, jpy, eur }) });
|
||||||
|
|
||||||
|
elCostDesc.value = "";
|
||||||
|
elCostJpy.value = "";
|
||||||
|
elCostEur.value = "";
|
||||||
|
|
||||||
|
await renderExpenses();
|
||||||
|
});
|
||||||
|
|
||||||
|
(function init() {
|
||||||
|
document.getElementById("rate").textContent = String(EURJPY);
|
||||||
|
renderCalendar(DAYS[0].id);
|
||||||
|
renderDay(DAYS[0].id);
|
||||||
|
renderTodos();
|
||||||
|
renderExpenses();
|
||||||
|
})();
|
||||||
241
static/style.css
Normal file
241
static/style.css
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
:root{
|
||||||
|
--bg:#f7f2ea;
|
||||||
|
--paper:#fffaf2;
|
||||||
|
--ink:#1b1b1b;
|
||||||
|
--muted:#6b6b6b;
|
||||||
|
--line:rgba(27,27,27,.12);
|
||||||
|
--shadow:0 10px 30px rgba(0,0,0,.08);
|
||||||
|
|
||||||
|
/* Japan-inspired accents: vermilion + indigo + gold */
|
||||||
|
--vermilion:#d6453d;
|
||||||
|
--indigo:#1f2a44;
|
||||||
|
--gold:#b08a2e;
|
||||||
|
|
||||||
|
--radius:18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{
|
||||||
|
margin:0;
|
||||||
|
padding:0 0 92px;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 20% -10%, rgba(214,69,61,.10), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 90% 0%, rgba(31,42,68,.10), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
color:var(--ink);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
.page{min-height:100vh}
|
||||||
|
|
||||||
|
.wrap{max-width:860px;margin:0 auto;padding:0 16px}
|
||||||
|
|
||||||
|
.topbar{
|
||||||
|
position:sticky;
|
||||||
|
top:0;
|
||||||
|
z-index:20;
|
||||||
|
background:color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.topbar-inner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0}
|
||||||
|
|
||||||
|
.brand{display:flex;align-items:center;gap:12px}
|
||||||
|
.brand-mark{
|
||||||
|
width:34px;height:34px;border-radius:999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 35% 35%, rgba(255,255,255,.9), rgba(255,255,255,0) 55%),
|
||||||
|
linear-gradient(135deg, var(--vermilion), color-mix(in srgb, var(--vermilion) 55%, var(--indigo)));
|
||||||
|
box-shadow: 0 10px 20px rgba(214,69,61,.18);
|
||||||
|
border:1px solid rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.brand-title{font-weight:900;letter-spacing:.2px}
|
||||||
|
.brand-sub{font-size:12px;color:var(--muted)}
|
||||||
|
|
||||||
|
main{padding:16px 0}
|
||||||
|
|
||||||
|
.card{
|
||||||
|
background:color-mix(in srgb, var(--paper) 92%, white);
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
padding:16px;
|
||||||
|
margin-bottom:14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section{
|
||||||
|
font-size:16px;
|
||||||
|
font-weight:950;
|
||||||
|
margin:0 0 10px;
|
||||||
|
color:var(--indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small{font-size:12px;color:var(--muted)}
|
||||||
|
.muted{font-size:12px;color:var(--muted);margin-top:4px}
|
||||||
|
|
||||||
|
.notice{
|
||||||
|
background: rgba(31,42,68,.06);
|
||||||
|
border:1px solid rgba(31,42,68,.18);
|
||||||
|
color: var(--indigo);
|
||||||
|
border-radius:14px;
|
||||||
|
padding:10px 12px;
|
||||||
|
font-size:12px;
|
||||||
|
line-height:1.5;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.notice--error{
|
||||||
|
background: rgba(214,69,61,.08);
|
||||||
|
border-color: rgba(214,69,61,.25);
|
||||||
|
color: #7a1f1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.btn{
|
||||||
|
appearance:none;
|
||||||
|
border:none;
|
||||||
|
text-decoration:none;
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background: var(--indigo);
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.btn.ghost{
|
||||||
|
background: transparent;
|
||||||
|
color: var(--indigo);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.btn.big-btn{width:100%}
|
||||||
|
|
||||||
|
.toolbar{display:grid;grid-template-columns:1fr auto;gap:10px;margin-bottom:12px}
|
||||||
|
@media(max-width:560px){.toolbar{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
.search{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width:0;
|
||||||
|
}
|
||||||
|
.search input{
|
||||||
|
border:none;
|
||||||
|
outline:none;
|
||||||
|
background:transparent;
|
||||||
|
color:var(--ink);
|
||||||
|
font-size:16px;
|
||||||
|
flex:1;
|
||||||
|
min-height:24px;
|
||||||
|
min-width:0;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpis{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
@media(max-width:560px){.kpis{grid-template-columns:1fr}}
|
||||||
|
.kpi{
|
||||||
|
background: rgba(255,255,255,.55);
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||||
|
.value{font-size:14px;font-weight:900;color:var(--indigo);margin-top:6px;line-height:1.35}
|
||||||
|
|
||||||
|
.meal{
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255,255,255,.55);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.meal h4{margin:0 0 8px;color:var(--indigo);font-size:13px;letter-spacing:.02em}
|
||||||
|
.meal ul{margin:0;padding-left:18px}
|
||||||
|
.meal li{font-size:13px;line-height:1.55;margin:6px 0}
|
||||||
|
|
||||||
|
.timeline{display:grid;gap:10px;margin-top:10px}
|
||||||
|
.trow{display:grid;grid-template-columns:56px 1fr auto;gap:10px;align-items:start}
|
||||||
|
@media(max-width:560px){.trow{grid-template-columns:48px 1fr auto}}
|
||||||
|
.t{font-size:12px;font-weight:950;color:var(--vermilion)}
|
||||||
|
.p{font-size:14px;font-weight:900;color:var(--indigo)}
|
||||||
|
.d{font-size:13px;line-height:1.4;color:var(--muted)}
|
||||||
|
|
||||||
|
.placeLink{color:var(--indigo);text-decoration:none;font-weight:900;border-bottom:1px solid rgba(31,42,68,.25)}
|
||||||
|
.placeLink:hover{border-bottom-color: rgba(31,42,68,.55)}
|
||||||
|
|
||||||
|
.todo-item,.cost-row{display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.todo-item:last-child,.cost-row:last-child{border-bottom:none}
|
||||||
|
.todo-check{display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0}
|
||||||
|
.todo-check input{margin-top:3px}
|
||||||
|
.todo-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
|
||||||
|
.cost-inputs{display:grid;grid-template-columns:1.2fr .9fr .9fr;gap:10px;margin-top:10px;align-items:stretch}
|
||||||
|
.cost-inputs > *{min-width:0}
|
||||||
|
@media(max-width:700px){.cost-inputs{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
.totals{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}
|
||||||
|
|
||||||
|
.footer{
|
||||||
|
position:fixed;
|
||||||
|
left:0;right:0;bottom:0;
|
||||||
|
background: color-mix(in srgb, var(--paper) 88%, transparent);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-top:1px solid var(--line);
|
||||||
|
padding:8px 8px calc(8px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.footer .inner{max-width:860px;margin:0 auto;display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||||
|
.navbtn{
|
||||||
|
text-decoration:none;
|
||||||
|
text-align:center;
|
||||||
|
color: var(--indigo);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 950;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,.55);
|
||||||
|
border:1px solid var(--line);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-offset{scroll-margin-top:84px}
|
||||||
|
|
||||||
|
/* Calendar */
|
||||||
|
.calendar{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:12px}
|
||||||
|
@media(max-width:560px){.calendar{grid-template-columns:1fr}}
|
||||||
|
.cal-day{
|
||||||
|
text-align:left;
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255,255,255,.55);
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.cal-day.is-active{
|
||||||
|
border-color: rgba(214,69,61,.35);
|
||||||
|
box-shadow: 0 12px 26px rgba(214,69,61,.12);
|
||||||
|
}
|
||||||
|
.cal-date{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||||
|
.cal-title{margin-top:6px;font-size:14px;font-weight:950;color:var(--indigo)}
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
.auth-card{max-width:520px;margin:40px auto 0}
|
||||||
|
.form{display:grid;gap:12px;margin-top:10px}
|
||||||
|
.field{display:grid;gap:6px}
|
||||||
|
.field input{
|
||||||
|
border:1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline:none;
|
||||||
|
background: rgba(255,255,255,.7);
|
||||||
|
}
|
||||||
|
.field input:focus{border-color: rgba(31,42,68,.35); box-shadow: 0 0 0 4px rgba(31,42,68,.08)}
|
||||||
17
style.css
Normal file
17
style.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
:root{--bg:#0a1220;--card:#101a2d;--text:#e8eef9;--muted:#97a6bb;--line:#22324a;--primary:#9ecbff;--soft:#16243b;--accent:#132f52}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;padding:0 0 92px;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||||
|
header{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 94%, transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.wrap{max-width:780px;margin:0 auto;padding:0 14px}.brand{padding:14px 0}
|
||||||
|
main{padding:14px 0}.card{background:var(--card);border:1px solid var(--line);border-radius:22px;padding:16px;margin-bottom:14px;overflow:hidden}.section{font-size:17px;font-weight:900;color:var(--primary);margin:0 0 10px}
|
||||||
|
.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{appearance:none;border:none;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:var(--bg);border-radius:16px;padding:13px 15px;font-size:15px;font-weight:900;min-height:46px;cursor:pointer;max-width:100%}.btn.sec{background:var(--card);color:var(--primary);border:1px solid var(--line)}.btn.ghost{background:var(--soft);color:var(--primary)}.big-btn{width:100%;justify-content:center}
|
||||||
|
.toolbar{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}@media(max-width:560px){.toolbar{grid-template-columns:1fr}}.search{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.search input,.search select{border:none;outline:none;background:transparent;color:var(--text);font-size:17px;flex:1;min-height:28px;min-width:0;width:100%}
|
||||||
|
.kpis{display:grid;grid-template-columns:1fr 1fr;gap:8px}.kpi{background:var(--soft);border:1px solid var(--line);border-radius:16px;padding:12px}.label{font-size:11px;color:var(--muted);text-transform:uppercase}.value{font-size:14px;font-weight:900;color:var(--primary);margin-top:5px;line-height:1.35}
|
||||||
|
.nextcard{background:var(--accent);border:1px solid var(--line);border-radius:18px;padding:14px;margin-top:12px}.nextcard .time{font-size:13px;font-weight:900;color:var(--primary)}.nextcard .place{font-size:18px;font-weight:900;color:var(--primary);margin-top:4px}.nextcard .desc{font-size:14px;line-height:1.45;margin-top:6px}
|
||||||
|
.daylist-item{border:1px solid var(--line);border-radius:18px;padding:12px;background:var(--card);margin-bottom:10px}.daylist-item h3{margin:0;color:var(--primary);font-size:16px}.muted{font-size:12px;color:var(--muted);margin-top:4px}
|
||||||
|
.timeline{display:grid;gap:8px;margin-top:10px}.trow{display:grid;grid-template-columns:52px 1fr auto;gap:10px;align-items:start}.t{font-size:13px;font-weight:900;color:var(--primary)}.p{font-size:14px;font-weight:800}.d{font-size:13px;line-height:1.4}
|
||||||
|
.meal{border:1px solid var(--line);border-radius:16px;padding:12px;background:var(--card);margin-top:12px}.meal h4{margin:0 0 6px;color:var(--primary);font-size:13px}.meal ul{margin:0;padding-left:18px}.meal li{font-size:13px;line-height:1.55;margin:4px 0}
|
||||||
|
.footer{position:fixed;left:0;right:0;bottom:0;background:color-mix(in srgb,var(--card) 96%, transparent);backdrop-filter:blur(14px);border-top:1px solid var(--line);padding:8px 8px calc(8px + env(safe-area-inset-bottom))}.footer .inner{max-width:780px;margin:0 auto;display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.navbtn{text-decoration:none;text-align:center;color:var(--primary);font-size:11px;font-weight:900;padding:10px 4px;border-radius:12px;background:none;border:none;min-height:44px}.anchor-offset{scroll-margin-top:84px}
|
||||||
|
.todo-item,.cost-row{display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--line)}.todo-item:last-child,.cost-row:last-child{border-bottom:none}
|
||||||
|
.cost-inputs{display:grid;grid-template-columns:1.2fr .9fr .9fr;gap:8px;margin-top:10px;align-items:stretch}.cost-inputs > *{min-width:0}@media(max-width:700px){.cost-inputs{grid-template-columns:1fr}}
|
||||||
|
.small{font-size:12px;color:var(--muted)} .totals{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
|
||||||
|
.weatherBox{display:flex;justify-content:space-between;gap:10px;align-items:center} .weatherTemp{font-size:22px;font-weight:900;color:var(--primary)} .placeLink{color:var(--primary);text-decoration:none;font-weight:800}
|
||||||
|
.notice{background:#12253e;border:1px solid #284b75;color:#cfe3ff;border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.5;margin-top:10px}
|
||||||
|
@media(max-width:560px){.kpis{grid-template-columns:1fr}.trow{grid-template-columns:44px 1fr auto}}
|
||||||
102
templates/index.html
Normal file
102
templates/index.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Viaggio</title>
|
||||||
|
<meta name="theme-color" content="#f7f2ea" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body class="page">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="wrap topbar-inner">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true"></div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">Viaggio di nozze</div>
|
||||||
|
<div class="brand-sub">Piano, spostamenti, todo e costi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button class="btn ghost" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<section id="calendario" class="anchor-offset card">
|
||||||
|
<h2 class="section">Calendario</h2>
|
||||||
|
<div class="small">Clicca un giorno per vedere il piano (lorem ipsum).</div>
|
||||||
|
<div id="calendar" class="calendar"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="giorno" class="anchor-offset card">
|
||||||
|
<h2 class="section">Giorno selezionato</h2>
|
||||||
|
<div id="dayView"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="todo" class="anchor-offset card">
|
||||||
|
<h2 class="section">To do</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search">
|
||||||
|
<span>+</span>
|
||||||
|
<input id="todoNewText" placeholder="Aggiungi una cosa da fare" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" id="todoAddBtn">Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="todoList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="costi" class="anchor-offset card">
|
||||||
|
<h2 class="section">Costi</h2>
|
||||||
|
<div class="small">Cambio di riferimento: 1 EUR = <span id="rate">182.85</span> JPY</div>
|
||||||
|
|
||||||
|
<div class="cost-inputs">
|
||||||
|
<div class="search">
|
||||||
|
<span>🧾</span>
|
||||||
|
<input id="costDesc" placeholder="Descrizione spesa" />
|
||||||
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<span>¥</span>
|
||||||
|
<input id="costJpy" type="number" min="0" step="1" placeholder="Importo JPY" />
|
||||||
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<span>€</span>
|
||||||
|
<input id="costEur" type="number" min="0" step="0.01" placeholder="Importo EUR" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="margin-top: 10px">
|
||||||
|
<button class="btn" id="addCostBtn">Aggiungi spesa</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="costList"></div>
|
||||||
|
|
||||||
|
<div class="totals">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="label">Totale JPY</div>
|
||||||
|
<div class="value" id="totalJpy">¥0</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="label">Totale EUR</div>
|
||||||
|
<div class="value" id="totalEur">€0,00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="footer">
|
||||||
|
<div class="inner">
|
||||||
|
<a class="navbtn" href="#calendario">Calendario</a>
|
||||||
|
<a class="navbtn" href="#giorno">Giorno</a>
|
||||||
|
<a class="navbtn" href="#todo">To do</a>
|
||||||
|
<a class="navbtn" href="#costi">Costi</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
templates/login.html
Normal file
45
templates/login.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Login · Viaggio</title>
|
||||||
|
<meta name="theme-color" content="#f7f2ea" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body class="page">
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="card auth-card">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true"></div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">Viaggio di nozze</div>
|
||||||
|
<div class="brand-sub">Accesso riservato</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notice notice--error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login" class="form">
|
||||||
|
<label class="field">
|
||||||
|
<div class="label">Username</div>
|
||||||
|
<input name="username" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<div class="label">Password</div>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn big-btn" type="submit">Entra</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="small" style="margin-top: 10px">
|
||||||
|
Suggerimento: questo sito non espone porte verso l'host; sta dietro reverse proxy.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user