Compare commits

..

No commits in common. "b6468a17d181f38bee74120b612c11a4fc417686" and "a24fd589b6bf7181aa51ed9b4028bba479925986" have entirely different histories.

10 changed files with 19 additions and 484 deletions

11
.runner
View file

@ -1,11 +0,0 @@
{
"WARNING": "This file is automatically generated by forgejo-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 14,
"uuid": "2f3252bd-5f69-4d3c-8f77-8b54caf784af",
"name": "nixos-runner",
"token": "993ebcbb295c7c58ac35fd5c2ec07cc752c99152",
"address": "https://git.derickphan.com",
"labels": [
"ubuntu-latest:docker"
]
}

View file

@ -11,7 +11,6 @@ A personal, decentralized search engine built on the [Reticulum](https://reticul
- **Custom templates** — Full HTML/CSS/JS template editor to personalize your instance
- **Import/export** — JSON-based backup and restore
- **Mesh-native** — Works over Reticulum without the internet; encrypted and decentralized by default
- **Forum plugin** — Optional link-sharing discussion board over the mesh (see Forum section below)
## Performance & Scale
@ -51,37 +50,34 @@ Run the downloaded file — no installation required.
## Docker
TinyWeb is distributed as source. Clone the repo, then build and run with Docker Compose:
Pull and run TinyWeb from the container registry:
```bash
git clone https://git.derickphan.com/lichenblankie/tinyweb.git
cd tinyweb
docker compose up -d
docker run -p 8080:8080 registry.derickphan.com/tinyweb:latest
```
The bundled `docker-compose.yml` builds the image from source and persists your data in a named volume:
Or with a specific version:
```bash
docker run -p 8080:8080 registry.derickphan.com/tinyweb:v0.1.0
```
### Docker Compose
```yaml
services:
tinyweb:
build: .
image: registry.derickphan.com/tinyweb:latest
ports:
- "8080:8080"
volumes:
- tinyweb-data:/data
restart: unless-stopped
volumes:
tinyweb-data:
```
After the first build, the image is cached locally and subsequent `docker compose up -d` calls are instant. To update to the latest source:
```bash
git pull && docker compose up -d --build
```
If you're on macOS or need to reach a Reticulum node over TCP, uncomment the `RNS_TCP_HOST` / `RNS_TCP_PORT` block in `docker-compose.yml` and point it at a host running Reticulum. On Linux with LAN auto-discovery, leave it as-is (or switch to `network_mode: host`).
Run with `docker compose up -d`.
### Storage Estimates
@ -108,7 +104,6 @@ Your data is stored in `~/.tinyweb/`:
|------|-------------|
| `index.db` | SQLite database with your indexed pages |
| `tinyweb_identity` | Your Reticulum identity (keep safe!) |
| `forum.db` | Forum plugin database (only if forum is enabled) |
| `models/` | Downloaded AI models for semantic search |
| `index.hnsw` | Semantic search index |
@ -120,7 +115,6 @@ Back up the whole `~/.tinyweb/` directory periodically. The two files that matte
- **`tinyweb_identity`** is your permanent mesh identity. If you lose it, your destination hash changes and every subscriber has to re-subscribe to the new one. Keep it somewhere you trust; the file is `0600` by default.
- **`index.db`** is your full reading history — every page, note, tag, and synced remote page. Losing it loses everything you've curated.
- **`forum.db`** (if the forum plugin is enabled) — all threads, posts, upvotes, and moderation settings. Losing it loses your forum data.
`models/` and `index.hnsw` are re-derivable (the model will re-download, and the HNSW index rebuilds from the database on next startup with semantic search enabled) so they don't need to be backed up.
@ -128,18 +122,13 @@ The `/export` page produces a JSON dump of your pages. It's a migration aid —
### Docker
When you run via `docker compose up` (above), data is stored in the `tinyweb-data` named volume and persists across rebuilds. To inspect or back up:
Data is stored in the `/data` volume inside the container. Use a volume mount to persist data:
```bash
docker compose exec tinyweb ls -la /data
docker compose down # stop without removing the volume
docker run -p 8080:8080 -v tinyweb-data:/data registry.derickphan.com/tinyweb:latest
```
To reset everything (destroys your index and identity — back up first):
```bash
docker compose down -v
```
Or with docker-compose (see above) — data persists in the named volume.
### Command line options
@ -179,31 +168,6 @@ This connects over Reticulum and serves the remote instance at `http://localhost
3. **Subscribe** — Add a friend's destination hash on `/subscriptions` to sync their shared index
4. **Customize** — Edit your site name, HTML template, and sharing settings on `/style`
## Forum plugin
TinyWeb ships with an optional [tinyweb-forum](https://git.derickphan.com/lichenblankie/tinyweb-forum) plugin — a decentralized link-sharing discussion board that runs in-process alongside TinyWeb.
### Install
```bash
pip install tinyweb-forum
```
Enable it on the `/style` page under "Forum". A "Forum" link will appear in the navigation bar.
### How it works
- Threads and posts are stored in `~/.tinyweb/forum.db` (separate from your search index)
- Instances are discovered automatically via mesh announces — no manual setup needed
- Sync is manual by default: click "sync now" on the forum page. Auto-sync every 5 minutes is optional (toggle on moderation page)
- At scale, sync uses epidemic gossip: 20 random peers per cycle, converging globally within ~O(log N) cycles
- Authors are identified by a short pseudonymous identity hash (no accounts, no sign-up)
- Auto-discovery can be disabled in the moderation page
- Threads are auto-pruned after 30 days (configurable, or set to 0 to keep everything)
- Moderation is local: block authors, mute threads, keyword filters, and gossip block lists with peers (auto-block after 3 peer reports)
For full feature docs, see the [tinyweb-forum README](https://git.derickphan.com/lichenblankie/tinyweb-forum).
## Project structure
```
@ -229,7 +193,6 @@ Other hardening measures:
- **XSS escaping** — All user-supplied content is HTML-escaped before rendering
- **Bookmark authentication** — The bookmarklet endpoint requires a secret token
- **Identity file protection** — The Reticulum identity key is restricted to owner-only permissions (0600)
- **Forum caveats** — See [tinyweb-forum Security](https://git.derickphan.com/lichenblankie/tinyweb-forum#security) for forum-specific risks (voluntary retractions, block gossip manipulation, no rate limiting)
## Maintenance

20
app.py
View file

@ -8,8 +8,6 @@ from http.server import HTTPServer
from db import init_db, get_setting, set_setting
from handlers import dispatch_request
import handlers as handlers_mod
import templates as templates_mod
import gateway
from gateway import GatewayState, GatewayHandler
@ -33,7 +31,6 @@ def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
for port in range(start, start + max_attempts):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return port
except OSError:
@ -100,7 +97,6 @@ def rns_request_handler(path, data, request_id, link_id, remote_identity, reques
def start_gateway(reticulum, bind_host="127.0.0.1"):
GatewayState.reticulum = reticulum
GatewayState.local_dispatch = dispatch_request
HTTPServer.allow_reuse_address = True
server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
@ -275,22 +271,6 @@ def main():
allow=RNS.Destination.ALLOW_ALL,
)
# Initialize forum plugin if available
forum = None
try:
from tinyweb_forum import ForumPlugin
from db import get_site_name
forum = ForumPlugin(DATA_DIR, identity, reticulum, site_name=get_site_name())
if get_setting("forum_enabled", "0") == "1":
forum.enable()
templates_mod.FORUM_ENABLED = True
handlers_mod.forum_plugin = forum
print(f"Forum plugin: {'enabled' if forum.is_enabled() else 'available (enable in settings)'}")
except ImportError:
print("Forum plugin not installed (pip install tinyweb[forum])")
except Exception as e:
print(f"Forum plugin error: {e}")
# Brief delay to ensure all interfaces (especially TCP) are fully ready
time.sleep(2)
destination.announce()

18
db.py
View file

@ -291,24 +291,6 @@ def init_db():
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_page ON page_tags(page_id)")
db.execute("CREATE INDEX IF NOT EXISTS idx_page_tags_tag ON page_tags(tag_id)")
# Migrate custom_template: replace hardcoded forum link with {{forum_link}} placeholder
cur = db.execute("SELECT value FROM settings WHERE key='custom_template'")
row = cur.fetchone()
if row:
updated = row[0].replace('<a href="/forum">forum</a>', "{{forum_link}}")
if updated != row[0]:
db.execute("UPDATE settings SET value=? WHERE key='custom_template'", (updated,))
db.commit()
# Migrate custom_template: replace hardcoded site name with {{site_name}} placeholder
cur = db.execute("SELECT value FROM settings WHERE key='custom_template'")
row = cur.fetchone()
if row and '{{site_name}}' not in row[0]:
updated = row[0].replace('href="/">tinyweb</a>', 'href="/">{{site_name}}</a>')
if updated != row[0]:
db.execute("UPDATE settings SET value=? WHERE key='custom_template'", (updated,))
db.commit()
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=NORMAL")
db.execute("PRAGMA cache_size=-64000")

View file

@ -6,11 +6,9 @@ from datetime import datetime
from urllib.parse import unquote
from db import get_db, return_db, get_setting, set_setting, get_site_name, index_url, clean_url
import templates as templates_mod
from templates import esc, wrap_page, DEFAULT_TEMPLATE
from rns_client import fetch_remote_sites
forum_plugin = None
_request_local = threading.local()
@ -362,7 +360,7 @@ def handle_search(query):
)
def handle_add_form(msg="", action_type="index", prefill_url=""):
def handle_add_form(msg="", action_type="index"):
if action_type == "subscribe":
return _respond(
f"<h1>subscribe</h1>"
@ -376,13 +374,12 @@ def handle_add_form(msg="", action_type="index", prefill_url=""):
f"<p>{msg}</p>"
f'<a href="/">back</a>'
)
url_value = f'value="{esc(prefill_url)}" ' if prefill_url else ""
return _respond(
f"<h1>add url</h1>"
f"<p>Add a site to your index</p>"
f'<form method="post" action="/add">'
f'{_csrf_field()}'
f'<input name="url" placeholder="https://example.com" size="50" {url_value}><br><br>'
f'<input name="url" placeholder="https://example.com" size="50"><br><br>'
f'<input name="note" placeholder="why are you saving this? (optional)" size="50"><br><br>'
f'<input name="tags" placeholder="tags (comma-separated, e.g. solarpunk, mesh)" size="50"><br>'
f'<small>tag: private to exclude from sharing</small><br><br>'
@ -817,8 +814,6 @@ def handle_style_form(msg=""):
sharing = get_setting("sharing_enabled", "0")
checked = " checked" if sharing == "1" else ""
sharing_mode = get_setting("sharing_mode", "exclude_private")
forum = get_setting("forum_enabled", "0")
forum_checked = " checked" if forum == "1" else ""
exclude_checked = " checked" if sharing_mode != "require_public" else ""
require_checked = " checked" if sharing_mode == "require_public" else ""
shared_count = _count_shared_pages()
@ -845,17 +840,6 @@ def handle_style_form(msg=""):
lora_txpower = get_setting("lora_txpower", "7")
lora_sf = get_setting("lora_sf", "8")
lora_cr = get_setting("lora_cr", "5")
if forum_plugin is not None:
forum_section = (
f"<h2>forum</h2>"
f'<label><input type="checkbox" name="forum_enabled" value="1"{forum_checked}>'
f" enable forum (shared URL discussion board)</label><br>"
f"<small>Share URLs and discuss them with other TinyWeb instances. "
f"Requires <code>tinyweb-forum</code> — "
f'<a href="https://git.derickphan.com/lichenblankie/tinyweb-forum">more info</a>.</small><br><br>'
)
else:
forum_section = ""
return _respond(
f"<h1>customize</h1>"
f"<h2>name your search engine</h2>"
@ -931,7 +915,6 @@ def handle_style_form(msg=""):
f"<small>Saves ~50% on storage for embeddings. Slight quality reduction at large scale.</small><br><br>"
f'<a href="/reindex">manage semantic index</a><br><br>'
f"</div>"
f"{forum_section}"
f"<h2>custom html</h2>"
f"<p>Edit the full page template. Use <code>{esc('{{content}}')}</code> "
f"where page content should appear.</p>"
@ -991,27 +974,6 @@ def handle_style_submit(body):
set_setting("lora_txpower", body.get("lora_txpower", ["7"])[0].strip())
set_setting("lora_sf", body.get("lora_sf", ["8"])[0].strip())
set_setting("lora_cr", body.get("lora_cr", ["5"])[0].strip())
forum_enabled = "1" if body.get("forum_enabled") else "0"
current_forum = get_setting("forum_enabled", "0")
if forum_enabled != current_forum:
if forum_enabled == "1" and forum_plugin is None:
return handle_style_form(
"Forum plugin not installed. Run: pip install tinyweb-forum"
)
if forum_enabled == "1":
forum_plugin.enable()
try:
forum_plugin.fdb.set_setting("forum_enabled", "1")
except Exception:
pass
else:
forum_plugin.disable()
try:
forum_plugin.fdb.set_setting("forum_enabled", "0")
except Exception:
pass
set_setting("forum_enabled", forum_enabled)
templates_mod.FORUM_ENABLED = (forum_enabled == "1")
return handle_style_form("Saved. Restart TinyWeb for mesh network changes to take effect.")
@ -1692,11 +1654,7 @@ def _dispatch_inner(data):
return handle_search(query)
elif path == "/add":
action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
return handle_add_form(
action_type=action_type if action_type == "subscribe" else "index",
prefill_url=prefill_url,
)
return handle_add_form(action_type=action_type if action_type == "subscribe" else "index")
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
@ -1731,15 +1689,7 @@ def _dispatch_inner(data):
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
elif path.startswith("/forum"):
if forum_plugin and forum_plugin.is_enabled():
return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
return _error(404)
elif method == "POST":
if path.startswith("/forum"):
if forum_plugin and forum_plugin.is_enabled():
return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
return _error(404)
if not _check_csrf(body):
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
if path == "/add":
@ -1787,28 +1737,7 @@ def _dispatch_inner(data):
def dispatch_request(data):
path = data.get("path", "/")
cookies = data.get("cookies", {})
# Forum handles its own CSRF — skip main CSRF to avoid cookie conflicts
if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
resp = _dispatch_inner(data)
resp.setdefault("headers", {})
resp["headers"]["X-Frame-Options"] = "DENY"
resp["headers"]["X-Content-Type-Options"] = "nosniff"
if resp.get("content_type", "").startswith("text/html"):
resp["body"] = wrap_page(resp.get("body", ""))
resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src * data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
return resp
csrf_token = cookies.get("_csrf", "")
if not csrf_token:
csrf_token = secrets.token_hex(32)

View file

@ -1,4 +1,3 @@
# forum: pip install tinyweb-forum (optional, adds URL discussion board)
requests
beautifulsoup4
rns

View file

@ -1,2 +0,0 @@
#!/bin/sh
exec /nix/store/vhgmnrmvvfdiw0kc2xz8px7rvg60lszc-python3-3.13.12-env/bin/python /home/lichenblankie/apps/tinyweb/app.py --bind 0.0.0.0

View file

@ -1,24 +1,22 @@
import html
from db import get_setting
FORUM_ENABLED = False
def esc(s):
return html.escape(str(s))
DEFAULT_TEMPLATE = "<html>\n<head>\n<meta name=\"referrer\" content=\"no-referrer\">\n<meta http-equiv=\"x-dns-prefetch-control\" content=\"off\">\n</head>\n<body>\n{{content}}\n</body>\n</html>"
def _default_template():
name = esc(get_setting("site_name", "tinyweb"))
forum_link = ' | <a href="/forum">forum</a>' if FORUM_ENABLED else ""
return (
'<html>\n<head>\n<meta name="referrer" content="no-referrer">\n<meta http-equiv="x-dns-prefetch-control" content="off">\n</head>\n<body>\n'
f'<p><b><a href="/">{name}</a></b>'
' | <a href="/">search</a> | <a href="/pages">browse</a>'
' | <a href="/tags">tags</a> | <a href="/subscriptions">subscriptions</a>'
f'{forum_link}'
' | <a href="/style">customize</a> | <a href="/about">about</a></p>\n'
"<hr>\n{{content}}\n</body>\n</html>"
)
@ -31,7 +29,4 @@ def wrap_page(body_html, use_default=False):
template = get_setting("custom_template") or _default_template()
if "{{content}}" not in template:
template = _default_template()
forum_link = ' <a href="/forum">forum</a>' if FORUM_ENABLED else ""
template = template.replace("{{forum_link}}", forum_link)
template = template.replace("{{site_name}}", esc(get_setting("site_name", "tinyweb")))
return template.replace("{{content}}", body_html)

View file

@ -143,7 +143,6 @@
input[type="text"],
input[type="url"],
input:not([type]),
input[name="q"],
input[name="url"],
input[name="note"],
@ -329,12 +328,6 @@
label { color: #5a7880; }
input[type="checkbox"] { accent-color: #3a6858; }
a.forum-action, a.forum-action-inline {
color: #5a7880; border: 1px solid rgba(40, 70, 65, 0.3); border-radius: 4px; padding: 6px 12px;
transition: background 0.2s, color 0.2s; font-size: 0.85rem;
}
a.forum-action:hover, a.forum-action-inline:hover { color: #90b4ac; background: rgba(10, 22, 25, 0.6); }
a.forum-action-inline { padding: 2px 6px; border: none; }
hr { border: none; border-top: 1px solid rgba(30, 55, 50, 0.3); margin: 1rem 0; }
small {
@ -375,12 +368,11 @@
<canvas id="trail"></canvas>
<div class="shell">
<nav>
<a class="site" href="/">{{site_name}}</a>
<a class="site" href="/">tinyweb</a>
<div class="links">
<a href="/pages">browse</a>
<a href="/tags">tags</a>
<a href="/subscriptions">network</a>
{{forum_link}}
<a href="/style">customize</a>
<a href="/about">about</a>
</div>

View file

@ -1,292 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { min-height: 100vh; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 18px;
line-height: 1.6;
color: #444;
background: #fff;
padding: 60px 40px;
display: flex;
flex-direction: column;
align-items: center;
}
a { color: #444; text-decoration: none; cursor: pointer; }
a:hover { color: #222; }
input, textarea {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
}
input[type="text"],
input[type="url"],
input[type="search"],
input:not([type]),
textarea, select {
border: 1px solid #ccc;
padding: 8px 12px;
font-size: 15px;
border-radius: 0;
background: #fff;
color: #444;
}
input:focus, textarea:focus {
outline: none;
border-color: #999;
}
button, input[type="submit"] {
border: 1px solid #ccc;
padding: 8px 18px;
font-size: 13px;
text-transform: uppercase;
background: #fff;
color: #444;
cursor: pointer;
border-radius: 0;
transition: background 0.2s;
}
button:hover, input[type="submit"]:hover {
background: #f5f5f5;
}
.shell {
width: 100%;
max-width: 650px;
}
nav {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 12px;
}
nav .site {
font-size: 28px;
font-weight: bold;
color: #222;
text-decoration: none;
border-bottom: none;
}
nav .site:hover { color: #222; }
nav .links { display: flex; gap: 8px; flex-wrap: wrap; }
nav .links a {
font-size: 13px;
text-transform: uppercase;
padding: 6px 14px;
border: 1px solid #ccc;
background: #fff;
color: #444;
text-decoration: none;
transition: background 0.2s;
}
nav .links a:hover {
background: #f5f5f5;
color: #444;
}
.content { width: 100%; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
h1 {
font-size: 32px;
font-weight: bold;
color: #222;
margin-bottom: 20px;
line-height: 1.2;
}
h1 a { color: #222; text-decoration: none; }
h2 {
font-size: 22px;
font-weight: bold;
color: #222;
margin: 24px 0 12px;
}
p { margin: 12px 0; color: #444; }
a { color: #444; text-decoration: none; border-bottom: 1px solid #ddd; }
a:hover { color: #222; border-bottom-color: #999; }
em { color: #666; }
.result {
padding: 20px 0;
border-bottom: 1px solid #eee;
}
.result:last-child { border-bottom: none; }
.result > a:first-child {
font-size: 18px;
color: #222;
border-bottom: none;
}
.result > a:first-child:hover { color: #222; }
.note { margin-top: 4px; font-size: 15px; color: #666; }
.meta {
font-size: 13px;
color: #999;
}
.pagination {
font-size: 13px;
color: #999;
margin: 24px 0;
text-align: center;
}
.pagination a {
color: #444;
border-bottom: 1px solid #ccc;
}
.pagination a:hover { color: #222; }
.success {
color: #444;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px 16px;
font-size: 15px;
}
.tags { margin-top: 4px; }
.tag, .tags a {
font-size: 12px;
color: #999;
border: 1px solid #ddd;
padding: 2px 8px;
margin-right: 4px;
text-decoration: none;
}
.tag:hover, .tags a:hover {
color: #444;
border-color: #ccc;
}
details {
margin: 16px 0;
border: 1px solid #eee;
padding: 12px 16px;
background: #fafafa;
}
summary { font-size: 15px; color: #666; cursor: pointer; }
summary:hover { color: #444; }
details ul { margin-top: 8px; padding-left: 20px; }
details li { margin: 6px 0; font-size: 15px; }
ul, ol { padding-left: 20px; margin: 8px 0; }
li { margin: 6px 0; color: #444; }
li a { border-bottom: none; }
pre {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 14px;
background: #f5f5f5;
border: 1px solid #eee;
padding: 16px;
overflow-x: auto;
color: #444;
margin: 12px 0;
}
code {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 14px;
background: #f5f5f5;
padding: 2px 6px;
color: #444;
}
textarea {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.6;
resize: vertical;
width: 100%;
border: 1px solid #ccc;
padding: 10px 12px;
background: #fff;
color: #444;
}
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
th {
text-align: left;
font-size: 12px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 12px;
border-bottom: 1px solid #eee;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 15px;
}
label { color: #666; }
input[type="checkbox"] { accent-color: #444; }
.forum-form input, .forum-form button, .forum-toolbar input { border-radius: 0; }
.forum-actions a, a.forum-action, a.forum-action-inline {
border: 1px solid #ccc; padding: 6px 14px; text-transform: uppercase; font-size: 13px;
}
.forum-actions a:hover, a.forum-action:hover, a.forum-action-inline:hover {
background: #f5f5f5;
}
a.forum-action-inline { text-transform: none; font-size: 13px; padding: 2px 6px; border: none; }
hr { border: none; border-top: 1px solid #eee; margin: 16px 0; }
small {
font-size: 13px;
color: #999;
}
footer {
width: 100%;
margin-top: 40px;
padding-top: 16px;
border-top: 1px solid #eee;
text-align: center;
color: #999;
font-size: 13px;
}
footer .clock {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
font-size: 12px;
color: #ccc;
margin-top: 4px;
}
::selection { background: #222; color: #fff; }
@media (max-width: 600px) {
body { padding: 30px 20px; font-size: 16px; }
h1 { font-size: 26px; }
nav { flex-direction: column; align-items: flex-start; }
nav .links { gap: 6px; }
nav .links a { padding: 5px 10px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="shell">
<nav>
<a class="site" href="/">{{site_name}}</a>
<div class="links">
<a href="/pages">browse</a>
<a href="/tags">tags</a>
<a href="/subscriptions">network</a>
{{forum_link}}
<a href="/style">customize</a>
<a href="/about">about</a>
</div>
</nav>
<div class="content">
{{content}}
</div>
<footer>
<div>curated by hand &middot; shared over mesh</div>
<div class="clock" id="clock"></div>
</footer>
</div>
<script>
(function() {
var d = document.getElementById('clock');
if (d) {
function tick() {
var n = new Date();
d.textContent = n.toLocaleString();
}
tick();
setInterval(tick, 1000);
}
})();
</script>
</body>
</html>