Template
1
Fork 0
blog-dart/web/main.dart
Leonardo Devai ae72d24ce1 Review and modernize all 42 projects to the updated standard
Pinned 2026 toolchains (Go 1.26, Rust 1.98/edition 2024, Python 3.14 + uv, Node 24,
Zig 0.16, NixOS 26.05), postgres 18 / mongo 8, lockfiles built from, non-root
runtimes, .dockerignore, per-project LICENSE, READMEs with the git.devai.io clone
line, checkout@v7 CI. Security fixes in the legacy Rust APIs (any-password login,
self-assigned admin, hard-coded JWT secret), JWT alg/exp/sub enforcement across the
blog series, safe markdown links in the frontends, and many smaller bugs — every
project was built, run and exercised end to end.

Adds scripts/publish.sh + a CI publish job that splits every folder into its own
repo at git.devai.io/templates/<folder>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
2026-09-27 21:10:38 +02:00

475 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:js_interop';
import 'package:web/web.dart';
// Default /api: same origin, forwarded to the API by nginx.
const apiUrl = String.fromEnvironment('API_URL', defaultValue: '/api');
const _tokenKey = 'blog_token';
// The token lives in memory; localStorage only rehydrates it across reloads.
String? _token = window.localStorage.getItem(_tokenKey);
bool get authed => _token != null;
void _setToken(String? value) {
_token = value;
if (value == null) {
window.localStorage.removeItem(_tokenKey);
} else {
window.localStorage.setItem(_tokenKey, value);
}
}
class ApiException implements Exception {
ApiException(this.status, this.message);
final int status;
final String message;
@override
String toString() => message;
}
Future<dynamic> _request(String method, String path, {Map<String, Object?>? body}) async {
final headers = Headers();
if (body != null) headers.append('Content-Type', 'application/json');
if (_token != null) headers.append('Authorization', 'Bearer $_token');
final res = await window
.fetch(
'$apiUrl$path'.toJS,
RequestInit(
method: method,
headers: headers,
body: body == null ? null : jsonEncode(body).toJS,
),
)
.toDart;
final text = (await res.text().toDart).toDart;
if (!res.ok) {
var message = 'HTTP ${res.status}';
try {
final decoded = jsonDecode(text);
if (decoded is Map && decoded['error'] is String) message = decoded['error'] as String;
} catch (_) {
// non-JSON error body; keep the status message
}
throw ApiException(res.status, message);
}
return text.isEmpty ? null : jsonDecode(text);
}
Map<String, dynamic> _asMap(dynamic value) => (value as Map).cast<String, dynamic>();
class PostSummary {
PostSummary.fromJson(Map<String, dynamic> j)
: id = '${j['id']}',
title = j['title'] as String,
slug = j['slug'] as String,
excerpt = j['excerpt'] as String,
publishedAt = j['published_at'] as String;
final String id, title, slug, excerpt, publishedAt;
}
class Post {
Post.fromJson(Map<String, dynamic> j)
: id = '${j['id']}',
title = j['title'] as String,
slug = j['slug'] as String,
body = j['body'] as String,
published = j['published'] as bool,
createdAt = j['created_at'] as String;
final String id, title, slug, body, createdAt;
final bool published;
}
Future<void> login(String email, String password) async {
final data = _asMap(
await _request('POST', '/auth/login', body: {'email': email, 'password': password}),
);
_setToken(data['token'] as String);
}
Future<List<PostSummary>> listPosts() async {
final data = await _request('GET', '/posts') as List<dynamic>;
return [for (final item in data) PostSummary.fromJson(_asMap(item))];
}
Future<Post> getPost(String slug) async =>
Post.fromJson(_asMap(await _request('GET', '/posts/${Uri.encodeComponent(slug)}')));
Future<Post> createPost(String title, String body) async =>
Post.fromJson(_asMap(await _request('POST', '/posts', body: {'title': title, 'body': body})));
Future<Post> updatePost(String id, Map<String, Object?> fields) async =>
Post.fromJson(_asMap(await _request('PUT', '/posts/${Uri.encodeComponent(id)}', body: fields)));
Future<void> deletePost(String id) async =>
await _request('DELETE', '/posts/${Uri.encodeComponent(id)}');
HTMLElement el(String tag, {String? cls, String? text}) {
final node = document.createElement(tag) as HTMLElement;
if (cls != null) node.className = cls;
if (text != null) node.textContent = text;
return node;
}
HTMLAnchorElement link(String href, String text, {String? cls}) =>
(el('a', cls: cls, text: text) as HTMLAnchorElement)..href = href;
void clear(Element node) {
while (node.firstChild != null) {
node.removeChild(node.firstChild!);
}
}
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String fmtDate(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return iso;
return '${_months[d.month - 1]} ${d.day}, ${d.year}';
}
// Markdown → DOM nodes via textContent (never parsed as HTML); only web/mail links become <a>.
final _safeHref = RegExp(r'^(https?:|mailto:|/|#)', caseSensitive: false);
final _inlineRe = RegExp(r'(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[([^\]]+)\]\(([^)\s]+)\))');
final _blockStart = RegExp(r'^(#|```|[-*]\s|> )');
final _heading = RegExp(r'^(#{1,4})\s+(.*)$');
final _bullet = RegExp(r'^[-*]\s+');
List<Node> _inline(String text) {
final out = <Node>[];
var last = 0;
for (final m in _inlineRe.allMatches(text)) {
if (m.start > last) out.add(Text(text.substring(last, m.start)));
final whole = m.group(0)!;
if (m.group(1) != null) {
out.add(el('code', text: whole.substring(1, whole.length - 1)));
} else if (m.group(2) != null) {
out.add(el('strong', text: whole.substring(2, whole.length - 2)));
} else if (m.group(3) != null) {
out.add(el('em', text: whole.substring(1, whole.length - 1)));
} else if (_safeHref.hasMatch(m.group(6)!)) {
out.add(
link(m.group(6)!, m.group(5)!)
..target = '_blank'
..rel = 'noreferrer',
);
} else {
out.add(Text(m.group(5)!));
}
last = m.end;
}
if (last < text.length) out.add(Text(text.substring(last)));
return out;
}
HTMLElement _withInline(HTMLElement node, String text) {
for (final child in _inline(text)) {
node.appendChild(child);
}
return node;
}
HTMLElement renderMarkdown(String source) {
final root = el('div', cls: 'prose');
final lines = source.split('\n');
var i = 0;
while (i < lines.length) {
final line = lines[i];
if (line.trim().isEmpty) {
i++;
} else if (line.startsWith('```')) {
final code = <String>[];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
code.add(lines[i++]);
}
i++; // closing fence
root.appendChild(el('pre')..appendChild(el('code', text: code.join('\n'))));
} else if (_heading.hasMatch(line)) {
final h = _heading.firstMatch(line)!;
root.appendChild(_withInline(el('h${h.group(1)!.length}'), h.group(2)!));
i++;
} else if (_bullet.hasMatch(line)) {
final ul = el('ul');
while (i < lines.length && _bullet.hasMatch(lines[i])) {
ul.appendChild(_withInline(el('li'), lines[i++].replaceFirst(_bullet, '')));
}
root.appendChild(ul);
} else if (line.startsWith('> ')) {
final quote = <String>[];
while (i < lines.length && lines[i].startsWith('> ')) {
quote.add(lines[i++].substring(2));
}
root.appendChild(_withInline(el('blockquote'), quote.join(' ')));
} else {
final para = <String>[];
while (i < lines.length && lines[i].trim().isNotEmpty && !_blockStart.hasMatch(lines[i])) {
para.add(lines[i++]);
}
root.appendChild(_withInline(el('p'), para.join(' ')));
}
}
return root;
}
final _app = document.getElementById('app') as HTMLElement;
void _navigate(String route) {
if (window.location.hash == '#$route') {
render(); // hash unchanged → no hashchange event, re-render explicitly
} else {
window.location.hash = route;
}
}
HTMLElement _header() {
final bar = el('div', cls: 'bar')..appendChild(link('#/', 'blog', cls: 'brand'));
final nav = el('nav');
if (authed) {
nav.appendChild(link('#/write', 'Write'));
final out = el('button', text: 'Log out');
out.onclick = ((Event _) {
_setToken(null);
_navigate('/');
}).toJS;
nav.appendChild(out);
} else {
nav.appendChild(link('#/login', 'Log in'));
}
bar.appendChild(nav);
return el('header')..appendChild(bar);
}
HTMLElement _message(String text, {bool isError = false}) =>
el('p', cls: isError ? 'error' : 'muted', text: text);
Future<void> _postList(HTMLElement main) async {
main.appendChild(_message('Loading…'));
try {
final posts = await listPosts();
clear(main);
if (posts.isEmpty) {
main.appendChild(_message('No posts yet.'));
return;
}
final list = el('ul', cls: 'posts');
for (final post in posts) {
list.appendChild(
el('li')
..appendChild(el('time', text: fmtDate(post.publishedAt)))
..appendChild(el('h2')..appendChild(link('#/posts/${post.slug}', post.title)))
..appendChild(el('p', text: post.excerpt)),
);
}
main.appendChild(list);
} catch (e) {
clear(main);
main.appendChild(_message('$e', isError: true));
}
}
Future<void> _postDetail(HTMLElement main, String slug) async {
main.appendChild(_message('Loading…'));
try {
final post = await getPost(slug);
clear(main);
final article = el('article')
..appendChild(el('time', text: fmtDate(post.createdAt)))
..appendChild(el('h1', text: post.title));
if (authed) {
final del = el('button', text: 'Delete');
del.onclick = ((Event _) {
if (!window.confirm('Delete this post?')) return;
deletePost(post.id).then((_) => _navigate('/')).catchError((Object e) {
article.appendChild(_message('$e', isError: true));
});
}).toJS;
article.appendChild(
el('div', cls: 'actions')
..appendChild(link('#/edit/${post.slug}', 'Edit'))
..appendChild(del),
);
}
article.appendChild(renderMarkdown(post.body));
main.appendChild(article);
} catch (e) {
clear(main);
main.appendChild(_message('$e', isError: true));
}
}
void _loginView(HTMLElement main) {
final email = (el('input') as HTMLInputElement)
..type = 'email'
..required = true
..placeholder = 'Email'
..autocomplete = 'email';
final password = (el('input') as HTMLInputElement)
..type = 'password'
..required = true
..placeholder = 'Password'
..autocomplete = 'current-password';
final error = el('p', cls: 'error');
final submit = (el('button', cls: 'primary', text: 'Sign in') as HTMLButtonElement)
..type = 'submit';
final form = el('form') as HTMLFormElement;
form.onsubmit = ((Event e) {
e.preventDefault();
submit.disabled = true;
error.textContent = '';
login(email.value, password.value).then((_) => _navigate('/')).catchError((Object err) {
error.textContent = '$err';
submit.disabled = false;
});
}).toJS;
form
..appendChild(email)
..appendChild(password)
..appendChild(error)
..appendChild(submit);
main.appendChild(
el('div', cls: 'narrow')
..appendChild(el('h1', text: 'Log in'))
..appendChild(form),
);
}
Future<void> _editorView(HTMLElement main, String? slug) async {
Post? existing;
if (slug != null) {
main.appendChild(_message('Loading…'));
try {
existing = await getPost(slug);
} catch (e) {
clear(main);
main.appendChild(_message('$e', isError: true));
return;
}
clear(main);
}
var id = existing?.id;
final heading = el('h1', text: id == null ? 'New post' : 'Edit post');
final title = (el('input') as HTMLInputElement)
..type = 'text'
..required = true
..placeholder = 'Title'
..value = existing?.title ?? '';
final body = (el('textarea') as HTMLTextAreaElement)
..required = true
..placeholder = 'Write in markdown…'
..rows = 16
..value = existing?.body ?? '';
final published = (el('input') as HTMLInputElement)
..type = 'checkbox'
..checked = existing?.published ?? false;
final submit = (el('button', cls: 'primary', text: 'Save') as HTMLButtonElement)..type = 'submit';
final notice = el('p', cls: 'muted');
final error = el('p', cls: 'error');
Future<Post> save() async {
final current = id;
if (current != null) {
return updatePost(current, {
'title': title.value,
'body': body.value,
'published': published.checked,
});
}
final post = await createPost(title.value, body.value);
// Posts are created unpublished; flip the flag in a follow-up update.
return published.checked ? updatePost(post.id, {'published': true}) : post;
}
final form = el('form') as HTMLFormElement;
form.onsubmit = ((Event e) {
e.preventDefault();
submit.disabled = true;
notice.textContent = '';
error.textContent = '';
save()
.then((post) {
if (post.published) {
_navigate('/posts/${post.slug}');
return;
}
// The API never serves drafts back, so keep editing this one right here.
id = post.id;
heading.textContent = 'Edit post';
notice.textContent = 'Draft saved. Tick Published and save again to make it public.';
submit.disabled = false;
})
.catchError((Object err) {
error.textContent = '$err';
submit.disabled = false;
});
}).toJS;
final toggle = el('label', cls: 'toggle')
..appendChild(published)
..appendChild(Text('Published'));
form
..appendChild(title)
..appendChild(body)
..appendChild(
el('div', cls: 'row')
..appendChild(toggle)
..appendChild(submit),
)
..appendChild(notice)
..appendChild(error);
main
..appendChild(heading)
..appendChild(form);
}
// Hash routes (#/, #/posts/<slug>, #/login, #/write, #/edit/<slug>) need no server config.
void render() {
clear(_app);
final main = el('main');
_app
..appendChild(_header())
..appendChild(main);
final hash = window.location.hash;
final route = hash.length <= 1 ? '/' : Uri.decodeComponent(hash.substring(1));
if (route == '/login') {
_loginView(main);
} else if (route == '/write') {
_editorView(main, null);
} else if (route.startsWith('/edit/')) {
_editorView(main, route.substring('/edit/'.length));
} else if (route.startsWith('/posts/')) {
_postDetail(main, route.substring('/posts/'.length));
} else {
_postList(main);
}
}
void main() {
window.addEventListener('hashchange', ((Event _) => render()).toJS);
render();
}