Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e24d290127 | |||
| 7e8545f8db | |||
| ae2737fed1 | |||
| 4641ca9b72 | |||
| 7cec9541e2 | |||
| 37e91af5bd | |||
| 08f451ec71 | |||
| cf7ce027b1 | |||
| fa14d91359 | |||
| b781193d44 |
@@ -0,0 +1,174 @@
|
|||||||
|
"""Budget transparency endpoints (/api/budget)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from bincio.serve import deps
|
||||||
|
from bincio.serve.db import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_BUDGET_FILE = "budget.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_path() -> Path:
|
||||||
|
return deps._get_data_dir() / _BUDGET_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
p = _budget_path()
|
||||||
|
if not p.exists():
|
||||||
|
return {"monthly_target_eur": None, "entries": []}
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {"monthly_target_eur": None, "entries": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data: dict) -> None:
|
||||||
|
_budget_path().write_text(
|
||||||
|
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialise_recurring(data: dict) -> bool:
|
||||||
|
"""Auto-add a copy of each recurring entry for the current month if absent.
|
||||||
|
|
||||||
|
Returns True if data was modified (caller should save).
|
||||||
|
Copies reference the template via recurring_from so they're not re-generated.
|
||||||
|
"""
|
||||||
|
current = date.today().strftime("%Y-%m")
|
||||||
|
templates = [e for e in data.get("entries", []) if e.get("recurring")]
|
||||||
|
if not templates:
|
||||||
|
return False
|
||||||
|
modified = False
|
||||||
|
for t in templates:
|
||||||
|
if t["month"] == current:
|
||||||
|
continue # template is already this month
|
||||||
|
already = any(
|
||||||
|
e.get("recurring_from") == t["id"] and e["month"] == current
|
||||||
|
for e in data["entries"]
|
||||||
|
)
|
||||||
|
if not already:
|
||||||
|
data["entries"].append({
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"type": t["type"],
|
||||||
|
"label": t["label"],
|
||||||
|
"amount_eur": t["amount_eur"],
|
||||||
|
"month": current,
|
||||||
|
"note": t.get("note", ""),
|
||||||
|
"recurring_from": t["id"],
|
||||||
|
})
|
||||||
|
modified = True
|
||||||
|
return modified
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/budget")
|
||||||
|
async def get_budget() -> JSONResponse:
|
||||||
|
data = _load()
|
||||||
|
if _materialise_recurring(data):
|
||||||
|
_save(data)
|
||||||
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/budget/settings")
|
||||||
|
async def update_settings(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(deps._require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
body = await request.json()
|
||||||
|
data = _load()
|
||||||
|
if "monthly_target_eur" in body:
|
||||||
|
v = body["monthly_target_eur"]
|
||||||
|
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
|
||||||
|
_save(data)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/budget/entries")
|
||||||
|
async def add_entry(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(deps._require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
body = await request.json()
|
||||||
|
entry_type = body.get("type")
|
||||||
|
if entry_type not in ("donation", "expense"):
|
||||||
|
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
||||||
|
label = str(body.get("label", "")).strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(400, "label is required")
|
||||||
|
try:
|
||||||
|
amount = round(float(body["amount_eur"]), 2)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "amount_eur must be a number")
|
||||||
|
month = str(body.get("month", "")).strip()
|
||||||
|
if len(month) != 7 or month[4] != "-":
|
||||||
|
raise HTTPException(400, "month must be YYYY-MM")
|
||||||
|
note = str(body.get("note", "")).strip()
|
||||||
|
|
||||||
|
entry: dict = {
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"type": entry_type,
|
||||||
|
"label": label,
|
||||||
|
"amount_eur": amount,
|
||||||
|
"month": month,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
if body.get("recurring"):
|
||||||
|
entry["recurring"] = True
|
||||||
|
data = _load()
|
||||||
|
data.setdefault("entries", []).append(entry)
|
||||||
|
_save(data)
|
||||||
|
return JSONResponse(entry, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/budget/entries/{entry_id}")
|
||||||
|
async def update_entry(
|
||||||
|
entry_id: str,
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(deps._require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
body = await request.json()
|
||||||
|
data = _load()
|
||||||
|
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "Entry not found")
|
||||||
|
if "label" in body:
|
||||||
|
entry["label"] = str(body["label"]).strip()
|
||||||
|
if "type" in body:
|
||||||
|
if body["type"] not in ("donation", "expense"):
|
||||||
|
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
||||||
|
entry["type"] = body["type"]
|
||||||
|
if "amount_eur" in body:
|
||||||
|
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
|
||||||
|
if "month" in body:
|
||||||
|
entry["month"] = str(body["month"]).strip()
|
||||||
|
if "note" in body:
|
||||||
|
entry["note"] = str(body["note"]).strip()
|
||||||
|
if "recurring" in body:
|
||||||
|
if body["recurring"]:
|
||||||
|
entry["recurring"] = True
|
||||||
|
else:
|
||||||
|
entry.pop("recurring", None)
|
||||||
|
_save(data)
|
||||||
|
return JSONResponse(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/budget/entries/{entry_id}")
|
||||||
|
async def delete_entry(
|
||||||
|
entry_id: str,
|
||||||
|
_: User = Depends(deps._require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
data = _load()
|
||||||
|
before = len(data.get("entries", []))
|
||||||
|
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
|
||||||
|
if len(data["entries"]) == before:
|
||||||
|
raise HTTPException(404, "Entry not found")
|
||||||
|
_save(data)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""Merge and unmerge activity endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from bincio.serve import deps, tasks
|
||||||
|
from bincio.serve.db import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_MERGES_FILE = "_merges.json"
|
||||||
|
_BACKUP_DIR = "_merge_backup"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_merges(user_dir: Path) -> dict:
|
||||||
|
p = user_dir / _MERGES_FILE
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return {"hidden": [], "merges": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_merges(user_dir: Path, data: dict) -> None:
|
||||||
|
(user_dir / _MERGES_FILE).write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/merges")
|
||||||
|
async def get_merges(user: User = Depends(deps._require_auth)) -> JSONResponse:
|
||||||
|
user_dir = deps._get_data_dir() / user.handle
|
||||||
|
return JSONResponse(_read_merges(user_dir))
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRequest(BaseModel):
|
||||||
|
activity_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/merge")
|
||||||
|
async def merge_activities(
|
||||||
|
body: MergeRequest,
|
||||||
|
user: User = Depends(deps._require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
if len(body.activity_ids) < 2:
|
||||||
|
raise HTTPException(400, "Need at least 2 activities to merge")
|
||||||
|
|
||||||
|
dd = deps._get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
|
||||||
|
activities: list[dict] = []
|
||||||
|
for aid in body.activity_ids:
|
||||||
|
deps._check_id(aid)
|
||||||
|
p = acts_dir / f"{aid}.json"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, f"Activity {aid} not found")
|
||||||
|
try:
|
||||||
|
a = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
raise HTTPException(500, f"Could not read activity {aid}")
|
||||||
|
a["_id"] = aid
|
||||||
|
activities.append(a)
|
||||||
|
|
||||||
|
sports = {a.get("sport") for a in activities}
|
||||||
|
if len(sports) > 1:
|
||||||
|
raise HTTPException(400, "Cannot merge activities of different sports")
|
||||||
|
|
||||||
|
activities.sort(key=lambda a: a.get("started_at", ""))
|
||||||
|
primary = activities[0]
|
||||||
|
secondaries = activities[1:]
|
||||||
|
primary_id = primary["_id"]
|
||||||
|
secondary_ids = [a["_id"] for a in secondaries]
|
||||||
|
|
||||||
|
def _sum(key: str) -> float | None:
|
||||||
|
vals = [a.get(key) for a in activities if a.get(key) is not None]
|
||||||
|
return sum(vals) if vals else None
|
||||||
|
|
||||||
|
def _wavg(key: str) -> float | None:
|
||||||
|
pairs = [(a.get(key), a.get("moving_time_s")) for a in activities]
|
||||||
|
pairs = [(v, w) for v, w in pairs if v is not None and w and w > 0]
|
||||||
|
if not pairs:
|
||||||
|
return None
|
||||||
|
total_w = sum(w for _, w in pairs)
|
||||||
|
return sum(v * w for v, w in pairs) / total_w if total_w > 0 else None
|
||||||
|
|
||||||
|
distance_m = _sum("distance_m")
|
||||||
|
duration_s = _sum("duration_s")
|
||||||
|
moving_time_s = _sum("moving_time_s")
|
||||||
|
elevation_gain_m = _sum("elevation_gain_m")
|
||||||
|
avg_speed_kmh = (distance_m / moving_time_s * 3.6) if distance_m and moving_time_s else None
|
||||||
|
avg_hr_bpm_val = _wavg("avg_hr_bpm")
|
||||||
|
avg_power_w_val = _wavg("avg_power_w")
|
||||||
|
|
||||||
|
backup_dir = user_dir / _BACKUP_DIR
|
||||||
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||||
|
src = acts_dir / f"{primary_id}{suffix}"
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, backup_dir / f"{primary_id}{suffix}.bak")
|
||||||
|
|
||||||
|
primary_data: dict[str, Any] = {k: v for k, v in primary.items() if not k.startswith("_")}
|
||||||
|
primary_data["distance_m"] = distance_m
|
||||||
|
primary_data["duration_s"] = duration_s
|
||||||
|
primary_data["moving_time_s"] = moving_time_s
|
||||||
|
primary_data["elevation_gain_m"] = elevation_gain_m
|
||||||
|
if avg_speed_kmh is not None:
|
||||||
|
primary_data["avg_speed_kmh"] = round(avg_speed_kmh, 3)
|
||||||
|
if avg_hr_bpm_val is not None:
|
||||||
|
primary_data["avg_hr_bpm"] = round(avg_hr_bpm_val)
|
||||||
|
if avg_power_w_val is not None:
|
||||||
|
primary_data["avg_power_w"] = round(avg_power_w_val)
|
||||||
|
primary_data["merged_ids"] = secondary_ids
|
||||||
|
|
||||||
|
(acts_dir / f"{primary_id}.json").write_text(
|
||||||
|
json.dumps(primary_data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
_merge_geojson(acts_dir, primary_id, secondary_ids)
|
||||||
|
_merge_timeseries(acts_dir, primary_id, secondary_ids, activities)
|
||||||
|
|
||||||
|
merges = _read_merges(user_dir)
|
||||||
|
for sid in secondary_ids:
|
||||||
|
if sid not in merges["hidden"]:
|
||||||
|
merges["hidden"].append(sid)
|
||||||
|
merges.setdefault("merges", {})[primary_id] = {
|
||||||
|
"secondary_ids": secondary_ids,
|
||||||
|
"merged_at": _now_iso(),
|
||||||
|
}
|
||||||
|
_write_merges(user_dir, merges)
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
merge_one(user_dir, primary_id)
|
||||||
|
tasks._trigger_rebuild(user.handle)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "primary_id": primary_id, "hidden": secondary_ids})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/unmerge/{primary_id}")
|
||||||
|
async def unmerge_activity(
|
||||||
|
primary_id: str,
|
||||||
|
user: User = Depends(deps._require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
deps._check_id(primary_id)
|
||||||
|
dd = deps._get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
backup_dir = user_dir / _BACKUP_DIR
|
||||||
|
|
||||||
|
merges = _read_merges(user_dir)
|
||||||
|
merge_info = merges.get("merges", {}).get(primary_id)
|
||||||
|
if not merge_info:
|
||||||
|
raise HTTPException(404, "No merge record found for this activity")
|
||||||
|
|
||||||
|
secondary_ids: list[str] = merge_info.get("secondary_ids", [])
|
||||||
|
|
||||||
|
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||||
|
bak = backup_dir / f"{primary_id}{suffix}.bak"
|
||||||
|
if bak.exists():
|
||||||
|
shutil.copy2(bak, acts_dir / f"{primary_id}{suffix}")
|
||||||
|
bak.unlink()
|
||||||
|
|
||||||
|
merges["hidden"] = [h for h in merges.get("hidden", []) if h not in secondary_ids]
|
||||||
|
merges["merges"].pop(primary_id, None)
|
||||||
|
_write_merges(user_dir, merges)
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
merge_one(user_dir, primary_id)
|
||||||
|
tasks._trigger_rebuild(user.handle)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "restored": secondary_ids})
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_geojson(acts_dir: Path, primary_id: str, secondary_ids: list[str]) -> None:
|
||||||
|
primary_path = acts_dir / f"{primary_id}.geojson"
|
||||||
|
if not primary_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
geo = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
features: list = list(geo.get("features", []))
|
||||||
|
for sid in secondary_ids:
|
||||||
|
p = acts_dir / f"{sid}.geojson"
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sec = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
features.extend(sec.get("features", []))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
geo["features"] = features
|
||||||
|
primary_path.write_text(json.dumps(geo, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_timeseries(
|
||||||
|
acts_dir: Path,
|
||||||
|
primary_id: str,
|
||||||
|
secondary_ids: list[str],
|
||||||
|
activities: list[dict],
|
||||||
|
) -> None:
|
||||||
|
primary_path = acts_dir / f"{primary_id}.timeseries.json"
|
||||||
|
if not primary_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
merged = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
merged = {k: list(v) if isinstance(v, list) else v for k, v in merged.items()}
|
||||||
|
|
||||||
|
_ARRAY_KEYS = ("lat", "lon", "elevation_m", "speed_kmh", "hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
||||||
|
|
||||||
|
for i, sid in enumerate(secondary_ids):
|
||||||
|
sec_path = acts_dir / f"{sid}.timeseries.json"
|
||||||
|
if not sec_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sec = json.loads(sec_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
sec_t: list = sec.get("t") or []
|
||||||
|
if not sec_t:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
p_start = datetime.fromisoformat(
|
||||||
|
activities[0].get("started_at", "").replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
s_start = datetime.fromisoformat(
|
||||||
|
activities[i + 1].get("started_at", "").replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
offset = s_start - p_start
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cur_t: list = merged.get("t") or []
|
||||||
|
offset = (cur_t[-1] + 1) if cur_t else 0
|
||||||
|
|
||||||
|
adjusted_t = [offset + t for t in sec_t]
|
||||||
|
primary_len = len(merged.get("t") or [])
|
||||||
|
|
||||||
|
if merged.get("t") is None:
|
||||||
|
merged["t"] = []
|
||||||
|
merged["t"].extend(adjusted_t)
|
||||||
|
|
||||||
|
for key in _ARRAY_KEYS:
|
||||||
|
sec_vals = sec.get(key)
|
||||||
|
if sec_vals is None:
|
||||||
|
if merged.get(key) is not None:
|
||||||
|
merged[key] = merged[key] + [None] * len(sec_t)
|
||||||
|
else:
|
||||||
|
if merged.get(key) is None:
|
||||||
|
merged[key] = [None] * primary_len + list(sec_vals)
|
||||||
|
else:
|
||||||
|
merged[key] = merged[key] + list(sec_vals)
|
||||||
|
|
||||||
|
primary_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
|
||||||
@@ -20,12 +20,14 @@ from bincio.serve.routers import (
|
|||||||
activities,
|
activities,
|
||||||
admin,
|
admin,
|
||||||
auth,
|
auth,
|
||||||
|
budget,
|
||||||
download,
|
download,
|
||||||
feed,
|
feed,
|
||||||
garmin,
|
garmin,
|
||||||
gear,
|
gear,
|
||||||
ideas,
|
ideas,
|
||||||
me,
|
me,
|
||||||
|
merge,
|
||||||
ogimage,
|
ogimage,
|
||||||
segments,
|
segments,
|
||||||
strava,
|
strava,
|
||||||
@@ -53,7 +55,7 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org",
|
allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org",
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "DELETE"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
allow_headers=["Content-Type"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +65,8 @@ for _router in [
|
|||||||
me.router,
|
me.router,
|
||||||
admin.router,
|
admin.router,
|
||||||
activities.router,
|
activities.router,
|
||||||
|
merge.router,
|
||||||
|
budget.router,
|
||||||
download.router,
|
download.router,
|
||||||
uploads.router,
|
uploads.router,
|
||||||
segments.router,
|
segments.router,
|
||||||
|
|||||||
@@ -36,6 +36,25 @@
|
|||||||
pr_elapsed_s: number;
|
pr_elapsed_s: number;
|
||||||
}
|
}
|
||||||
let segmentEfforts: SegmentEffortHit[] = [];
|
let segmentEfforts: SegmentEffortHit[] = [];
|
||||||
|
let unmergeWorking = false;
|
||||||
|
|
||||||
|
async function _unmerge() {
|
||||||
|
if (!confirm('Unmerge this activity? The original tracks will be restored and the secondary activities will reappear in your feed.')) return;
|
||||||
|
unmergeWorking = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/unmerge/${activity.id}`, { method: 'POST', credentials: 'include' });
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const d = await r.json();
|
||||||
|
alert(d.detail ?? 'Unmerge failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Unmerge failed');
|
||||||
|
} finally {
|
||||||
|
unmergeWorking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local overrides applied immediately after a save (no re-fetch needed)
|
// Local overrides applied immediately after a save (no re-fetch needed)
|
||||||
let localTitle = '';
|
let localTitle = '';
|
||||||
@@ -381,6 +400,20 @@
|
|||||||
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
|
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
|
||||||
>+ segment</a>
|
>+ segment</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if detail?.merged_ids?.length}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded border border-zinc-600 text-zinc-400 shrink-0">
|
||||||
|
Merged ({detail.merged_ids.length})
|
||||||
|
</span>
|
||||||
|
{#if editEnabled}
|
||||||
|
<button
|
||||||
|
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0 disabled:opacity-40"
|
||||||
|
disabled={unmergeWorking}
|
||||||
|
on:click={_unmerge}
|
||||||
|
>
|
||||||
|
{unmergeWorking ? 'Working…' : 'Unmerge'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if descriptionHtml}
|
{#if descriptionHtml}
|
||||||
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
|
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
|
||||||
|
|||||||
@@ -59,6 +59,73 @@
|
|||||||
/** Logged-in handle — resolved async via bincio:me event. */
|
/** Logged-in handle — resolved async via bincio:me event. */
|
||||||
let me: string = '';
|
let me: string = '';
|
||||||
|
|
||||||
|
// ── Bulk / select mode ────────────────────────────────────────────────────
|
||||||
|
let selectMode = false;
|
||||||
|
let selected: Set<string> = new Set();
|
||||||
|
let hiddenIds: Set<string> = new Set();
|
||||||
|
let bulkWorking = false;
|
||||||
|
|
||||||
|
async function _loadMerges() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/merges', { credentials: 'include' });
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
hiddenIds = new Set(data.hidden ?? []);
|
||||||
|
}
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleSelect(id: string) {
|
||||||
|
const s = new Set(selected);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
selected = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _exitSelect() {
|
||||||
|
selectMode = false;
|
||||||
|
selected = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _deleteSelected() {
|
||||||
|
const ids = [...selected];
|
||||||
|
if (!confirm(`Delete ${ids.length} activit${ids.length === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return;
|
||||||
|
bulkWorking = true;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/activity/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (r.ok) all = all.filter(a => a.id !== id);
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
bulkWorking = false;
|
||||||
|
_exitSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _mergeSelected() {
|
||||||
|
const ids = [...selected];
|
||||||
|
if (ids.length < 2) return;
|
||||||
|
bulkWorking = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ activity_ids: ids }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
for (const hid of data.hidden ?? []) hiddenIds.add(hid);
|
||||||
|
hiddenIds = new Set(hiddenIds);
|
||||||
|
_exitSelect();
|
||||||
|
} else {
|
||||||
|
alert(data.detail ?? 'Merge failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Merge failed');
|
||||||
|
} finally {
|
||||||
|
bulkWorking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
||||||
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
||||||
if (/^\d{4}$/.test(preset)) {
|
if (/^\d{4}$/.test(preset)) {
|
||||||
@@ -74,11 +141,14 @@
|
|||||||
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' };
|
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out secondary activities hidden by a merge.
|
||||||
|
$: withMerge = all.filter(a => !hiddenIds.has(a.id));
|
||||||
|
|
||||||
// Show private activities only to their owner.
|
// Show private activities only to their owner.
|
||||||
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
|
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
|
||||||
// On the global feed: show unlisted only for the logged-in user's own activities.
|
// On the global feed: show unlisted only for the logged-in user's own activities.
|
||||||
$: isOwner = filterHandle !== '' && me === filterHandle;
|
$: isOwner = filterHandle !== '' && me === filterHandle;
|
||||||
$: withPrivacy = all.filter(a => {
|
$: withPrivacy = withMerge.filter(a => {
|
||||||
if (isUnlisted(a.privacy)) {
|
if (isUnlisted(a.privacy)) {
|
||||||
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
||||||
}
|
}
|
||||||
@@ -196,8 +266,12 @@
|
|||||||
// Resolve the logged-in handle so we can show the owner their private activities.
|
// Resolve the logged-in handle so we can show the owner their private activities.
|
||||||
if ((window as any).__bincioMe !== undefined) {
|
if ((window as any).__bincioMe !== undefined) {
|
||||||
me = (window as any).__bincioMe;
|
me = (window as any).__bincioMe;
|
||||||
|
_loadMerges();
|
||||||
} else {
|
} else {
|
||||||
window.addEventListener('bincio:me', (e: Event) => { me = (e as CustomEvent).detail; }, { once: true });
|
window.addEventListener('bincio:me', (e: Event) => {
|
||||||
|
me = (e as CustomEvent).detail;
|
||||||
|
_loadMerges();
|
||||||
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +361,17 @@
|
|||||||
on:click={() => viewMode = 'map'}
|
on:click={() => viewMode = 'map'}
|
||||||
>Map</button>
|
>Map</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
class="px-3 py-2 rounded-lg border text-sm transition-colors shrink-0"
|
||||||
|
class:border-zinc-700={!selectMode}
|
||||||
|
class:text-zinc-400={!selectMode}
|
||||||
|
class:border-[--accent]={selectMode}
|
||||||
|
class:text-white={selectMode}
|
||||||
|
style={selectMode ? 'background:var(--accent-dim)' : ''}
|
||||||
|
on:click={() => { if (selectMode) _exitSelect(); else selectMode = true; }}
|
||||||
|
>{selectMode ? 'Cancel' : 'Select'}</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,8 +453,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{#each visible as a (a.id)}
|
{#each visible as a (a.id)}
|
||||||
|
{@const isSelected = selected.has(a.id)}
|
||||||
<!-- relative + isolate so the stretched activity link stays below the handle link -->
|
<!-- relative + isolate so the stretched activity link stays below the handle link -->
|
||||||
<div class="relative rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group">
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="relative rounded-xl bg-zinc-900 border p-4 transition-all group {isSelected ? 'border-[--accent]' : 'border-zinc-800'} {selectMode ? 'cursor-pointer' : 'hover:border-zinc-600 hover:bg-zinc-800/80'}"
|
||||||
|
style={isSelected ? 'background:var(--accent-dim)' : ''}
|
||||||
|
on:click={selectMode ? () => _toggleSelect(a.id) : undefined}
|
||||||
|
>
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<div class="flex items-start justify-between gap-2 mb-3">
|
<div class="flex items-start justify-between gap-2 mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -384,19 +476,34 @@
|
|||||||
{#if isUnlisted(a.privacy)}
|
{#if isUnlisted(a.privacy)}
|
||||||
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
{#if selectMode}
|
||||||
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
<span class="truncate">{a.title}</span>
|
||||||
class="before:absolute before:inset-0 before:content-[''] truncate"
|
{:else}
|
||||||
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(a)); } catch {} }}
|
<a
|
||||||
>{a.title}</a>
|
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
||||||
|
class="before:absolute before:inset-0 before:content-[''] truncate"
|
||||||
|
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(filterHandle && !a.handle ? { ...a, handle: filterHandle } : a)); } catch {} }}
|
||||||
|
>{a.title}</a>
|
||||||
|
{/if}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span
|
{#if selectMode}
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
<div
|
||||||
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
class="shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors"
|
||||||
>
|
class:border-zinc-600={!isSelected}
|
||||||
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
class:border-[--accent]={isSelected}
|
||||||
</span>
|
style={isSelected ? 'background:var(--accent)' : ''}
|
||||||
|
>
|
||||||
|
{#if isSelected}<span class="text-[10px] text-white leading-none">✓</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||||
|
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
||||||
|
>
|
||||||
|
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- track thumbnail -->
|
<!-- track thumbnail -->
|
||||||
@@ -466,3 +573,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Bulk action bar — fixed at bottom when select mode is active -->
|
||||||
|
{#if selectMode}
|
||||||
|
<div class="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-3 px-4 py-4 bg-zinc-950/95 backdrop-blur border-t border-zinc-800">
|
||||||
|
<span class="text-sm text-zinc-400 mr-2">
|
||||||
|
{selected.size} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg border border-red-700 text-red-400 hover:bg-red-900/30 disabled:opacity-40 transition-colors text-sm"
|
||||||
|
disabled={selected.size === 0 || bulkWorking}
|
||||||
|
on:click={_deleteSelected}
|
||||||
|
>
|
||||||
|
Delete ({selected.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg border border-zinc-600 text-zinc-300 hover:bg-zinc-800 disabled:opacity-40 transition-colors text-sm"
|
||||||
|
disabled={selected.size < 2 || bulkWorking}
|
||||||
|
on:click={_mergeSelected}
|
||||||
|
>
|
||||||
|
Merge ({selected.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-500 hover:text-zinc-300 transition-colors text-sm"
|
||||||
|
on:click={_exitSelect}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{#if bulkWorking}
|
||||||
|
<span class="text-xs text-zinc-500">Working…</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ try {
|
|||||||
fetch('/api/me', { credentials: 'include' })
|
fetch('/api/me', { credentials: 'include' })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.status === 401 || r.status === 404) {
|
if (r.status === 401 || r.status === 404) {
|
||||||
window.location.replace(authUrl ? authUrl + '/login/' : '/login/');
|
const next = authUrl ? '?next=' + encodeURIComponent(window.location.href) : '';
|
||||||
|
window.location.replace(authUrl ? authUrl + '/login/' + next : '/login/');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-auth-pending');
|
document.body.removeAttribute('data-auth-pending');
|
||||||
}
|
}
|
||||||
@@ -257,15 +258,10 @@ try {
|
|||||||
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
||||||
>Planner</a>
|
>Planner</a>
|
||||||
)}
|
)}
|
||||||
<!-- Ideas / About — always visible on desktop -->
|
|
||||||
<a
|
<a
|
||||||
href={`${baseUrl}ideas/`}
|
href={`${baseUrl}support/`}
|
||||||
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
||||||
>Ideas</a>
|
>Support</a>
|
||||||
<a
|
|
||||||
href={`${baseUrl}about/`}
|
|
||||||
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
|
||||||
>About</a>
|
|
||||||
<!-- Settings link — hidden until logged in -->
|
<!-- Settings link — hidden until logged in -->
|
||||||
<a
|
<a
|
||||||
id="nav-settings"
|
id="nav-settings"
|
||||||
@@ -330,12 +326,9 @@ try {
|
|||||||
style="display:none; color: var(--text-4)"
|
style="display:none; color: var(--text-4)"
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors">Planner</a>
|
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors">Planner</a>
|
||||||
)}
|
)}
|
||||||
<a href={`${baseUrl}ideas/`}
|
<a href={`${baseUrl}support/`}
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
style="color: var(--text-4)">Ideas</a>
|
style="color: var(--text-4)">Support</a>
|
||||||
<a href={`${baseUrl}about/`}
|
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
style="color: var(--text-4)">About</a>
|
|
||||||
<a href={`${baseUrl}settings/`}
|
<a href={`${baseUrl}settings/`}
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
style="color: var(--text-4)">Settings</a>
|
style="color: var(--text-4)">Settings</a>
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
|
|||||||
source_file?: string | null;
|
source_file?: string | null;
|
||||||
download_disabled?: boolean;
|
download_disabled?: boolean;
|
||||||
custom: Record<string, unknown>;
|
custom: Record<string, unknown>;
|
||||||
|
/** IDs of secondary activities merged into this one (set by /api/merge). */
|
||||||
|
merged_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lap {
|
export interface Lap {
|
||||||
|
|||||||
@@ -1,252 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
|
||||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
const labels = {
|
const target = `${baseUrl}support/`;
|
||||||
community: 'Community',
|
|
||||||
members: 'member',
|
|
||||||
members_pl: 'members',
|
|
||||||
day: 'day',
|
|
||||||
days: 'days',
|
|
||||||
invited_by: 'invited by',
|
|
||||||
founder: 'founder',
|
|
||||||
loading: 'Loading…',
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
<Base title="About — BincioActivity" public={true}>
|
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||||
<div class="max-w-2xl mx-auto">
|
<script define:vars={{ target }}>window.location.replace(target);</script>
|
||||||
<div class="flex items-baseline justify-between mb-1">
|
|
||||||
<h1 class="text-2xl font-bold text-white">About BincioActivity</h1>
|
|
||||||
<div class="flex gap-3 text-xs text-zinc-500">
|
|
||||||
<span class="text-zinc-300 font-medium">EN</span>
|
|
||||||
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
|
|
||||||
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
|
|
||||||
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-zinc-500 mb-4">Open-source, self-hosted activity tracking</p>
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
<a
|
|
||||||
href="https://ko-fi.com/brutsalvadi"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
|
||||||
style="background:#FF5E5B; color:#fff;"
|
|
||||||
>
|
|
||||||
☕ Support on Ko-fi
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
|
||||||
style="background:#E3162C; color:#fff;"
|
|
||||||
>
|
|
||||||
Satispay @brutsalvadi
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="mb-8">
|
|
||||||
<img
|
|
||||||
src="/satispay-qr.jpg"
|
|
||||||
alt="Satispay QR code — @brutsalvadi"
|
|
||||||
class="w-36 h-36 rounded-xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
|
||||||
|
|
||||||
<!-- Community stats (shown only in multi-user mode) -->
|
|
||||||
<section id="stats-section" style="display:none">
|
|
||||||
<h2 class="text-base font-semibold text-white mb-3">Community</h2>
|
|
||||||
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
|
|
||||||
<div id="stats-tree" class="text-sm"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
|
|
||||||
<p>
|
|
||||||
BincioActivity is a free, open-source platform for tracking your outdoor activities —
|
|
||||||
cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone
|
|
||||||
you trust) run the server, and your data stays under your control.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
Activities are stored in an open JSON format called BAS (BincioActivity Schema),
|
|
||||||
which is designed to be readable and portable. The platform has no hidden analytics,
|
|
||||||
no advertising, and no third-party data sharing.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">Joining & invitations</h2>
|
|
||||||
<p>
|
|
||||||
This instance is invite-only. To join, you need an invite link from an existing
|
|
||||||
member — each link is single-use and tied to a unique code.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
|
|
||||||
share with people you trust. You can manage your invites from the <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>
|
|
||||||
(requires login).
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">Your data on this server</h2>
|
|
||||||
<p>
|
|
||||||
When you upload a FIT, GPX, or TCX file, the server converts it to BAS format.
|
|
||||||
By default the original source file is also kept in your account's
|
|
||||||
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> folder.
|
|
||||||
You can opt out of this at upload time by unchecking <em>"Keep original file on server"</em>.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
Keeping originals is recommended during these early stages of the project: if the
|
|
||||||
processing pipeline improves (better elevation smoothing, speed calculation, lap
|
|
||||||
detection, etc.) you can re-import your files to take advantage of the changes.
|
|
||||||
If you chose not to keep originals, you would need to upload the files again manually.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
When syncing from Strava, the raw activity data fetched from the Strava API can
|
|
||||||
similarly be stored locally. This is controlled by an instance-wide setting
|
|
||||||
configured by the server operator.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">Early-stage software</h2>
|
|
||||||
<p>
|
|
||||||
BincioActivity is under active development. The data format, processing pipeline,
|
|
||||||
and server API may change between versions. Breaking changes are possible, especially
|
|
||||||
at this stage. When they occur, re-importing your original files is the safest way
|
|
||||||
to bring your data up to date.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
There is no guarantee of uptime, data integrity, or forward compatibility for
|
|
||||||
any particular version. Use this software at your own risk, and keep your own
|
|
||||||
backups of important data.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">Disclaimer</h2>
|
|
||||||
<p>
|
|
||||||
BincioActivity is provided <strong class="text-zinc-300">"as is"</strong>, without
|
|
||||||
warranty of any kind. The authors and server operators accept no responsibility for:
|
|
||||||
</p>
|
|
||||||
<ul class="list-disc list-inside mt-2 space-y-1">
|
|
||||||
<li>Loss, corruption, or unauthorised access to your activity data</li>
|
|
||||||
<li>Data exposed through misconfiguration of the server or infrastructure</li>
|
|
||||||
<li>Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)</li>
|
|
||||||
<li>Any consequences of acting on information displayed by this application</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mt-3">
|
|
||||||
You are responsible for securing your account with a strong password, reviewing
|
|
||||||
what data you share, and making your own backups. GPS and health data can be
|
|
||||||
sensitive — think carefully about what you upload and who can see it.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
|
|
||||||
<p>
|
|
||||||
BincioActivity is open-source software. You are free to inspect the code,
|
|
||||||
self-host your own instance, and contribute improvements.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Base>
|
|
||||||
|
|
||||||
<script define:vars={{ labels }}>
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
|
||||||
if (!me.ok) return; // not logged in — hide community section
|
|
||||||
} catch { return; }
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/stats');
|
|
||||||
if (!r.ok) return; // single-user mode — no stats endpoint
|
|
||||||
data = await r.json();
|
|
||||||
} catch { return; }
|
|
||||||
|
|
||||||
// Fix invites link to use absolute base URL
|
|
||||||
const invLink = document.getElementById('invites-link');
|
|
||||||
if (invLink) invLink.href = '/invites/';
|
|
||||||
|
|
||||||
if (!data.user_count) return;
|
|
||||||
|
|
||||||
const section = document.getElementById('stats-section');
|
|
||||||
const summary = document.getElementById('stats-summary');
|
|
||||||
const treeEl = document.getElementById('stats-tree');
|
|
||||||
|
|
||||||
const n = data.user_count;
|
|
||||||
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
|
|
||||||
section.style.display = '';
|
|
||||||
|
|
||||||
// Build adjacency map: handle → [children]
|
|
||||||
const byHandle = {};
|
|
||||||
for (const m of data.members) byHandle[m.handle] = m;
|
|
||||||
const children = {};
|
|
||||||
const roots = [];
|
|
||||||
for (const m of data.members) {
|
|
||||||
if (m.invited_by && byHandle[m.invited_by]) {
|
|
||||||
(children[m.invited_by] ??= []).push(m.handle);
|
|
||||||
} else {
|
|
||||||
roots.push(m.handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(days) {
|
|
||||||
if (days < 1) return `< 1 ${labels.day}`;
|
|
||||||
if (days === 1) return `1 ${labels.day}`;
|
|
||||||
if (days < 30) return `${days} ${labels.days}`;
|
|
||||||
const months = Math.floor(days / 30);
|
|
||||||
return months === 1 ? `1 mo` : `${months} mo`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNode(handle, depth) {
|
|
||||||
const m = byHandle[handle];
|
|
||||||
const indent = depth * 20;
|
|
||||||
const isRoot = !m.invited_by;
|
|
||||||
const sub = isRoot
|
|
||||||
? labels.founder
|
|
||||||
: `${labels.invited_by} @${m.invited_by}`;
|
|
||||||
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
|
|
||||||
row.style.paddingLeft = `${indent}px`;
|
|
||||||
if (depth > 0) {
|
|
||||||
const connector = document.createElement('span');
|
|
||||||
connector.className = 'text-zinc-700 shrink-0';
|
|
||||||
connector.textContent = '└';
|
|
||||||
row.appendChild(connector);
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'text-white font-medium';
|
|
||||||
name.textContent = m.display_name || `@${handle}`;
|
|
||||||
row.appendChild(name);
|
|
||||||
|
|
||||||
const handle_el = document.createElement('span');
|
|
||||||
handle_el.className = 'text-zinc-600 text-xs';
|
|
||||||
handle_el.textContent = `@${handle}`;
|
|
||||||
row.appendChild(handle_el);
|
|
||||||
|
|
||||||
const spacer = document.createElement('span');
|
|
||||||
spacer.className = 'flex-1';
|
|
||||||
row.appendChild(spacer);
|
|
||||||
|
|
||||||
const meta = document.createElement('span');
|
|
||||||
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
|
|
||||||
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
|
|
||||||
row.appendChild(meta);
|
|
||||||
|
|
||||||
treeEl.appendChild(row);
|
|
||||||
|
|
||||||
for (const child of (children[handle] ?? [])) {
|
|
||||||
renderNode(child, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of roots) renderNode(root, 0);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Base from '../../layouts/Base.astro';
|
|||||||
const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
|
const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
|
||||||
---
|
---
|
||||||
<Base title="Admin — BincioActivity">
|
<Base title="Admin — BincioActivity">
|
||||||
<div class="max-w-3xl mx-auto px-4 py-10">
|
<div class="max-w-3xl mx-auto px-4 py-10" data-auth-url={authUrl}>
|
||||||
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
|
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
|
||||||
|
|
||||||
<!-- Disk overview -->
|
<!-- Disk overview -->
|
||||||
@@ -113,7 +113,8 @@ const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
|
|||||||
</div>
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<script define:vars={{ authUrl }}>
|
<script>
|
||||||
|
const authUrl = (document.querySelector<HTMLElement>('.max-w-3xl[data-auth-url]')?.dataset.authUrl) ?? '';
|
||||||
const overviewEl = document.getElementById('disk-overview')!;
|
const overviewEl = document.getElementById('disk-overview')!;
|
||||||
const tbodyEl = document.getElementById('user-list')!;
|
const tbodyEl = document.getElementById('user-list')!;
|
||||||
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
|
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
|
||||||
@@ -407,10 +408,26 @@ const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
|
|||||||
const resetUrl = authUrl
|
const resetUrl = authUrl
|
||||||
? `${authUrl}/reset-password/?code=${d.code}`
|
? `${authUrl}/reset-password/?code=${d.code}`
|
||||||
: `/reset-password/?code=${d.code}`;
|
: `/reset-password/?code=${d.code}`;
|
||||||
btn.textContent = '🔗 Copy reset link';
|
// Show the URL inline so the admin can always see and copy it
|
||||||
btn.title = resetUrl;
|
const cell = btn.closest('td')!;
|
||||||
btn.classList.add('text-yellow-300');
|
btn.remove();
|
||||||
btn.addEventListener('click', () => navigator.clipboard.writeText(resetUrl), { once: true });
|
cell.innerHTML = `
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<input readonly value="${resetUrl}"
|
||||||
|
class="w-full px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-yellow-300 text-xs font-mono focus:outline-none focus:border-zinc-500 cursor-text"
|
||||||
|
onclick="this.select()" />
|
||||||
|
<button class="copy-url-btn text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition-colors">Copy</button>
|
||||||
|
</div>`;
|
||||||
|
const input = cell.querySelector<HTMLInputElement>('input')!;
|
||||||
|
const copyBtn = cell.querySelector<HTMLButtonElement>('.copy-url-btn')!;
|
||||||
|
input.select();
|
||||||
|
navigator.clipboard.writeText(resetUrl).catch(() => {});
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(resetUrl).catch(() => {});
|
||||||
|
input.select();
|
||||||
|
copyBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = 'Error';
|
btn.textContent = 'Error';
|
||||||
btn.classList.add('text-red-400');
|
btn.classList.add('text-red-400');
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
const target = (import.meta.env.BASE_URL ?? '/') + 'support/#ideas';
|
||||||
import IdeasPage from '../../components/IdeasPage.svelte';
|
|
||||||
---
|
---
|
||||||
<Base title="Ideas — BincioActivity">
|
<meta http-equiv="refresh" content={`0;url=${target}`} />
|
||||||
<IdeasPage client:only="svelte" />
|
<script define:vars={{ target }}>window.location.replace(target);</script>
|
||||||
</Base>
|
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import IdeasPage from '../../components/IdeasPage.svelte';
|
||||||
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
|
---
|
||||||
|
<Base title="Support — BincioActivity">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-1">Support</h1>
|
||||||
|
<p class="text-sm text-zinc-500 mb-5">Open-source, self-hosted activity tracking</p>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="flex gap-1 border-b border-zinc-800 mb-6 overflow-x-auto">
|
||||||
|
<button data-tab="donate" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Donate</button>
|
||||||
|
<button data-tab="ideas" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Ideas</button>
|
||||||
|
<button data-tab="feedback" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Feedback</button>
|
||||||
|
<button data-tab="community" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Community</button>
|
||||||
|
<button data-tab="about" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">About</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DONATE ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-donate" class="tab-panel">
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<a href="https://ko-fi.com/brutsalvadi" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
style="background:#FF5E5B; color:#fff;">
|
||||||
|
☕ Support on Ko-fi
|
||||||
|
</a>
|
||||||
|
<div class="relative">
|
||||||
|
<a href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
style="background:#E3162C; color:#fff;">
|
||||||
|
Satispay @brutsalvadi
|
||||||
|
</a>
|
||||||
|
<span class="absolute -top-2 -right-2 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-green-500 text-white leading-none">
|
||||||
|
preferred
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<img src="/satispay-qr.jpg" alt="Satispay QR code — @brutsalvadi" class="w-36 h-36 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget section -->
|
||||||
|
<div class="border-t border-zinc-800 pt-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-white">Running costs</h2>
|
||||||
|
<p class="text-xs text-zinc-500 mt-0.5">Transparent breakdown of donations and expenses</p>
|
||||||
|
</div>
|
||||||
|
<div id="budget-admin-controls" class="hidden flex gap-2">
|
||||||
|
<button id="budget-set-goal-btn"
|
||||||
|
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white text-xs font-medium transition-colors border border-zinc-700">
|
||||||
|
Set monthly goal
|
||||||
|
</button>
|
||||||
|
<button id="budget-add-btn"
|
||||||
|
class="px-3 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-xs font-medium transition-opacity">
|
||||||
|
+ Add entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Set-goal form (admin, hidden by default) -->
|
||||||
|
<form id="budget-goal-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm">
|
||||||
|
<label class="block text-zinc-400 mb-1 text-xs">Monthly goal (€)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="budget-goal-input" type="number" min="0" step="0.01" placeholder="e.g. 20"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
|
||||||
|
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Save</button>
|
||||||
|
<button type="button" id="budget-goal-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<p id="budget-goal-err" class="text-red-400 text-xs mt-1 hidden"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Add-entry form (admin, hidden by default) -->
|
||||||
|
<form id="budget-add-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" data-type="donation" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">💚 Donation</button>
|
||||||
|
<button type="button" data-type="expense" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">🔴 Expense</button>
|
||||||
|
</div>
|
||||||
|
<input id="entry-label" type="text" placeholder="Label (e.g. VPS hosting)"
|
||||||
|
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">€</span>
|
||||||
|
<input id="entry-amount" type="number" min="0" step="0.01" placeholder="0.00"
|
||||||
|
class="w-full pl-7 pr-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
|
||||||
|
</div>
|
||||||
|
<input id="entry-month" type="month"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent] [color-scheme:dark]" />
|
||||||
|
</div>
|
||||||
|
<input id="entry-note" type="text" placeholder="Note (optional)"
|
||||||
|
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
|
||||||
|
<label class="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none">
|
||||||
|
<input id="entry-recurring" type="checkbox" class="accent-[--accent] w-3.5 h-3.5" />
|
||||||
|
Repeat every month
|
||||||
|
</label>
|
||||||
|
<p id="budget-add-err" class="text-red-400 text-xs hidden"></p>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Add</button>
|
||||||
|
<button type="button" id="budget-add-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="budget-list">
|
||||||
|
<p class="text-zinc-500 text-sm text-center py-6">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── IDEAS ────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-ideas" class="tab-panel" style="display:none">
|
||||||
|
<IdeasPage client:only="svelte" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── ABOUT ──────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-about" class="tab-panel" style="display:none">
|
||||||
|
<div class="flex justify-end gap-3 text-xs text-zinc-500 mb-5">
|
||||||
|
<span class="text-zinc-300 font-medium">EN</span>
|
||||||
|
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
|
||||||
|
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
|
||||||
|
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
|
||||||
|
<p>
|
||||||
|
BincioActivity is a free, open-source platform for tracking your outdoor activities —
|
||||||
|
cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone
|
||||||
|
you trust) run the server, and your data stays under your control.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
Activities are stored in an open JSON format called BAS (BincioActivity Schema),
|
||||||
|
which is designed to be readable and portable. The platform has no hidden analytics,
|
||||||
|
no advertising, and no third-party data sharing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">Joining & invitations</h2>
|
||||||
|
<p>
|
||||||
|
This instance is invite-only. To join, you need an invite link from an existing
|
||||||
|
member — each link is single-use and tied to a unique code.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
|
||||||
|
share with people you trust. You can manage your invites from the
|
||||||
|
<a href="/invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">Your data on this server</h2>
|
||||||
|
<p>
|
||||||
|
When you upload a FIT, GPX, or TCX file, the server converts it to BAS format.
|
||||||
|
By default the original source file is also kept in your account's
|
||||||
|
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> folder.
|
||||||
|
You can opt out of this at upload time by unchecking <em>"Keep original file on server"</em>.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
Keeping originals is recommended during these early stages of the project: if the
|
||||||
|
processing pipeline improves (better elevation smoothing, speed calculation, lap
|
||||||
|
detection, etc.) you can re-import your files to take advantage of the changes.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">Early-stage software</h2>
|
||||||
|
<p>
|
||||||
|
BincioActivity is under active development. The data format, processing pipeline,
|
||||||
|
and server API may change between versions. Breaking changes are possible, especially
|
||||||
|
at this stage. When they occur, re-importing your original files is the safest way
|
||||||
|
to bring your data up to date.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
There is no guarantee of uptime, data integrity, or forward compatibility for
|
||||||
|
any particular version. Use this software at your own risk, and keep your own
|
||||||
|
backups of important data.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">Disclaimer</h2>
|
||||||
|
<p>
|
||||||
|
BincioActivity is provided <strong class="text-zinc-300">"as is"</strong>, without
|
||||||
|
warranty of any kind. The authors and server operators accept no responsibility for:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>Loss, corruption, or unauthorised access to your activity data</li>
|
||||||
|
<li>Data exposed through misconfiguration of the server or infrastructure</li>
|
||||||
|
<li>Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)</li>
|
||||||
|
<li>Any consequences of acting on information displayed by this application</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3">
|
||||||
|
You are responsible for securing your account with a strong password, reviewing
|
||||||
|
what data you share, and making your own backups. GPS and health data can be
|
||||||
|
sensitive — think carefully about what you upload and who can see it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
|
||||||
|
<p>
|
||||||
|
BincioActivity is open-source software. You are free to inspect the code,
|
||||||
|
self-host your own instance, and contribute improvements.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── COMMUNITY ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-community" class="tab-panel" style="display:none">
|
||||||
|
<p id="community-summary" class="text-zinc-500 text-xs mb-4"></p>
|
||||||
|
<div id="community-tree" class="text-sm"></div>
|
||||||
|
<p id="community-empty" class="text-zinc-500 text-sm text-center py-6" style="display:none">Community stats not available.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FEEDBACK ───────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-feedback" class="tab-panel" style="display:none">
|
||||||
|
<h2 class="text-base font-semibold text-white mb-1">Send feedback</h2>
|
||||||
|
<p class="text-sm text-zinc-500 mb-5">Report a bug, suggest a feature, or share anything useful.</p>
|
||||||
|
|
||||||
|
<form id="feedback-form" class="space-y-4">
|
||||||
|
<textarea id="fb-text" rows="6" placeholder="What's on your mind?"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-[--accent] resize-none"></textarea>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
|
||||||
|
<div id="fb-drop"
|
||||||
|
class="border-2 border-dashed border-zinc-700 rounded-lg p-5 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors">
|
||||||
|
<span>Drop images or click to browse</span>
|
||||||
|
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
|
||||||
|
</div>
|
||||||
|
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="fb-error" class="text-red-400 text-sm hidden"></p>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium text-sm transition-opacity">
|
||||||
|
Send feedback
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="fb-success" class="hidden text-center mt-12">
|
||||||
|
<p class="text-2xl mb-2">Thanks!</p>
|
||||||
|
<p class="text-zinc-400 text-sm">Your feedback has been received.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const TABS = ['donate', 'ideas', 'feedback', 'community', 'about'] as const;
|
||||||
|
type TabName = typeof TABS[number];
|
||||||
|
|
||||||
|
function showTab(name: TabName) {
|
||||||
|
TABS.forEach(t => {
|
||||||
|
const panel = document.getElementById(`tab-${t}`)!;
|
||||||
|
const btn = document.querySelector<HTMLElement>(`.tab-btn[data-tab="${t}"]`)!;
|
||||||
|
const active = t === name;
|
||||||
|
panel.style.display = active ? '' : 'none';
|
||||||
|
btn.classList.toggle('text-white', active);
|
||||||
|
btn.classList.toggle('border-b-2', active);
|
||||||
|
btn.classList.toggle('border-[--accent]', active);
|
||||||
|
btn.classList.toggle('-mb-px', active);
|
||||||
|
btn.classList.toggle('text-zinc-400', !active);
|
||||||
|
});
|
||||||
|
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = location.hash.slice(1) as TabName;
|
||||||
|
showTab(TABS.includes(hash) ? hash : 'donate');
|
||||||
|
document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => showTab(btn.dataset.tab as TabName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Community tree ────────────────────────────────────────────────────────────
|
||||||
|
let communityLoaded = false;
|
||||||
|
|
||||||
|
async function loadCommunity() {
|
||||||
|
const summaryEl = document.getElementById('community-summary')!;
|
||||||
|
const treeEl = document.getElementById('community-tree')!;
|
||||||
|
const emptyEl = document.getElementById('community-empty')!;
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/stats');
|
||||||
|
if (!r.ok) { emptyEl.style.display = ''; return; }
|
||||||
|
data = await r.json();
|
||||||
|
} catch { emptyEl.style.display = ''; return; }
|
||||||
|
if (!data.user_count) { emptyEl.style.display = ''; return; }
|
||||||
|
|
||||||
|
const n = data.user_count;
|
||||||
|
summaryEl.textContent = `${n} ${n === 1 ? 'member' : 'members'}`;
|
||||||
|
|
||||||
|
const byHandle: Record<string, any> = {};
|
||||||
|
for (const m of data.members) byHandle[m.handle] = m;
|
||||||
|
const children: Record<string, string[]> = {};
|
||||||
|
const roots: string[] = [];
|
||||||
|
for (const m of data.members) {
|
||||||
|
if (m.invited_by && byHandle[m.invited_by]) {
|
||||||
|
(children[m.invited_by] ??= []).push(m.handle);
|
||||||
|
} else {
|
||||||
|
roots.push(m.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDays(days: number) {
|
||||||
|
if (days < 1) return '< 1 day';
|
||||||
|
if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'}`;
|
||||||
|
const mo = Math.floor(days / 30);
|
||||||
|
return mo === 1 ? '1 mo' : `${mo} mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(handle: string, depth: number) {
|
||||||
|
const m = byHandle[handle];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
|
||||||
|
row.style.paddingLeft = `${depth * 20}px`;
|
||||||
|
if (depth > 0) {
|
||||||
|
const c = document.createElement('span');
|
||||||
|
c.className = 'text-zinc-700 shrink-0';
|
||||||
|
c.textContent = '└';
|
||||||
|
row.appendChild(c);
|
||||||
|
}
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'text-white font-medium';
|
||||||
|
name.textContent = m.display_name || `@${handle}`;
|
||||||
|
row.appendChild(name);
|
||||||
|
const hEl = document.createElement('span');
|
||||||
|
hEl.className = 'text-zinc-600 text-xs';
|
||||||
|
hEl.textContent = `@${handle}`;
|
||||||
|
row.appendChild(hEl);
|
||||||
|
const spacer = document.createElement('span');
|
||||||
|
spacer.className = 'flex-1';
|
||||||
|
row.appendChild(spacer);
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
|
||||||
|
const sub = m.invited_by ? `invited by @${m.invited_by}` : 'founder';
|
||||||
|
meta.innerHTML = `${formatDays(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
|
||||||
|
row.appendChild(meta);
|
||||||
|
treeEl.appendChild(row);
|
||||||
|
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
|
||||||
|
}
|
||||||
|
for (const root of roots) renderNode(root, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector<HTMLElement>('.tab-btn[data-tab="community"]')!.addEventListener('click', () => {
|
||||||
|
if (!communityLoaded) { communityLoaded = true; loadCommunity(); }
|
||||||
|
});
|
||||||
|
if (location.hash.slice(1) === 'community') { communityLoaded = true; loadCommunity(); }
|
||||||
|
|
||||||
|
// ── Budget ────────────────────────────────────────────────────────────────────
|
||||||
|
let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
|
||||||
|
let isAdmin = false;
|
||||||
|
let addEntryType: 'donation' | 'expense' = 'donation';
|
||||||
|
|
||||||
|
async function loadBudget() {
|
||||||
|
try {
|
||||||
|
const [br, mr] = await Promise.all([
|
||||||
|
fetch('/api/budget'),
|
||||||
|
fetch('/api/me', { credentials: 'include' }),
|
||||||
|
]);
|
||||||
|
budgetData = br.ok ? await br.json() : { monthly_target_eur: null, entries: [] };
|
||||||
|
if (mr.ok) { const me = await mr.json(); isAdmin = !!me.is_admin; }
|
||||||
|
} catch {
|
||||||
|
budgetData = { monthly_target_eur: null, entries: [] };
|
||||||
|
}
|
||||||
|
renderBudget();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtEur(n: number) {
|
||||||
|
return `€${n.toFixed(2).replace('.00', '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(ym: string) {
|
||||||
|
const [y, m] = ym.split('-');
|
||||||
|
return new Date(parseInt(y), parseInt(m) - 1, 1)
|
||||||
|
.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBudget() {
|
||||||
|
const listEl = document.getElementById('budget-list')!;
|
||||||
|
const adminCtrls = document.getElementById('budget-admin-controls')!;
|
||||||
|
const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
adminCtrls.classList.remove('hidden');
|
||||||
|
if (budgetData?.monthly_target_eur != null) goalInput.value = String(budgetData.monthly_target_eur);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = budgetData?.entries ?? [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
listEl.innerHTML = `<p class="text-zinc-500 text-sm text-center py-6">${isAdmin ? 'No entries yet. Add the first one above.' : 'No budget entries yet.'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byMonth: Record<string, any[]> = {};
|
||||||
|
for (const e of entries) (byMonth[e.month] ??= []).push(e);
|
||||||
|
const months = Object.keys(byMonth).sort().reverse();
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const ym of months) {
|
||||||
|
const items = byMonth[ym];
|
||||||
|
const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0);
|
||||||
|
const spent = items.filter(e => e.type === 'expense' ).reduce((s, e) => s + e.amount_eur, 0);
|
||||||
|
const balance = donated - spent;
|
||||||
|
const target = budgetData?.monthly_target_eur;
|
||||||
|
const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null;
|
||||||
|
|
||||||
|
html += `<div class="mb-6">`;
|
||||||
|
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
|
||||||
|
|
||||||
|
if (pct !== null) {
|
||||||
|
html += `<div class="mb-3">
|
||||||
|
<div class="flex justify-between text-xs text-zinc-500 mb-1">
|
||||||
|
<span>Donations toward monthly goal</span>
|
||||||
|
<span>${pct}% of ${fmtEur(target!)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full transition-all" style="width:${pct}%;background:var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
|
||||||
|
for (const e of items) {
|
||||||
|
const icon = e.type === 'donation' ? '💚' : '🔴';
|
||||||
|
const sign = e.type === 'donation' ? '+' : '−';
|
||||||
|
const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400';
|
||||||
|
const recurBadge = e.recurring
|
||||||
|
? `<span class="text-zinc-500 text-xs shrink-0" title="Repeats every month">↻</span>`
|
||||||
|
: e.recurring_from
|
||||||
|
? `<span class="text-zinc-600 text-xs shrink-0" title="Auto-added (recurring)">↻</span>`
|
||||||
|
: '';
|
||||||
|
const adminBtns = isAdmin
|
||||||
|
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
|
||||||
|
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
|
||||||
|
: '';
|
||||||
|
html += `<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 text-sm" data-entry-id="${e.id}">
|
||||||
|
<span class="text-base shrink-0">${icon}</span>
|
||||||
|
<span class="flex-1 text-zinc-300 min-w-0">
|
||||||
|
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
${recurBadge}
|
||||||
|
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
|
||||||
|
${adminBtns}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1">
|
||||||
|
<span>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span>
|
||||||
|
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
|
||||||
|
<span>Balance: <span class="${balance >= 0 ? 'text-zinc-300' : 'text-red-400'}">${balance >= 0 ? '+' : '−'}${fmtEur(Math.abs(balance))}</span></span>
|
||||||
|
</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Delete this entry?')) return;
|
||||||
|
const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (r.ok) { budgetData!.entries = budgetData!.entries.filter(e => e.id !== btn.dataset.del); renderBudget(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.edit!;
|
||||||
|
const entry = budgetData!.entries.find(e => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
const row = listEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!;
|
||||||
|
row.innerHTML = `
|
||||||
|
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}">
|
||||||
|
<select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
|
||||||
|
<option value="donation" ${entry.type==='donation'?'selected':''}>💚 Donation</option>
|
||||||
|
<option value="expense" ${entry.type==='expense' ?'selected':''}>🔴 Expense</option>
|
||||||
|
</select>
|
||||||
|
<input name="label" value="${entry.label}" placeholder="Label"
|
||||||
|
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
|
||||||
|
<input name="amount_eur" type="number" value="${entry.amount_eur}" step="0.01" min="0"
|
||||||
|
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
|
||||||
|
<input name="month" type="month" value="${entry.month}"
|
||||||
|
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
|
||||||
|
<input name="note" value="${entry.note}" placeholder="Note"
|
||||||
|
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-400 cursor-pointer">
|
||||||
|
<input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
|
||||||
|
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const fd = new FormData(ev.target as HTMLFormElement);
|
||||||
|
const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' };
|
||||||
|
const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); }
|
||||||
|
});
|
||||||
|
row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load budget on page load (donate is the default tab)
|
||||||
|
loadBudget();
|
||||||
|
|
||||||
|
// Goal form
|
||||||
|
const goalForm = document.getElementById('budget-goal-form') as HTMLFormElement;
|
||||||
|
const goalBtn = document.getElementById('budget-set-goal-btn')!;
|
||||||
|
const goalCancel = document.getElementById('budget-goal-cancel')!;
|
||||||
|
const goalErr = document.getElementById('budget-goal-err')!;
|
||||||
|
goalBtn.addEventListener('click', () => goalForm.classList.toggle('hidden'));
|
||||||
|
goalCancel.addEventListener('click', () => goalForm.classList.add('hidden'));
|
||||||
|
goalForm.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
goalErr.classList.add('hidden');
|
||||||
|
const v = parseFloat((document.getElementById('budget-goal-input') as HTMLInputElement).value);
|
||||||
|
const r = await fetch('/api/budget/settings', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monthly_target_eur: isNaN(v) ? null : v }) });
|
||||||
|
if (r.ok) { if (budgetData) budgetData.monthly_target_eur = isNaN(v) ? null : v; goalForm.classList.add('hidden'); renderBudget(); }
|
||||||
|
else { goalErr.textContent = 'Failed to save goal.'; goalErr.classList.remove('hidden'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add-entry form
|
||||||
|
const addForm = document.getElementById('budget-add-form') as HTMLFormElement;
|
||||||
|
const addBtn = document.getElementById('budget-add-btn')!;
|
||||||
|
const addCancel = document.getElementById('budget-add-cancel')!;
|
||||||
|
const addErr = document.getElementById('budget-add-err')!;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
|
||||||
|
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
||||||
|
|
||||||
|
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
addEntryType = btn.dataset.type as 'donation' | 'expense';
|
||||||
|
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(b => {
|
||||||
|
const a = b.dataset.type === addEntryType;
|
||||||
|
b.classList.toggle('border-[--accent]', a); b.classList.toggle('text-white', a); b.classList.toggle('bg-zinc-800', a);
|
||||||
|
b.classList.toggle('border-zinc-700', !a); b.classList.toggle('text-zinc-400', !a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => { addForm.classList.toggle('hidden'); addErr.classList.add('hidden'); });
|
||||||
|
addCancel.addEventListener('click', () => addForm.classList.add('hidden'));
|
||||||
|
|
||||||
|
addForm.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
addErr.classList.add('hidden');
|
||||||
|
const label = (document.getElementById('entry-label') as HTMLInputElement).value.trim();
|
||||||
|
const amount = parseFloat((document.getElementById('entry-amount') as HTMLInputElement).value);
|
||||||
|
const month = (document.getElementById('entry-month') as HTMLInputElement).value;
|
||||||
|
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
|
||||||
|
if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; }
|
||||||
|
if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; }
|
||||||
|
const recurring = (document.getElementById('entry-recurring') as HTMLInputElement).checked;
|
||||||
|
const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note, recurring: recurring || undefined }) });
|
||||||
|
if (r.ok) {
|
||||||
|
budgetData!.entries.push(await r.json());
|
||||||
|
addForm.classList.add('hidden'); addForm.reset();
|
||||||
|
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
||||||
|
(document.getElementById('entry-recurring') as HTMLInputElement).checked = false;
|
||||||
|
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
|
||||||
|
renderBudget();
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
addErr.textContent = d.detail ?? 'Failed to add entry.'; addErr.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Feedback ──────────────────────────────────────────────────────────────────
|
||||||
|
const MAX_IMAGES = 3;
|
||||||
|
const MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const fbForm = document.getElementById('feedback-form') as HTMLFormElement;
|
||||||
|
const fbDrop = document.getElementById('fb-drop')!;
|
||||||
|
const fbInput = document.getElementById('fb-input') as HTMLInputElement;
|
||||||
|
const fbPreviews = document.getElementById('fb-previews')!;
|
||||||
|
const fbErr = document.getElementById('fb-error')!;
|
||||||
|
const fbSuccess = document.getElementById('fb-success')!;
|
||||||
|
let fbFiles: File[] = [];
|
||||||
|
|
||||||
|
function fbShowErr(msg: string) { fbErr.textContent = msg; fbErr.classList.remove('hidden'); }
|
||||||
|
function fbClearErr() { fbErr.classList.add('hidden'); }
|
||||||
|
function fbRenderPreviews() {
|
||||||
|
fbPreviews.innerHTML = '';
|
||||||
|
fbFiles.forEach((f, i) => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'relative';
|
||||||
|
wrap.innerHTML = `<img src="${URL.createObjectURL(f)}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
|
||||||
|
<button type="button" data-i="${i}" class="rm-btn absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 hover:text-white text-xs flex items-center justify-center">×</button>`;
|
||||||
|
fbPreviews.appendChild(wrap);
|
||||||
|
});
|
||||||
|
fbPreviews.querySelectorAll<HTMLElement>('.rm-btn').forEach(b => {
|
||||||
|
b.addEventListener('click', () => { fbFiles.splice(parseInt(b.dataset.i!), 1); fbRenderPreviews(); fbClearErr(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function fbAddFiles(files: FileList | File[]) {
|
||||||
|
fbClearErr();
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
if (fbFiles.length >= MAX_IMAGES) { fbShowErr(`Max ${MAX_IMAGES} images.`); break; }
|
||||||
|
if (f.size > MAX_BYTES) { fbShowErr(`"${f.name}" exceeds 2 MB.`); continue; }
|
||||||
|
fbFiles.push(f);
|
||||||
|
}
|
||||||
|
fbRenderPreviews(); fbInput.value = '';
|
||||||
|
}
|
||||||
|
fbDrop.addEventListener('click', () => fbInput.click());
|
||||||
|
fbDrop.addEventListener('dragover', e => { e.preventDefault(); fbDrop.style.borderColor = 'var(--accent)'; });
|
||||||
|
fbDrop.addEventListener('dragleave', () => { fbDrop.style.borderColor = ''; });
|
||||||
|
fbDrop.addEventListener('drop', e => { e.preventDefault(); fbDrop.style.borderColor = ''; if (e.dataTransfer?.files.length) fbAddFiles(e.dataTransfer.files); });
|
||||||
|
fbInput.addEventListener('change', () => { if (fbInput.files?.length) fbAddFiles(fbInput.files); });
|
||||||
|
fbForm.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault(); fbClearErr();
|
||||||
|
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
|
||||||
|
if (!text && fbFiles.length === 0) { fbShowErr('Please write something or attach an image.'); return; }
|
||||||
|
const fd = new FormData(); fd.append('text', text);
|
||||||
|
for (const f of fbFiles) fd.append('images', f);
|
||||||
|
const btn = fbForm.querySelector('button[type=submit]') as HTMLButtonElement;
|
||||||
|
btn.disabled = true; btn.textContent = 'Sending…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/feedback', { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail ?? `Error ${r.status}`); }
|
||||||
|
fbForm.classList.add('hidden'); fbSuccess.classList.remove('hidden');
|
||||||
|
} catch (err: any) { fbShowErr(err.message); btn.disabled = false; btn.textContent = 'Send feedback'; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user