Compare commits
No commits in common. "b6468a17d181f38bee74120b612c11a4fc417686" and "a24fd589b6bf7181aa51ed9b4028bba479925986" have entirely different histories.
b6468a17d1
...
a24fd589b6
10 changed files with 19 additions and 484 deletions
11
.runner
11
.runner
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
65
README.md
65
README.md
|
|
@ -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
20
app.py
|
|
@ -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
18
db.py
|
|
@ -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")
|
||||
|
|
|
|||
77
handlers.py
77
handlers.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# forum: pip install tinyweb-forum (optional, adds URL discussion board)
|
||||
requests
|
||||
beautifulsoup4
|
||||
rns
|
||||
|
|
|
|||
2
start.sh
2
start.sh
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 · 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue