Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite
- auth.py/auth_routes.py: Login Flow v2 (verified against real NC source/docs) - no OAuth2 client registration needed, backend polls server-side so no CORS issues. Sessions are hashed-token cookies; NC app passwords encrypted at rest (Fernet). Every /api/* route guarded by a blueprint-wide before_request, not per-route decorators, so future routes are protected by default. - receipts/groups scoped per owner_nc_user_id; cross-user access 404s. - nc_client/cospend_client take (username, app_password) per call instead of one shared global credential - each user's uploads/shares/ bills now happen as themselves. - Frontend: LoginGate component drives the login flow (open NC login in a new tab, poll our backend, done). - Merged the old separate review step into the split screen, redesigned with Tailwind (was unstyled/broken), default-excluded-per-item splitting with one-click "include everyone" fixed, DD.MM.YYYY date field, receipt-icon branding. - Dropped LLM bounding-box highlighting - unreliable on real receipts, plain photo upload instead. - Only mention who a bill is split with in its title when there's more than one bill off the same receipt to disambiguate.
@@ -1,12 +1,13 @@
|
||||
# Copy to .env and fill in. Never commit .env.
|
||||
|
||||
# --- Nextcloud ---
|
||||
# Server address only - each user logs in themselves via Nextcloud Login
|
||||
# Flow v2 (see /auth/login/start); there's no shared NC_USERNAME/APP_PASSWORD
|
||||
# any more.
|
||||
NC_BASE_URL=https://cloud.dominik-roth.eu
|
||||
NC_USERNAME=dodox
|
||||
# Settings -> Security -> "Devices & Sessions" -> create app password
|
||||
NC_APP_PASSWORD=
|
||||
# Folder (relative to the user's files root) receipt images get uploaded to.
|
||||
# Created automatically on first upload if missing (each path segment).
|
||||
# Folder (relative to each user's own files root) receipt images get
|
||||
# uploaded to. Created automatically on first upload if missing (each path
|
||||
# segment).
|
||||
NC_UPLOAD_FOLDER=Documents/Cospend/Assets
|
||||
|
||||
# --- Cospend ---
|
||||
@@ -29,3 +30,15 @@ LLM_MODEL=
|
||||
# --- Flask ---
|
||||
FLASK_SECRET_KEY=dev-change-me
|
||||
DATABASE_PATH=instance/wgbill.sqlite3
|
||||
|
||||
# --- Auth ---
|
||||
# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Encrypts stored Nextcloud app passwords at rest. Required - the app
|
||||
# refuses to store/read a token without it. Keep this secret; losing it
|
||||
# means every logged-in user has to log in again.
|
||||
TOKEN_ENCRYPTION_KEY=
|
||||
# Idle session lifetime in days (sliding - refreshed on each use).
|
||||
SESSION_TTL_DAYS=30
|
||||
# Cookies need Secure (HTTPS-only) for any real deployment. Only set to
|
||||
# false for plain-http localhost dev.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
@@ -15,13 +15,22 @@ def create_app() -> Flask:
|
||||
os.makedirs(Config.UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# Frontend runs on a different origin (vite dev server) during
|
||||
# development; lock this down to that origin in production.
|
||||
CORS(app, resources={r"/api/*": {"origins": Config.CORS_ORIGIN}})
|
||||
# development; lock this down to that origin in production. Auth now
|
||||
# relies on a session cookie, so credentials must be allowed - and with
|
||||
# supports_credentials, the origin allowlist can't be "*", it has to be
|
||||
# this one explicit origin (flask-cors enforces that).
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/api/*": {"origins": Config.CORS_ORIGIN}, r"/auth/*": {"origins": Config.CORS_ORIGIN}},
|
||||
supports_credentials=True,
|
||||
)
|
||||
|
||||
init_db(Config.DATABASE_PATH)
|
||||
|
||||
from .auth_routes import bp as auth_bp
|
||||
from .routes import bp as api_bp
|
||||
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
app.register_blueprint(auth_bp, url_prefix="/auth")
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Login via Nextcloud's Login Flow v2 (the same mechanism NC's own desktop/
|
||||
mobile clients use to obtain an app password without ever seeing a
|
||||
third-party app's code touch the user's real password).
|
||||
|
||||
Flow, doc-verified against docs.nextcloud.com/server/stable/developer_manual
|
||||
/client_apis/LoginFlow/index.html:
|
||||
1. POST {NC_BASE_URL}/index.php/login/v2 (no auth) ->
|
||||
{poll: {token, endpoint}, login: <url the user opens>}
|
||||
2. User opens `login`, authenticates directly with Nextcloud, approves.
|
||||
3. POST {poll.endpoint} with body token=<poll.token> -> 404 while pending,
|
||||
200 once (single-use) with {server, loginName, appPassword} once
|
||||
granted. Token valid 20 minutes.
|
||||
|
||||
Because our backend does the polling (not browser JS), none of the CORS
|
||||
issues that rule out a pure-frontend NC login apply here - the browser only
|
||||
ever does a full-page navigation to NC's own login page and back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import requests
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from flask import g, jsonify, request
|
||||
|
||||
from .config import Config
|
||||
from .db import get_conn
|
||||
|
||||
# In-memory store for login flows in progress: flow_id -> {poll_token,
|
||||
# poll_endpoint, created_at}. Short-lived (20 min NC-side) and low-stakes if
|
||||
# lost on a backend restart (the user just retries the login button) - not
|
||||
# worth a DB table.
|
||||
_pending_flows: dict[str, dict] = {}
|
||||
_FLOW_TTL_SECONDS = 20 * 60
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
if not Config.TOKEN_ENCRYPTION_KEY:
|
||||
raise RuntimeError(
|
||||
"TOKEN_ENCRYPTION_KEY is not set (see .env.example) - refusing to "
|
||||
"store or read an NC app password without it"
|
||||
)
|
||||
return Fernet(Config.TOKEN_ENCRYPTION_KEY.encode())
|
||||
|
||||
|
||||
def _hash_session_id(session_id: str) -> str:
|
||||
return hashlib.sha256(session_id.encode()).hexdigest()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ login flow
|
||||
|
||||
def start_login_flow() -> dict:
|
||||
"""Kicks off a Login Flow v2 against NC. Returns {flow_id, login_url}."""
|
||||
resp = requests.post(f"{Config.NC_BASE_URL}/index.php/login/v2", timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
_pending_flows[flow_id] = {
|
||||
"poll_token": data["poll"]["token"],
|
||||
"poll_endpoint": data["poll"]["endpoint"],
|
||||
"created_at": time.time(),
|
||||
}
|
||||
_gc_expired_flows()
|
||||
return {"flow_id": flow_id, "login_url": data["login"]}
|
||||
|
||||
|
||||
def poll_login_flow(flow_id: str) -> dict:
|
||||
"""Returns {"status": "pending"} | {"status": "expired"} |
|
||||
{"status": "done", "nc_user_id": str, "session_id": str}.
|
||||
|
||||
On success, provisions/updates the user row and creates a new session -
|
||||
the caller still has to actually set the cookie on the response.
|
||||
"""
|
||||
flow = _pending_flows.get(flow_id)
|
||||
if flow is None:
|
||||
return {"status": "expired"}
|
||||
if time.time() - flow["created_at"] > _FLOW_TTL_SECONDS:
|
||||
del _pending_flows[flow_id]
|
||||
return {"status": "expired"}
|
||||
|
||||
resp = requests.post(
|
||||
flow["poll_endpoint"], data={"token": flow["poll_token"]}, timeout=15
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return {"status": "pending"}
|
||||
resp.raise_for_status()
|
||||
|
||||
# Single-use - NC only returns this once, so this flow is done either way.
|
||||
del _pending_flows[flow_id]
|
||||
|
||||
granted = resp.json()
|
||||
nc_user_id = granted["loginName"]
|
||||
app_password = granted["appPassword"]
|
||||
|
||||
encrypted = _fernet().encrypt(app_password.encode())
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO users (nc_user_id, nc_app_password_encrypted)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(nc_user_id) DO UPDATE SET
|
||||
nc_app_password_encrypted = excluded.nc_app_password_encrypted,
|
||||
last_login_at = datetime('now')""",
|
||||
(nc_user_id, encrypted),
|
||||
)
|
||||
|
||||
session_id = create_session(nc_user_id)
|
||||
return {"status": "done", "nc_user_id": nc_user_id, "session_id": session_id}
|
||||
|
||||
|
||||
def _gc_expired_flows() -> None:
|
||||
now = time.time()
|
||||
expired = [fid for fid, f in _pending_flows.items() if now - f["created_at"] > _FLOW_TTL_SECONDS]
|
||||
for fid in expired:
|
||||
del _pending_flows[fid]
|
||||
|
||||
|
||||
# --------------------------------------------------------------- sessions
|
||||
|
||||
def create_session(nc_user_id: str) -> str:
|
||||
"""Returns the raw session id to set as the cookie value."""
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id_hash, nc_user_id, expires_at) VALUES (?, ?, ?)",
|
||||
(_hash_session_id(session_id), nc_user_id, expires_at),
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def resolve_session(session_id: str) -> str | None:
|
||||
"""Returns the nc_user_id for a valid, non-expired session, refreshing
|
||||
its expiry (sliding session), or None if invalid/expired."""
|
||||
session_hash = _hash_session_id(session_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT nc_user_id, expires_at FROM sessions WHERE session_id_hash = ?",
|
||||
(session_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if datetime.fromisoformat(row["expires_at"]) < datetime.utcnow():
|
||||
conn.execute("DELETE FROM sessions WHERE session_id_hash = ?", (session_hash,))
|
||||
return None
|
||||
|
||||
new_expiry = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
|
||||
conn.execute(
|
||||
"UPDATE sessions SET expires_at = ? WHERE session_id_hash = ?",
|
||||
(new_expiry, session_hash),
|
||||
)
|
||||
return row["nc_user_id"]
|
||||
|
||||
|
||||
def destroy_session(session_id: str) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_id_hash = ?", (_hash_session_id(session_id),)
|
||||
)
|
||||
|
||||
|
||||
def get_app_password(nc_user_id: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT nc_app_password_encrypted FROM users WHERE nc_user_id = ?", (nc_user_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError(f"no stored credentials for {nc_user_id!r}")
|
||||
try:
|
||||
return _fernet().decrypt(bytes(row["nc_app_password_encrypted"])).decode()
|
||||
except InvalidToken as exc:
|
||||
raise RuntimeError(
|
||||
"stored app password could not be decrypted - TOKEN_ENCRYPTION_KEY "
|
||||
"changed or is wrong"
|
||||
) from exc
|
||||
|
||||
|
||||
# ------------------------------------------------------------- decorator
|
||||
|
||||
def enforce_login():
|
||||
"""Populates g.nc_user_id/g.nc_app_password from the session cookie, or
|
||||
returns a 401 response if there isn't a valid one. Used both as a
|
||||
blueprint-wide `before_request` (so any route added to that blueprint is
|
||||
protected by default, not only ones someone remembered to decorate) and
|
||||
directly by the `login_required` decorator for routes registered
|
||||
elsewhere.
|
||||
"""
|
||||
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
|
||||
nc_user_id = resolve_session(session_id) if session_id else None
|
||||
if nc_user_id is None:
|
||||
return jsonify(error="not logged in"), 401
|
||||
g.nc_user_id = nc_user_id
|
||||
g.nc_app_password = get_app_password(nc_user_id)
|
||||
return None
|
||||
|
||||
|
||||
def login_required(view):
|
||||
"""Per-route variant of enforce_login(), for routes on a blueprint that
|
||||
isn't (or shouldn't be) guarded wholesale."""
|
||||
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
rejection = enforce_login()
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from . import auth
|
||||
from .config import Config
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@bp.post("/login/start")
|
||||
def login_start():
|
||||
try:
|
||||
result = auth.start_login_flow()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify(error=f"could not start login: {exc}"), 502
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.get("/login/poll")
|
||||
def login_poll():
|
||||
flow_id = request.args.get("flow_id", "")
|
||||
if not flow_id:
|
||||
return jsonify(error="flow_id is required"), 400
|
||||
|
||||
try:
|
||||
result = auth.poll_login_flow(flow_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify(error=f"login poll failed: {exc}"), 502
|
||||
|
||||
if result["status"] != "done":
|
||||
return jsonify(status=result["status"])
|
||||
|
||||
resp = jsonify(status="done", nc_user_id=result["nc_user_id"])
|
||||
resp.set_cookie(
|
||||
Config.SESSION_COOKIE_NAME,
|
||||
result["session_id"],
|
||||
max_age=Config.SESSION_TTL_DAYS * 24 * 3600,
|
||||
httponly=True,
|
||||
secure=Config.SESSION_COOKIE_SECURE,
|
||||
samesite="Lax",
|
||||
path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
def logout():
|
||||
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
|
||||
if session_id:
|
||||
auth.destroy_session(session_id)
|
||||
resp = jsonify(status="ok")
|
||||
resp.delete_cookie(Config.SESSION_COOKIE_NAME, path="/")
|
||||
return resp
|
||||
@@ -17,10 +17,10 @@ class Config:
|
||||
|
||||
CORS_ORIGIN = os.environ.get("CORS_ORIGIN", "http://localhost:5173")
|
||||
|
||||
# Server address only - who's calling is now per logged-in user (see
|
||||
# auth.py), not a single shared NC_USERNAME/NC_APP_PASSWORD.
|
||||
NC_BASE_URL = os.environ.get("NC_BASE_URL", "").rstrip("/")
|
||||
NC_USERNAME = os.environ.get("NC_USERNAME", "")
|
||||
NC_APP_PASSWORD = os.environ.get("NC_APP_PASSWORD", "")
|
||||
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "wgBill").strip("/")
|
||||
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "Documents/Cospend/Assets").strip("/")
|
||||
|
||||
# Optional - pre-selects a project in the UI; the app lists all of the
|
||||
# user's Cospend projects via the API either way, so this isn't required.
|
||||
@@ -29,3 +29,16 @@ class Config:
|
||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/")
|
||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "")
|
||||
|
||||
# Fernet key (Fernet.generate_key()) used to encrypt stored NC app
|
||||
# passwords at rest. Required in production; a request-time error is
|
||||
# raised if missing so this can't be silently skipped.
|
||||
TOKEN_ENCRYPTION_KEY = os.environ.get("TOKEN_ENCRYPTION_KEY", "")
|
||||
|
||||
SESSION_COOKIE_NAME = "wgbill_session"
|
||||
# Sessions are sliding - refreshed on use, so an active user never gets
|
||||
# logged out; an idle one expires after this many days.
|
||||
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
|
||||
# Cookies need Secure (HTTPS-only) in any real deployment; only disable
|
||||
# for plain-http local dev.
|
||||
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Cospend's authenticated (NC-login) API - same app-password auth as
|
||||
nc_client. This is an OCS API (same family as the Share API), confirmed
|
||||
against the real routes/controller source in julien-nc/cospend-nc:
|
||||
"""Cospend's authenticated (NC-login) API - same per-user app-password auth
|
||||
as nc_client (see auth.py). This is an OCS API (same family as the Share
|
||||
API), confirmed against the real routes/controller source in
|
||||
julien-nc/cospend-nc:
|
||||
|
||||
appinfo/routes.php ('ocs' section):
|
||||
GET /api/{v}/projects -> api#getLocalProjects
|
||||
@@ -28,10 +29,6 @@ from .config import Config
|
||||
_HEADERS = {"OCS-APIRequest": "true"}
|
||||
|
||||
|
||||
def _auth() -> tuple[str, str]:
|
||||
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
|
||||
|
||||
|
||||
def _base(project_id: str | None = None) -> str:
|
||||
root = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/cospend/api/v1"
|
||||
if project_id is None:
|
||||
@@ -39,11 +36,11 @@ def _base(project_id: str | None = None) -> str:
|
||||
return f"{root}/projects/{project_id}"
|
||||
|
||||
|
||||
def get_projects() -> list[dict[str, Any]]:
|
||||
def get_projects(username: str, app_password: str) -> list[dict[str, Any]]:
|
||||
"""Lists Cospend projects visible to the authenticated user."""
|
||||
resp = requests.get(
|
||||
f"{_base()}/projects",
|
||||
auth=_auth(),
|
||||
auth=(username, app_password),
|
||||
headers=_HEADERS,
|
||||
params={"format": "json"},
|
||||
timeout=15,
|
||||
@@ -52,10 +49,10 @@ def get_projects() -> list[dict[str, Any]]:
|
||||
return resp.json()["ocs"]["data"]
|
||||
|
||||
|
||||
def get_members(project_id: str) -> list[dict[str, Any]]:
|
||||
def get_members(username: str, app_password: str, project_id: str) -> list[dict[str, Any]]:
|
||||
resp = requests.get(
|
||||
f"{_base(project_id)}/members",
|
||||
auth=_auth(),
|
||||
auth=(username, app_password),
|
||||
headers=_HEADERS,
|
||||
params={"format": "json"},
|
||||
timeout=15,
|
||||
@@ -65,6 +62,8 @@ def get_members(project_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def create_bill(
|
||||
username: str,
|
||||
app_password: str,
|
||||
*,
|
||||
project_id: str,
|
||||
what: str,
|
||||
@@ -82,7 +81,7 @@ def create_bill(
|
||||
"""
|
||||
resp = requests.post(
|
||||
f"{_base(project_id)}/bills",
|
||||
auth=_auth(),
|
||||
auth=(username, app_password),
|
||||
headers=_HEADERS,
|
||||
params={"format": "json"},
|
||||
json={
|
||||
@@ -92,6 +91,9 @@ def create_bill(
|
||||
"payedFor": ",".join(str(i) for i in ower_ids),
|
||||
"comment": comment,
|
||||
"date": date, # YYYY-MM-DD, the receipt's issue date, not today
|
||||
# required by LocalProjectService::createBill (400s without it);
|
||||
# 'n' = FREQUENCY_NO, i.e. this bill doesn't repeat.
|
||||
"repeat": "n",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Draws highlight boxes over selected items on the original receipt image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
_BOX_COLOR = (255, 210, 0) # translucent yellow highlighter look
|
||||
_BOX_WIDTH = 4
|
||||
|
||||
|
||||
def highlight_items(image_bytes: bytes, bboxes: list[list[float]]) -> bytes:
|
||||
"""`bboxes` are normalized [x, y, w, h] (0-1). Returns JPEG bytes."""
|
||||
base = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
|
||||
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
w, h = base.size
|
||||
|
||||
for bbox in bboxes:
|
||||
if not bbox or len(bbox) != 4:
|
||||
continue
|
||||
x, y, bw, bh = bbox
|
||||
left, top = x * w, y * h
|
||||
right, bottom = (x + bw) * w, (y + bh) * h
|
||||
draw.rectangle([left, top, right, bottom], fill=(*_BOX_COLOR, 70))
|
||||
draw.rectangle([left, top, right, bottom], outline=(*_BOX_COLOR, 255), width=_BOX_WIDTH)
|
||||
|
||||
combined = Image.alpha_composite(base, overlay).convert("RGB")
|
||||
out = io.BytesIO()
|
||||
combined.save(out, format="JPEG", quality=90)
|
||||
return out.getvalue()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Receipt -> [{id, label, price, bbox}] via any OpenAI-compatible chat
|
||||
"""Receipt -> [{id, label, price}] via any OpenAI-compatible chat
|
||||
completions endpoint (works unmodified with OpenAI; point LLM_BASE_URL at
|
||||
Gemini's OpenAI-compat layer to use that instead, no code change needed).
|
||||
"""
|
||||
@@ -23,16 +23,12 @@ Respond with ONLY a JSON object, no prose, no markdown fences, shaped like:
|
||||
"store_name": "<store/shop name as printed, or null if unreadable>",
|
||||
"date": "<receipt date as YYYY-MM-DD, or null if unreadable>",
|
||||
"items": [
|
||||
{"label": "<item name as printed>", "price": <number>, \
|
||||
"bbox": [x, y, w, h]}
|
||||
{"label": "<item name as printed>", "price": <number>}
|
||||
]
|
||||
}
|
||||
|
||||
- price is the item's price in the receipt's currency, as a plain number \
|
||||
(no currency symbol).
|
||||
- bbox is the item's approximate bounding box on the image, normalized to \
|
||||
0-1 (x, y = top-left corner; w, h = width/height as a fraction of the \
|
||||
image). Best effort is fine.
|
||||
- Skip subtotal/tax/total lines, only real purchased items.
|
||||
- If a line item's price is unclear, make your best guess rather than \
|
||||
omitting it.
|
||||
@@ -79,7 +75,6 @@ def extract_receipt(image_bytes: bytes, mime_type: str = "image/jpeg") -> dict:
|
||||
"id": str(uuid.uuid4()),
|
||||
"label": str(entry.get("label", "")).strip(),
|
||||
"price": float(entry.get("price", 0) or 0),
|
||||
"bbox": entry.get("bbox") or None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need.
|
||||
|
||||
Auth is HTTP Basic with an app password (Settings -> Security -> Devices &
|
||||
Sessions -> create app password), never the account's real password.
|
||||
Auth is HTTP Basic with an app password obtained per-user via Login Flow v2
|
||||
(see auth.py) - callers pass (username, app_password) explicitly rather than
|
||||
this module reading a single global credential, since every user acts as
|
||||
themselves now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,15 +13,11 @@ import requests
|
||||
from .config import Config
|
||||
|
||||
|
||||
def _auth() -> tuple[str, str]:
|
||||
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
|
||||
def _webdav_root(username: str) -> str:
|
||||
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{username}"
|
||||
|
||||
|
||||
def _webdav_root() -> str:
|
||||
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{Config.NC_USERNAME}"
|
||||
|
||||
|
||||
def ensure_upload_folder() -> None:
|
||||
def ensure_upload_folder(username: str, app_password: str) -> None:
|
||||
"""MKCOL each segment of the configured upload folder path if missing.
|
||||
|
||||
NC_UPLOAD_FOLDER may be nested (e.g. "Documents/Cospend/Assets"); MKCOL
|
||||
@@ -32,35 +30,42 @@ def ensure_upload_folder() -> None:
|
||||
partial = ""
|
||||
for segment in segments:
|
||||
partial = f"{partial}/{segment}" if partial else segment
|
||||
url = f"{_webdav_root()}/{partial}"
|
||||
resp = requests.request("MKCOL", url, auth=_auth(), timeout=15)
|
||||
url = f"{_webdav_root(username)}/{partial}"
|
||||
resp = requests.request("MKCOL", url, auth=(username, app_password), timeout=15)
|
||||
# 201 = created, 405 = already exists. Anything else is a real problem.
|
||||
if resp.status_code not in (201, 405):
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def upload_file(filename: str, content: bytes, content_type: str = "image/jpeg") -> str:
|
||||
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT.
|
||||
def upload_file(
|
||||
username: str, app_password: str, filename: str, content: bytes, content_type: str = "image/jpeg"
|
||||
) -> str:
|
||||
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT, into
|
||||
the given user's own NC files.
|
||||
|
||||
Returns the server-relative path (e.g. "wgBill/receipt-123.jpg"), which is
|
||||
what the Share API expects as `path`.
|
||||
"""
|
||||
ensure_upload_folder()
|
||||
ensure_upload_folder(username, app_password)
|
||||
rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename
|
||||
url = f"{_webdav_root()}/{rel_path}"
|
||||
url = f"{_webdav_root(username)}/{rel_path}"
|
||||
resp = requests.put(
|
||||
url, data=content, auth=_auth(), headers={"Content-Type": content_type}, timeout=30
|
||||
url,
|
||||
data=content,
|
||||
auth=(username, app_password),
|
||||
headers={"Content-Type": content_type},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return rel_path
|
||||
|
||||
|
||||
def create_public_share(rel_path: str) -> str:
|
||||
def create_public_share(username: str, app_password: str, rel_path: str) -> str:
|
||||
"""Creates a public read-only link share for `rel_path`. Returns the share URL."""
|
||||
url = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares"
|
||||
resp = requests.post(
|
||||
url,
|
||||
auth=_auth(),
|
||||
auth=(username, app_password),
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
data={
|
||||
"path": f"/{rel_path}",
|
||||
|
||||
@@ -6,13 +6,24 @@ import uuid
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from . import cospend_client, highlight, llm_client, nc_client
|
||||
from . import cospend_client, llm_client, nc_client
|
||||
from .auth import enforce_login
|
||||
from .config import Config
|
||||
from .db import get_conn
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
# Every route on this blueprint requires a valid session by default - a
|
||||
# route added here later is protected automatically, not only if someone
|
||||
# remembers to decorate it. (auth_routes.py's /auth/* blueprint is
|
||||
# deliberately separate and unguarded - it's the login mechanism itself.)
|
||||
bp.before_request(enforce_login)
|
||||
|
||||
|
||||
@bp.get("/me")
|
||||
def me():
|
||||
return jsonify(nc_user_id=g.nc_user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- receipts
|
||||
@@ -43,9 +54,9 @@ def create_receipt():
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO receipts (id, image_path, items_json, store_name, receipt_date)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(receipt_id, image_path, json.dumps(items), store_name, receipt_date),
|
||||
"""INSERT INTO receipts (id, owner_nc_user_id, image_path, items_json, store_name, receipt_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(receipt_id, g.nc_user_id, image_path, json.dumps(items), store_name, receipt_date),
|
||||
)
|
||||
|
||||
return jsonify(id=receipt_id, items=items, store_name=store_name, date=receipt_date), 201
|
||||
@@ -53,7 +64,7 @@ def create_receipt():
|
||||
|
||||
@bp.get("/receipts/<receipt_id>")
|
||||
def get_receipt(receipt_id: str):
|
||||
receipt = _load_receipt(receipt_id)
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
if receipt is None:
|
||||
return jsonify(error="not found"), 404
|
||||
return jsonify(receipt)
|
||||
@@ -69,8 +80,8 @@ def update_items(receipt_id: str):
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE receipts SET items_json = ? WHERE id = ?",
|
||||
(json.dumps(items), receipt_id),
|
||||
"UPDATE receipts SET items_json = ? WHERE id = ? AND owner_nc_user_id = ?",
|
||||
(json.dumps(items), receipt_id, g.nc_user_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return jsonify(error="not found"), 404
|
||||
@@ -95,19 +106,24 @@ def update_meta(receipt_id: str):
|
||||
if not fields:
|
||||
return jsonify(error="nothing to update"), 400
|
||||
|
||||
values.append(receipt_id)
|
||||
values.extend([receipt_id, g.nc_user_id])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(f"UPDATE receipts SET {', '.join(fields)} WHERE id = ?", values)
|
||||
cur = conn.execute(
|
||||
f"UPDATE receipts SET {', '.join(fields)} WHERE id = ? AND owner_nc_user_id = ?", values
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return jsonify(error="not found"), 404
|
||||
|
||||
receipt = _load_receipt(receipt_id)
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
return jsonify(receipt)
|
||||
|
||||
|
||||
def _load_receipt(receipt_id: str) -> dict | None:
|
||||
def _load_receipt(receipt_id: str, owner_nc_user_id: str) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
|
||||
(receipt_id, owner_nc_user_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
@@ -125,7 +141,7 @@ def _load_receipt(receipt_id: str) -> dict | None:
|
||||
@bp.get("/cospend/projects")
|
||||
def cospend_projects():
|
||||
try:
|
||||
projects = cospend_client.get_projects()
|
||||
projects = cospend_client.get_projects(g.nc_user_id, g.nc_app_password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify(error=f"cospend request failed: {exc}"), 502
|
||||
return jsonify(projects=projects, default_project_id=Config.COSPEND_DEFAULT_PROJECT_ID or None)
|
||||
@@ -134,7 +150,7 @@ def cospend_projects():
|
||||
@bp.get("/cospend/projects/<project_id>/members")
|
||||
def cospend_members(project_id: str):
|
||||
try:
|
||||
members = cospend_client.get_members(project_id)
|
||||
members = cospend_client.get_members(g.nc_user_id, g.nc_app_password, project_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify(error=f"cospend request failed: {exc}"), 502
|
||||
return jsonify(members)
|
||||
@@ -144,13 +160,16 @@ def cospend_members(project_id: str):
|
||||
|
||||
@bp.post("/receipts/<receipt_id>/groups")
|
||||
def create_group(receipt_id: str):
|
||||
"""Body: {name, cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}"""
|
||||
receipt = _load_receipt(receipt_id)
|
||||
"""Body: {cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}
|
||||
|
||||
No user-facing "name" - the group is fully described by the project +
|
||||
who's in the split, which is already what payer/member_ids capture.
|
||||
"""
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
if receipt is None:
|
||||
return jsonify(error="not found"), 404
|
||||
|
||||
body = request.get_json(force=True) or {}
|
||||
name = body.get("name") or "WG"
|
||||
project_id = body.get("cospend_project_id")
|
||||
payer_member_id = body.get("payer_member_id")
|
||||
member_ids = body.get("member_ids") or []
|
||||
@@ -168,13 +187,12 @@ def create_group(receipt_id: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO groups
|
||||
(id, receipt_id, name, cospend_project_id, payer_member_id,
|
||||
(id, receipt_id, cospend_project_id, payer_member_id,
|
||||
member_ids_json, item_ids_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
group_id,
|
||||
receipt_id,
|
||||
name,
|
||||
project_id,
|
||||
payer_member_id,
|
||||
json.dumps(member_ids),
|
||||
@@ -188,6 +206,10 @@ def create_group(receipt_id: str):
|
||||
|
||||
@bp.get("/receipts/<receipt_id>/groups")
|
||||
def list_groups(receipt_id: str):
|
||||
# Ownership check on the parent receipt is enough - a group can't exist
|
||||
# without a receipt row, and receipts are already scoped per-owner.
|
||||
if _load_receipt(receipt_id, g.nc_user_id) is None:
|
||||
return jsonify(error="not found"), 404
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM groups WHERE receipt_id = ? ORDER BY created_at", (receipt_id,)
|
||||
@@ -199,7 +221,6 @@ def _group_dict(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"receipt_id": row["receipt_id"],
|
||||
"name": row["name"],
|
||||
"cospend_project_id": row["cospend_project_id"],
|
||||
"payer_member_id": row["payer_member_id"],
|
||||
"member_ids": json.loads(row["member_ids_json"]),
|
||||
@@ -213,8 +234,8 @@ def _group_dict(row) -> dict:
|
||||
|
||||
@bp.post("/receipts/<receipt_id>/groups/<group_id>/submit")
|
||||
def submit_group(receipt_id: str, group_id: str):
|
||||
"""Highlights selected items, uploads to NC, shares, creates the Cospend bill."""
|
||||
receipt = _load_receipt(receipt_id)
|
||||
"""Uploads the receipt photo to NC, shares it, creates the Cospend bill."""
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
if receipt is None:
|
||||
return jsonify(error="receipt not found"), 404
|
||||
|
||||
@@ -232,33 +253,61 @@ def submit_group(receipt_id: str, group_id: str):
|
||||
return jsonify(error="no matching items on this receipt"), 400
|
||||
|
||||
total = round(sum(item["price"] for item in selected), 2)
|
||||
bboxes = [item["bbox"] for item in selected if item.get("bbox")]
|
||||
|
||||
with open(_receipt_image_path(receipt_id), "rb") as f:
|
||||
with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f:
|
||||
original_bytes = f.read()
|
||||
|
||||
try:
|
||||
highlighted = highlight.highlight_items(original_bytes, bboxes)
|
||||
# Members list is fetched here (not stored at group-creation time) so
|
||||
# the label always reflects real names - also gives us the payer's
|
||||
# name for a slightly more readable title.
|
||||
members = cospend_client.get_members(
|
||||
g.nc_user_id, g.nc_app_password, group["cospend_project_id"]
|
||||
)
|
||||
names_by_id = {str(m["id"]): m["name"] for m in members}
|
||||
# member ids come back from Cospend as JSON numbers, not strings, and
|
||||
# may have round-tripped through JS (which doesn't distinguish) - str()
|
||||
# everything here so a missed match can't leave a raw int in the list
|
||||
# `join` chokes on.
|
||||
ower_names = [names_by_id.get(str(m), str(m)) for m in group["member_ids"]]
|
||||
members_label = " + ".join(ower_names) if ower_names else "split"
|
||||
|
||||
# No highlighting - LLM-provided bounding boxes were unreliable
|
||||
# enough on real receipts to not be worth it. Just upload the
|
||||
# original photo, into the submitting user's own NC files.
|
||||
# Timestamp for human sorting/browsing in NC, group id suffix so two
|
||||
# submits in the same second (or a retry) never collide/overwrite.
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", group["name"]).strip("-") or "group"
|
||||
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_name}_{group_id[:8]}.jpg"
|
||||
rel_path = nc_client.upload_file(filename, highlighted)
|
||||
share_url = nc_client.create_public_share(rel_path)
|
||||
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", members_label).strip("-") or "group"
|
||||
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_label}_{group_id[:8]}.jpg"
|
||||
rel_path = nc_client.upload_file(
|
||||
g.nc_user_id, g.nc_app_password, filename, original_bytes
|
||||
)
|
||||
share_url = nc_client.create_public_share(g.nc_user_id, g.nc_app_password, rel_path)
|
||||
|
||||
item_lines = "\n".join(f"- {i['label']}: {i['price']}" for i in selected)
|
||||
|
||||
# Descriptive title: store name when we have one, else just the
|
||||
# group name - either way followed by the share link, since
|
||||
# Descriptive title: store name when we have one, else fall back to
|
||||
# who's splitting it - either way followed by the share link, since
|
||||
# Cospend's UI only recognizes a bill as having an attachment when
|
||||
# the link is in `what` (the title); a link in `comment` is just
|
||||
# plain text and isn't picked up.
|
||||
# Only mention who it's split with when there's more than one bill
|
||||
# off this receipt to tell apart - with a single bill it's just
|
||||
# noise (the split is already visible in the bill itself).
|
||||
with get_conn() as conn:
|
||||
(bill_count,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM groups WHERE receipt_id = ?", (receipt_id,)
|
||||
).fetchone()
|
||||
store_name = receipt.get("store_name")
|
||||
label = f"{store_name} ({group['name']})" if store_name else group["name"]
|
||||
if bill_count > 1:
|
||||
label = f"{store_name} ({members_label})" if store_name else members_label
|
||||
else:
|
||||
label = store_name or "receipt"
|
||||
bill_date = receipt.get("date") or date_cls.today().isoformat()
|
||||
|
||||
bill_id = cospend_client.create_bill(
|
||||
g.nc_user_id,
|
||||
g.nc_app_password,
|
||||
project_id=group["cospend_project_id"],
|
||||
what=f"{label} {share_url}",
|
||||
amount=total,
|
||||
@@ -285,7 +334,10 @@ def submit_group(receipt_id: str, group_id: str):
|
||||
return jsonify(share_url=share_url, bill_id=bill_id, amount=total)
|
||||
|
||||
|
||||
def _receipt_image_path(receipt_id: str) -> str:
|
||||
def _receipt_image_path(receipt_id: str, owner_nc_user_id: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT image_path FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT image_path FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
|
||||
(receipt_id, owner_nc_user_id),
|
||||
).fetchone()
|
||||
return row["image_path"]
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
nc_user_id TEXT PRIMARY KEY,
|
||||
-- Fernet-encrypted NC app password obtained via Login Flow v2. Never
|
||||
-- returned by any API response.
|
||||
nc_app_password_encrypted BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
-- SHA-256 hex digest of the actual session cookie value - never the raw
|
||||
-- token, so a DB read alone can't yield a usable session (same
|
||||
-- principle as password hashing).
|
||||
session_id_hash TEXT PRIMARY KEY,
|
||||
nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
|
||||
image_path TEXT NOT NULL,
|
||||
image_width INTEGER,
|
||||
image_height INTEGER,
|
||||
@@ -14,7 +34,6 @@ CREATE TABLE IF NOT EXISTS receipts (
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
receipt_id TEXT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
cospend_project_id TEXT NOT NULL,
|
||||
payer_member_id TEXT NOT NULL,
|
||||
member_ids_json TEXT NOT NULL, -- json list of cospend member ids (owers)
|
||||
|
||||
@@ -3,3 +3,4 @@ Flask-Cors==4.0.1
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
Pillow==10.4.0
|
||||
cryptography==43.0.1
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-icons": "^5.7.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
@@ -2587,6 +2590,539 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.11.1",
|
||||
"@emnapi/runtime": "^1.11.1",
|
||||
"@emnapi/wasi-threads": "^1.2.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||
"version": "3.0.0-pre1",
|
||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||
@@ -3184,6 +3720,20 @@
|
||||
"integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4219,6 +4769,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4866,6 +5426,15 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -5474,6 +6043,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
|
||||
@@ -12,14 +12,17 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-icons": "^5.7.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,199 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fafafa;
|
||||
--fg: #1a1a1a;
|
||||
--accent: #2b6cb0;
|
||||
--border: #ddd;
|
||||
--danger: #c0392b;
|
||||
--selected: #e3f0ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #17181a;
|
||||
--fg: #eee;
|
||||
--border: #3a3a3a;
|
||||
--selected: #1c3350;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1rem 3rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.step h1,
|
||||
.step h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
margin: 0.75rem 0;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
button.icon-btn {
|
||||
width: auto;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.meta-row label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-row input[type='text'],
|
||||
.item-row input:not([type]) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-row input[type='number'] {
|
||||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.item-list.selectable li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-list.selectable li.selected {
|
||||
background: var(--selected);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.member-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.payer-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.submitted-groups {
|
||||
background: var(--selected);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary-list li {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -1,52 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
import { CaptureStep } from './components/CaptureStep'
|
||||
import { ReviewStep } from './components/ReviewStep'
|
||||
import { GroupStep } from './components/GroupStep'
|
||||
import { LoginGate } from './components/LoginGate'
|
||||
import { SummaryStep } from './components/SummaryStep'
|
||||
import type { Group, Item } from './types'
|
||||
|
||||
type Stage =
|
||||
| { name: 'capture' }
|
||||
| { name: 'review'; receiptId: string; items: Item[]; storeName: string | null; date: string }
|
||||
| { name: 'group'; receiptId: string; items: Item[] }
|
||||
| { name: 'group'; receiptId: string; items: Item[]; storeName: string | null; date: string }
|
||||
| { name: 'summary'; groups: Group[] }
|
||||
|
||||
export default function App() {
|
||||
const [stage, setStage] = useState<Stage>({ name: 'capture' })
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{stage.name === 'capture' && (
|
||||
<CaptureStep
|
||||
onExtracted={(receiptId, items, storeName, date) =>
|
||||
setStage({ name: 'review', receiptId, items, storeName, date: date || today() })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<LoginGate>
|
||||
{({ ncUserId, logout }) => (
|
||||
<div className="mx-auto min-h-screen max-w-2xl px-4 pb-16 pt-8 sm:px-6">
|
||||
<div className="mb-4 flex items-center justify-end gap-2 text-xs text-neutral-400">
|
||||
<span>{ncUserId}</span>
|
||||
<button onClick={logout} className="underline hover:text-neutral-600 dark:hover:text-neutral-200">
|
||||
log out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stage.name === 'review' && (
|
||||
<ReviewStep
|
||||
receiptId={stage.receiptId}
|
||||
initialItems={stage.items}
|
||||
initialStoreName={stage.storeName}
|
||||
initialDate={stage.date}
|
||||
onConfirmed={(items) => setStage({ name: 'group', receiptId: stage.receiptId, items })}
|
||||
/>
|
||||
)}
|
||||
{stage.name === 'capture' && (
|
||||
<CaptureStep
|
||||
onExtracted={(receiptId, items, storeName, date) =>
|
||||
setStage({ name: 'group', receiptId, items, storeName, date: date || today() })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{stage.name === 'group' && (
|
||||
<GroupStep
|
||||
receiptId={stage.receiptId}
|
||||
items={stage.items}
|
||||
onAllDone={(groups) => setStage({ name: 'summary', groups })}
|
||||
/>
|
||||
)}
|
||||
{stage.name === 'group' && (
|
||||
<GroupStep
|
||||
receiptId={stage.receiptId}
|
||||
initialItems={stage.items}
|
||||
initialStoreName={stage.storeName}
|
||||
initialDate={stage.date}
|
||||
onAllDone={(groups) => setStage({ name: 'summary', groups })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{stage.name === 'summary' && (
|
||||
<SummaryStep groups={stage.groups} onRestart={() => setStage({ name: 'capture' })} />
|
||||
{stage.name === 'summary' && (
|
||||
<SummaryStep groups={stage.groups} onRestart={() => setStage({ name: 'capture' })} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LoginGate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@ import type { CospendProject, Group, Item, Member, Receipt, SubmitResult } from
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
// Auth is a cookie now (see auth.py) - needed even same-origin-via-proxy
|
||||
// in dev, and required for a cross-origin prod deployment.
|
||||
credentials: 'include',
|
||||
headers: init?.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError('not logged in')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || `${res.status} ${res.statusText}`)
|
||||
@@ -15,52 +23,75 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getMe(): Promise<{ nc_user_id: string }> {
|
||||
return req('/api/me')
|
||||
},
|
||||
|
||||
authStart(): Promise<{ flow_id: string; login_url: string }> {
|
||||
return req('/auth/login/start', { method: 'POST' })
|
||||
},
|
||||
|
||||
authPoll(
|
||||
flowId: string,
|
||||
): Promise<{ status: 'pending' | 'expired' | 'done'; nc_user_id?: string }> {
|
||||
return req(`/auth/login/poll?flow_id=${encodeURIComponent(flowId)}`)
|
||||
},
|
||||
|
||||
logout(): Promise<{ status: string }> {
|
||||
return req('/auth/logout', { method: 'POST' })
|
||||
},
|
||||
|
||||
uploadReceipt(
|
||||
file: File | Blob,
|
||||
): Promise<{ id: string; items: Item[]; store_name: string | null; date: string }> {
|
||||
const form = new FormData()
|
||||
form.append('image', file, 'receipt.jpg')
|
||||
return req('/receipts', { method: 'POST', body: form })
|
||||
return req('/api/receipts', { method: 'POST', body: form })
|
||||
},
|
||||
|
||||
getReceipt(id: string): Promise<Receipt> {
|
||||
return req(`/receipts/${id}`)
|
||||
return req(`/api/receipts/${id}`)
|
||||
},
|
||||
|
||||
updateItems(id: string, items: Item[]): Promise<Receipt> {
|
||||
return req(`/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) })
|
||||
return req(`/api/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) })
|
||||
},
|
||||
|
||||
updateMeta(id: string, meta: { store_name?: string; date?: string }): Promise<Receipt> {
|
||||
return req(`/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) })
|
||||
return req(`/api/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) })
|
||||
},
|
||||
|
||||
getProjects(): Promise<{ projects: CospendProject[]; default_project_id: string | null }> {
|
||||
return req('/cospend/projects')
|
||||
return req('/api/cospend/projects')
|
||||
},
|
||||
|
||||
getMembers(projectId: string): Promise<Member[]> {
|
||||
return req(`/cospend/projects/${projectId}/members`)
|
||||
async getMembers(projectId: string): Promise<Member[]> {
|
||||
// Cospend returns member ids as JSON numbers; normalize to strings here
|
||||
// so every consumer downstream can rely on the Member/Group types'
|
||||
// `string` id fields actually being strings, not silently numbers.
|
||||
const raw = await req<Array<{ id: number | string; name: string }>>(
|
||||
`/api/cospend/projects/${projectId}/members`,
|
||||
)
|
||||
return raw.map((m) => ({ id: String(m.id), name: m.name }))
|
||||
},
|
||||
|
||||
createGroup(
|
||||
receiptId: string,
|
||||
group: {
|
||||
name: string
|
||||
cospend_project_id: string
|
||||
payer_member_id: string
|
||||
member_ids: string[]
|
||||
item_ids: string[]
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
return req(`/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) })
|
||||
return req(`/api/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) })
|
||||
},
|
||||
|
||||
submitGroup(receiptId: string, groupId: string): Promise<SubmitResult> {
|
||||
return req(`/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' })
|
||||
return req(`/api/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' })
|
||||
},
|
||||
|
||||
listGroups(receiptId: string): Promise<Group[]> {
|
||||
return req(`/receipts/${receiptId}/groups`)
|
||||
return req(`/api/receipts/${receiptId}/groups`)
|
||||
},
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { CiReceipt } from 'react-icons/ci'
|
||||
import { api } from '../api'
|
||||
import type { Item } from '../types'
|
||||
|
||||
@@ -10,6 +11,7 @@ export function CaptureStep({ onExtracted }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
async function handleFile(file: File | undefined) {
|
||||
if (!file) return
|
||||
@@ -25,10 +27,33 @@ export function CaptureStep({ onExtracted }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = [...e.dataTransfer.files].find((f) => f.type.startsWith('image/'))
|
||||
handleFile(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="step capture-step">
|
||||
<h1>wgBill</h1>
|
||||
<p>Take a photo of the receipt to get started.</p>
|
||||
<div
|
||||
className={`flex min-h-[70vh] flex-col items-center justify-center rounded-2xl border-2 border-dashed p-10 text-center transition-colors ${
|
||||
dragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30'
|
||||
: 'border-transparent'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<CiReceipt className="h-16 w-16 text-blue-600 dark:text-blue-400" />
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">wgBill</h1>
|
||||
<p className="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||
Take a photo of the receipt to get started.
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
@@ -37,10 +62,19 @@ export function CaptureStep({ onExtracted }: Props) {
|
||||
hidden
|
||||
onChange={(e) => handleFile(e.target.files?.[0])}
|
||||
/>
|
||||
<button disabled={loading} onClick={() => inputRef.current?.click()}>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{loading ? 'Reading receipt…' : 'Take photo'}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<p className="mt-4 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
or drop an image anywhere on this page
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-4 text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CiReceipt } from 'react-icons/ci'
|
||||
import { api } from '../api'
|
||||
import type { CospendProject, Group, Item, Member } from '../types'
|
||||
|
||||
// <input type="date">'s displayed format follows the browser/OS locale, not
|
||||
// anything we control from the page - so for a fixed DD.MM.YYYY display we
|
||||
// use a plain text field instead, converting to/from the ISO date the
|
||||
// backend expects.
|
||||
function isoToEu(iso: string): string {
|
||||
const [y, m, d] = iso.split('-')
|
||||
return y && m && d ? `${d}.${m}.${y}` : ''
|
||||
}
|
||||
function euToIso(eu: string): string | null {
|
||||
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(eu.trim())
|
||||
if (!match) return null
|
||||
const [, d, m, y] = match
|
||||
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
receiptId: string
|
||||
items: Item[]
|
||||
initialItems: Item[]
|
||||
initialStoreName: string | null
|
||||
initialDate: string
|
||||
onAllDone: (groups: Group[]) => void
|
||||
}
|
||||
|
||||
export function GroupStep({ receiptId, items, onAllDone }: Props) {
|
||||
/** One computed bill: a distinct set of owers, and the items assigned to it. */
|
||||
interface Partition {
|
||||
memberIds: string[]
|
||||
items: Item[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export function GroupStep({
|
||||
receiptId,
|
||||
initialItems,
|
||||
initialStoreName,
|
||||
initialDate,
|
||||
onAllDone,
|
||||
}: Props) {
|
||||
// --- receipt-level fields (was the separate "review" step) ---
|
||||
const [items, setItems] = useState<Item[]>(initialItems)
|
||||
const [storeName, setStoreName] = useState(initialStoreName ?? '')
|
||||
const [date, setDate] = useState(initialDate)
|
||||
const [dateText, setDateText] = useState(isoToEu(initialDate))
|
||||
|
||||
// --- cospend data ---
|
||||
const [projects, setProjects] = useState<CospendProject[]>([])
|
||||
const [projectId, setProjectId] = useState<string | null>(null)
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [remainingIds, setRemainingIds] = useState<Set<string>>(new Set(items.map((i) => i.id)))
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
|
||||
// current draft
|
||||
const [name, setName] = useState('WG')
|
||||
const [payerId, setPayerId] = useState<string | null>(null)
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set())
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set())
|
||||
// itemId -> set of member ids splitting that item. Missing/empty = excluded.
|
||||
const [itemMembers, setItemMembers] = useState<Record<string, Set<string>>>({})
|
||||
const [customizing, setCustomizing] = useState<string | null>(null)
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// load Cospend projects once, pick the configured default (or the only one)
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProjects()
|
||||
@@ -37,78 +71,124 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) {
|
||||
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
|
||||
}, [])
|
||||
|
||||
// load members whenever the selected project changes
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
api
|
||||
.getMembers(projectId)
|
||||
.then((m) => {
|
||||
Promise.all([api.getMembers(projectId), api.getMe()])
|
||||
.then(([m, { nc_user_id }]) => {
|
||||
setMembers(m)
|
||||
if (m.length > 0) {
|
||||
setPayerId(m[0].id)
|
||||
setSelectedMemberIds(new Set(m.map((mm) => mm.id)))
|
||||
setPayerId(fuzzyMatchMember(m, nc_user_id)?.id ?? m[0].id)
|
||||
}
|
||||
})
|
||||
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
|
||||
}, [projectId])
|
||||
|
||||
function toggleItem(id: string) {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
const allMemberIds = useMemo(() => members.map((m) => m.id), [members])
|
||||
|
||||
function updateItem(id: string, patch: Partial<Item>) {
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id))
|
||||
setItemMembers((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleMember(id: string) {
|
||||
setSelectedMemberIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
function addItem() {
|
||||
const id = crypto.randomUUID()
|
||||
setItems((prev) => [...prev, { id, label: '', price: 0 }])
|
||||
}
|
||||
|
||||
function isIncluded(itemId: string) {
|
||||
return (itemMembers[itemId]?.size ?? 0) > 0
|
||||
}
|
||||
|
||||
/** The primary, one-click action: off -> everyone, on -> off. */
|
||||
function toggleIncluded(itemId: string) {
|
||||
setItemMembers((prev) => {
|
||||
const next = { ...prev }
|
||||
if (isIncluded(itemId)) {
|
||||
delete next[itemId]
|
||||
} else {
|
||||
next[itemId] = new Set(allMemberIds)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const remainingItems = items.filter((i) => remainingIds.has(i.id))
|
||||
const groupTotal = items
|
||||
.filter((i) => selectedItemIds.has(i.id))
|
||||
.reduce((sum, i) => sum + i.price, 0)
|
||||
function toggleItemMember(itemId: string, memberId: string) {
|
||||
setItemMembers((prev) => {
|
||||
const current = new Set(prev[itemId] ?? [])
|
||||
current.has(memberId) ? current.delete(memberId) : current.add(memberId)
|
||||
const next = { ...prev, [itemId]: current }
|
||||
if (current.size === 0) delete next[itemId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function submitCurrentGroup() {
|
||||
if (!projectId) {
|
||||
setError('pick a Cospend project')
|
||||
// Group included items by identical ower-set - each distinct set becomes
|
||||
// its own bill, since a Cospend bill can only have one split.
|
||||
const partitions: Partition[] = useMemo(() => {
|
||||
const byKey = new Map<string, Partition>()
|
||||
for (const item of items) {
|
||||
const memberSet = itemMembers[item.id]
|
||||
if (!memberSet || memberSet.size === 0) continue
|
||||
const memberIds = [...memberSet].sort()
|
||||
const key = memberIds.join(',')
|
||||
const existing = byKey.get(key)
|
||||
if (existing) {
|
||||
existing.items.push(item)
|
||||
existing.total += item.price
|
||||
} else {
|
||||
byKey.set(key, { memberIds, items: [item], total: item.price })
|
||||
}
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}, [items, itemMembers])
|
||||
|
||||
const excludedCount = items.length - partitions.reduce((n, p) => n + p.items.length, 0)
|
||||
|
||||
function memberName(id: string) {
|
||||
return members.find((m) => m.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
async function submitAll() {
|
||||
if (!projectId || !payerId) {
|
||||
setError('pick a Cospend project and who paid')
|
||||
return
|
||||
}
|
||||
if (!payerId || selectedMemberIds.size === 0 || selectedItemIds.size === 0) {
|
||||
setError('pick a payer, at least one member, and at least one item')
|
||||
if (partitions.length === 0) {
|
||||
setError('include at least one item')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { id: groupId } = await api.createGroup(receiptId, {
|
||||
name,
|
||||
cospend_project_id: projectId,
|
||||
payer_member_id: payerId,
|
||||
member_ids: [...selectedMemberIds],
|
||||
item_ids: [...selectedItemIds],
|
||||
})
|
||||
const result = await api.submitGroup(receiptId, groupId)
|
||||
const allGroups = await api.listGroups(receiptId)
|
||||
setGroups(allGroups)
|
||||
await api.updateItems(receiptId, items)
|
||||
await api.updateMeta(receiptId, { store_name: storeName, date })
|
||||
|
||||
setRemainingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
selectedItemIds.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
const wasLastGroup = remainingItems.length === selectedItemIds.size
|
||||
setSelectedItemIds(new Set())
|
||||
setName('WG')
|
||||
|
||||
if (wasLastGroup) {
|
||||
onAllDone(allGroups)
|
||||
// Create every group first, then submit - so by the time any of them
|
||||
// submits, the backend can see the receipt's real total bill count
|
||||
// (and only mention who's splitting it in the title when there's more
|
||||
// than one bill to tell apart).
|
||||
const groupIds: string[] = []
|
||||
for (const partition of partitions) {
|
||||
const { id: groupId } = await api.createGroup(receiptId, {
|
||||
cospend_project_id: projectId,
|
||||
payer_member_id: payerId,
|
||||
member_ids: partition.memberIds,
|
||||
item_ids: partition.items.map((i) => i.id),
|
||||
})
|
||||
groupIds.push(groupId)
|
||||
}
|
||||
void result
|
||||
for (const groupId of groupIds) {
|
||||
await api.submitGroup(receiptId, groupId)
|
||||
}
|
||||
onAllDone(await api.listGroups(receiptId))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -117,108 +197,220 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) {
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="step">
|
||||
<p className="error">Couldn't load Cospend data: {loadError}</p>
|
||||
</div>
|
||||
)
|
||||
return <p className="mt-8 text-center text-red-500">Couldn't load Cospend data: {loadError}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="step group-step">
|
||||
<h2>Who's splitting this?</h2>
|
||||
<p className="hint">
|
||||
Select the items for one splitting arrangement, pick who's in on it, submit. Repeat for
|
||||
any items that need a different group (e.g. only your snack).
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CiReceipt className="h-8 w-8 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<h2 className="text-2xl font-bold">Split the receipt</h2>
|
||||
</div>
|
||||
|
||||
<label className="payer-select">
|
||||
Cospend project
|
||||
<select value={projectId ?? ''} onChange={(e) => setProjectId(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
select a project…
|
||||
</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="submitted-groups">
|
||||
{groups.map((g) => (
|
||||
<p key={g.id}>
|
||||
✓ {g.name} bill created —{' '}
|
||||
<a href={g.share_url ?? '#'} target="_blank" rel="noreferrer">
|
||||
receipt
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="group-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="group name (e.g. WG, just me + Alex)"
|
||||
/>
|
||||
|
||||
<h3>Items ({remainingItems.length} left)</h3>
|
||||
<ul className="item-list selectable">
|
||||
{remainingItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={selectedItemIds.has(item.id) ? 'selected' : ''}
|
||||
onClick={() => toggleItem(item.id)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Field label="Store">
|
||||
<input
|
||||
value={storeName}
|
||||
placeholder="(unreadable)"
|
||||
onChange={(e) => setStoreName(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={dateText}
|
||||
onChange={(e) => {
|
||||
setDateText(e.target.value)
|
||||
const iso = euToIso(e.target.value)
|
||||
if (iso) setDate(iso)
|
||||
}}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cospend project">
|
||||
<select
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<span>{item.label || '(unnamed)'}</span>
|
||||
<span>{item.price.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3>Split with</h3>
|
||||
<ul className="member-list">
|
||||
{members.map((m) => (
|
||||
<li key={m.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.has(m.id)}
|
||||
onChange={() => toggleMember(m.id)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<label className="payer-select">
|
||||
Paid by
|
||||
<select value={payerId ?? ''} onChange={(e) => setPayerId(e.target.value)}>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
<option value="" disabled>
|
||||
select…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Paid by">
|
||||
<select
|
||||
value={payerId ?? ''}
|
||||
onChange={(e) => setPayerId(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="total">Group total: {groupTotal.toFixed(2)}</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const included = isIncluded(item.id)
|
||||
const selectedMembers = itemMembers[item.id] ?? new Set<string>()
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`rounded-xl border p-3 transition-colors ${
|
||||
included
|
||||
? 'border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30'
|
||||
: 'border-neutral-200 dark:border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleIncluded(item.id)}
|
||||
aria-pressed={included}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border text-xs font-bold transition-colors ${
|
||||
included
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: 'border-neutral-300 text-transparent dark:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
<button disabled={submitting || selectedItemIds.size === 0} onClick={submitCurrentGroup}>
|
||||
{submitting ? 'Creating bill…' : 'Create bill for this group'}
|
||||
<input
|
||||
value={item.label}
|
||||
placeholder="item"
|
||||
onChange={(e) => updateItem(item.id, { label: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm focus:border-neutral-300 focus:bg-white focus:outline-none dark:focus:border-neutral-700 dark:focus:bg-neutral-900"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.id, { price: Number(e.target.value) })}
|
||||
className="w-20 shrink-0 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-right text-sm tabular-nums focus:border-neutral-300 focus:bg-white focus:outline-none dark:focus:border-neutral-700 dark:focus:bg-neutral-900"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomizing(customizing === item.id ? null : item.id)}
|
||||
className="shrink-0 rounded-md px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{included ? memberInitials(selectedMembers, members) : 'excluded'} ▾
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
aria-label="remove item"
|
||||
className="shrink-0 rounded-md px-1.5 py-1 text-neutral-400 hover:text-red-500"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customizing === item.id && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 border-t border-neutral-200 pt-2 dark:border-neutral-800">
|
||||
{members.map((m) => (
|
||||
<label
|
||||
key={m.id}
|
||||
className="flex items-center gap-1.5 rounded-full border border-neutral-300 px-2.5 py-1 text-xs dark:border-neutral-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMembers.has(m.id)}
|
||||
onChange={() => toggleItemMember(item.id, m.id)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="w-full rounded-lg border border-dashed border-neutral-300 py-2 text-sm text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-neutral-700 dark:hover:border-neutral-500"
|
||||
>
|
||||
+ add missed item
|
||||
</button>
|
||||
|
||||
{remainingItems.length === 0 && groups.length > 0 && (
|
||||
<button className="secondary" onClick={() => onAllDone(groups)}>
|
||||
All items assigned — finish
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-neutral-900">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{partitions.length} bill{partitions.length === 1 ? '' : 's'} to create
|
||||
{excludedCount > 0 && (
|
||||
<span className="font-normal text-neutral-500"> · {excludedCount} excluded</span>
|
||||
)}
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{partitions.map((p) => (
|
||||
<li key={p.memberIds.join(',')} className="flex justify-between">
|
||||
<span>
|
||||
{p.memberIds.map(memberName).join(' + ')}{' '}
|
||||
<span className="text-neutral-400">({p.items.length} item(s))</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{p.total.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
disabled={submitting || partitions.length === 0}
|
||||
onClick={submitAll}
|
||||
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating bills…' : `Create ${partitions.length || ''} bill(s)`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-effort match of the logged-in NC user against a Cospend member
|
||||
* name, so we can preselect them as the default payer without hardcoding a
|
||||
* project-specific member id anywhere. Case-insensitive exact match first,
|
||||
* then substring containment either direction; null if nothing looks close
|
||||
* enough (caller falls back to the first member). */
|
||||
function fuzzyMatchMember(members: Member[], ncUsername: string): Member | null {
|
||||
const needle = ncUsername.trim().toLowerCase()
|
||||
if (!needle) return null
|
||||
|
||||
const exact = members.find((m) => m.name.trim().toLowerCase() === needle)
|
||||
if (exact) return exact
|
||||
|
||||
const partial = members.find((m) => {
|
||||
const name = m.name.trim().toLowerCase()
|
||||
return name.includes(needle) || needle.includes(name)
|
||||
})
|
||||
return partial ?? null
|
||||
}
|
||||
|
||||
function memberInitials(ids: Set<string>, members: Member[]): string {
|
||||
if (ids.size === members.length && members.length > 0) return 'everyone'
|
||||
return [...ids].map((id) => members.find((m) => m.id === id)?.name.slice(0, 3) ?? '?').join(',')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CiReceipt } from 'react-icons/ci'
|
||||
import { api, UnauthorizedError } from '../api'
|
||||
|
||||
interface Props {
|
||||
/** Rendered once a session is confirmed. */
|
||||
children: (ctx: { ncUserId: string; logout: () => void }) => React.ReactNode
|
||||
}
|
||||
|
||||
type Status =
|
||||
| { name: 'checking' }
|
||||
| { name: 'loggedOut' }
|
||||
| { name: 'starting' }
|
||||
| { name: 'polling'; flowId: string }
|
||||
| { name: 'error'; message: string }
|
||||
| { name: 'loggedIn'; ncUserId: string }
|
||||
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
|
||||
export function LoginGate({ children }: Props) {
|
||||
const [status, setStatus] = useState<Status>({ name: 'checking' })
|
||||
const openedWindow = useRef<Window | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getMe()
|
||||
.then(({ nc_user_id }) => setStatus({ name: 'loggedIn', ncUserId: nc_user_id }))
|
||||
.catch((e) => {
|
||||
if (e instanceof UnauthorizedError) setStatus({ name: 'loggedOut' })
|
||||
else setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.name !== 'polling') return
|
||||
const flowId = status.flowId
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const result = await api.authPoll(flowId)
|
||||
if (result.status === 'done' && result.nc_user_id) {
|
||||
openedWindow.current?.close()
|
||||
setStatus({ name: 'loggedIn', ncUserId: result.nc_user_id })
|
||||
} else if (result.status === 'expired') {
|
||||
setStatus({ name: 'error', message: 'Login timed out - try again.' })
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [status])
|
||||
|
||||
async function login() {
|
||||
setStatus({ name: 'starting' })
|
||||
try {
|
||||
const { flow_id, login_url } = await api.authStart()
|
||||
openedWindow.current = window.open(login_url, '_blank')
|
||||
setStatus({ name: 'polling', flowId: flow_id })
|
||||
} catch (e) {
|
||||
setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
api.logout().finally(() => setStatus({ name: 'loggedOut' }))
|
||||
}
|
||||
|
||||
if (status.name === 'loggedIn') {
|
||||
return <>{children({ ncUserId: status.ncUserId, logout })}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center px-4 text-center">
|
||||
<CiReceipt className="h-16 w-16 text-blue-600 dark:text-blue-400" />
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">wgBill</h1>
|
||||
|
||||
{status.name === 'checking' && (
|
||||
<p className="mt-4 text-neutral-500 dark:text-neutral-400">Checking login…</p>
|
||||
)}
|
||||
|
||||
{(status.name === 'loggedOut' || status.name === 'starting') && (
|
||||
<>
|
||||
<p className="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||
Log in with your Nextcloud account to get started.
|
||||
</p>
|
||||
<button
|
||||
disabled={status.name === 'starting'}
|
||||
onClick={login}
|
||||
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{status.name === 'starting' ? 'Starting…' : 'Log in with Nextcloud'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.name === 'polling' && (
|
||||
<p className="mt-4 text-neutral-500 dark:text-neutral-400">
|
||||
Complete the login in the tab that just opened, then come back here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.name === 'error' && (
|
||||
<>
|
||||
<p className="mt-4 text-red-500">{status.message}</p>
|
||||
<button
|
||||
onClick={login}
|
||||
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 sm:w-auto"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Item } from '../types'
|
||||
|
||||
interface Props {
|
||||
receiptId: string
|
||||
initialItems: Item[]
|
||||
initialStoreName: string | null
|
||||
initialDate: string
|
||||
onConfirmed: (items: Item[], storeName: string, date: string) => void
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
receiptId,
|
||||
initialItems,
|
||||
initialStoreName,
|
||||
initialDate,
|
||||
onConfirmed,
|
||||
}: Props) {
|
||||
const [items, setItems] = useState<Item[]>(initialItems)
|
||||
const [storeName, setStoreName] = useState(initialStoreName ?? '')
|
||||
const [date, setDate] = useState(initialDate)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function updateItem(id: string, patch: Partial<Item>) {
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id))
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: '', price: 0, bbox: null },
|
||||
])
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.updateItems(receiptId, items)
|
||||
await api.updateMeta(receiptId, { store_name: storeName, date })
|
||||
onConfirmed(items, storeName, date)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const total = items.reduce((sum, it) => sum + (Number(it.price) || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="step review-step">
|
||||
<h2>Check the extracted items</h2>
|
||||
<p className="hint">Fix anything the receipt reader got wrong before splitting.</p>
|
||||
|
||||
<div className="meta-row">
|
||||
<label>
|
||||
Store
|
||||
<input
|
||||
value={storeName}
|
||||
placeholder="(unreadable - add it)"
|
||||
onChange={(e) => setStoreName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Date
|
||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul className="item-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="item-row">
|
||||
<input
|
||||
value={item.label}
|
||||
placeholder="item"
|
||||
onChange={(e) => updateItem(item.id, { label: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.id, { price: Number(e.target.value) })}
|
||||
/>
|
||||
<button className="icon-btn" onClick={() => removeItem(item.id)} aria-label="remove item">
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="secondary" onClick={addItem}>
|
||||
+ add missed item
|
||||
</button>
|
||||
<p className="total">Total: {total.toFixed(2)}</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button disabled={saving || items.length === 0} onClick={confirm}>
|
||||
{saving ? 'Saving…' : 'Looks good, continue'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,26 +7,37 @@ interface Props {
|
||||
|
||||
export function SummaryStep({ groups, onRestart }: Props) {
|
||||
return (
|
||||
<div className="step summary-step">
|
||||
<h2>Done 🎉</h2>
|
||||
<ul className="summary-list">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Done 🎉</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{groups.map((g) => (
|
||||
<li key={g.id}>
|
||||
<strong>{g.name}</strong>
|
||||
<li
|
||||
key={g.id}
|
||||
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm dark:border-neutral-800"
|
||||
>
|
||||
{g.status === 'submitted' ? (
|
||||
<>
|
||||
{' — bill created. '}
|
||||
<a href={g.share_url ?? '#'} target="_blank" rel="noreferrer">
|
||||
view receipt
|
||||
<a
|
||||
href={g.share_url ?? '#'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-blue-600 underline dark:text-blue-400"
|
||||
>
|
||||
View receipt
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<span className="error"> — failed: {g.error}</span>
|
||||
<span className="text-red-500">failed: {g.error}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={onRestart}>Scan another receipt</button>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="mt-6 w-full rounded-xl bg-blue-600 py-3 font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Scan another receipt
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,111 +1,15 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
@import 'tailwindcss';
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@apply m-0 min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
@layer components {
|
||||
.input {
|
||||
@apply rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export type BBox = [number, number, number, number] // x, y, w, h, normalized 0-1
|
||||
|
||||
export interface Item {
|
||||
id: string
|
||||
label: string
|
||||
price: number
|
||||
bbox: BBox | null
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
@@ -29,7 +26,6 @@ export interface CospendProject {
|
||||
export interface Group {
|
||||
id: string
|
||||
receipt_id: string
|
||||
name: string
|
||||
cospend_project_id: string
|
||||
payer_member_id: string
|
||||
member_ids: string[]
|
||||
@@ -45,12 +41,3 @@ export interface SubmitResult {
|
||||
amount: number
|
||||
bill_id: number
|
||||
}
|
||||
|
||||
/** A group draft in progress client-side, before it's persisted via POST /groups. */
|
||||
export interface GroupDraft {
|
||||
localId: string
|
||||
name: string
|
||||
payerMemberId: string | null
|
||||
memberIds: string[]
|
||||
itemIds: string[]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
@@ -6,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
@@ -25,6 +27,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:5000',
|
||||
'/auth': 'http://localhost:5000',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||