Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes, 53 endpoints / 52 fonctions) en package `app/routers/collections/` : - 10 modules de routes : crud 337 L (6 r.), properties 322 (8), linked 286 (7), structure 267 (8), dashboard_views 214 (3), meta 197 (5), views 187 (6), pages 184 (4), data_api 122 (2), boards 61 (3) - _common.py (220 L) : 8 helpers auth/permissions/validation - _renderers.py (667 L) : 15 rendus HTML des vues + CHART_MAX_GROUPS - __init__.py : ré-exports connus (_validate_page_properties pour automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart pour les tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - docstring d'origine conservée dans le header copié → F404 (from __future__ après un statement) → slice [1:30] - décorateurs empilés (view_collection ×2) : segment sans def → skip du 2e décorateur (53 endpoints = 52 unités) - CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers - test_csp_no_cdn_and_vendor lisait collections.py → balayage du package Reste A28 : board.py 2 101 L (lot 4). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
668 lines
26 KiB
Python
668 lines
26 KiB
Python
"""FlowDeck — Collections : rendus HTML des vues (A28).
|
|
|
|
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
|
|
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from app.db import get_conn
|
|
from app.templating import CSP_NONCE
|
|
|
|
CHART_MAX_GROUPS = 200
|
|
|
|
|
|
|
|
# ── View renderers (v1.6.0) ──
|
|
|
|
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
if view_type == "calendar":
|
|
return _render_calendar(view_type, collection, pages, config)
|
|
elif view_type == "gallery":
|
|
return _render_gallery(view_type, collection, pages, config)
|
|
elif view_type == "list":
|
|
return _render_list(view_type, collection, pages, config)
|
|
elif view_type == "timeline":
|
|
return _render_timeline(view_type, collection, pages, config)
|
|
elif view_type == "chart":
|
|
return _render_chart(view_type, collection, pages, config)
|
|
elif view_type == "form":
|
|
return _render_form(view_type, collection, pages, config)
|
|
elif view_type == "map":
|
|
return _render_map(view_type, collection, pages, config)
|
|
elif view_type == "feed":
|
|
return _render_feed(view_type, collection, pages, config)
|
|
elif view_type == "gantt":
|
|
return _render_gantt(view_type, collection, pages, config)
|
|
else:
|
|
return _render_table(view_type, collection, pages, config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
|
<style>
|
|
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
|
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
|
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
|
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
|
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
|
</style></head><body>
|
|
<h1>{icon} {title}</h1>
|
|
<div class="view-tabs">
|
|
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
|
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
|
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
|
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
|
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
|
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
|
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
|
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
|
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
|
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
|
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
|
</div>
|
|
{body}
|
|
</body></html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
from datetime import date as dt_date
|
|
from datetime import timedelta
|
|
today = dt_date.today()
|
|
# Determine month/year from config or current
|
|
year = config.get("year", today.year)
|
|
month = config.get("month", today.month)
|
|
first = dt_date(year, month, 1)
|
|
# Start from Monday of first week
|
|
start = first - timedelta(days=first.weekday())
|
|
days_in_month = []
|
|
for i in range(42): # 6 weeks
|
|
d = start + timedelta(days=i)
|
|
days_in_month.append(d)
|
|
|
|
# Map pages to dates
|
|
date_pages: dict[str, list[dict]] = {}
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
for v in props.values():
|
|
if isinstance(v, str) and v.startswith("20"):
|
|
d = v[:10]
|
|
date_pages.setdefault(d, []).append(p)
|
|
break
|
|
|
|
cells = ""
|
|
for d in days_in_month:
|
|
iso = d.isoformat()
|
|
items = date_pages.get(iso, [])
|
|
other_month = " other-month" if d.month != month else ""
|
|
today_class = " today" if d == today else ""
|
|
items_html = "".join(
|
|
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
|
for p in items
|
|
)
|
|
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
|
|
|
prev = first - timedelta(days=1)
|
|
next_month = first + timedelta(days=32)
|
|
next_month = next_month.replace(day=1)
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
|
<style>
|
|
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
|
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
|
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
|
.cal-day.other-month{{opacity:.35}}
|
|
.cal-day.today{{background:#1a2744}}
|
|
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
|
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
|
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
|
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
|
.cal-nav span{{font-size:16px;font-weight:600}}
|
|
</style>
|
|
<div class="cal-nav">
|
|
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
|
<span>{first.strftime('%B %Y')}</span>
|
|
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
|
</div>
|
|
<div class="calendar">
|
|
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
|
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
|
{cells}
|
|
</div>
|
|
<p class="desc">{len(pages)} pages in collection</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
card_size = config.get("card_size", "medium")
|
|
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
|
|
|
cards = ""
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
cover_url = config.get("cover_property")
|
|
cover_html = ""
|
|
if cover_url:
|
|
for _k, v in props.items():
|
|
if isinstance(v, list) and len(v) > 0:
|
|
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
|
if url.startswith("http"):
|
|
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
|
break
|
|
|
|
prop_tags = "".join(
|
|
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
|
for v in list(props.values())[:3] if v
|
|
)
|
|
|
|
cards += f"""<div class='gal-card'>
|
|
{cover_html}
|
|
<div class='gal-body'>
|
|
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
|
<div class='gal-props'>{prop_tags}</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
|
<style>
|
|
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
|
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
|
.gal-card:hover{{border-color:#555}}
|
|
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
|
.gal-body{{padding:12px}}
|
|
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
|
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
|
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
|
</style>
|
|
<div class="gallery">{cards}</div>
|
|
<p class="desc">{len(pages)} cards</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
items = ""
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
|
items += f"""<div class='list-item'>
|
|
<span class='list-icon'>{p.get('icon','📄')}</span>
|
|
<div class='list-content'>
|
|
<div class='list-title'>{p['title']}</div>
|
|
<div class='list-preview'>{preview}</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
|
<style>
|
|
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
|
.list-item:hover{{border-color:#444}}
|
|
.list-icon{{font-size:18px;margin-top:1px}}
|
|
.list-content{{flex:1;min-width:0}}
|
|
.list-title{{font-size:14px;font-weight:500}}
|
|
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
</style>
|
|
{items}
|
|
<p class="desc">{len(pages)} items</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
# Find date range
|
|
dates = []
|
|
page_dates = []
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
start_val = end_val = None
|
|
for _k, v in props.items():
|
|
if isinstance(v, str) and v.startswith("20"):
|
|
if "..." in v:
|
|
parts = v.split("...")
|
|
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
|
else:
|
|
start_val = end_val = v[:10]
|
|
break
|
|
if start_val:
|
|
dates.append(start_val)
|
|
if end_val:
|
|
dates.append(end_val)
|
|
page_dates.append((p, start_val, end_val or start_val))
|
|
|
|
if not dates:
|
|
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
|
"<p class='desc'>No date data to display timeline.</p>")
|
|
|
|
from datetime import date as dt_date
|
|
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
|
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
|
total = (max_date - min_date).days or 1
|
|
|
|
bars = ""
|
|
for p, start, end in page_dates:
|
|
sd = dt_date.fromisoformat(start)
|
|
ed = dt_date.fromisoformat(end)
|
|
left = (sd - min_date).days / total * 100
|
|
width = max((ed - sd).days / total * 100, 1)
|
|
bars += f"""<div class='tl-row'>
|
|
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
|
<div class='tl-track'>
|
|
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
|
<style>
|
|
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
|
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
|
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
|
</style>
|
|
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
|
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
|
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
|
</div>
|
|
{bars}
|
|
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
|
""")
|
|
|
|
|
|
# ── v4.3.0: New view types ──
|
|
|
|
# Multi-collection dashboards and chart aggregations cap the number of
|
|
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
|
|
|
|
|
|
|
|
|
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
|
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
|
values: list[float] = []
|
|
for p in pages[:CHART_MAX_GROUPS]:
|
|
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
|
v = props.get(chart_property)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
values.append(float(v))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
|
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
|
values = _chart_values(pages, chart_property)
|
|
if aggregate == "count":
|
|
return float(len(pages[:CHART_MAX_GROUPS]))
|
|
if not values:
|
|
return 0.0
|
|
if aggregate == "sum":
|
|
return float(sum(values))
|
|
if aggregate == "avg":
|
|
return float(sum(values) / len(values))
|
|
if aggregate == "min":
|
|
return float(min(values))
|
|
if aggregate == "max":
|
|
return float(max(values))
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_number(value: float) -> str:
|
|
if abs(value) >= 1e9:
|
|
return f"{value / 1e9:.2f}B"
|
|
if abs(value) >= 1e6:
|
|
return f"{value / 1e6:.2f}M"
|
|
if abs(value) >= 1e3:
|
|
return f"{value / 1e3:.1f}K"
|
|
if value == int(value):
|
|
return str(int(value))
|
|
return f"{value:.2f}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
|
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
|
chart_type = config.get("chart_type", "bar")
|
|
chart_property = config.get("chart_property", "")
|
|
|
|
if chart_type == "number":
|
|
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
|
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
|
aggregate = "sum" if chart_property else "count"
|
|
num = _chart_aggregate(pages, chart_property, aggregate)
|
|
label = config.get("title") or chart_property or collection["name"]
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
|
<style>
|
|
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
|
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
|
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
|
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
|
</style>
|
|
<div class="kpi">
|
|
<div class="kpi-label">{label}</div>
|
|
<div class="kpi-value">{_fmt_number(num)}</div>
|
|
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
|
{' of ' + chart_property if chart_property else ''}</div>
|
|
</div>
|
|
""")
|
|
|
|
labels = []
|
|
values = []
|
|
for p in pages[:CHART_MAX_GROUPS]:
|
|
labels.append(str(p.get("title") or "")[:30])
|
|
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
|
val = 0.0
|
|
if chart_property:
|
|
v_raw = props.get(chart_property, 0)
|
|
try:
|
|
val = float(v_raw) if v_raw else 0.0
|
|
except (ValueError, TypeError):
|
|
val = 0.0
|
|
values.append(val)
|
|
|
|
labels_json = json.dumps(labels)
|
|
values_json = json.dumps(values)
|
|
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
|
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
|
<style>
|
|
.chart-container{{max-width:800px;margin:0 auto}}
|
|
canvas{{max-height:400px}}
|
|
</style>
|
|
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
|
<script src="/static/js/vendor/chart.umd.js"></script>
|
|
<script nonce="{CSP_NONCE.get()}">
|
|
new Chart(document.getElementById('chartCanvas'), {{
|
|
type: '{chart_type}',
|
|
data: {{
|
|
labels: {labels_json},
|
|
datasets: [{{
|
|
label: '{config.get("title") or collection["name"]}',
|
|
data: {values_json},
|
|
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
|
}}]
|
|
}},
|
|
options: {{ responsive: true }}
|
|
}});
|
|
</script>
|
|
<p class="desc">{subtitle}</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
"""Form view — generates an HTML form that creates new pages in the collection."""
|
|
properties = []
|
|
with get_conn() as conn:
|
|
props = conn.execute(
|
|
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
|
|
(collection["id"],),
|
|
).fetchall()
|
|
for p in props:
|
|
prop_dict = dict(p)
|
|
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
|
|
properties.append(prop_dict)
|
|
|
|
fields = ""
|
|
for prop in properties:
|
|
name = prop["name"]
|
|
ptype = prop["prop_type"]
|
|
if ptype in ("text", "email", "url", "phone", "number"):
|
|
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
|
|
elif ptype in ("select", "status"):
|
|
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
|
|
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
|
|
elif ptype == "checkbox":
|
|
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
|
|
elif ptype == "date":
|
|
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
|
<style>
|
|
.form-field{{margin-bottom:12px}}
|
|
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
|
|
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
|
|
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
|
|
.form-submit:hover{{background:#254E99}}
|
|
</style>
|
|
<div class="form-container">
|
|
<h3>New entry in {collection['name']}</h3>
|
|
<form id="collectionForm" onsubmit="submitForm(event)">
|
|
{fields}
|
|
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
|
|
<button type='submit' class='form-submit'>Submit</button>
|
|
</form>
|
|
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
|
</div>
|
|
<script nonce="{CSP_NONCE.get()}">
|
|
async function submitForm(e) {{
|
|
e.preventDefault();
|
|
const form = document.getElementById('collectionForm');
|
|
const fd = new FormData(form);
|
|
const properties = {{}};
|
|
const title = fd.get('title') || 'New entry';
|
|
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
|
|
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
|
|
method:'POST', headers:{{'Content-Type':'application/json'}},
|
|
body: JSON.stringify({{title, properties}})
|
|
}});
|
|
if(resp.ok) {{
|
|
document.getElementById('formResult').style.display='block';
|
|
form.reset();
|
|
}}
|
|
}}
|
|
</script>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
"""Map view — displays pages with location data on Leaflet map."""
|
|
markers = []
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
lat, lng = None, None
|
|
for _k, v in props.items():
|
|
if isinstance(v, str) and "," in v:
|
|
parts = v.split(",")
|
|
try:
|
|
lat, lng = float(parts[0].strip()), float(parts[1].strip())
|
|
except ValueError:
|
|
continue
|
|
elif isinstance(v, dict):
|
|
lat = v.get("lat")
|
|
lng = v.get("lng")
|
|
if lat and lng:
|
|
markers.append({"title": p["title"], "lat": lat, "lng": lng})
|
|
|
|
markers_json = json.dumps(markers)
|
|
center_lat = markers[0]["lat"] if markers else 45.5
|
|
center_lng = markers[0]["lng"] if markers else -73.5
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
|
|
<style>
|
|
#map{{height:400px;border-radius:8px}}
|
|
</style>
|
|
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
|
<div id="map"></div>
|
|
<script src="/static/js/vendor/leaflet.js"></script>
|
|
<script nonce="{CSP_NONCE.get()}">
|
|
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
|
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
|
const markers = {markers_json};
|
|
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
|
|
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
|
|
</script>
|
|
<p class="desc">{len(markers)} location(s) mapped</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
"""Feed view — chronological feed of pages, newest first."""
|
|
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
|
|
items = ""
|
|
for p in sorted_pages:
|
|
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
|
|
items += f"""<div class='feed-item'>
|
|
<div class='feed-meta'>{created}</div>
|
|
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
|
|
<style>
|
|
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
|
|
.feed-item:hover{{border-left-color:#3366CC}}
|
|
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
|
|
.feed-title{{font-size:14px;font-weight:500}}
|
|
</style>
|
|
{items}
|
|
<p class="desc">{len(sorted_pages)} entries</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
"""Gantt view — timeline with dependencies and group_by support."""
|
|
group_by = config.get("group_by", "")
|
|
|
|
gantt_data = []
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
group = None
|
|
start_val = end_val = None
|
|
for _k, v in props.items():
|
|
if isinstance(v, str) and v.startswith("20"):
|
|
if "→" in v:
|
|
parts = v.split("→")
|
|
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
|
|
elif "..." in v:
|
|
parts = v.split("...")
|
|
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
|
else:
|
|
start_val = end_val = v[:10]
|
|
break
|
|
if group_by:
|
|
group = str(props.get(group_by, props.get("Status", "")))[:20]
|
|
if start_val:
|
|
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
|
|
|
|
if not gantt_data:
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
|
|
|
|
from datetime import date as dt_date_2
|
|
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
|
|
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
|
|
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
|
|
total_days = max((max_date - min_date).days, 1)
|
|
|
|
groups = {}
|
|
for d in gantt_data:
|
|
groups.setdefault(d["group"], []).append(d)
|
|
if not groups or all(k == "" for k in groups):
|
|
groups = {"": gantt_data}
|
|
|
|
rows = ""
|
|
for group_name, items in sorted(groups.items()):
|
|
if group_name:
|
|
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
|
|
for item in items:
|
|
sd = dt_date_2.fromisoformat(item["start"])
|
|
ed = dt_date_2.fromisoformat(item["end"])
|
|
left = max((sd - min_date).days / total_days * 100, 0)
|
|
width = max((ed - sd).days / total_days * 100, 1)
|
|
rows += f"""<div class='gantt-row'>
|
|
<span class='gantt-label'>{item['title'][:30]}</span>
|
|
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
|
|
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
|
<style>
|
|
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
|
|
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
|
|
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
|
|
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
|
|
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
|
|
</style>
|
|
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
|
|
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
|
|
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
|
|
</div>
|
|
{rows}
|
|
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
|
rows = ""
|
|
for p in pages:
|
|
props = json.loads(p.get("property_values_json", "{}"))
|
|
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
|
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
|
<style>
|
|
table{{width:100%;border-collapse:collapse}}
|
|
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
|
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
|
tr:hover td{{background:#222}}
|
|
</style>
|
|
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
|
<p class="desc">{len(pages)} rows</p>
|
|
""")
|
|
|
|
|
|
|