Spaces:
Sleeping
Sleeping
| """Shared-state API for the Tokyo Flat Finder viewer. | |
| Stores the couple's likes/dislikes/notes and syncs them across devices. | |
| State is held in memory and persisted to a PRIVATE HF Dataset (state.json) so | |
| it survives Space restarts. Every request must carry the shared X-App-Key. | |
| Env (set as Space secrets): | |
| HF_TOKEN write token for the dataset | |
| DATASET_REPO e.g. ArthurZ/tokyo-flat-finder-state | |
| APP_SECRET shared key the viewer sends as X-App-Key | |
| """ | |
| from __future__ import annotations | |
| import html as _html | |
| import json | |
| import os | |
| import pathlib | |
| import threading | |
| from fastapi import FastAPI, Header, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import HTMLResponse | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from pydantic import BaseModel | |
| # Slim public listing data for the shareable preview page (baked at deploy). | |
| try: | |
| PUBLIC = json.loads(pathlib.Path("public_listings.json").read_text()) | |
| except Exception: | |
| PUBLIC = {} | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| DATASET_REPO = os.environ.get("DATASET_REPO", "") | |
| APP_SECRET = os.environ.get("APP_SECRET", "") | |
| STATE_FILE = "state.json" | |
| api = HfApi(token=HF_TOKEN) | |
| _lock = threading.Lock() | |
| _state: dict = {"userStates": {}, "notes": {}} | |
| def _load() -> None: | |
| global _state | |
| try: | |
| path = hf_hub_download(DATASET_REPO, STATE_FILE, repo_type="dataset", | |
| token=HF_TOKEN) | |
| with open(path) as f: | |
| data = json.load(f) | |
| _state = {"userStates": data.get("userStates", {}), "notes": data.get("notes", {})} | |
| except Exception: | |
| _state = {"userStates": {}, "notes": {}} | |
| def _persist() -> None: | |
| buf = json.dumps(_state, ensure_ascii=False).encode("utf-8") | |
| api.upload_file(path_or_fileobj=buf, path_in_repo=STATE_FILE, | |
| repo_id=DATASET_REPO, repo_type="dataset", | |
| commit_message="update shared state") | |
| app = FastAPI() | |
| app.add_middleware( | |
| CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], | |
| ) | |
| def _startup() -> None: | |
| _load() | |
| def _auth(key: str | None) -> None: | |
| if not APP_SECRET or key != APP_SECRET: | |
| raise HTTPException(status_code=403, detail="bad key") | |
| def health() -> dict: | |
| return {"ok": True, "listings": len(_state["userStates"]), "notes": len(_state["notes"])} | |
| def get_state(x_app_key: str | None = Header(default=None)) -> dict: | |
| _auth(x_app_key) | |
| return _state | |
| class UserStateIn(BaseModel): | |
| listingId: str | |
| handle: str # "me" | "partner" | |
| state: str # "unseen" | "like" | "dislike" | "veto" | |
| def set_user_state(body: UserStateIn, x_app_key: str | None = Header(default=None)) -> dict: | |
| _auth(x_app_key) | |
| with _lock: | |
| us = _state["userStates"] | |
| cur = us.get(body.listingId, {}) | |
| if body.state == "unseen": | |
| cur.pop(body.handle, None) | |
| else: | |
| cur[body.handle] = body.state | |
| if cur: | |
| us[body.listingId] = cur | |
| else: | |
| us.pop(body.listingId, None) | |
| _persist() | |
| return {"ok": True} | |
| class NoteIn(BaseModel): | |
| listingId: str | |
| note: str | |
| def set_note(body: NoteIn, x_app_key: str | None = Header(default=None)) -> dict: | |
| _auth(x_app_key) | |
| with _lock: | |
| if body.note.strip(): | |
| _state["notes"][body.listingId] = body.note | |
| else: | |
| _state["notes"].pop(body.listingId, None) | |
| _persist() | |
| return {"ok": True} | |
| # --- Public shareable preview page (no key — anyone with the link) ----------- | |
| def preview(listing_id: str) -> str: | |
| d = PUBLIC.get(listing_id) | |
| if not d: | |
| return HTMLResponse("<h1>Listing not found</h1>", status_code=404) | |
| e = _html.escape | |
| yen = f"¥{d['rent_jpy']:,}" if d.get("rent_jpy") else "—" | |
| eur = f"€{round(d['rent_jpy'] / 186):,}" if d.get("rent_jpy") else "" # ponytail: fixed 186 rate | |
| lat, lon = d.get("lat"), d.get("lon") | |
| ote = (f"{d['ote_min']} min" + (" · " + "→".join(d["ote_lines"]) if d.get("ote_lines") else "")) \ | |
| if d.get("ote_min") is not None else "—" | |
| photos = "".join(f'<img src="{e(u)}" loading="lazy">' for u in d.get("photos", [])) | |
| gmap = f"https://www.google.com/maps/search/?api=1&query={lat},{lon}" | |
| osm = (f"https://www.openstreetmap.org/export/embed.html?bbox={lon-0.012},{lat-0.01}," | |
| f"{lon+0.012},{lat+0.01}&layer=mapnik&marker={lat},{lon}") if lat and lon else "" | |
| return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>{e(d.get('title') or 'Listing')}</title> | |
| <style> | |
| body{{margin:0;font:16px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:#f5f3ee;color:#2a2a2a}} | |
| .wrap{{max-width:560px;margin:0 auto;background:#fff;min-height:100vh}} | |
| .photos{{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}} | |
| .photos img{{width:100%;flex:0 0 100%;height:240px;object-fit:cover;scroll-snap-align:center}} | |
| .pad{{padding:16px 18px}} | |
| h1{{font-size:19px;margin:0 0 8px}} | |
| .badges{{color:#555;font-size:14px;margin-bottom:12px}} | |
| .rent{{font-size:24px;font-weight:700}}.rent small{{font-size:14px;color:#888;font-weight:400}} | |
| .row{{display:flex;gap:8px;margin:10px 0;font-size:15px}}.row b{{min-width:96px;color:#555;font-weight:600}} | |
| iframe{{width:100%;height:260px;border:0;border-radius:10px;margin-top:8px}} | |
| a.btn{{display:block;text-align:center;background:#28a55b;color:#fff;text-decoration:none; | |
| padding:14px;border-radius:10px;font-weight:600;margin-top:14px}} | |
| </style></head><body><div class="wrap"> | |
| <div class="photos">{photos}</div> | |
| <div class="pad"> | |
| <h1>🏠 {e(d.get('title') or '')}</h1> | |
| <div class="badges">{e(d.get('layout') or '?')} · {d.get('size_m2') or '?'} m² · {e(d.get('ward') or '')}</div> | |
| <div class="rent">{eur} <small>/mo · {yen}</small></div> | |
| <div class="row"><b>→ Otemachi</b><span>{e(ote)}</span></div> | |
| {f'<iframe src="{osm}"></iframe>' if osm else ''} | |
| <a class="btn" href="{gmap}" target="_blank" rel="noopener">📍 Open in Google Maps</a> | |
| {f'<a class="btn" style="background:#3b82f6" href="{e(d["url"])}" target="_blank" rel="noopener">↗ View original listing</a>' if d.get("url") else ''} | |
| </div></div></body></html>""" | |