From 80c0abd89b61d2ab6d3635040c94b36cc55749d9 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 10:34:06 +0200 Subject: [PATCH 01/72] Add private visibility, unified signup, and private-by-default pages --- cli/jottit_cli/commands/publish.py | 4 +- migrations/026_add_private_visibility.sql | 4 ++ routes/__init__.py | 11 +++- routes/admin.py | 45 ++++++++++++++-- routes/api.py | 2 +- routes/mcp.py | 4 +- routes/public.py | 10 ++-- routes/site.py | 10 ++-- templates/page.html | 1 + templates/setup_username.html | 20 +++++++ tests/test_api.py | 2 +- tests/test_auth.py | 60 ++++++++++++++++++--- tests/test_subdomain.py | 65 +++++++++++++++++++++-- 13 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 migrations/026_add_private_visibility.sql create mode 100644 templates/setup_username.html diff --git a/cli/jottit_cli/commands/publish.py b/cli/jottit_cli/commands/publish.py index 9d0507c..7d388cb 100644 --- a/cli/jottit_cli/commands/publish.py +++ b/cli/jottit_cli/commands/publish.py @@ -25,9 +25,9 @@ def _read_content(file, title): @click.option("--slug", help="Custom slug for the page") @click.option( "--visibility", - type=click.Choice(["unlisted", "listed", "pinned"]), + type=click.Choice(["private", "unlisted", "listed", "pinned"]), default=None, - help="Page visibility (default: unlisted)", + help="Page visibility (default: private)", ) @click.option("--title", help="Page title (prepended as # heading if missing)") @click.option( diff --git a/migrations/026_add_private_visibility.sql b/migrations/026_add_private_visibility.sql new file mode 100644 index 0000000..0a3d9dc --- /dev/null +++ b/migrations/026_add_private_visibility.sql @@ -0,0 +1,4 @@ +-- Re-add 'private' as a valid visibility state. +-- Private pages are only visible to their owner. +-- Lifecycle: private → unlisted → listed → pinned +-- (Previously removed in migration 018.) diff --git a/routes/__init__.py b/routes/__init__.py index c697ae1..25cbfcd 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -43,7 +43,7 @@ }, } -VISIBILITY_OPTIONS = ("unlisted", "listed", "pinned") +VISIBILITY_OPTIONS = ("private", "unlisted", "listed", "pinned") def _get_subdomain(): @@ -106,6 +106,15 @@ def can_edit(page_meta): return is_creator(page_meta) +def check_page_visibility(page_meta): + """Abort 404 if page is private and current user is not the owner.""" + if not page_meta: + return + if page_meta.get("visibility") == "private": + if session.get("user_id") != page_meta["user_id"]: + abort(404) + + def send_verification(email, purpose): code = create_verification_code(email, purpose) send_verification_email(email, code) diff --git a/routes/admin.py b/routes/admin.py index e13763e..13d02ab 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -8,6 +8,7 @@ delete_api_token, delete_user, find_or_create_user, + set_user_username, get_api_tokens, get_pages_for_user, get_user, @@ -50,9 +51,6 @@ def signin(): "signin.html", error="Please enter a valid email address." ) - if not user_exists(email): - return render_template("signin.html", not_found=True) - send_verification(email, "signin") return redirect("/signin/verify") @@ -84,7 +82,7 @@ def signin_verify(): user = get_user(user_id) if user and user.get("username"): return redirect(profile_url(user["username"])) - return redirect("/") + return redirect("/setup/username") @bp.route("/signout", methods=["POST"]) @@ -93,6 +91,45 @@ def signout(): return redirect("/") +@bp.route("/setup/username", methods=["GET", "POST"]) +def setup_username(): + user_id = session.get("user_id") + if not user_id: + return redirect("/signin") + user = get_user(user_id) + if user and user.get("username"): + return redirect(profile_url(user["username"])) + + if request.method == "GET": + return render_template("setup_username.html") + + username = request.form.get("username", "").strip().lower() + if not username: + return render_template("setup_username.html", error="Username is required.") + if not valid_username(username): + return render_template( + "setup_username.html", + error="Username must be lowercase letters, numbers, and hyphens only.", + username=username, + ) + if username in RESERVED_USERNAMES: + return render_template( + "setup_username.html", + error="That username is reserved.", + username=username, + ) + if not check_username_available(username): + return render_template( + "setup_username.html", + error="That username is already taken.", + username=username, + ) + + set_user_username(user_id, username) + update_user_settings(user_id, name="", username=username, bio="", license=None) + return redirect(profile_url(username)) + + @bp.route("/pages") def user_pages(): from routes.public import _build_page_item, sidebar_vars, compute_initials diff --git a/routes/api.py b/routes/api.py index d5b12d7..a7dbac8 100644 --- a/routes/api.py +++ b/routes/api.py @@ -155,7 +155,7 @@ def create_page(): slug = slugify(data.get("slug", "")) or generate_slug() if user: - visibility = data.get("visibility", "unlisted") + visibility = data.get("visibility", "private") if visibility not in VISIBILITY_OPTIONS: return _error( f"Visibility must be one of: {', '.join(VISIBILITY_OPTIONS)}", 400 diff --git a/routes/mcp.py b/routes/mcp.py index 93f38e2..ba72947 100644 --- a/routes/mcp.py +++ b/routes/mcp.py @@ -59,7 +59,7 @@ }, "visibility": { "type": "string", - "enum": ["unlisted", "listed", "pinned"], + "enum": ["private", "unlisted", "listed", "pinned"], "default": "unlisted", }, }, @@ -76,7 +76,7 @@ "content": {"type": "string", "description": "Markdown content"}, "visibility": { "type": "string", - "enum": ["unlisted", "listed", "pinned"], + "enum": ["private", "unlisted", "listed", "pinned"], }, }, "required": ["slug"], diff --git a/routes/public.py b/routes/public.py index 25daee2..108fcf9 100644 --- a/routes/public.py +++ b/routes/public.py @@ -51,6 +51,7 @@ LICENSES, _set_profile_user, account_link_vars, + check_page_visibility, compute_initials, find_page, is_creator, @@ -71,7 +72,7 @@ def install_cli(): return send_from_directory("static", "install-cli.sh", mimetype="text/plain") -_VISIBILITY_TABS = ("all", "unlisted", "listed", "pinned") +_VISIBILITY_TABS = ("all", "private", "unlisted", "listed", "pinned") @bp.route("/") @@ -154,12 +155,13 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): def subdomain_home(user): pages = get_pages_for_user(user["id"]) + is_owner = session.get("user_id") == user["id"] pinned = [] listed = [] for p in pages: if is_special_slug(p["slug"]): continue - if p["visibility"] not in ("listed", "pinned"): + if not is_owner and p["visibility"] not in ("listed", "pinned"): continue item = _build_page_item(p) if p["visibility"] == "pinned": @@ -169,7 +171,6 @@ def subdomain_home(user): page_list = pinned + listed site_title = user.get("name") or user.get("username") - is_owner = session.get("user_id") == user["id"] owner_initials = None owner_avatar_url = None profile_incomplete = False @@ -356,6 +357,8 @@ def _resolve_page(slug, suffix=""): if not row: abort(404) + check_page_visibility(page_meta) + return page_meta, row @@ -546,6 +549,7 @@ def page_history(slug): page_meta = find_page(slug) if not page_meta: abort(404) + check_page_visibility(page_meta) total = get_revision_count(page_meta["id"]) if total == 0: diff --git a/routes/site.py b/routes/site.py index e538014..9d561e8 100644 --- a/routes/site.py +++ b/routes/site.py @@ -254,12 +254,16 @@ def edit_page(slug): is_new = page_meta is None subdomain_user_id = subdomain_user["id"] if subdomain_user else None - # Preserve existing visibility on edit; default to "listed" for new pages - # Special slugs are always unlisted + # Preserve existing visibility on edit; default to "private" for signed-in + # users, "listed" for anonymous. Special slugs are always unlisted. if is_special_slug(slug): visibility = "unlisted" + elif page_meta: + visibility = page_meta["visibility"] + elif session.get("user_id") or subdomain_user: + visibility = "private" else: - visibility = page_meta["visibility"] if page_meta else "listed" + visibility = "listed" slug = save_page(slug, content, visibility, subdomain_user_id) if is_new: diff --git a/templates/page.html b/templates/page.html index 4cc92aa..9007f12 100644 --- a/templates/page.html +++ b/templates/page.html @@ -39,6 +39,7 @@
+
+ jottit.org/@ + +
+

{% if error %}{{ error }}{% endif %}

+ + + +
+{% endblock %} diff --git a/tests/test_api.py b/tests/test_api.py index 9b43e53..d6d11aa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -73,7 +73,7 @@ def test_create_page(client): data = r.get_json() assert data["title"] == "My Page" assert data["content"] == "# My Page\n\nHello world" - assert data["visibility"] == "unlisted" + assert data["visibility"] == "private" def test_create_page_with_custom_slug(client): diff --git a/tests/test_auth.py b/tests/test_auth.py index e27f980..a8b07b0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -390,12 +390,10 @@ def test_signin_page(client): # Sign-in with unknown email is rejected -def test_signin_rejects_unknown_email(client): +def test_signin_accepts_unknown_email_for_signup(client): r = client.post("/signin", data={"email": "nobody@example.com"}) - assert r.status_code == 200 - assert b"Email not found" in r.data - assert b"Create a page" in r.data - assert b"Try another email" in r.data + assert r.status_code == 302 + assert "/signin/verify" in r.headers["Location"] # Full sign-in: submit email, verify code, session contains user_id @@ -414,7 +412,7 @@ def test_signin_full_flow(client): r = client.post("/signin/verify", data={"code": code, "email": "user@example.com"}) assert r.status_code == 302 - assert r.headers["Location"] == "/" + assert r.headers["Location"] == "/setup/username" with client.session_transaction() as sess: assert "user_id" in sess @@ -434,6 +432,56 @@ def test_signin_with_username_redirects_to_profile(client): assert r.headers["Location"] == "/@signinuser" +# Full signup: new email, verify code, pick username, land on profile +def test_signup_full_flow(client): + r = client.post("/signin", data={"email": "newuser@example.com"}) + assert r.status_code == 302 + + code = create_verification_code("newuser@example.com", "signin") + r = client.post( + "/signin/verify", data={"code": code, "email": "newuser@example.com"} + ) + assert r.status_code == 302 + assert r.headers["Location"] == "/setup/username" + + r = client.get("/setup/username") + assert r.status_code == 200 + + r = client.post("/setup/username", data={"username": "newuser"}) + assert r.status_code == 302 + assert r.headers["Location"] == "/@newuser" + + +def test_setup_username_requires_signin(client): + r = client.get("/setup/username") + assert r.status_code == 302 + assert "/signin" in r.headers["Location"] + + +def test_setup_username_rejects_taken(client): + user_id = find_or_create_user("existing@example.com") + set_user_username(user_id, "taken") + + new_id = find_or_create_user("newperson@example.com") + with client.session_transaction() as sess: + sess["user_id"] = new_id + + r = client.post("/setup/username", data={"username": "taken"}) + assert r.status_code == 200 + assert b"already taken" in r.data + + +def test_setup_username_skips_if_already_set(client): + user_id = find_or_create_user("hasname@example.com") + set_user_username(user_id, "hasname") + with client.session_transaction() as sess: + sess["user_id"] = user_id + + r = client.get("/setup/username") + assert r.status_code == 302 + assert "/@hasname" in r.headers["Location"] + + # Invalid sign-in code shows an error def test_signin_invalid_code(client): find_or_create_user("user@example.com") diff --git a/tests/test_subdomain.py b/tests/test_subdomain.py index 760ef64..cb4aa94 100644 --- a/tests/test_subdomain.py +++ b/tests/test_subdomain.py @@ -116,11 +116,21 @@ def test_old_slug_redirects_unclaimed_page(client): # -- Visibility -- -# New pages default to "listed" visibility (via create_user_with_username) -def test_visibility_default_is_listed(client): - user_id = create_user_with_username(client, "list1@example.com", "listuser1", "lp1") - page_meta = get_page_meta("lp1", user_id) - assert page_meta["visibility"] == "listed" +# New pages by signed-in users default to "private" visibility +def test_visibility_default_is_private(client): + from db import find_or_create_user, set_user_username + + user_id = find_or_create_user("list1@example.com") + set_user_username(user_id, "listuser1") + with client.session_transaction() as sess: + sess["user_id"] = user_id + client.post( + "/@listuser1/newpage/edit", + data={"content": "# Test\n\nContent"}, + ) + page_meta = get_page_meta("newpage", user_id) + assert page_meta is not None + assert page_meta["visibility"] == "private" # Owner can change a page's visibility to "unlisted" @@ -134,6 +144,51 @@ def test_update_visibility(client): assert page_meta["visibility"] == "unlisted" +# Private pages are blocked for non-owners +def test_private_page_blocked_for_non_owner(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "priv@example.com", "privuser", "privpage" + ) + page_meta = get_page_meta("privpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@privuser/privpage") + assert r.status_code == 404 + + +# Private pages are accessible to the owner +def test_private_page_accessible_for_owner(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "privo@example.com", "privowner", "privownpage" + ) + page_meta = get_page_meta("privownpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess["user_id"] = user_id + r = client.get("/@privowner/privownpage") + assert r.status_code == 200 + + +# Private pages don't appear on the profile for visitors +def test_private_page_hidden_from_profile(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "privh@example.com", "privhidden", "privhpage" + ) + page_meta = get_page_meta("privhpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@privhidden") + assert b"privhpage" not in r.data + + # Unlisted pages don't appear on the profile homepage def test_unlisted_page_hidden_from_profile(client): user_id = create_user_with_username(client, "list3@example.com", "listuser3", "lp3") From 7067449c67561b45d905ca5a6ef24266a14bd204 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 10:38:47 +0200 Subject: [PATCH 02/72] Add homepage repo story, INDEX.md profile, and visibility badges --- routes/public.py | 16 +++++- static/theme.css | 6 +++ static/ui.css | 110 ++++++++++++++++++++++++++++++++++++++++ templates/home.html | 54 ++++++++++++++++++-- templates/profile.html | 10 +++- tests/test_pages.py | 2 +- tests/test_subdomain.py | 44 ++++++++++++++++ 7 files changed, 233 insertions(+), 9 deletions(-) diff --git a/routes/public.py b/routes/public.py index 108fcf9..2f660de 100644 --- a/routes/public.py +++ b/routes/public.py @@ -156,10 +156,23 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): def subdomain_home(user): pages = get_pages_for_user(user["id"]) is_owner = session.get("user_id") == user["id"] + + # INDEX.md: if user has a page with slug "index", render it on the profile + index_page = None + index_html = None + for p in pages: + if p["slug"] == "index": + # Owner sees their index regardless of visibility; + # visitors only see it if listed or unlisted + if is_owner or p["visibility"] in ("listed", "unlisted", "pinned"): + index_page = p + index_html = render_markdown(p["content"]) + break + pinned = [] listed = [] for p in pages: - if is_special_slug(p["slug"]): + if is_special_slug(p["slug"]) or p["slug"] == "index": continue if not is_owner and p["visibility"] not in ("listed", "pinned"): continue @@ -185,6 +198,7 @@ def subdomain_home(user): "profile.html", user=user, pages=page_list, + index_html=index_html, site_title=site_title, site_username=user.get("username", ""), is_owner=is_owner, diff --git a/static/theme.css b/static/theme.css index 733dece..4c25a0d 100644 --- a/static/theme.css +++ b/static/theme.css @@ -363,6 +363,12 @@ color: var(--site-color-muted); } +.page-listing-meta { + display: flex; + align-items: center; + gap: var(--space-1); +} + .page-listing-date { font-size: var(--font-size-xs); font-family: var(--site-font-heading); diff --git a/static/ui.css b/static/ui.css index 978e22d..aa4e205 100644 --- a/static/ui.css +++ b/static/ui.css @@ -171,11 +171,121 @@ margin-bottom: var(--space-3); } +.home-headline { + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--text-primary); + margin: 0; +} + +.home-subtitle { + font-size: var(--font-size-lg); + color: var(--text-secondary); + line-height: 1.5; + max-width: 500px; + text-align: center; + margin-bottom: var(--space-4); +} + +.home-signup-form { + display: flex; + gap: var(--space-2); + align-items: stretch; + flex-wrap: wrap; + justify-content: center; +} + +.home-email-input { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-base); + border: 1px solid var(--border); + border-radius: var(--radius); + min-width: 240px; + background: var(--bg-primary); + color: var(--text-primary); +} + +.home-email-input::placeholder { + color: var(--muted); +} + +.home-create-link { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-2); +} + .home-hint { font-size: var(--font-size-xs); color: var(--muted); } +.home-greeting { + font-size: var(--font-size-xl); + font-weight: 700; + margin-bottom: var(--space-3); +} + +.home-tabs { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.home-tab { + font-size: var(--font-size-sm); + color: var(--text-secondary); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius); + text-decoration: none; +} + +.home-tab:hover { + background: var(--bg-secondary); +} + +.home-tab-active { + color: var(--text-primary); + background: var(--bg-secondary); + font-weight: 600; +} + +.home-pages { + width: 100%; + max-width: 600px; +} + +.home-empty { + color: var(--text-secondary); +} + +.vis-badge { + font-size: var(--font-size-xs); + padding: 1px 6px; + border-radius: 3px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.vis-private { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.vis-unlisted { + background: color-mix(in srgb, var(--brand-1) 10%, transparent); + color: var(--brand-1); +} + +.vis-pinned { + background: color-mix(in srgb, var(--brand-2) 10%, transparent); + color: var(--brand-2); +} + .user-header { display: flex; align-items: center; diff --git a/templates/home.html b/templates/home.html index 8ae64a0..3b0ffda 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,17 +1,61 @@ {% extends "base.html" %} {% block body %} +{% if not signed_in %}
{% include "_account_link.html" %}

Jottit

-

Put your ideas on the web

- Create a page - {% if not signed_in %} -

no sign-up required

- {% endif %} +

Where ideas live

+

Capture insights, refine your thinking, share when ready.
For humans and AI agents. Markdown in, beautiful page out.

+ + or create a page without signing up
{% include "_footer.html" %}
+{% else %} +
+ {% include "_account_link.html" %} +
+ {% if display_name %} +

{{ display_name }}

+ {% else %} +

Your pages

+ {% endif %} + {% if pages %} +
+ {% for t in ["all", "private", "unlisted", "listed", "pinned"] %} + {% set count = counts.get(t, 0) %} + {% if t == "all" or count > 0 %} + {{ t | capitalize }} ({{ count }}) + {% endif %} + {% endfor %} +
+ + {% else %} +

No pages yet. Create your first page.

+ {% endif %} +
+ {% include "_footer.html" %} +
+{% endif %} {% endblock %} {% block head %} diff --git a/templates/profile.html b/templates/profile.html index 7c181f2..2cf25bd 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -28,12 +28,18 @@

{{ site_title }}

- {% if pages %} + {% if index_html %} +
{{ index_html | safe }}
+ {% elif pages %} {% for page in pages %}

{% if page.pinned %} {% endif %}{{ page.title }}

- +
+ {% if is_owner and page.visibility == "private" %}Private{% endif %} + {% if is_owner and page.visibility == "unlisted" %}Unlisted{% endif %} + +
{% if page.description %}

{{ page.description }}

{% endif %} diff --git a/tests/test_pages.py b/tests/test_pages.py index 647b306..c3ea290 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -266,7 +266,7 @@ def test_homepage_logged_out_shows_landing(client): r = client.get("/") body = r.data.decode() assert "tab--active" not in body - assert "Create a page" in body + assert "Sign up for Jottit" in body # /pages requires sign-in diff --git a/tests/test_subdomain.py b/tests/test_subdomain.py index cb4aa94..9cd1b28 100644 --- a/tests/test_subdomain.py +++ b/tests/test_subdomain.py @@ -341,3 +341,47 @@ def test_subdomain_redirects_to_profile(client): r = client.get("/lp1", headers={"Host": "legacyuser.jottit.localhost:8000"}) assert r.status_code == 301 assert "/@legacyuser" in r.headers["Location"] + + +# INDEX.md: page with slug "index" renders as profile content +def test_index_page_renders_on_profile(client): + user_id = find_or_create_user("idx@example.com") + set_user_username(user_id, "idxuser") + save_page("index", "# Welcome\n\nThis is my profile.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + r = client.get("/@idxuser") + assert r.status_code == 200 + assert b"This is my profile" in r.data + + +# INDEX.md: private index page visible to owner but not visitors +def test_index_page_private_hidden_from_visitors(client): + from db import update_page_visibility + + user_id = find_or_create_user("idxp@example.com") + set_user_username(user_id, "idxpriv") + save_page("index", "# Secret\n\nPrivate index.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@idxpriv") + assert b"Private index" not in r.data + + +# INDEX.md: private index page visible to owner +def test_index_page_private_visible_to_owner(client): + from db import update_page_visibility + + user_id = find_or_create_user("idxo@example.com") + set_user_username(user_id, "idxown") + save_page("index", "# My Index\n\nOwner sees this.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess["user_id"] = user_id + r = client.get("/@idxown") + assert b"Owner sees this" in r.data From 5b2a41986ec5caa82fe3f9406a40525c3dbe704a Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 10:39:38 +0200 Subject: [PATCH 03/72] Fix lint warning, update homepage copy to matter-of-fact tone --- routes/public.py | 1 - templates/home.html | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/routes/public.py b/routes/public.py index 2f660de..5d4ad8f 100644 --- a/routes/public.py +++ b/routes/public.py @@ -165,7 +165,6 @@ def subdomain_home(user): # Owner sees their index regardless of visibility; # visitors only see it if listed or unlisted if is_owner or p["visibility"] in ("listed", "unlisted", "pinned"): - index_page = p index_html = render_markdown(p["content"]) break diff --git a/templates/home.html b/templates/home.html index 3b0ffda..fcfdc0c 100644 --- a/templates/home.html +++ b/templates/home.html @@ -4,8 +4,8 @@ {% include "_account_link.html" %}

Jottit

-

Where ideas live

-

Capture insights, refine your thinking, share when ready.
For humans and AI agents. Markdown in, beautiful page out.

+

Markdown pages for humans and agents

+

Capture insights, refine your thinking, share when ready.
Markdown in, beautiful page out.

+ {% else %}

No pages yet.

{% endif %} From 8ec217a67d0a9a86f97c88acd97486b47c7bcf6e Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 10:51:07 +0200 Subject: [PATCH 13/72] Remove unused username variable in MCP config endpoint --- routes/admin.py | 1 - routes/public.py | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/admin.py b/routes/admin.py index 4101a4f..a36aa69 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -147,7 +147,6 @@ def setup_mcp_config(): if token is None: token, _ = create_api_token(user_id, "mcp-default") - username = user.get("username", "") config = { "mcpServers": { "jottit": { diff --git a/routes/public.py b/routes/public.py index ce11c6b..4374ff8 100644 --- a/routes/public.py +++ b/routes/public.py @@ -191,7 +191,11 @@ def subdomain_home(user): owner_avatar_url = user.get("avatar") if not user.get("avatar") and not user.get("bio"): profile_incomplete = True - checklist = get_setup_checklist(user["id"]) if is_owner and not page_list and not index_html else None + checklist = ( + get_setup_checklist(user["id"]) + if is_owner and not page_list and not index_html + else None + ) bio = user.get("bio") bio_html = render_bio(bio, g.url_prefix) if bio else "" return render_template( From 4d0f4620d7bc1406db3da23867407e8a624bb95d Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 10:54:35 +0200 Subject: [PATCH 14/72] Add server-side event logging for signup, signin, page creation, and MCP config --- db.py | 13 +++++++++++++ migrations/027_add_user_events.sql | 10 ++++++++++ routes/admin.py | 4 ++++ routes/site.py | 3 +++ schema.sql | 14 +++++++++++++- 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 migrations/027_add_user_events.sql diff --git a/db.py b/db.py index d8f9d7d..6b45346 100644 --- a/db.py +++ b/db.py @@ -1,5 +1,6 @@ import hashlib import importlib.util +import json import os import secrets import threading @@ -612,6 +613,18 @@ def get_setup_checklist(user_id): } +def log_event(user_id, event, metadata=None): + try: + with get_db() as conn: + conn.execute( + "INSERT INTO user_events (user_id, event, metadata) VALUES (%s, %s, %s)", + (user_id, event, json.dumps(metadata) if metadata else None), + ) + conn.commit() + except Exception: + pass + + def get_api_tokens(user_id): with get_db() as conn: return conn.execute( diff --git a/migrations/027_add_user_events.sql b/migrations/027_add_user_events.sql new file mode 100644 index 0000000..f42be93 --- /dev/null +++ b/migrations/027_add_user_events.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS user_events ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + event TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id); +CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC); diff --git a/routes/admin.py b/routes/admin.py index a36aa69..e2ca9d1 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -10,6 +10,7 @@ delete_user, find_or_create_user, get_or_create_api_token, + log_event, set_user_username, get_api_tokens, get_pages_for_user, @@ -83,7 +84,9 @@ def signin_verify(): session["user_id"] = user_id user = get_user(user_id) if user and user.get("username"): + log_event(user_id, "signin") return redirect(profile_url(user["username"])) + log_event(user_id, "signup") return redirect("/setup/username") @@ -157,6 +160,7 @@ def setup_mcp_config(): } } } + log_event(user_id, "mcp_config_copy") return jsonify({"config": config, "config_text": json.dumps(config, indent=2)}) diff --git a/routes/site.py b/routes/site.py index 9d561e8..4cd6f28 100644 --- a/routes/site.py +++ b/routes/site.py @@ -25,6 +25,7 @@ delete_comment, delete_page, find_or_create_user, + log_event, find_page_owner_for_redirect, get_comment, get_export_pages, @@ -266,6 +267,8 @@ def edit_page(slug): visibility = "listed" slug = save_page(slug, content, visibility, subdomain_user_id) + if is_new and session.get("user_id"): + log_event(session["user_id"], "page_create", {"slug": slug}) if is_new: new_page_meta = get_page_meta(slug, subdomain_user_id) if new_page_meta and session.get("user_id") and not subdomain_user_id: diff --git a/schema.sql b/schema.sql index 650e7af..d4bd08b 100644 --- a/schema.sql +++ b/schema.sql @@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS slug_redirects ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS user_events ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + event TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -119,6 +127,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS slug_redirects_lookup_unclaimed ON slug_redire CREATE INDEX IF NOT EXISTS comments_page_id_idx ON comments (page_id); CREATE INDEX IF NOT EXISTS comments_ip_created_idx ON comments (ip_hash, created_at); CREATE INDEX IF NOT EXISTS comments_user_id_idx ON comments (user_id); +CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id); +CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC); -- On fresh DBs (no sites table), seed historical migrations as already applied DO $$ BEGIN @@ -147,7 +157,9 @@ DO $$ BEGIN ('021_add_comments.sql'), ('022_add_comment_user_id.sql'), ('024_drop_comment_author_fields.sql'), - ('025_drop_comment_is_pinned.sql') + ('025_drop_comment_is_pinned.sql'), + ('026_add_private_visibility.sql'), + ('027_add_user_events.sql') ON CONFLICT DO NOTHING; END IF; END $$; From fea9846a7f182954f30d2b914660a8bf9596ec30 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:01:07 +0200 Subject: [PATCH 15/72] Improve verify and username pages: add logo, show email, add resend hint --- static/ui.css | 18 ++++++++++++++++++ templates/setup_username.html | 1 + templates/verify.html | 6 ++++++ 3 files changed, 25 insertions(+) diff --git a/static/ui.css b/static/ui.css index 6edc6f3..deb1cde 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1691,6 +1691,24 @@ button.setup-item-title:hover { gap: var(--space-3); } +.verify-logo { + font-size: 1.5rem; + font-weight: 800; + text-decoration: none; + letter-spacing: -0.02em; + margin-bottom: var(--space-5); +} + +.verify-hint { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-4); +} + +.verify-hint a { + color: var(--text-secondary); +} + .shell-centered .auth-form { flex-direction: column; align-items: center; diff --git a/templates/setup_username.html b/templates/setup_username.html index 2fb14c6..e41d75b 100644 --- a/templates/setup_username.html +++ b/templates/setup_username.html @@ -4,6 +4,7 @@ {% block body %}
+

Choose your username

This is your home for ideas on Jottit.

diff --git a/templates/verify.html b/templates/verify.html index 7861072..79ac8a9 100644 --- a/templates/verify.html +++ b/templates/verify.html @@ -4,8 +4,13 @@ {% block body %}
+

Check your email

+ {% if email %} +

We sent a 6-digit code to {{ email }}

+ {% else %}

We sent you a 6-digit code.

+ {% endif %} {% if error %}

{{ error }}

{% endif %} @@ -29,6 +34,7 @@

Check your email

+

Didn't get the code? Check your spam folder or try again.

{% endblock %} From a49741c34864f1c75f4dac61f4873cc9c1d0b370 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:02:45 +0200 Subject: [PATCH 16/72] Move verify/setup logo to top-left corner --- static/ui.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/ui.css b/static/ui.css index deb1cde..73eba5d 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1696,7 +1696,9 @@ button.setup-item-title:hover { font-weight: 800; text-decoration: none; letter-spacing: -0.02em; - margin-bottom: var(--space-5); + position: absolute; + top: var(--space-3); + left: var(--space-5); } .verify-hint { From 4ac7eee42f0f3be05914f3b19a44c635a27a0f3c Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:03:22 +0200 Subject: [PATCH 17/72] Use landing page header on verify and username pages --- templates/setup_username.html | 5 ++++- templates/verify.html | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/templates/setup_username.html b/templates/setup_username.html index e41d75b..c2dc691 100644 --- a/templates/setup_username.html +++ b/templates/setup_username.html @@ -4,7 +4,10 @@ {% block body %}
- +
+ + +

Choose your username

This is your home for ideas on Jottit.

diff --git a/templates/verify.html b/templates/verify.html index 79ac8a9..b77b232 100644 --- a/templates/verify.html +++ b/templates/verify.html @@ -4,7 +4,10 @@ {% block body %}
- +
+ + +

Check your email

{% if email %}

We sent a 6-digit code to {{ email }}

From 27ab2b25328d8e3a08d7a191f7b588bde16ac201 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:03:50 +0200 Subject: [PATCH 18/72] Remove max-width on homepage header for full-width layout --- static/ui.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/ui.css b/static/ui.css index 73eba5d..dace216 100644 --- a/static/ui.css +++ b/static/ui.css @@ -179,8 +179,6 @@ justify-content: space-between; padding: var(--space-3) var(--space-5); width: 100%; - max-width: 1200px; - margin: 0 auto; } .home-header-logo { From 739065dd6f108f779e7a020aaeff1552a822551f Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:04:10 +0200 Subject: [PATCH 19/72] Fix header on verify/setup pages: position absolute to span full width --- static/ui.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/static/ui.css b/static/ui.css index dace216..f21bddb 100644 --- a/static/ui.css +++ b/static/ui.css @@ -178,7 +178,10 @@ align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-5); - width: 100%; + position: absolute; + top: 0; + left: 0; + right: 0; } .home-header-logo { From a3b019e241eccaed332e17df59974c0b365acb52 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:04:46 +0200 Subject: [PATCH 20/72] Remove unused verify-logo CSS --- static/ui.css | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/static/ui.css b/static/ui.css index f21bddb..b544337 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1692,16 +1692,6 @@ button.setup-item-title:hover { gap: var(--space-3); } -.verify-logo { - font-size: 1.5rem; - font-weight: 800; - text-decoration: none; - letter-spacing: -0.02em; - position: absolute; - top: var(--space-3); - left: var(--space-5); -} - .verify-hint { font-size: var(--font-size-sm); color: var(--muted); From c1df125144749bb88082ee32f41dd3bcfdd1ec7b Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:09:38 +0200 Subject: [PATCH 21/72] Restore sidebar on empty profile, add links section, fix checklist condition --- routes/public.py | 2 +- static/ui.css | 6 ++++++ templates/_sidebar.html | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/routes/public.py b/routes/public.py index 4374ff8..e246b76 100644 --- a/routes/public.py +++ b/routes/public.py @@ -193,7 +193,7 @@ def subdomain_home(user): profile_incomplete = True checklist = ( get_setup_checklist(user["id"]) - if is_owner and not page_list and not index_html + if is_owner and not pages and not index_html else None ) bio = user.get("bio") diff --git a/static/ui.css b/static/ui.css index b544337..074376e 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1478,6 +1478,12 @@ button.setup-item-title:hover { color: var(--text); } +.sidebar-empty { + font-size: var(--font-size-sm); + color: var(--muted); + padding: 0 var(--space-2); +} + @media (max-width: 639px) { .layout-with-sidebar { grid-template-columns: 1fr; diff --git a/templates/_sidebar.html b/templates/_sidebar.html index b21377f..878508b 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -12,6 +12,7 @@ {% endif %} + {% if sidebar_special %} - {% if sidebar_special %}
{% endif %} -
+
{% if index_html %} @@ -120,6 +120,6 @@

...or push via the API

{% include "_page_footer.html" %}
-{% if is_owner %}{% include "_sidebar.html" %}{% endif %} +{% if is_owner and not checklist %}{% include "_sidebar.html" %}{% endif %}
{% endblock %} From f6799fa221f0394f8ea9003020e09259180da925 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 11:13:50 +0200 Subject: [PATCH 25/72] Use agent-agnostic language for MCP setup --- templates/profile.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/profile.html b/templates/profile.html index 1b0123b..04f252b 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -56,8 +56,8 @@

Welcome to Jottit, @{{ sidebar_username }}

-

...or connect Claude via MCP

-

Add this to your Claude Desktop or Claude Code config:

+

...or connect an AI agent via MCP

+

Add this to your MCP client config:

-
-

...or push via the API

-
-
curl -X POST https://jottit.org/api/v1/pages \
-  -H "Authorization: Bearer YOUR_TOKEN" \
-  -H "Content-Type: application/json" \
-  -d '{"content": "# Hello\n\nMy first page."}'
-
-

Create an API token to get started.

-
{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index 3c56585..e55682a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -454,7 +454,7 @@ def test_signup_full_flow(client): r = client.get("/setup/profile") assert r.status_code == 200 - r = client.post("/setup/profile", data={"name": "New User", "bio": "Hello"}) + r = client.post("/setup/profile", data={"name": "New User"}) assert r.status_code == 302 assert r.headers["Location"] == "/@newuser" From 271701e3b408419496636c5488823de88a40b114 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 12:58:22 +0200 Subject: [PATCH 37/72] Redesign profile setup: horizontal card layout mirroring the actual profile header --- static/ui.css | 71 ++++++++++++++++++++++++++---------- templates/setup_profile.html | 20 +++++----- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/static/ui.css b/static/ui.css index fbea64e..fc53d46 100644 --- a/static/ui.css +++ b/static/ui.css @@ -418,23 +418,37 @@ color: var(--text-primary); } +.setup-profile-card { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-5); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + width: 100%; + max-width: 420px; + margin-bottom: var(--space-4); +} + .setup-avatar-upload { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); - margin-bottom: var(--space-4); + flex-shrink: 0; } .setup-avatar-preview { - width: 80px; - height: 80px; + width: 72px; + height: 72px; border-radius: 50%; - background: var(--bg-raised); + background: var(--bg-surface); display: flex; align-items: center; justify-content: center; overflow: hidden; + cursor: pointer; } .setup-avatar-initials { @@ -449,28 +463,48 @@ object-fit: cover; } -.setup-avatar-btn { +.setup-avatar-label { + font-size: var(--font-size-xs); + color: var(--brand-1); cursor: pointer; + font-weight: 600; } -.setup-profile-field { - width: 100%; - max-width: 360px; - text-align: left; - margin-bottom: var(--space-3); +.setup-avatar-label:hover { + text-decoration: underline; } -.setup-profile-label { - display: block; - font-size: var(--font-size-sm); - font-weight: 600; - margin-bottom: var(--space-1); - color: var(--text-secondary); +.setup-profile-fields { + flex: 1; + min-width: 0; } -.setup-profile-field .auth-input { +.setup-name-input { width: 100%; - text-align: left; + padding: var(--space-2); + font-size: var(--font-size-lg); + font-weight: 600; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text-primary); + outline: none; +} + +.setup-name-input::placeholder { + color: var(--muted); + font-weight: 400; +} + +.setup-name-input:focus { + border-color: var(--brand-1); +} + +.setup-username-display { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-1); + padding-left: var(--space-2); } .setup-profile-actions { @@ -478,7 +512,6 @@ flex-direction: column; align-items: center; gap: var(--space-2); - margin-top: var(--space-2); } .setup-skip-link { diff --git a/templates/setup_profile.html b/templates/setup_profile.html index 4c836c5..635a9c4 100644 --- a/templates/setup_profile.html +++ b/templates/setup_profile.html @@ -12,16 +12,18 @@

Almost there, @{{ username }}

Add a photo and name so people know who you are.

-
-
- {{ username[0] | upper }} +
+
+
+ {{ username[0] | upper }} +
+ + +
+
+ +
@{{ username }}
- - -
-
- -
From 436ff34970723dd46154564f5dedeff43e968156 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 12:58:54 +0200 Subject: [PATCH 38/72] Make avatar placeholder clickable to trigger file picker --- templates/setup_profile.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/setup_profile.html b/templates/setup_profile.html index 635a9c4..ad17877 100644 --- a/templates/setup_profile.html +++ b/templates/setup_profile.html @@ -14,9 +14,9 @@

Almost there, @{{ username }}

-
+
+
From 7c603d1147a2babf017fa6eb1d12a1a67b5c13e1 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 12:59:13 +0200 Subject: [PATCH 39/72] Left-align username display in profile setup card --- static/ui.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/ui.css b/static/ui.css index fc53d46..d1cd990 100644 --- a/static/ui.css +++ b/static/ui.css @@ -505,6 +505,7 @@ color: var(--muted); margin-top: var(--space-1); padding-left: var(--space-2); + text-align: left; } .setup-profile-actions { From a0b5e2de5bf9fc423560a0b01f5b025987822825 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 12:59:53 +0200 Subject: [PATCH 40/72] Fix avatar preview image display in profile setup --- static/ui.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/ui.css b/static/ui.css index d1cd990..81043fe 100644 --- a/static/ui.css +++ b/static/ui.css @@ -458,9 +458,11 @@ } .setup-avatar-img { - width: 100%; - height: 100%; + width: 72px; + height: 72px; object-fit: cover; + display: block; + border-radius: 50%; } .setup-avatar-label { From 03a4f27a5ad193802bcefd9acbd7e3ecd12f3e4d Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:00:41 +0200 Subject: [PATCH 41/72] Fix avatar click: use div with onclick instead of label to avoid event conflicts --- templates/setup_profile.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/setup_profile.html b/templates/setup_profile.html index ad17877..bbd7671 100644 --- a/templates/setup_profile.html +++ b/templates/setup_profile.html @@ -14,9 +14,9 @@

Almost there, @{{ username }}

-
From 04728142219e6ec20a17ca5332c46e3d70e41c12 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:04:02 +0200 Subject: [PATCH 42/72] Fix CSP: move inline scripts to external files, hide file input with sr-only class --- static/setup-mcp.js | 28 ++++++++++++++++++++++++++++ static/setup-profile.js | 18 ++++++++++++++++++ templates/profile.html | 32 ++------------------------------ templates/setup_profile.html | 20 +++----------------- 4 files changed, 51 insertions(+), 47 deletions(-) create mode 100644 static/setup-mcp.js create mode 100644 static/setup-profile.js diff --git a/static/setup-mcp.js b/static/setup-mcp.js new file mode 100644 index 0000000..633a4c5 --- /dev/null +++ b/static/setup-mcp.js @@ -0,0 +1,28 @@ +document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('mcp-config-btn'); + var output = document.getElementById('mcp-config-output'); + var text = document.getElementById('mcp-config-text'); + var copyBtn = document.getElementById('mcp-copy-btn'); + if (!btn) return; + var csrf = btn.getAttribute('data-csrf'); + btn.addEventListener('click', function() { + btn.textContent = 'Loading...'; + fetch('/setup/mcp-config', {method: 'POST', headers: {'X-CSRFToken': csrf}}) + .then(function(r) { return r.json(); }) + .then(function(data) { + text.textContent = data.config_text; + output.classList.remove('setup-hidden'); + btn.style.display = 'none'; + if (navigator.clipboard) { + navigator.clipboard.writeText(data.config_text); + copyBtn.title = 'Copied!'; + setTimeout(function() { copyBtn.title = 'Copy to clipboard'; }, 2000); + } + }); + }); + copyBtn.addEventListener('click', function() { + navigator.clipboard.writeText(text.textContent); + copyBtn.title = 'Copied!'; + setTimeout(function() { copyBtn.title = 'Copy to clipboard'; }, 2000); + }); +}); diff --git a/static/setup-profile.js b/static/setup-profile.js new file mode 100644 index 0000000..6e2da50 --- /dev/null +++ b/static/setup-profile.js @@ -0,0 +1,18 @@ +document.addEventListener('DOMContentLoaded', function() { + var input = document.getElementById('avatar-file'); + var preview = document.getElementById('avatar-preview'); + if (!input || !preview) return; + + preview.addEventListener('click', function() { + input.click(); + }); + + input.addEventListener('change', function() { + if (!input.files || !input.files[0]) return; + var reader = new FileReader(); + reader.onload = function(e) { + preview.innerHTML = ''; + }; + reader.readAsDataURL(input.files[0]); + }); +}); diff --git a/templates/profile.html b/templates/profile.html index 4b540e4..5c5bb98 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -62,7 +62,7 @@

...or connect an AI agent via MCP


                     
                 
- +
@@ -75,35 +75,7 @@

...or publish from the command line

- + {% else %}

No pages yet.

{% endif %} diff --git a/templates/setup_profile.html b/templates/setup_profile.html index bbd7671..62edd06 100644 --- a/templates/setup_profile.html +++ b/templates/setup_profile.html @@ -14,11 +14,11 @@

Almost there, @{{ username }}

-
+
{{ username[0] | upper }}
- +
@@ -31,19 +31,5 @@

Almost there, @{{ username }}

- + {% endblock %} From 6583d5159baee1813e53e3ef9ab540cc63ad7fd7 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:04:29 +0200 Subject: [PATCH 43/72] Fix avatar preview: use blob URL instead of data URL for CSP compatibility --- static/setup-profile.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/static/setup-profile.js b/static/setup-profile.js index 6e2da50..ea376cb 100644 --- a/static/setup-profile.js +++ b/static/setup-profile.js @@ -9,10 +9,7 @@ document.addEventListener('DOMContentLoaded', function() { input.addEventListener('change', function() { if (!input.files || !input.files[0]) return; - var reader = new FileReader(); - reader.onload = function(e) { - preview.innerHTML = ''; - }; - reader.readAsDataURL(input.files[0]); + var url = URL.createObjectURL(input.files[0]); + preview.innerHTML = ''; }); }); From 98e5199d202d67521b67ad6f34c004cda4aa1419 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:05:17 +0200 Subject: [PATCH 44/72] Use createElement for avatar preview to avoid innerHTML CSP issues --- static/setup-profile.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/setup-profile.js b/static/setup-profile.js index ea376cb..80870fe 100644 --- a/static/setup-profile.js +++ b/static/setup-profile.js @@ -10,6 +10,10 @@ document.addEventListener('DOMContentLoaded', function() { input.addEventListener('change', function() { if (!input.files || !input.files[0]) return; var url = URL.createObjectURL(input.files[0]); - preview.innerHTML = ''; + var img = document.createElement('img'); + img.src = url; + img.className = 'setup-avatar-img'; + preview.textContent = ''; + preview.appendChild(img); }); }); From 6db935bdb055b67e2ae2290fb7fe4680d8553ccf Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:06:21 +0200 Subject: [PATCH 45/72] Allow blob: URLs in CSP img-src for avatar preview --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 9ee12f4..34074d5 100644 --- a/app.py +++ b/app.py @@ -83,7 +83,7 @@ def make_session_permanent(): _CSP = ( "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;" " connect-src 'self' https://cloudflareinsights.com; style-src 'self';" - " img-src 'self' https://*.fly.storage.tigris.dev" + " img-src 'self' blob: https://*.fly.storage.tigris.dev" ) From 0a6224017e76e1010f50f5d1e6733e3fccff7ade Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:08:04 +0200 Subject: [PATCH 46/72] Redirect signed-in users from / to their profile --- routes/public.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/public.py b/routes/public.py index 7887ea4..6d97d5f 100644 --- a/routes/public.py +++ b/routes/public.py @@ -81,6 +81,10 @@ def home(): if "user_id" not in session: return render_template("home.html", **account_link_vars()) + user = g.current_user + if user and user.get("username"): + return redirect(profile_url(user["username"])) + pages = get_pages_for_user(session["user_id"]) all_items = [_build_page_item(p) for p in pages if not is_special_slug(p["slug"])] From f44a7f9ba3113e55923bca0455d63ec5cf10ba16 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:15:36 +0200 Subject: [PATCH 47/72] Add Connect Agent settings page, sidebar CTA, fix MCP page styling --- routes/admin.py | 8 ++++++++ routes/public.py | 4 ++++ static/ui.css | 41 +++++++++++++++++++++++++++++++++++++ templates/_sidebar.html | 5 +++++ templates/settings.html | 16 ++++++++++----- templates/settings_mcp.html | 34 ++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 templates/settings_mcp.html diff --git a/routes/admin.py b/routes/admin.py index 012ac07..1229961 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -469,6 +469,14 @@ def settings_delete(): return redirect("/") +@bp.route("/settings/mcp") +def settings_mcp(): + user_id, user = require_user() + if not user: + return redirect("/signin") + return render_template("settings_mcp.html", user=user) + + @bp.route("/settings/tokens", methods=["GET", "POST"]) @limiter.limit("5 per 5 minutes", methods=["POST"]) def settings_tokens(): diff --git a/routes/public.py b/routes/public.py index 6d97d5f..bdc0fac 100644 --- a/routes/public.py +++ b/routes/public.py @@ -127,6 +127,8 @@ def _build_page_item(p): def sidebar_vars(user_id, username, active_slug=None, is_owner=False): + from db import get_api_tokens + all_pages = get_pages_for_user(user_id) pages = [p for p in all_pages if not is_special_slug(p["slug"])] special = [ @@ -148,6 +150,7 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): recent.append({"slug": p["slug"], "title": title or "Untitled"}) if len(recent) >= 5: break + has_token = bool(get_api_tokens(user_id)) if is_owner else False return { "sidebar_pinned": pinned, "sidebar_recent": recent, @@ -155,6 +158,7 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): "sidebar_total": len(pages), "sidebar_active_slug": active_slug, "sidebar_username": username, + "sidebar_has_token": has_token, } diff --git a/static/ui.css b/static/ui.css index 81043fe..7a6ce13 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1551,6 +1551,47 @@ color: var(--text); } +.settings-mcp-help { + margin-top: var(--space-5); + border-top: 1px solid var(--border); + padding-top: var(--space-5); +} + +.settings-mcp-help h3 { + font-size: var(--font-size-base); + font-weight: 600; + margin: 0 0 var(--space-2) 0; +} + +.settings-mcp-help ol { + padding-left: var(--space-5); + margin: 0 0 var(--space-3) 0; +} + +.settings-mcp-help li { + margin-bottom: var(--space-1); + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.settings-mcp-help p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0 0 var(--space-2) 0; +} + +.settings-mcp-help code { + background: var(--bg-raised); + padding: 1px 5px; + border-radius: 3px; + font-size: var(--font-size-xs); +} + +.sidebar-link--cta .sidebar-link-text { + color: var(--brand-1); + font-weight: 600; +} + .sidebar-empty { font-size: var(--font-size-sm); color: var(--muted); diff --git a/templates/_sidebar.html b/templates/_sidebar.html index 4e6d91c..fa16a21 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -25,6 +25,11 @@ {% endif %} + {% if not sidebar_has_token %} + + {% endif %} {% if sidebar_special %}
From e4ae9029d385b1971c29090ae2e5982f2d33897d Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:19:25 +0200 Subject: [PATCH 50/72] Unify action-link styling, remove sidebar profile hint --- static/ui.css | 28 +++++++--------------------- templates/_sidebar.html | 7 +------ templates/profile.html | 2 +- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/static/ui.css b/static/ui.css index 53761b7..2e5e6c9 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1029,18 +1029,21 @@ margin: 0; } -.profile-edit-link { +.action-link { font-size: var(--font-size-sm); font-weight: 600; color: var(--brand-1); - margin-top: var(--space-2); - display: inline-block; } -.profile-edit-link:hover { +.action-link:hover { text-decoration: underline; } +.profile-header .action-link { + margin-top: var(--space-2); + display: inline-block; +} + .profile-header--avatar .profile-bio, .profile-header--full .profile-bio { margin: var(--space-1) 0 0; @@ -1599,23 +1602,6 @@ font-size: var(--font-size-xs); } -.sidebar-link--cta .sidebar-link-text { - color: var(--brand-1); - font-weight: 600; -} - -.sidebar-hint { - font-size: var(--font-size-sm); - color: var(--muted); - font-style: italic; -} - -.sidebar-hint a { - font-style: normal; - font-weight: 600; - color: var(--brand-1); -} - .sidebar-empty { font-size: var(--font-size-sm); color: var(--muted); diff --git a/templates/_sidebar.html b/templates/_sidebar.html index 2018ea0..688cc87 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -1,10 +1,5 @@
From 0f2a07072487a3a363c9466072514330e83f407a Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:23:56 +0200 Subject: [PATCH 51/72] Move edit profile to right side, align profile header with content width --- static/ui.css | 11 ++++++++--- templates/profile.html | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/static/ui.css b/static/ui.css index 2e5e6c9..76fcafb 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1039,9 +1039,9 @@ text-decoration: underline; } -.profile-header .action-link { - margin-top: var(--space-2); - display: inline-block; +.profile-edit { + margin-left: auto; + align-self: flex-start; } .profile-header--avatar .profile-bio, @@ -1494,6 +1494,11 @@ .profile-header { padding-top: var(--space-10); margin-bottom: var(--space-8); + max-width: calc(var(--content-max-width) + 220px + var(--space-8)); + margin-left: auto; + margin-right: auto; + padding-left: var(--space-5); + padding-right: var(--space-5); } .profile-identity { diff --git a/templates/profile.html b/templates/profile.html index 5ce31d6..27381f8 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -23,10 +23,10 @@

{{ site_title }}

{% if bio_html %}
{{ bio_html | safe }}
{% endif %} - {% if is_owner %} - Edit profile - {% endif %}
+ {% if is_owner %} + Edit profile + {% endif %}
{% endif %} From 214a18d35e7dbdf4411a2f9220576ebfac3adbd7 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:25:09 +0200 Subject: [PATCH 52/72] Revert profile header width, remove About heading from sidebar, reorder sections --- static/ui.css | 5 ----- templates/_sidebar.html | 11 +++++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/static/ui.css b/static/ui.css index 76fcafb..9ac7c92 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1494,11 +1494,6 @@ .profile-header { padding-top: var(--space-10); margin-bottom: var(--space-8); - max-width: calc(var(--content-max-width) + 220px + var(--space-8)); - margin-left: auto; - margin-right: auto; - padding-left: var(--space-5); - padding-right: var(--space-5); } .profile-identity { diff --git a/templates/_sidebar.html b/templates/_sidebar.html index 688cc87..bcd2776 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -25,14 +25,8 @@ {% endif %} - {% if not sidebar_has_token %} - - {% endif %} {% if sidebar_special %} {% endif %} + {% if not sidebar_has_token %} + + {% endif %} {% endif %} From b268b17eeb0c35186a9e58b55900cbab84ca7b2a Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:27:06 +0200 Subject: [PATCH 53/72] Add bot icon next to Connect an agent link --- templates/_sidebar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/_sidebar.html b/templates/_sidebar.html index bcd2776..078c3a1 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -36,7 +36,7 @@ {% endif %} {% if not sidebar_has_token %} {% endif %} {% endif %} From 9d5f5ade6c4538424b4fe3122d4571b38c8078a3 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:27:55 +0200 Subject: [PATCH 54/72] Fix sidebar link padding, move bot icon to AGENTS.md --- static/ui.css | 2 +- templates/_sidebar.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/static/ui.css b/static/ui.css index 9ac7c92..6c61fd6 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1451,7 +1451,7 @@ font-size: var(--font-size-sm); color: var(--text-secondary); text-decoration: none; - padding: 5px 0; + padding: 5px var(--space-2); border-radius: var(--radius-sm); overflow: hidden; min-width: 0; diff --git a/templates/_sidebar.html b/templates/_sidebar.html index 078c3a1..db8905b 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -29,14 +29,14 @@ {% endif %} {% if not sidebar_has_token %} {% endif %} {% endif %} From f8d5d54f546b0e51fba8fbedc112797fd734b7cb Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:30:10 +0200 Subject: [PATCH 55/72] Make AGENTS.md sidebar link smaller and muted to distinguish from pages --- static/ui.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/ui.css b/static/ui.css index 6c61fd6..3bc39be 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1476,7 +1476,8 @@ .sidebar-link--special { font-family: var(--font-mono); - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); + color: var(--muted); } .sidebar-create { From 729a4e38e170302852b5e4efb0bc5af25e43a7d1 Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Mon, 13 Apr 2026 13:30:38 +0200 Subject: [PATCH 56/72] Display AGENTS.md as sidebar metadata, not a page link --- static/ui.css | 16 +++++++++++++++- templates/_sidebar.html | 12 +++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/static/ui.css b/static/ui.css index 3bc39be..d483f8d 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1474,10 +1474,24 @@ background: var(--subtle); } -.sidebar-link--special { +.sidebar-meta { + padding: var(--space-2) 0; + border-top: 1px solid var(--border); + margin-top: var(--space-2); +} + +.sidebar-meta-link { + display: flex; + align-items: center; + gap: var(--space-2); font-family: var(--font-mono); font-size: var(--font-size-xs); color: var(--muted); + padding: 3px 0; +} + +.sidebar-meta-link:hover { + color: var(--text-secondary); } .sidebar-create { diff --git a/templates/_sidebar.html b/templates/_sidebar.html index db8905b..98a4bf2 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -26,13 +26,11 @@ {% endif %} {% if sidebar_special %} - + {% endif %} {% if not sidebar_has_token %}