diff --git a/web/package-lock.json b/web/package-lock.json
index 05b9dd9..f94dd2c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -7,6 +7,11 @@
"": {
"name": "web",
"version": "0.0.0",
+ "dependencies": {
+ "marked": "^18.1.2",
+ "turndown": "^7.2.4",
+ "turndown-plugin-gfm": "^1.0.2"
+ },
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
@@ -63,6 +68,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mixmark-io/domino": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
+ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
@@ -818,6 +829,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
+ "node_modules/marked": {
+ "version": "18.1.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
+ "integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -998,6 +1021,25 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/turndown": {
+ "version": "7.2.4",
+ "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
+ "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@mixmark-io/domino": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18",
+ "npm": ">=9"
+ }
+ },
+ "node_modules/turndown-plugin-gfm": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
+ "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
diff --git a/web/package.json b/web/package.json
index f314b8c..c191b9f 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,5 +12,10 @@
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"vite": "^8.3.5"
+ },
+ "dependencies": {
+ "marked": "^18.1.2",
+ "turndown": "^7.2.4",
+ "turndown-plugin-gfm": "^1.0.2"
}
}
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 2d5b5b9..6eb236a 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'
import { api } from './lib/api.js'
import { parseRoute, buildHash, currentHash } from './lib/route.js'
+ import MarkdownEditor from './lib/MarkdownEditor.svelte'
let user = $state(null)
let loading = $state(true)
@@ -888,7 +889,7 @@
-
+
Time
diff --git a/web/src/app.css b/web/src/app.css
index cc40c47..07426e9 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -109,6 +109,13 @@ code {
padding: 0.05em 0.35em;
font-size: 0.9em;
}
+pre code {
+ background: none;
+ border: none;
+ border-radius: 0;
+ padding: 0;
+ font-size: inherit;
+}
.app-shell {
width: 100%;
@@ -444,3 +451,144 @@ td, th {
th { color: var(--muted); font-weight: 600; }
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
label > :is(input, select, textarea) { color: var(--ink); }
+
+.md-editor {
+ display: grid;
+ gap: 0.35rem;
+}
+.md-editor-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+.md-editor-label {
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+.md-editor-modes {
+ display: inline-flex;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ overflow: hidden;
+ background: #10151a;
+}
+.md-editor-modes button {
+ padding: 0.3rem 0.75rem;
+ border-radius: 0;
+ font-size: 0.82rem;
+ color: var(--muted);
+ background: transparent;
+}
+.md-editor-modes button:hover {
+ color: var(--ink);
+ background: color-mix(in srgb, var(--panel-2) 70%, transparent);
+}
+.md-editor-modes button.active {
+ color: var(--ink);
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
+}
+.md-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.15rem;
+ padding: 0.25rem;
+ border: 1px solid var(--line);
+ border-bottom: none;
+ border-radius: 6px 6px 0 0;
+ background: var(--panel-2);
+}
+.md-toolbar button {
+ padding: 0.3rem 0.55rem;
+ font-size: 0.82rem;
+ border-radius: 4px;
+ color: var(--muted);
+}
+.md-toolbar button:hover {
+ color: var(--ink);
+ background: color-mix(in srgb, var(--panel) 80%, transparent);
+}
+.md-visual,
+.md-source {
+ min-height: 14rem;
+ border: 1px solid var(--line);
+ border-radius: 0 0 6px 6px;
+ padding: 0.75rem 0.85rem;
+ background: #10151a;
+ color: var(--ink);
+ outline: none;
+}
+.md-source {
+ border-radius: 6px;
+ resize: vertical;
+ font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+.md-visual {
+ overflow-y: auto;
+ line-height: 1.55;
+}
+.md-visual:focus,
+.md-source:focus {
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
+}
+.md-visual :first-child { margin-top: 0; }
+.md-visual :last-child { margin-bottom: 0; }
+.md-visual h1,
+.md-visual h2,
+.md-visual h3 {
+ margin: 0.85em 0 0.4em;
+ font-weight: 650;
+ color: #f0f4f8;
+}
+.md-visual h1 { font-size: 1.35rem; }
+.md-visual h2 { font-size: 1.15rem; }
+.md-visual h3 { font-size: 1.05rem; }
+.md-visual p { margin: 0.5em 0; }
+.md-visual ul,
+.md-visual ol {
+ margin: 0.5em 0;
+ padding-left: 1.4rem;
+}
+.md-visual li { margin: 0.2em 0; }
+.md-visual li > input[type="checkbox"] {
+ width: auto;
+ margin-right: 0.4rem;
+ accent-color: var(--accent);
+ vertical-align: middle;
+}
+.md-visual a { color: var(--accent); }
+.md-visual pre,
+.md-visual code {
+ font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace;
+ font-size: 0.88em;
+}
+.md-visual pre {
+ margin: 0.6em 0;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: #0c1014;
+ overflow-x: auto;
+ white-space: pre-wrap;
+}
+.md-visual pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: inherit;
+ color: inherit;
+}
+.md-visual blockquote {
+ margin: 0.6em 0;
+ padding: 0.15rem 0 0.15rem 0.75rem;
+ border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--line));
+ color: var(--muted);
+}
+.md-visual hr {
+ border: none;
+ border-top: 1px solid var(--line);
+ margin: 1em 0;
+}
diff --git a/web/src/lib/MarkdownEditor.svelte b/web/src/lib/MarkdownEditor.svelte
new file mode 100644
index 0000000..084eec6
--- /dev/null
+++ b/web/src/lib/MarkdownEditor.svelte
@@ -0,0 +1,136 @@
+
+
+
+
+
{label}
+
+
+
+
+
+
+ {#if mode === 'visual'}
+
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
+ {/if}
+
diff --git a/web/src/lib/markdown.js b/web/src/lib/markdown.js
new file mode 100644
index 0000000..cba525d
--- /dev/null
+++ b/web/src/lib/markdown.js
@@ -0,0 +1,42 @@
+import { marked } from 'marked'
+import TurndownService from 'turndown'
+import { gfm } from 'turndown-plugin-gfm'
+
+marked.setOptions({
+ gfm: true,
+ breaks: false,
+})
+
+const turndown = new TurndownService({
+ headingStyle: 'atx',
+ codeBlockStyle: 'fenced',
+ bulletListMarker: '-',
+ emDelimiter: '*',
+})
+turndown.use(gfm)
+
+// Browsers' formatBlock('pre') often yields bare without .
+// Default turndown only fences ; keep bare as fenced blocks.
+turndown.addRule('barePre', {
+ filter(node) {
+ return (
+ node.nodeName === 'PRE' &&
+ !(node.firstChild && node.firstChild.nodeName === 'CODE')
+ )
+ },
+ replacement(_content, node) {
+ const text = node.textContent.replace(/\n$/, '')
+ return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`
+ },
+})
+
+export function markdownToHtml(md) {
+ if (!md || !md.trim()) return '
'
+ return marked.parse(md)
+}
+
+export function htmlToMarkdown(html) {
+ if (!html) return ''
+ const md = turndown.turndown(html)
+ return md.replace(/\n{3,}/g, '\n\n').trim()
+}