From 8992811486e364c767047cfcefe7e4dc0ba8899a Mon Sep 17 00:00:00 2001 From: Enrico Date: Tue, 17 Mar 2026 20:59:58 +0100 Subject: [PATCH] first commit --- .gitignore | 5 + Dockerfile | 29 ++++ README.md | 38 +++++ README.txt | 2 + app.js | 189 +++++++++++++++++++++++ docker-compose.yml | 24 +++ index.html | 11 ++ requirements.txt | 3 + server.py | 331 +++++++++++++++++++++++++++++++++++++++ static/app.js | 359 +++++++++++++++++++++++++++++++++++++++++++ static/style.css | 241 +++++++++++++++++++++++++++++ style.css | 17 ++ templates/index.html | 102 ++++++++++++ templates/login.html | 45 ++++++ 14 files changed, 1396 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 README.txt create mode 100644 app.js create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 style.css create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c4f490 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +/data/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..baa6f1b --- /dev/null +++ b/Dockerfile @@ -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", "-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..85cfb83 --- /dev/null +++ b/README.md @@ -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) diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..0f7b52f --- /dev/null +++ b/README.txt @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..71df30c --- /dev/null +++ b/app.js @@ -0,0 +1,189 @@ +const DAYS = [{"id": "d14", "date": "14 giugno", "title": "Tokyo moderna", "city": "Tokyo", "base": "Asakusa", "summary": "Arrivo, Meiji Jingu, Shibuya e Shinjuku.", "budget_jpy": [18000, 28000], "hint": "Non correte: primo highlight Meiji Jingu.", "weather_city": "Tokyo, Japan", "timeline": [["08:50", "Narita", "Arrivo", "Narita International Airport"], ["10:30", "Asakusa", "Check-in", "AMANEK Asakusa Sakurabashi"], ["15:00", "Meiji Jingu", "Passeggiata", "Meiji Jingu"], ["18:00", "Shibuya", "Crossing", "Shibuya Crossing"], ["19:45", "Shinjuku", "Cena", "Omoide Yokocho"]], "lunch": {"area": "Asakusa", "places": [{"name": "Yoshikami", "query": "Yoshikami Asakusa Tokyo"}, {"name": "Daikokuya Tempura", "query": "Daikokuya Tempura Asakusa Tokyo"}, {"name": "Opzione pranzo 3 · Asakusa", "query": "lunch Asakusa Tokyo Japan"}, {"name": "Opzione pranzo 4 · Asakusa", "query": "lunch Asakusa Tokyo Japan"}, {"name": "Opzione pranzo 5 · Asakusa", "query": "lunch Asakusa Tokyo Japan"}]}, "dinner": {"area": "Shinjuku", "places": [{"name": "Kamo to Negi", "query": "Kamo to Negi Shinjuku Tokyo"}, {"name": "Tsunahachi", "query": "Tsunahachi Shinjuku Tokyo"}, {"name": "Opzione cena 3 · Shinjuku", "query": "dinner Shinjuku Tokyo Japan"}, {"name": "Opzione cena 4 · Shinjuku", "query": "dinner Shinjuku Tokyo Japan"}, {"name": "Opzione cena 5 · Shinjuku", "query": "dinner Shinjuku Tokyo Japan"}]}, "onsen": [{"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Mikokuyu (sento)", "query": "Mikokuyu Sumida Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}, {"id": "d15", "date": "15 giugno", "title": "Tokyo tradizionale", "city": "Tokyo", "base": "Asakusa", "summary": "Senso-ji, Skytree, Ueno e Akihabara.", "budget_jpy": [16000, 30000], "hint": "Entrate presto a Senso-ji.", "weather_city": "Tokyo, Japan", "timeline": [["09:00", "Senso-ji", "Tempio e Nakamise", "Senso-ji"], ["11:00", "Tokyo Skytree", "Vista", "Tokyo Skytree"], ["14:40", "Ueno Park", "Passeggiata", "Ueno Park"], ["17:30", "Akihabara", "Anime ed elettronica", "Akihabara Station"]], "lunch": {"area": "Asakusa / Solamachi", "places": [{"name": "Sushi Zanmai Asakusa", "query": "Sushi Zanmai Asakusa Tokyo"}, {"name": "Tokyo Solamachi food hall", "query": "Tokyo Solamachi Tokyo"}, {"name": "Opzione pranzo 3 · Asakusa / Solamachi", "query": "lunch Asakusa / Solamachi Tokyo Japan"}, {"name": "Opzione pranzo 4 · Asakusa / Solamachi", "query": "lunch Asakusa / Solamachi Tokyo Japan"}, {"name": "Opzione pranzo 5 · Asakusa / Solamachi", "query": "lunch Asakusa / Solamachi Tokyo Japan"}]}, "dinner": {"area": "Akihabara / Asakusa", "places": [{"name": "Gyukatsu Motomura Akihabara", "query": "Gyukatsu Motomura Akihabara Tokyo"}, {"name": "Izakaya vicino ad Asakusa", "query": "Izakaya Asakusa Tokyo"}, {"name": "Opzione cena 3 · Akihabara / Asakusa", "query": "dinner Akihabara / Asakusa Tokyo Japan"}, {"name": "Opzione cena 4 · Akihabara / Asakusa", "query": "dinner Akihabara / Asakusa Tokyo Japan"}, {"name": "Opzione cena 5 · Akihabara / Asakusa", "query": "dinner Akihabara / Asakusa Tokyo Japan"}]}, "onsen": [{"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Daikokuyu (Asakusa area)", "query": "Daikokuyu Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}, {"id": "d16", "date": "16 giugno", "title": "teamLab + Tsukiji + Odaiba", "city": "Tokyo", "base": "Asakusa", "summary": "Arte immersiva, mercato, giardini e waterfront.", "budget_jpy": [22000, 36000], "hint": "teamLab va vissuto con calma.", "weather_city": "Tokyo, Japan", "timeline": [["09:30", "teamLab Planets", "Ingresso prenotato", "teamLab Planets TOKYO"], ["12:30", "Tsukiji", "Pranzo seafood", "Tsukiji Outer Market"], ["14:30", "Hama-rikyu", "Giardini", "Hama-rikyu Gardens"], ["16:40", "Odaiba", "Passeggiata", "The Gundam Base Tokyo"]], "lunch": {"area": "Tsukiji", "places": [{"name": "Sushizanmai Tsukiji", "query": "Sushizanmai Tsukiji Tokyo"}, {"name": "Tsukiji Itadori Bekkan", "query": "Tsukiji Itadori Bekkan Tokyo"}, {"name": "Opzione pranzo 3 · Tsukiji", "query": "lunch Tsukiji Tokyo Japan"}, {"name": "Opzione pranzo 4 · Tsukiji", "query": "lunch Tsukiji Tokyo Japan"}, {"name": "Opzione pranzo 5 · Tsukiji", "query": "lunch Tsukiji Tokyo Japan"}]}, "dinner": {"area": "Odaiba / Asakusa", "places": [{"name": "Bills Odaiba", "query": "Bills Odaiba Tokyo"}, {"name": "Ramen rientro Asakusa", "query": "Ramen Asakusa Tokyo"}, {"name": "Opzione cena 3 · Odaiba / Asakusa", "query": "dinner Odaiba / Asakusa Tokyo Japan"}, {"name": "Opzione cena 4 · Odaiba / Asakusa", "query": "dinner Odaiba / Asakusa Tokyo Japan"}, {"name": "Opzione cena 5 · Odaiba / Asakusa", "query": "dinner Odaiba / Asakusa Tokyo Japan"}]}, "onsen": [{"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Daikokuyu (Asakusa area)", "query": "Daikokuyu Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}, {"id": "d17", "date": "17 giugno", "title": "Kamakura", "city": "Kamakura", "base": "Asakusa", "summary": "Grande Buddha, Hasedera, centro storico e spiaggia.", "budget_jpy": [18000, 30000], "hint": "Non riempite troppo la giornata.", "weather_city": "Kamakura, Japan", "timeline": [["07:45", "Kamakura", "Partenza presto", "Kamakura Station"], ["10:00", "Kotoku-in", "Grande Buddha", "Kotoku-in"], ["11:00", "Hasedera", "Tempio e giardini", "Hasedera Temple"], ["14:00", "Komachi-dori", "Passeggiata", "Komachi-dori Street"], ["15:30", "Yuigahama", "Relax", "Yuigahama Beach"]], "lunch": {"area": "Kamakura", "places": [{"name": "Matsubara-an Komachi", "query": "Matsubara-an Komachi Kamakura"}, {"name": "Shirasu-don Komachi-dori", "query": "Shirasu don Komachi dori Kamakura"}, {"name": "Opzione pranzo 3 · Kamakura", "query": "lunch Kamakura Kamakura Japan"}, {"name": "Opzione pranzo 4 · Kamakura", "query": "lunch Kamakura Kamakura Japan"}, {"name": "Opzione pranzo 5 · Kamakura", "query": "lunch Kamakura Kamakura Japan"}]}, "dinner": {"area": "Asakusa", "places": [{"name": "Cena semplice Asakusa", "query": "Restaurant Asakusa Tokyo"}, {"name": "Soba Asakusa", "query": "Soba Asakusa Tokyo"}, {"name": "Opzione cena 3 · Asakusa", "query": "dinner Asakusa Kamakura Japan"}, {"name": "Opzione cena 4 · Asakusa", "query": "dinner Asakusa Kamakura Japan"}, {"name": "Opzione cena 5 · Asakusa", "query": "dinner Asakusa Kamakura Japan"}]}, "onsen": [{"name": "Inamuragasaki Onsen", "query": "Inamuragasaki Onsen Kamakura"}, {"name": "Enoshima Island Spa", "query": "Enoshima Island Spa"}, {"name": "Tattoo friendly bath near Kamakura", "query": "tattoo friendly bath Kamakura"}], "love_hotels": [{"name": "Love hotel in zona", "query": "love hotel Kamakura Japan"}, {"name": "Adult only hotel in zona", "query": "adult only hotel Kamakura Japan"}, {"name": "Short stay hotel in zona", "query": "short stay hotel Kamakura Japan"}]}, {"id": "d18", "date": "18 giugno", "title": "Tokyo → Kanazawa", "city": "Kanazawa", "base": "Kanazawa", "summary": "Shinkansen, Omicho Market e quartiere samurai.", "budget_jpy": [38000, 55000], "hint": "Mercato + quartiere samurai bastano.", "weather_city": "Kanazawa, Japan", "timeline": [["08:30", "Kanazawa", "Shinkansen", "Kanazawa Station"], ["12:15", "Omicho Market", "Pranzo", "Omicho Market"], ["14:30", "Nagamachi", "Passeggiata quartiere samurai", "Nagamachi Samurai District"], ["19:30", "Katamachi", "Cena", "Katamachi"]], "lunch": {"area": "Omicho Market", "places": [{"name": "Mori Mori Sushi", "query": "Mori Mori Sushi Omicho Kanazawa"}, {"name": "Iki-Iki Tei", "query": "Iki-Iki Tei Omicho Kanazawa"}, {"name": "Opzione pranzo 3 · Omicho Market", "query": "lunch Omicho Market Kanazawa Japan"}, {"name": "Opzione pranzo 4 · Omicho Market", "query": "lunch Omicho Market Kanazawa Japan"}, {"name": "Opzione pranzo 5 · Omicho Market", "query": "lunch Omicho Market Kanazawa Japan"}]}, "dinner": {"area": "Katamachi", "places": [{"name": "Izakaya Katamachi", "query": "Izakaya Katamachi Kanazawa"}, {"name": "Sushi di Kanazawa", "query": "Sushi Katamachi Kanazawa"}, {"name": "Opzione cena 3 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}, {"name": "Opzione cena 4 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}, {"name": "Opzione cena 5 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}]}, "onsen": [{"name": "Kashikiri / private bath Kanazawa", "query": "private onsen Kanazawa"}, {"name": "Ryokan private bath Kanazawa", "query": "ryokan private bath Kanazawa"}, {"name": "Tattoo friendly sento Kanazawa", "query": "tattoo friendly sento Kanazawa"}], "love_hotels": [{"name": "Love hotel in zona", "query": "love hotel Kanazawa Japan"}, {"name": "Adult only hotel in zona", "query": "adult only hotel Kanazawa Japan"}, {"name": "Short stay hotel in zona", "query": "short stay hotel Kanazawa Japan"}]}, {"id": "d19", "date": "19 giugno", "title": "Kanazawa classica", "city": "Kanazawa", "base": "Kanazawa", "summary": "Kenroku-en, castello e Higashi Chaya.", "budget_jpy": [18000, 35000], "hint": "Kenroku-en va fatto lentamente.", "weather_city": "Kanazawa, Japan", "timeline": [["09:15", "Kenroku-en", "Visita", "Kenroku-en"], ["11:30", "Kanazawa Castle", "Passaggio dal giardino al castello", "Kanazawa Castle"], ["13:10", "Higashi Chaya", "Passeggiata e tè", "Higashi Chaya District"], ["18:30", "Centro", "Cena", "Katamachi"]], "lunch": {"area": "Centro / Higashi Chaya", "places": [{"name": "Soba in centro", "query": "Soba Kanazawa city center"}, {"name": "Omicho Market bis", "query": "Omicho Market Kanazawa"}, {"name": "Opzione pranzo 3 · Centro / Higashi Chaya", "query": "lunch Centro / Higashi Chaya Kanazawa Japan"}, {"name": "Opzione pranzo 4 · Centro / Higashi Chaya", "query": "lunch Centro / Higashi Chaya Kanazawa Japan"}, {"name": "Opzione pranzo 5 · Centro / Higashi Chaya", "query": "lunch Centro / Higashi Chaya Kanazawa Japan"}]}, "dinner": {"area": "Katamachi", "places": [{"name": "Izakaya Katamachi", "query": "Izakaya Katamachi Kanazawa"}, {"name": "Sushi di Kanazawa", "query": "Sushi Kanazawa Katamachi"}, {"name": "Opzione cena 3 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}, {"name": "Opzione cena 4 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}, {"name": "Opzione cena 5 · Katamachi", "query": "dinner Katamachi Kanazawa Japan"}]}, "onsen": [{"name": "Kashikiri / private bath Kanazawa", "query": "private onsen Kanazawa"}, {"name": "Ryokan private bath Kanazawa", "query": "ryokan private bath Kanazawa"}, {"name": "Tattoo friendly sento Kanazawa", "query": "tattoo friendly sento Kanazawa"}], "love_hotels": [{"name": "Love hotel in zona", "query": "love hotel Kanazawa Japan"}, {"name": "Adult only hotel in zona", "query": "adult only hotel Kanazawa Japan"}, {"name": "Short stay hotel in zona", "query": "short stay hotel Kanazawa Japan"}]}, {"id": "d20", "date": "20 giugno", "title": "Shirakawa-go + Kyoto", "city": "Kyoto", "base": "Kyoto", "summary": "Villaggio UNESCO e trasferimento a Kyoto.", "budget_jpy": [35000, 50000], "hint": "Fate il viewpoint e poi rilassatevi.", "weather_city": "Kyoto, Japan", "timeline": [["08:10", "Shirakawa-go", "Bus espresso", "Shirakawa-go Bus Terminal"], ["09:35", "Shirakawa-go", "Villaggio + viewpoint", "Shirakawa-go"], ["16:00", "Kyoto", "Trasferimento", "Kyoto Station"], ["18:30", "Kyoto", "Check-in", "M's Plus Shijo Omiya"]], "lunch": {"area": "Shirakawa-go", "places": [{"name": "Irori", "query": "Irori Shirakawa-go"}, {"name": "Soba nel villaggio", "query": "Soba Shirakawa-go"}, {"name": "Opzione pranzo 3 · Shirakawa-go", "query": "lunch Shirakawa-go Kyoto Japan"}, {"name": "Opzione pranzo 4 · Shirakawa-go", "query": "lunch Shirakawa-go Kyoto Japan"}, {"name": "Opzione pranzo 5 · Shirakawa-go", "query": "lunch Shirakawa-go Kyoto Japan"}]}, "dinner": {"area": "Kyoto hotel area", "places": [{"name": "Ramen vicino all’hotel", "query": "Ramen Shijo Omiya Kyoto"}, {"name": "Izakaya vicino all’hotel", "query": "Izakaya Shijo Omiya Kyoto"}, {"name": "Opzione cena 3 · Kyoto hotel area", "query": "dinner Kyoto hotel area Kyoto Japan"}, {"name": "Opzione cena 4 · Kyoto hotel area", "query": "dinner Kyoto hotel area Kyoto Japan"}, {"name": "Opzione cena 5 · Kyoto hotel area", "query": "dinner Kyoto hotel area Kyoto Japan"}]}, "onsen": [{"name": "Arashiyama Onsen Kadensho", "query": "Arashiyama Onsen Kadensho Kyoto"}, {"name": "Hanaikada", "query": "Hanaikada Arashiyama Kyoto"}, {"name": "Fufu no Yu", "query": "Fufu no Yu Kyoto"}], "love_hotels": [{"name": "Hotel In The Green", "query": "Hotel In The Green Kyoto"}, {"name": "Hotel Chronos - Adult Only", "query": "Hotel Chronos Kyoto"}, {"name": "Hotel & Spa Lotus", "query": "Hotel & Spa Lotus Kyoto"}]}, {"id": "d21", "date": "21 giugno", "title": "Arashiyama + Kinkaku-ji", "city": "Kyoto", "base": "Kyoto", "summary": "Bamboo grove, Tenryu-ji, onsen e Kinkaku-ji.", "budget_jpy": [18000, 34000], "hint": "Bamboo grove presto, poi rallentate.", "weather_city": "Kyoto, Japan", "timeline": [["08:30", "Arashiyama", "Randen", "Arashiyama Bamboo Grove"], ["09:50", "Tenryu-ji", "Tempio e giardini", "Tenryu-ji"], ["13:15", "Fufu-no-yu", "Onsen", "Fufu-no-yu"], ["16:45", "Kinkaku-ji", "Visita", "Kinkaku-ji"]], "lunch": {"area": "Arashiyama", "places": [{"name": "Saga Tofu Ine", "query": "Saga Tofu Ine Arashiyama Kyoto"}, {"name": "Arashiyama Yoshimura", "query": "Arashiyama Yoshimura Kyoto"}, {"name": "Opzione pranzo 3 · Arashiyama", "query": "lunch Arashiyama Kyoto Japan"}, {"name": "Opzione pranzo 4 · Arashiyama", "query": "lunch Arashiyama Kyoto Japan"}, {"name": "Opzione pranzo 5 · Arashiyama", "query": "lunch Arashiyama Kyoto Japan"}]}, "dinner": {"area": "Centro Kyoto", "places": [{"name": "Ramen in centro", "query": "Ramen central Kyoto"}, {"name": "Izakaya centro Kyoto", "query": "Izakaya central Kyoto"}, {"name": "Opzione cena 3 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}, {"name": "Opzione cena 4 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}, {"name": "Opzione cena 5 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}]}, "onsen": [{"name": "Arashiyama Onsen Kadensho", "query": "Arashiyama Onsen Kadensho Kyoto"}, {"name": "Hanaikada", "query": "Hanaikada Arashiyama Kyoto"}, {"name": "Fufu no Yu", "query": "Fufu no Yu Kyoto"}], "love_hotels": [{"name": "Hotel In The Green", "query": "Hotel In The Green Kyoto"}, {"name": "Hotel Chronos - Adult Only", "query": "Hotel Chronos Kyoto"}, {"name": "Hotel & Spa Lotus", "query": "Hotel & Spa Lotus Kyoto"}]}, {"id": "d22", "date": "22 giugno", "title": "Nara + Fushimi Inari + Gion", "city": "Kyoto", "base": "Kyoto", "summary": "Nara, Grande Buddha, torii rossi e Gion.", "budget_jpy": [20000, 38000], "hint": "Salite un po’ a Fushimi Inari.", "weather_city": "Kyoto, Japan", "timeline": [["09:00", "Nara", "Trasferimento", "Nara Park"], ["10:50", "Todai-ji", "Grande Buddha", "Todai-ji"], ["15:30", "Fushimi Inari", "Torii rossi", "Fushimi Inari Taisha"], ["19:00", "Gion/Pontocho", "Cena e passeggiata", "Gion Kyoto"]], "lunch": {"area": "Nara", "places": [{"name": "Set lunch vicino al parco", "query": "Lunch Nara Park"}, {"name": "Soba a Nara", "query": "Soba Nara Park"}, {"name": "Opzione pranzo 3 · Nara", "query": "lunch Nara Kyoto Japan"}, {"name": "Opzione pranzo 4 · Nara", "query": "lunch Nara Kyoto Japan"}, {"name": "Opzione pranzo 5 · Nara", "query": "lunch Nara Kyoto Japan"}]}, "dinner": {"area": "Gion / Pontocho", "places": [{"name": "Cena speciale Gion", "query": "Restaurant Gion Kyoto"}, {"name": "Cena Pontocho", "query": "Restaurant Pontocho Kyoto"}, {"name": "Opzione cena 3 · Gion / Pontocho", "query": "dinner Gion / Pontocho Kyoto Japan"}, {"name": "Opzione cena 4 · Gion / Pontocho", "query": "dinner Gion / Pontocho Kyoto Japan"}, {"name": "Opzione cena 5 · Gion / Pontocho", "query": "dinner Gion / Pontocho Kyoto Japan"}]}, "onsen": [{"name": "Arashiyama Onsen Kadensho", "query": "Arashiyama Onsen Kadensho Kyoto"}, {"name": "Hanaikada", "query": "Hanaikada Arashiyama Kyoto"}, {"name": "Fufu no Yu", "query": "Fufu no Yu Kyoto"}], "love_hotels": [{"name": "Hotel In The Green", "query": "Hotel In The Green Kyoto"}, {"name": "Hotel Chronos - Adult Only", "query": "Hotel Chronos Kyoto"}, {"name": "Hotel & Spa Lotus", "query": "Hotel & Spa Lotus Kyoto"}]}, {"id": "d23", "date": "23 giugno", "title": "Kyoto storica + Nishiki + Otagi", "city": "Kyoto", "base": "Kyoto", "summary": "Castello, mercato e tempio laterale pieno di carattere.", "budget_jpy": [18000, 35000], "hint": "Otagi è il vero jolly.", "weather_city": "Kyoto, Japan", "timeline": [["09:00", "Nijo Castle", "Ingresso presto", "Nijo Castle"], ["12:00", "Nishiki Market", "Pranzo diffuso", "Nishiki Market"], ["15:00", "Otagi Nenbutsu-ji", "Visita del tempio", "Otagi Nenbutsu-ji"], ["19:30", "Centro", "Cena", "Shijo Kyoto"]], "lunch": {"area": "Nishiki Market", "places": [{"name": "Assaggi a Nishiki", "query": "Nishiki Market Kyoto"}, {"name": "Pranzo diffuso Nishiki", "query": "Nishiki Market lunch Kyoto"}, {"name": "Opzione pranzo 3 · Nishiki Market", "query": "lunch Nishiki Market Kyoto Japan"}, {"name": "Opzione pranzo 4 · Nishiki Market", "query": "lunch Nishiki Market Kyoto Japan"}, {"name": "Opzione pranzo 5 · Nishiki Market", "query": "lunch Nishiki Market Kyoto Japan"}]}, "dinner": {"area": "Centro Kyoto", "places": [{"name": "Kurakura", "query": "Kurakura Kyoto"}, {"name": "Piccolo izakaya centro", "query": "Izakaya central Kyoto"}, {"name": "Opzione cena 3 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}, {"name": "Opzione cena 4 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}, {"name": "Opzione cena 5 · Centro Kyoto", "query": "dinner Centro Kyoto Kyoto Japan"}]}, "onsen": [{"name": "Arashiyama Onsen Kadensho", "query": "Arashiyama Onsen Kadensho Kyoto"}, {"name": "Hanaikada", "query": "Hanaikada Arashiyama Kyoto"}, {"name": "Fufu no Yu", "query": "Fufu no Yu Kyoto"}], "love_hotels": [{"name": "Hotel In The Green", "query": "Hotel In The Green Kyoto"}, {"name": "Hotel Chronos - Adult Only", "query": "Hotel Chronos Kyoto"}, {"name": "Hotel & Spa Lotus", "query": "Hotel & Spa Lotus Kyoto"}]}, {"id": "d24", "date": "24 giugno", "title": "Kobe + Osaka", "city": "Osaka", "base": "Kyoto", "summary": "Kobe beef, castello e neon.", "budget_jpy": [30000, 65000], "hint": "Il momento simbolico è il pranzo a Kobe.", "weather_city": "Osaka, Japan", "timeline": [["09:30", "Kobe", "JR Special Rapid", "Kitanocho Kobe"], ["12:30", "Kobe", "Pranzo Kobe beef", "Sannomiya Kobe"], ["16:00", "Osaka Castle", "Passeggiata", "Osaka Castle"], ["18:30", "Dotonbori", "Cena e street food", "Dotonbori"]], "lunch": {"area": "Kobe", "places": [{"name": "Steakland Kobe", "query": "Steakland Kobe"}, {"name": "Mouriya", "query": "Mouriya Kobe"}, {"name": "Opzione pranzo 3 · Kobe", "query": "lunch Kobe Osaka Japan"}, {"name": "Opzione pranzo 4 · Kobe", "query": "lunch Kobe Osaka Japan"}, {"name": "Opzione pranzo 5 · Kobe", "query": "lunch Kobe Osaka Japan"}]}, "dinner": {"area": "Dotonbori", "places": [{"name": "Akaoni Takoyaki", "query": "Akaoni Dotonbori Osaka"}, {"name": "Ajinoya Honten", "query": "Ajinoya Honten Osaka"}, {"name": "Opzione cena 3 · Dotonbori", "query": "dinner Dotonbori Osaka Japan"}, {"name": "Opzione cena 4 · Dotonbori", "query": "dinner Dotonbori Osaka Japan"}, {"name": "Opzione cena 5 · Dotonbori", "query": "dinner Dotonbori Osaka Japan"}]}, "onsen": [{"name": "Solaniwa Onsen", "query": "Solaniwa Onsen Osaka"}, {"name": "Spa World", "query": "Spa World Osaka"}, {"name": "Naniwa no Yu", "query": "Naniwa no Yu Osaka"}], "love_hotels": [{"name": "Hotel Salle de bain", "query": "Hotel Salle de bain Osaka"}, {"name": "Hotel Lotus Umeda", "query": "Hotel Lotus Umeda Osaka"}, {"name": "HOTEL FIVE plus", "query": "HOTEL FIVE plus Osaka"}]}, {"id": "d25", "date": "25 giugno", "title": "Kyoto → Tokyo + Shibuya Sky", "city": "Tokyo", "base": "Asakusa", "summary": "Rientro morbido e grande tramonto finale su Tokyo.", "budget_jpy": [32000, 55000], "hint": "Shibuya Sky è il cuore della giornata.", "weather_city": "Tokyo, Japan", "timeline": [["10:00", "Tokyo", "Shinkansen", "Tokyo Station"], ["12:30", "Asakusa", "Transfer hotel", "AMANEK Asakusa Sakurabashi"], ["17:30", "Shibuya Sky", "Ingresso al tramonto", "SHIBUYA SKY"], ["19:15", "Shibuya", "Cena", "Shibuya Crossing"]], "lunch": {"area": "Asakusa / Tokyo Station", "places": [{"name": "Lunch veloce Tokyo Station", "query": "Tokyo Station lunch"}, {"name": "Lunch veloce Asakusa", "query": "Lunch Asakusa Tokyo"}, {"name": "Opzione pranzo 3 · Asakusa / Tokyo Station", "query": "lunch Asakusa / Tokyo Station Tokyo Japan"}, {"name": "Opzione pranzo 4 · Asakusa / Tokyo Station", "query": "lunch Asakusa / Tokyo Station Tokyo Japan"}, {"name": "Opzione pranzo 5 · Asakusa / Tokyo Station", "query": "lunch Asakusa / Tokyo Station Tokyo Japan"}]}, "dinner": {"area": "Shibuya", "places": [{"name": "Bistrot a Shibuya", "query": "Bistro Shibuya Tokyo"}, {"name": "Sushi easy Shibuya", "query": "Sushi Shibuya Tokyo"}, {"name": "Opzione cena 3 · Shibuya", "query": "dinner Shibuya Tokyo Japan"}, {"name": "Opzione cena 4 · Shibuya", "query": "dinner Shibuya Tokyo Japan"}, {"name": "Opzione cena 5 · Shibuya", "query": "dinner Shibuya Tokyo Japan"}]}, "onsen": [{"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Mikokuyu (sento)", "query": "Mikokuyu Sumida Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}, {"id": "d26", "date": "26 giugno", "title": "Nikko", "city": "Nikko", "base": "Asakusa", "summary": "Santuari nel bosco e passeggiata a Kanmangafuchi.", "budget_jpy": [22000, 38000], "hint": "Toshogu e Kanmangafuchi bastano.", "weather_city": "Nikko, Japan", "timeline": [["08:30", "Tobu Nikko", "Treno", "Tobu Asakusa Station"], ["11:00", "Nikko Toshogu", "Visita principale", "Nikko Toshogu"], ["14:45", "Kanmangafuchi", "Passeggiata", "Kanmangafuchi Abyss"], ["16:20", "Rientro", "Verso Asakusa", "AMANEK Asakusa Sakurabashi"]], "lunch": {"area": "Nikko", "places": [{"name": "Hippari Dako", "query": "Hippari Dako Nikko"}, {"name": "Yuba lunch Nikko", "query": "Yuba Nikko"}, {"name": "Opzione pranzo 3 · Nikko", "query": "lunch Nikko Nikko Japan"}, {"name": "Opzione pranzo 4 · Nikko", "query": "lunch Nikko Nikko Japan"}, {"name": "Opzione pranzo 5 · Nikko", "query": "lunch Nikko Nikko Japan"}]}, "dinner": {"area": "Asakusa", "places": [{"name": "Cena tranquilla Asakusa", "query": "Restaurant Asakusa Tokyo"}, {"name": "Soba Asakusa", "query": "Soba Asakusa Tokyo"}, {"name": "Opzione cena 3 · Asakusa", "query": "dinner Asakusa Nikko Japan"}, {"name": "Opzione cena 4 · Asakusa", "query": "dinner Asakusa Nikko Japan"}, {"name": "Opzione cena 5 · Asakusa", "query": "dinner Asakusa Nikko Japan"}]}, "onsen": [{"name": "Nikko Station Hotel Classic onsen", "query": "Nikko Station Hotel Classic onsen"}, {"name": "Tattoo friendly private bath Nikko", "query": "private bath Nikko"}, {"name": "Onsen near Tobu Nikko", "query": "onsen Tobu Nikko"}], "love_hotels": [{"name": "Love hotel in zona", "query": "love hotel Nikko Japan"}, {"name": "Adult only hotel in zona", "query": "adult only hotel Nikko Japan"}, {"name": "Short stay hotel in zona", "query": "short stay hotel Nikko Japan"}]}, {"id": "d27", "date": "27 giugno", "title": "Yanaka + ultimo giorno", "city": "Tokyo", "base": "Asakusa", "summary": "Tokyo bassa, ultimi acquisti e cena finale.", "budget_jpy": [20000, 45000], "hint": "Tenete energia per la cena d’addio.", "weather_city": "Tokyo, Japan", "timeline": [["10:00", "Yanaka Ginza", "Passeggiata lenta", "Yanaka Ginza"], ["12:00", "Pranzo", "Yanaka / Ueno / Asakusa", "Yanaka Ginza"], ["16:00", "Asakusa", "Ultimi acquisti", "Senso-ji"], ["19:30", "Cena finale", "Teppanyaki o sushi", "Asakusa"]], "lunch": {"area": "Yanaka / Ueno", "places": [{"name": "Lunch informale Yanaka", "query": "Lunch Yanaka Tokyo"}, {"name": "Opzione semplice Ueno", "query": "Lunch Ueno Tokyo"}, {"name": "Opzione pranzo 3 · Yanaka / Ueno", "query": "lunch Yanaka / Ueno Tokyo Japan"}, {"name": "Opzione pranzo 4 · Yanaka / Ueno", "query": "lunch Yanaka / Ueno Tokyo Japan"}, {"name": "Opzione pranzo 5 · Yanaka / Ueno", "query": "lunch Yanaka / Ueno Tokyo Japan"}]}, "dinner": {"area": "Asakusa", "places": [{"name": "Teppanyaki finale", "query": "Teppanyaki Asakusa Tokyo"}, {"name": "Sushi finale", "query": "Sushi Asakusa Tokyo"}, {"name": "Opzione cena 3 · Asakusa", "query": "dinner Asakusa Tokyo Japan"}, {"name": "Opzione cena 4 · Asakusa", "query": "dinner Asakusa Tokyo Japan"}, {"name": "Opzione cena 5 · Asakusa", "query": "dinner Asakusa Tokyo Japan"}]}, "onsen": [{"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Daikokuyu (Asakusa area)", "query": "Daikokuyu Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}, {"id": "d28", "date": "28 giugno", "title": "Rientro", "city": "Tokyo", "base": "Narita / volo", "summary": "Uscita semplice e senza stress.", "budget_jpy": [3000, 8000], "hint": "La prossima tappa è arrivare bene in aeroporto.", "weather_city": "Tokyo, Japan", "timeline": [["07:15", "Check-out", "Partenza con anticipo", "AMANEK Asakusa Sakurabashi"], ["07:30", "Narita", "Transfer aeroporto", "Narita International Airport"], ["09:00", "Narita", "Check-in e controlli", "Narita International Airport"], ["11:10", "Volo", "Partenza", "Narita International Airport"]], "lunch": {"area": "Aeroporto / volo", "places": [{"name": "Snack aeroporto", "query": "Narita Airport restaurants"}, {"name": "Food court Narita", "query": "Narita Airport food court"}, {"name": "Opzione pranzo 3 · Aeroporto / volo", "query": "lunch Aeroporto / volo Tokyo Japan"}, {"name": "Opzione pranzo 4 · Aeroporto / volo", "query": "lunch Aeroporto / volo Tokyo Japan"}, {"name": "Opzione pranzo 5 · Aeroporto / volo", "query": "lunch Aeroporto / volo Tokyo Japan"}]}, "dinner": {"area": "Volo / rientro", "places": [{"name": "Pasto in volo", "query": "Narita International Airport"}, {"name": "Opzione cena 2 · Volo / rientro", "query": "dinner Volo / rientro Tokyo Japan"}, {"name": "Opzione cena 3 · Volo / rientro", "query": "dinner Volo / rientro Tokyo Japan"}, {"name": "Opzione cena 4 · Volo / rientro", "query": "dinner Volo / rientro Tokyo Japan"}, {"name": "Opzione cena 5 · Volo / rientro", "query": "dinner Volo / rientro Tokyo Japan"}]}, "onsen": [{"name": "Asakusa ROX Matsuri-yu", "query": "Asakusa ROX Matsuri-yu Tokyo"}, {"name": "Thermae-Yu Shinjuku", "query": "Thermae-Yu Shinjuku Tokyo"}, {"name": "Daikokuyu (Asakusa area)", "query": "Daikokuyu Tokyo"}], "love_hotels": [{"name": "Bali An Resort Shinjuku Forest", "query": "Hotel Bali An Resort Shinjuku Forest Tokyo"}, {"name": "HOTEL & SPA AN Shinjuku Kabukicho", "query": "HOTEL & SPA AN Shinjuku Kabukicho Tokyo"}, {"name": "HOTEL & SPA J-MEX Shinjuku", "query": "HOTEL & SPA J-MEX Shinjuku Tokyo"}]}]; +const HOTELS = [{"city": "Tokyo", "name": "AMANEK Asakusa Sakurabashi", "dates": "14–18 giugno · 25–28 giugno", "query": "AMANEK Asakusa Sakurabashi Tokyo"}, {"city": "Kanazawa", "name": "Ryokan a Kanazawa", "dates": "18–20 giugno", "query": "ryokan Kanazawa station"}, {"city": "Kyoto", "name": "M's Hotel Plus Shijo Omiya", "dates": "20–25 giugno", "query": "M's Hotel Plus Shijo Omiya Kyoto"}]; +const EURJPY = 182.85; +const LAST_DAY_KEY='jp-trip-last-day-webapp-v27'; +const LEGACY_TODO_KEY='jp-trip-todo-stable'; +const LEGACY_COSTS_KEY='jp-trip-costs-stable'; +const DB_NAME='jp-trip-db-v27'; +const STORE='kv'; + +let dbPromise=null; +function openDB(){ + if(dbPromise) return dbPromise; + dbPromise = new Promise((resolve,reject)=>{ + const req = indexedDB.open(DB_NAME,1); + req.onupgradeneeded = ()=>{ + const db = req.result; + if(!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = ()=> resolve(req.result); + req.onerror = ()=> reject(req.error); + }); + return dbPromise; +} +async function idbGet(key, fallback){ + try{ + const db = await openDB(); + return await new Promise((resolve,reject)=>{ + const tx = db.transaction(STORE,'readonly'); + const req = tx.objectStore(STORE).get(key); + req.onsuccess = ()=> resolve(req.result === undefined ? fallback : req.result); + req.onerror = ()=> reject(req.error); + }); + }catch{ + return fallback; + } +} +async function idbSet(key, value){ + try{ + const db = await openDB(); + return await new Promise((resolve,reject)=>{ + const tx = db.transaction(STORE,'readwrite'); + tx.objectStore(STORE).put(value,key); + tx.oncomplete = ()=> resolve(true); + tx.onerror = ()=> reject(tx.error); + }); + }catch{ + return false; + } +} + +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); } + +const todaySelect=document.getElementById('todaySelect'),todayTop=document.getElementById('todayTop'),daysList=document.getElementById('daysList'),cityFilter=document.getElementById('cityFilter'); + +async function fetchWeather(city) { + try { + const g = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=it&format=json`); + const gj = await g.json(); + if(!gj.results || !gj.results.length) return null; + const r = gj.results[0]; + const w = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${r.latitude}&longitude=${r.longitude}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto`); + const wj = await w.json(); + return {name:r.name, current:wj.current}; + } catch(e) { return null; } +} +function weatherText(code) { + const map = {0:'Sereno',1:'Poco nuvoloso',2:'Parzialmente nuvoloso',3:'Coperto',45:'Nebbia',48:'Nebbia intensa',51:'Pioviggine leggera',53:'Pioviggine',55:'Pioviggine intensa',61:'Pioggia leggera',63:'Pioggia',65:'Pioggia intensa',71:'Neve leggera',73:'Neve',75:'Neve intensa',80:'Rovesci leggeri',81:'Rovesci',82:'Rovesci forti',95:'Temporale',96:'Temporale con grandine',99:'Temporale forte'}; + return map[code] || 'Meteo variabile'; +} +function populateSelects(){ + todaySelect.innerHTML=DAYS.map(d=>``).join(''); + const cities=[...new Set(DAYS.map(d=>d.city))].sort(); + cityFilter.innerHTML=''+cities.map(c=>``).join(''); + const last=localStorage.getItem(LAST_DAY_KEY); + if(last&&DAYS.find(d=>d.id===last))todaySelect.value=last; +} +async function renderToday(){ + const id=todaySelect.value||DAYS[0].id; + localStorage.setItem(LAST_DAY_KEY,id); + const day=DAYS.find(d=>d.id===id)||DAYS[0]; + const stop=day.timeline[0]; + const allStops=day.timeline.map(t=>`
${t[0]}
${t[1]}
${t[2]}
Maps
`).join(''); + const lunch=day.lunch.places.slice(0,5).map(p=>`
  • ${p.name}
  • `).join(''); + const dinner=day.dinner.places.slice(0,5).map(p=>`
  • ${p.name}
  • `).join(''); + const onsen=day.onsen.map(p=>`
  • ${p.name}
  • `).join(''); + const love=day.love_hotels.map(p=>`
  • ${p.name}
  • `).join(''); + todayTop.innerHTML=`
    Dove siete
    ${day.city} · ${day.base}
    Meteo
    Caricamento...
    ${stop?stop[0]:'—'}
    ${stop?stop[1]:'Nessuna tappa'}
    ${stop?stop[2]:''}

    Tappe di oggi

    ${allStops}

    Pranzo · zona ${day.lunch.area}

    Cena · zona ${day.dinner.area}

    Onsen · tattoo friendly / private

    Love hotel in zona

    `; + const weather=await fetchWeather(day.weather_city); + const wc=document.getElementById('weatherCard'); + if(wc){ + if(weather&&weather.current){ + wc.innerHTML=`
    Meteo · ${weather.name}
    ${weatherText(weather.current.weather_code)}
    Umidità ${weather.current.relative_humidity_2m}% · Vento ${weather.current.wind_speed_10m} km/h
    ${Math.round(weather.current.temperature_2m)}°
    `; + } else { + wc.innerHTML='
    Meteo
    Non disponibile
    '; + } + } +} +function renderDays(){ + const q=document.getElementById('searchInput').value.trim().toLowerCase(); + const city=cityFilter.value; + const items=DAYS.filter(d=>(!q||JSON.stringify(d).toLowerCase().includes(q))&&(!city||d.city===city)); + daysList.innerHTML=items.map(d=>`

    ${d.date} · ${d.title}

    ${d.city} · ${d.summary}
    `).join(''); + document.querySelectorAll('.open-day').forEach(b=>b.onclick=()=>{todaySelect.value=b.dataset.id;renderToday();location.hash='#oggi';}); +} +function renderHotels(){ + const host=document.getElementById('hotelList'); + host.innerHTML=HOTELS.map(h=>`

    ${h.name}

    ${h.city} · ${h.dates}
    `).join(''); +} +async function migrateLegacyData(){ + const legacyTodo = localStorage.getItem(LEGACY_TODO_KEY); + const legacyCosts = localStorage.getItem(LEGACY_COSTS_KEY); + const existingTodo = await idbGet('todoData', null); + const existingCosts = await idbGet('costData', null); + if(legacyTodo && existingTodo === null){ + try{ await idbSet('todoData', JSON.parse(legacyTodo)); }catch{} + } + if(legacyCosts && existingCosts === null){ + try{ await idbSet('costData', JSON.parse(legacyCosts)); }catch{} + } +} +async function renderTodo(){ + const defaultTodo = ['Passaporti controllati','Assicurazione viaggio attiva','Prenotazioni hotel verificate','Biglietti aerei salvati','eSIM / SIM dati attivata','Carte e contanti pronti','Power bank carico','Adattatore prese in valigia','Prenotazioni teamLab / Shibuya Sky','Bagaglio e documenti finali']; + let state = await idbGet('todoData', null); + if(state === null){ + state = defaultTodo.map(t=>({text:t,done:false})); + await idbSet('todoData', state); + } + const host=document.getElementById('todoList'); + host.innerHTML=state.map((item,i)=>``).join(''); + host.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.onchange=async ()=>{ + const s = await idbGet('todoData', []); + s[Number(cb.dataset.i)].done = cb.checked; + await idbSet('todoData', s); + }); +} +async function renderCosts(){ + const costs = await idbGet('costData', []); + const host=document.getElementById('costList'); + host.innerHTML=costs.length?costs.map((c,i)=>`
    ${c.desc}
    ${formatJpy(c.jpy)} · ${formatEur(c.eur)}
    `).join(''):'
    Nessuna spesa inserita.
    '; + const totalJpy=costs.reduce((a,c)=>a+Number(c.jpy||0),0); + const totalEur=costs.reduce((a,c)=>a+Number(c.eur||0),0); + document.getElementById('totalJpy').textContent=formatJpy(totalJpy); + document.getElementById('totalEur').textContent=formatEur(totalEur); + host.querySelectorAll('.del-cost').forEach(b=>b.onclick=async ()=>{ + const s = await idbGet('costData', []); + s.splice(Number(b.dataset.i),1); + await idbSet('costData', s); + renderCosts(); + }); +} + +todaySelect.onchange=renderToday; +document.getElementById('prevDay').onclick=()=>{const idx=DAYS.findIndex(d=>d.id===todaySelect.value);if(idx>0)todaySelect.value=DAYS[idx-1].id;renderToday();}; +document.getElementById('nextDay').onclick=()=>{const idx=DAYS.findIndex(d=>d.id===todaySelect.value);if(idx{ + const desc=document.getElementById('costDesc').value.trim(); + const jpyRaw=document.getElementById('costJpy').value; + const eurRaw=document.getElementById('costEur').value; + const jpyVal=Number(jpyRaw); + const eurVal=Number(eurRaw); + if(!desc) return; + let jpy=0, eur=0; + if(jpyVal>0){ jpy=jpyVal; eur=jpyToEur(jpyVal); } + else if(eurVal>0){ eur=eurVal; jpy=eurToJpy(eurVal); } + else { return; } + const s=await idbGet('costData',[]); + s.unshift({desc,jpy,eur}); + await idbSet('costData',s); + document.getElementById('costDesc').value=''; + document.getElementById('costJpy').value=''; + document.getElementById('costEur').value=''; + renderCosts(); +}; +document.getElementById('clearCostsBtn').onclick=async ()=>{await idbSet('costData',[]);renderCosts();}; + +(async function init(){ + populateSelects(); + await migrateLegacyData(); + renderToday(); + renderDays(); + renderHotels(); + await renderTodo(); + await renderCosts(); +})(); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eb215e5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/index.html b/index.html new file mode 100644 index 0000000..fa5db84 --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ +App viaggio +
    +
    +

    Oggi

    +

    Giorni

    +

    Hotel

    +

    To do

    Checklist pre-partenza spuntabile
    +

    Costi

    Cambio di riferimento: 1 EUR = 182.85 JPY · 1.000 JPY ≈ 5.47 EUR
    Totale JPY
    ¥0
    Totale EUR
    €0,00
    Questa versione salva To do e Costi con IndexedDB e prova anche a importare automaticamente i dati già presenti.
    +
    + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d8817b1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +python-dotenv==1.0.1 +gunicorn==22.0.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..0264543 --- /dev/null +++ b/server.py @@ -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/") + 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/") + 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/") + 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/") + 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) diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..9b77703 --- /dev/null +++ b/static/app.js @@ -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 ` + + `; + }).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) => ` +
    +
    ${m.time}
    +
    +
    ${m.from} → ${m.to}
    +
    ${m.mode} · Lorem ipsum dolor sit amet.
    +
    + +
    + ` + ) + .join(""); + + const attractions = day.attractions + .map( + (a) => ` +
  • + ${a.name} +
    ${a.note}
    +
  • + ` + ) + .join(""); + + const lunch = day.lunch + .map((p) => `
  • ${p.name}
  • `) + .join(""); + + const dinner = day.dinner + .map((p) => `
  • ${p.name}
  • `) + .join(""); + + elDayView.innerHTML = ` +
    +
    +
    Dove siete
    +
    ${day.city} · ${day.base}
    +
    +
    +
    Nota
    +
    ${day.summary}
    +
    +
    + +
    +

    Spostamenti

    +
    ${movements}
    +
    + +
    +

    Attrazioni

    +
      ${attractions}
    +
    + +
    +

    Pranzo (suggeriti)

    +
      ${lunch}
    +
    + +
    +

    Cena (suggeriti)

    +
      ${dinner}
    +
    + `; +} + +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) => ` +
    + +
    + + +
    +
    + ` + ) + .join("") + : `
    Nessuna voce. Lorem ipsum.
    `; + + 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) => ` +
    +
    +
    ${c.description}
    +
    ${formatJpy(c.jpy)} · ${formatEur(c.eur)}
    +
    +
    + + +
    +
    + ` + ) + .join("") + : `
    Nessuna spesa inserita. Lorem ipsum.
    `; + + 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(); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d0b71ea --- /dev/null +++ b/static/style.css @@ -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)} diff --git a/style.css b/style.css new file mode 100644 index 0000000..da6b30c --- /dev/null +++ b/style.css @@ -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}} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..8e5191d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,102 @@ + + + + + + Viaggio + + + + +
    +
    +
    + +
    +
    Viaggio di nozze
    +
    Piano, spostamenti, todo e costi
    +
    +
    + +
    + +
    +
    +
    + +
    +
    +

    Calendario

    +
    Clicca un giorno per vedere il piano (lorem ipsum).
    +
    +
    + +
    +

    Giorno selezionato

    +
    +
    + +
    +

    To do

    +
    + +
    + +
    +
    +
    +
    + +
    +

    Costi

    +
    Cambio di riferimento: 1 EUR = 182.85 JPY
    + +
    + + + +
    + +
    + +
    + +
    + +
    +
    +
    Totale JPY
    +
    ¥0
    +
    +
    +
    Totale EUR
    +
    €0,00
    +
    +
    +
    +
    + + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..64b7115 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,45 @@ + + + + + + Login · Viaggio + + + + +
    +
    +
    + +
    +
    Viaggio di nozze
    +
    Accesso riservato
    +
    +
    + + {% if error %} +
    {{ error }}
    + {% endif %} + +
    + + + + + +
    + +
    + Suggerimento: questo sito non espone porte verso l'host; sta dietro reverse proxy. +
    +
    +
    + +