diff --git a/jottit/__init__.py b/jottit/__init__.py index 6cb1e43..e8e0036 100644 --- a/jottit/__init__.py +++ b/jottit/__init__.py @@ -10,6 +10,7 @@ from jottit.blueprints.root import root_bp from jottit.blueprints.secret import secret_bp from jottit.blueprints.site import site_bp +from jottit.chrome import chrome_context from jottit.db import close_request_conn, make_engine from jottit.site_resolver import resolve_site @@ -32,5 +33,6 @@ def create_app() -> Flask: app.before_request(resolve_site) app.teardown_request(close_request_conn) + app.context_processor(chrome_context) return app diff --git a/jottit/chrome.py b/jottit/chrome.py new file mode 100644 index 0000000..dea5464 --- /dev/null +++ b/jottit/chrome.py @@ -0,0 +1,48 @@ +"""Shared template context: site, page list, signin state, and design row. + +Wired into the Flask app as a `context_processor` so every render_template +call sees these variables without each view having to pass them. +""" + +from __future__ import annotations + +from typing import Any + +from flask import g, request + +from jottit import auth +from jottit.db import get_design, get_request_conn, list_pages +from jottit.urls import page_slug, site_root + + +def chrome_context() -> dict[str, Any]: + """Variables every page template can rely on. + + `site` and `pages` are `None` on apex-domain routes (the front page); + templates should branch on `site` before reading from it. + """ + site = getattr(g, "site", None) + if site is None: + return { + "site": None, + "pages": [], + "design": None, + "is_signed_in": False, + "is_unclaimed": False, + "site_root_path": "/", + "page_slug": page_slug, + } + + conn = get_request_conn() + pages = list_pages(conn, site_id=site.id) if conn is not None else [] + design = get_design(conn, site_id=site.id) if conn is not None else None + return { + "site": site, + "pages": pages, + "design": design, + "is_signed_in": auth.is_signed_in_to(site.id), + "is_unclaimed": site.password is None, + "site_root_path": site_root(), + "page_slug": page_slug, + "current_path": request.path, + } diff --git a/jottit/db.py b/jottit/db.py index eccfe0e..cfa9cf2 100644 --- a/jottit/db.py +++ b/jottit/db.py @@ -635,6 +635,19 @@ def update_design(conn: Connection, *, site_id: int, **fields: object) -> None: # ---- Export ---- +def list_pages(conn: Connection, *, site_id: int) -> list[Row]: + """List a site's non-deleted pages, alphabetically by name. + + Used by the chrome sidebar to render a navigation list. Home (the + empty-name page) sorts first. + """ + rows = conn.execute( + select(pages.c.name).where(pages.c.site_id == site_id, pages.c.deleted.is_(False)) + ).all() + # Empty-name (home) goes to the top; remaining pages sort case-insensitively. + return sorted(rows, key=lambda r: (r.name != "", r.name.lower())) + + def get_pages_for_export(conn: Connection, *, site_id: int) -> list[Row]: """Latest non-deleted pages with their latest revision content, for export. @@ -704,13 +717,17 @@ def new_site( .returning(sites.c.id) ).scalar_one() + # `system-ui` resolves to whatever the visitor's OS uses for UI text + # (San Francisco on macOS, Segoe UI on Windows, etc); `sans-serif` is + # the fallback. Replaces the 2007 default of "Lucida_Grande". + default_font = "system-ui, sans-serif" conn.execute( insert(designs).values( site_id=site_id, - title_font="Lucida_Grande", - subtitle_font="Lucida_Grande", - headings_font="Lucida_Grande", - content_font="Lucida_Grande", + title_font=default_font, + subtitle_font=default_font, + headings_font=default_font, + content_font=default_font, header_color=scheme.header_color, title_color=scheme.title_color, subtitle_color=scheme.subtitle_color, diff --git a/jottit/static/base.css b/jottit/static/base.css new file mode 100644 index 0000000..eff48d2 --- /dev/null +++ b/jottit/static/base.css @@ -0,0 +1,104 @@ +html { + font-family: var(--content-font); + font-size: var(--content-size); + line-height: 1.5; + color: var(--fg); + background: var(--bg); +} + +body { + min-height: 100vh; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--headings-font); + line-height: 1.2; + margin-top: var(--space-5); + margin-bottom: var(--space-3); +} + +h1 { font-size: var(--headings-size); } +h2 { font-size: calc(var(--headings-size) * 0.85); } +h3 { font-size: calc(var(--headings-size) * 0.72); } + +p, ul, ol, dl, blockquote, pre, table, figure { + margin-top: 0; + margin-bottom: var(--space-4); +} + +ol, ul { + padding-left: var(--space-5); + list-style: revert; +} + +a { + color: var(--link); + text-decoration: underline; + text-underline-offset: 2px; +} + +a:visited { color: var(--link-visited); } +a:hover, a:focus { text-decoration-thickness: 2px; } + +button { + cursor: pointer; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +button:hover { background: var(--surface-alt); } + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="number"], +textarea { + width: 100%; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +textarea { font-family: var(--content-font); resize: vertical; min-height: 12rem; } + +label { display: block; } + +fieldset { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); +} + +legend { padding: 0 var(--space-2); font-weight: 600; } + +hr { + border: 0; + border-top: 1px solid var(--border); + margin: var(--space-5) 0; +} + +code, pre, kbd, samp { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.95em; +} + +pre { + padding: var(--space-3); + background: var(--surface-alt); + border-radius: var(--radius); + overflow-x: auto; +} + +blockquote { + border-left: 3px solid var(--border); + padding-left: var(--space-4); + color: var(--muted); +} + +small { color: var(--muted); } + +time { color: var(--muted); } diff --git a/jottit/static/components.css b/jottit/static/components.css new file mode 100644 index 0000000..13f6d4f --- /dev/null +++ b/jottit/static/components.css @@ -0,0 +1,238 @@ +.page { + max-width: 60rem; + margin: 0 auto; + padding: var(--space-4); + display: grid; + grid-template-columns: 1fr; + gap: var(--space-5); +} + +@media (min-width: 50rem) { + .page { + grid-template-columns: minmax(0, 1fr) 16rem; + grid-template-areas: + "header header" + "main sidebar" + "footer footer"; + } + .site-header { grid-area: header; } + main { grid-area: main; } + .site-sidebar { grid-area: sidebar; } + .site-footer { grid-area: footer; } +} + +.site-header { + background: var(--header-color); + color: var(--title-color); + padding: var(--space-5) var(--space-5) var(--space-3); + border-radius: var(--radius); +} + +.site-header a { color: var(--title-color); } + +.site-header hgroup h1 { + font-family: var(--title-font); + font-size: var(--title-size); + margin: 0; +} + +.site-header .subtitle { + font-family: var(--subtitle-font); + font-size: var(--subtitle-size); + color: var(--subtitle-color); + margin: var(--space-1) 0 0; +} + +.claim-banner { + background: rgba(255,255,255,0.15); + padding: var(--space-2) var(--space-3); + margin: 0 0 var(--space-3); + border-radius: var(--radius); +} + +.site-nav { + margin-top: var(--space-3); +} + +.site-nav ul { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding: 0; + list-style: none; + margin: 0; +} + +.site-nav a, +.site-nav button.link { + color: var(--subtitle-color); + text-decoration: none; + font-size: 0.9em; +} + +.site-nav a:hover, +.site-nav button.link:hover { text-decoration: underline; } + +.site-sidebar { + border-left: 1px solid var(--border); + padding-left: var(--space-4); +} + +@media (max-width: 50rem) { + .site-sidebar { + border-left: 0; + border-top: 1px solid var(--border); + padding-left: 0; + padding-top: var(--space-4); + } +} + +.page-list { + list-style: none; + padding: 0; + margin: 0 0 var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.page-list strong { color: var(--fg); } + +.new-page { + display: flex; + gap: var(--space-2); +} + +.new-page input[type="text"] { flex: 1; } + +.site-footer { + text-align: center; + color: var(--muted); + font-size: 0.9em; + padding-top: var(--space-4); + border-top: 1px solid var(--border); +} + +main > article { + max-width: var(--measure); +} + +.front .lede { + font-size: 1.2em; + color: var(--muted); + margin-bottom: var(--space-5); +} + +.new-site, +.site-form, +.form { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: var(--measure); +} + +.form-error { + background: var(--danger-bg); + color: var(--danger); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + border: 1px solid var(--danger); +} + +button.destructive { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +button.destructive:hover { filter: brightness(0.9); } + +button.link { + background: none; + border: 0; + padding: 0; + color: var(--link); + text-decoration: underline; + cursor: pointer; +} + +.form.inline, +form.inline { display: inline; } + +.revision-banner { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3); + margin-bottom: var(--space-4); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; +} + +.page-meta { + color: var(--muted); + font-size: 0.9em; + margin-bottom: var(--space-3); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.admin .admin-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.revisions { + list-style: none; + padding: 0; + margin: 0 0 var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.revisions li { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: baseline; +} + +.revisions .changes { + color: var(--muted); + font-size: 0.9em; +} + +.pagination { + margin-top: var(--space-4); +} + +.diff-body ins { + background: #e6ffed; + text-decoration: none; +} + +.diff-body del { + background: #ffebe9; + text-decoration: line-through; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-4); +} + +.hint { + color: var(--muted); + font-size: 0.9em; +} diff --git a/jottit/static/reset.css b/jottit/static/reset.css new file mode 100644 index 0000000..98538e2 --- /dev/null +++ b/jottit/static/reset.css @@ -0,0 +1,17 @@ +*, *::before, *::after { box-sizing: border-box; } + +html, body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ol, ul, fieldset { + margin: 0; + padding: 0; +} + +ol, ul { list-style: none; } + +img, picture, svg, video { max-width: 100%; display: block; } + +button, input, select, textarea { + font: inherit; + color: inherit; +} + +a { color: inherit; } diff --git a/jottit/static/tokens.css b/jottit/static/tokens.css new file mode 100644 index 0000000..105fb69 --- /dev/null +++ b/jottit/static/tokens.css @@ -0,0 +1,40 @@ +:root { + --hue: 210; + --brightness: 100; + + --header-color: hsl(var(--hue) 60% calc(var(--brightness) * 0.22%)); + --title-color: #fff; + --subtitle-color: #bfe8ff; + + --bg: hsl(var(--hue) 30% calc(var(--brightness) * 0.99%)); + --fg: #222; + --muted: #666; + --link: #06c; + --link-visited: #639; + --border: #ddd; + --surface: #fff; + --surface-alt: #f7f7f7; + --danger: #b00020; + --danger-bg: #fdecea; + + --title-font: system-ui, sans-serif; + --subtitle-font: system-ui, sans-serif; + --headings-font: system-ui, sans-serif; + --content-font: system-ui, sans-serif; + + --title-size: 200%; + --subtitle-size: 100%; + --headings-size: 150%; + --content-size: 100%; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-8: 3rem; + + --radius: 4px; + --measure: 38rem; +} diff --git a/jottit/static/utilities.css b/jottit/static/utilities.css new file mode 100644 index 0000000..19a3c90 --- /dev/null +++ b/jottit/static/utilities.css @@ -0,0 +1,19 @@ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.deleted { color: var(--muted); } + +.empty { color: var(--muted); } + +.cancel { color: var(--muted); } + +.count { color: var(--muted); } diff --git a/jottit/templates/admin_change_password.html b/jottit/templates/admin_change_password.html index ca6c8e6..4895e5a 100644 --- a/jottit/templates/admin_change_password.html +++ b/jottit/templates/admin_change_password.html @@ -1,25 +1,25 @@ - - -
- -{{ error }}
{% endif %} - - - - +{% extends "base.html" %} +{% block title %}Change password{% endblock %} +{% block content %} +{{ error }}
{% endif %} - - - - +{% extends "base.html" %} +{% block title %}Change site address{% endblock %} +{% block content %} +This will remove the site from public listings and free its subdomain. Pages and revisions stay on file in case we ever add a restore path.
- - - - +{% extends "base.html" %} +{% block title %}Delete site{% endblock %} +{% block content %} +This will remove the site from public listings and free its subdomain. Pages and revisions stay on file in case we ever add a restore path.
+ + + + +{{ error }}
{% endif %} - + + + +{% endblock %} diff --git a/jottit/templates/admin_settings.html b/jottit/templates/admin_settings.html index 5049629..c9ce99d 100644 --- a/jottit/templates/admin_settings.html +++ b/jottit/templates/admin_settings.html @@ -1,37 +1,46 @@ - - - - -{{ error }}
{% endif %} -- -
-- -
-- -
- - -Site address: {{ public_url or "(none)" }}
- - - +{% extends "base.html" %} +{% block title %}Settings{% endblock %} +{% block content %} ++ +
++ +
++ +
+ + + + +{{ error }}
{% endif %} -- -
- -+ +
+ +No changes yet.
{% endif %} - {% else %} -No changes yet.
- {% endif %} - - - + + +{{ error }}
{% endif %} -- -
-- -
- - -Set a password and pick how open you want this site to be. We'll email you a recovery link if you forget the password later.
+ + {% include "partials/form_error.html" %} + ++ +
++ +
+ + + + +“{{ page_name }}” is gone. Append ?r=N to view a specific revision.
“{{ page_name }}” is gone. Append ?r=N to view a specific revision, or restore it from the page's history.
- Comparing - revision {{ a.revision }} - with - revision {{ b.revision }}. -
-+ Comparing + revision {{ a.revision }} + with + revision {{ b.revision }}. +
+- - {% if page_name %}{% endif %} -
-+ +
++ + Cancel + {% if page_name %} + + {% endif %} +
+We've emailed a password reset link to the address on file.
- {% else %} -We'll email a one-time password reset link to the email address on file for this site.
-We've emailed a password-reset link to the address on file. The link works once — if you need another, come back to this page.
+ {% else %} +We'll email a one-time password-reset link to the email address on file for this site.
+{{ total }} revision{{ "s" if total != 1 }}.
-{{ total }} revision{{ "s" if total != 1 }}.
+Getting a website should be as easy as filling out a textbox.
++ +
++ +
+ +There's no page named “{{ page_name }}” on this site.
- - +{% extends "base.html" %} +{% block title %}Not found{% endblock %} +{% block content %} +There's no page named “{{ page_name }}” on this site.
+ {% if is_signed_in or is_unclaimed %} + + {% endif %} +{{ error }}
{% endif %} diff --git a/jottit/templates/partials/site_header.html b/jottit/templates/partials/site_header.html new file mode 100644 index 0000000..3cf80fe --- /dev/null +++ b/jottit/templates/partials/site_header.html @@ -0,0 +1,34 @@ +{# Header chrome rendered on every site page: title, subtitle, primary nav. + The unclaimed-site banner sits above the header so it's the most visible + action for a brand-new site. #} +{{ site.subtitle }}
{% endif %} + + + +{{ error }}
{% endif %} -- -
- -+ +
+ +