diff options
| author | grm <grm@eyesin.space> | 2026-09-15 22:26:13 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-15 22:26:13 +0300 |
| commit | 55732bdfc25933bab26220ccd82fe5e172d9e673 (patch) | |
| tree | b871ba453d1b55db11ff08a963a77ed343c5e117 | |
| parent | 778cb72c8a0902bd0b8159ebd3bb7eff93f28c83 (diff) | |
| download | blogspace-55732bdfc25933bab26220ccd82fe5e172d9e673.tar.gz blogspace-55732bdfc25933bab26220ccd82fe5e172d9e673.tar.bz2 blogspace-55732bdfc25933bab26220ccd82fe5e172d9e673.zip | |
Give the editor a Markdown toolbar and preview, restyle the dashboard
The post/page/announcement editor was a bare textarea, so bloggers had to
know the Markdown syntax by heart. It now has a toolbar (bold, italic,
strike, heading cycle, quote, code, lists, rule, link box, insert file),
Ctrl+B/I/K, list continuation on Enter and a Write/Preview toggle that
renders the text through the same goldmark + bluemonday pipeline a save
uses (POST /b/{sub}/preview; nothing is stored). Every edit goes through
execCommand("insertText") so browser undo keeps working. The toolbar
stays hidden without JavaScript, leaving the old form untouched.
The dashboard moves from the "paper & ink" look to a lean one: neutral
surfaces, 1px borders, one blue accent, system sans, with custom
properties and flexbox/grid now allowed there (blog.css is unchanged).
Class names were kept so the templates barely change; a global [hidden]
rule keeps flex containers from overriding the hidden attribute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
| -rw-r--r-- | AGENTS.md | 24 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | internal/i18n/el.go | 16 | ||||
| -rw-r--r-- | internal/web/handlers_posts.go | 7 | ||||
| -rw-r--r-- | internal/web/routes.go | 1 | ||||
| -rw-r--r-- | internal/web/static/dashboard.css | 342 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/page_form.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/post_form.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/section_form.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/partials/dashnav.html | 4 | ||||
| -rw-r--r-- | internal/web/templates/partials/editor.html | 177 | ||||
| -rw-r--r-- | internal/web/web_test.go | 19 |
12 files changed, 425 insertions, 175 deletions
@@ -29,8 +29,9 @@ table and deployment notes. blog is backed up and restored with plain `pg_dump`/`psql`; the control database (`DATABASE_URL`) holds only `users` and the `blogs` registry. - Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's - "Insert file" is the one scripted convenience (`partials/editor.html`); - it must keep its no-JS fallback (file appended on save). + toolbar, preview and "Insert file" are scripted conveniences over a plain + textarea (`partials/editor.html`); the no-JS fallback (bare textarea, file + appended on save) must keep working. - Changes are live immediately — there is no draft/preview system. The UX is "save, then refresh your blog tab"; keep the "View blog ↗" links. - Theme customisation is a structured form only; **no custom CSS input**. @@ -62,8 +63,9 @@ internal/web/ server.go (host router, middleware, render helpers) files = the upload library), filetype.go (what an upload is, how it may be served) theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping) templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/) - static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex; - blog.css — floats except the three-column `.cols`, which is flexbox) + static/ (dashboard.css — lean look; custom properties, flexbox and grid + are fine there. blog.css — floats except the three-column `.cols`, + which is flexbox; it must keep working on old browsers) ``` ## Key mechanics @@ -211,10 +213,16 @@ internal/web/ server.go (host router, middleware, render helpers) 10), set at `POST /admin/blogs/{id}/upload-limit` from `admin/index.html`; `Blog.UploadLimit(cfg)` resolves it. - **Editor** (`partials/editor.html`, args via `dict`: name, value, rows, - tall, upload, csrf): textarea + "Insert file" + cheat-sheet; paste and - drop take any file. Forms using it must be `multipart/form-data` and - their save handler must call `s.readUpload(r, "inline_file", false)` + - `appendFileMD` (the no-JS path). + tall, upload, preview, csrf): a textarea with a Markdown toolbar (bold, + italic, strike, heading cycle, quote, code, lists, rule, link box, Insert + file), Ctrl+B/I/K, list continuation on Enter, and a Write/Preview toggle + that POSTs the text to `/b/{sub}/preview` (`handlePreview`: `markdown.Render` + → `{html}`, nothing stored). Every edit goes through one `replace()` built on + `execCommand("insertText")` so browser undo works. The toolbar is `hidden` + until the script runs; without JS it is a bare textarea + "Insert file" + + cheat-sheet, and paste/drop take any file. Forms using it must be + `multipart/form-data` and their save handler must call + `s.readUpload(r, "inline_file", false)` + `appendFileMD` (the no-JS path). - **Announcements** (`sections` table, `store/sections.go`, `handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with `placement` (`<column>-<position>`: left|main|right × top|bottom, split by @@ -4,7 +4,9 @@ A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at `example.com` to manage their blog; each blog is served at `<name>.example.com`. Server-rendered HTML, no JavaScript required, works on old browsers and phones. -- Posts and page intros are written in **Markdown** (sanitized on save). Files of any +- Posts and page intros are written in **Markdown** (sanitized on save), in an editor + with a formatting toolbar (bold, italic, headings, links, lists, quotes, code…), + keyboard shortcuts and a rendered preview. Files of any kind — images, PDFs, archives, audio… — can be inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript off the file is appended on save): images are shown, everything else becomes a download link. The **Files** tab lists diff --git a/internal/i18n/el.go b/internal/i18n/el.go index 1fe5860..ba29c83 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -206,6 +206,22 @@ var el = map[string]string{ "upload failed": "η μεταφόρτωση απέτυχε", "Inserted": "Προστέθηκε η", "Could not upload:": "Αποτυχία μεταφόρτωσης:", + "Bold": "Έντονα", + "Italic": "Πλάγια", + "Strikethrough": "Διαγραμμένα", + "Quote": "Παράθεση", + "Code": "Κώδικας", + "Bulleted list": "Λίστα με κουκκίδες", + "Numbered list": "Αριθμημένη λίστα", + "Horizontal rule": "Οριζόντια γραμμή", + "Link": "Σύνδεσμος", + "Insert link": "Εισαγωγή συνδέσμου", + "text": "κείμενο", + "Write": "Γράψιμο", + "Preview": "Προεπισκόπηση", + "Rendering…": "Απόδοση…", + "Nothing to show yet.": "Δεν υπάρχει τίποτα ακόμη.", + "Could not render:": "Αποτυχία απόδοσης:", "Formatting cheat-sheet": "Οδηγός μορφοποίησης", "Smaller heading": "Μικρότερη επικεφαλίδα", "bold": "έντονα", diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index 2ec2b2e..d38c5f2 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -12,6 +12,13 @@ import ( "github.com/gramanas/blogspace/internal/store" ) +// handlePreview renders Markdown for the editor's Preview toggle, with the same +// pipeline a save uses; nothing is stored. Login, ownership, CSRF and the body +// cap are all withBlog's. +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"html": markdown.Render(r.FormValue("body"))}) +} + func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) { pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64) pages, err := blogStore(r).ListPages(r.Context()) diff --git a/internal/web/routes.go b/internal/web/routes.go index 273a832..26264ef 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -37,6 +37,7 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave)) m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm)) m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete)) + m.HandleFunc("POST /b/{sub}/preview", s.withBlog(s.handlePreview)) m.HandleFunc("GET /b/{sub}/announcements", s.withBlog(s.handleSections)) m.HandleFunc("GET /b/{sub}/announcements/new", s.withBlog(s.handleSectionForm)) m.HandleFunc("POST /b/{sub}/announcements/new", s.withBlog(s.handleSectionSave)) diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 8fe5f8a..b85d515 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -1,216 +1,256 @@ -/* Blogspace dashboard — "paper & ink": flat colours, 2px rules, hard shadows, - square corners. Plain CSS (no variables, no flex for layout) so it renders on - old browsers; works down to ~320px. */ +/* Blogspace dashboard — lean: neutral surfaces, 1px borders, one accent, system + sans. Flexbox/grid and custom properties are fine here (the public blog.css + stays float-based); works down to ~320px. */ +:root { + --bg: #f5f6f8; --surface: #fff; --surface-2: #f8f9fb; + --border: #e2e5ea; --border-strong: #c9ced6; + --text: #1f2328; --muted: #6b7280; + --accent: #2563eb; --accent-hover: #1d4ed8; --accent-soft: #eef3ff; --accent-ring: rgba(37, 99, 235, 0.25); + --danger: #dc2626; --danger-hover: #b91c1c; --danger-soft: #fef2f2; + --ok-bg: #ecfdf3; --ok-border: #a7e3bd; --ok-text: #14532d; + --err-bg: #fef2f2; --err-border: #f5b5b5; --err-text: #7f1d1d; + --radius: 8px; --radius-sm: 6px; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.04); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; +} * { box-sizing: border-box; } -html { background: #f4efe4; } -body { margin: 0; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1d1a17; background: #f4efe4; } -a { color: #c8541a; } -a:hover { color: #a3420f; } -h1, h2, h3, .mono { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; letter-spacing: -0.01em; } -h1 { font-size: 1.5em; margin: 0.2em 0 0.6em; line-height: 1.2; } -h2 { font-size: 1.05em; margin: 0 0 0.8em; text-transform: uppercase; letter-spacing: 0.06em; } -h3 { font-size: 0.95em; margin: 0 0 0.4em; text-transform: uppercase; letter-spacing: 0.06em; } -code { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; background: #ebe5d6; padding: 0 0.3em; font-size: 0.92em; } +[hidden] { display: none !important; } +html { background: var(--bg); } +body { margin: 0; font-family: var(--font); font-size: 15px; line-height: 1.55; color: var(--text); background: var(--bg); -webkit-text-size-adjust: 100%; } +a { color: var(--accent); } +a:hover { color: var(--accent-hover); } +h1, h2, h3 { font-weight: 600; letter-spacing: -0.01em; line-height: 1.25; } +h1 { font-size: 1.5em; margin: 0 0 0.6em; } +h2 { font-size: 1.1em; margin: 0 0 0.8em; } +h3 { font-size: 0.95em; margin: 0 0 0.4em; } +code, .mono { font-family: var(--mono); } +code { background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 0 0.3em; font-size: 0.9em; } p { margin: 0.6em 0; } -.muted { color: #6b655c; } +.muted { color: var(--muted); } .nowrap { white-space: nowrap; } .narrow { max-width: 480px; } .center { margin-left: auto; margin-right: auto; } .small { font-size: 0.9em; } /* ---- bars ---------------------------------------------------------------- */ -.topbar { background: #1d1a17; color: #f4efe4; } -.topbar a { color: #f4efe4; text-decoration: none; } -.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.55em 1em; overflow: hidden; } -.brand { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-weight: bold; text-transform: uppercase; letter-spacing: 0.12em; margin-right: 1.2em; } -.who { color: #b8b0a3; font-size: 0.9em; } -.topbar .links { float: right; font-size: 0.9em; } -.topbar .links a, .topbar .links form { margin-left: 1.2em; } -.topbar .links a:hover, .topbar .links .linkbtn:hover { color: #f2a67c; } -.topbar .links a.active { border-bottom: 2px solid #f2a67c; } -.blogbar { background: #fffdf8; border-bottom: 2px solid #1d1a17; } -.blogbar .topbar-inner { padding: 0.5em 1em 0; } -.blogbar .blogname { display: inline-block; margin: 0 1em 0.5em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; } -.blogbar .tabs { display: inline-block; vertical-align: bottom; } -.blogbar .tabs a { display: inline-block; padding: 0.35em 0.8em; margin: 0 0.15em 0 0; text-decoration: none; color: #1d1a17; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; border: 2px solid transparent; border-bottom: 0; } -.blogbar .tabs a:hover { border-color: #1d1a17; } -.blogbar .tabs a.active { background: #1d1a17; color: #f4efe4; border-color: #1d1a17; } -.blogbar .view { float: right; margin: 0 0 0.5em; padding: 0.25em 0.7em; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-size: 0.85em; font-weight: bold; background: #fffdf8; } -.blogbar .view:hover { background: #1d1a17; color: #f4efe4; } +.topbar { background: var(--surface); border-bottom: 1px solid var(--border); } +.topbar a { color: var(--text); text-decoration: none; } +.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.6em 1em; display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em 1em; } +.brand { font-weight: 700; letter-spacing: -0.01em; } +.who { color: var(--muted); font-size: 0.9em; } +.topbar .links { margin-left: auto; font-size: 0.9em; display: flex; gap: 1.2em; align-items: center; } +.topbar .links a, .topbar .links .linkbtn { color: var(--muted); } +.topbar .links a:hover, .topbar .links .linkbtn:hover { color: var(--text); } +.topbar .links a.active { color: var(--text); font-weight: 600; } +.blogbar { background: var(--surface); border-bottom: 1px solid var(--border); } +.blogbar .topbar-inner { padding: 0.5em 1em 0; gap: 0 1.2em; } +.blogbar .blogname { font-weight: 600; padding-bottom: 0.55em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 20em; } +.blogbar .tabs { display: flex; gap: 0.2em; } +.blogbar .tabs a { display: inline-block; padding: 0.35em 0.7em 0.55em; text-decoration: none; color: var(--muted); font-size: 0.92em; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; } +.blogbar .tabs a:hover { color: var(--text); } +.blogbar .tabs a.active { color: var(--accent); border-color: var(--accent); } +.blogbar .view { margin: 0 0 0.5em auto; padding: 0.3em 0.75em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text); text-decoration: none; font-size: 0.85em; font-weight: 500; background: var(--surface); white-space: nowrap; } +.blogbar .view:hover { background: var(--surface-2); border-color: var(--muted); } /* ---- page ---------------------------------------------------------------- */ -.main { max-width: 1100px; margin: 0 auto; padding: 1.4em 1em 3em; } -.pagehead { overflow: hidden; margin-bottom: 0.8em; } -.pagehead h1 { float: left; margin: 0.1em 1em 0.3em 0; } -.pagehead .actions { float: right; margin-top: 0.1em; } -.pagehead .lead { clear: both; margin: 0; } -.card { background: #fffdf8; border: 2px solid #1d1a17; box-shadow: 4px 4px 0 #1d1a17; padding: 1.1em 1.3em; margin: 0 4px 1.6em 0; } +.main { max-width: 1100px; margin: 0 auto; padding: 1.5em 1em 3em; } +.pagehead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4em 1em; margin-bottom: 1em; } +.pagehead h1 { margin: 0; flex: 1 1 auto; } +.pagehead .actions { margin-left: auto; } +.pagehead .lead { flex-basis: 100%; margin: 0; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25em 1.4em; margin: 0 0 1.4em; } .card > :last-child { margin-bottom: 0; } -.cols { overflow: hidden; } -.cols .card { float: left; width: 48.5%; margin-right: 3%; } -.cols .card + .card { margin-right: 4px; } -.flash { padding: 0.6em 1em; border: 2px solid #1d1a17; margin: 0 4px 1.2em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; } -.flash.ok { background: #e4f0dc; } -.flash.err { background: #f6dfd9; } -.tag { display: inline-block; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid #1d1a17; color: #1d1a17; padding: 0 0.45em; margin-left: 0.5em; vertical-align: middle; line-height: 1.6; } -.tag.off { color: #6b655c; border-color: #b8b0a3; } +.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4em; margin-bottom: 1.4em; } +.cols .card { margin: 0; } +.flash { padding: 0.65em 1em; border: 1px solid; border-radius: var(--radius-sm); margin: 0 0 1.2em; font-size: 0.95em; } +.flash.ok { background: var(--ok-bg); border-color: var(--ok-border); color: var(--ok-text); } +.flash.err { background: var(--err-bg); border-color: var(--err-border); color: var(--err-text); } +.tag { display: inline-block; font-size: 0.7em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; border-radius: 999px; background: var(--accent-soft); color: var(--accent-hover); padding: 0.1em 0.6em; margin-left: 0.5em; vertical-align: middle; line-height: 1.6; } +.tag.off { background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); } /* ---- forms --------------------------------------------------------------- */ -label { display: block; margin: 0.8em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } -label .muted { text-transform: none; letter-spacing: 0; } -label code { text-transform: none; letter-spacing: 0; } -input[type=text], input[type=password], input[type=number], input[type=datetime-local], input:not([type]), input[type=file], select, textarea { - width: 100%; max-width: 100%; padding: 0.5em 0.6em; margin-top: 0.25em; border: 2px solid #1d1a17; font: inherit; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; text-transform: none; letter-spacing: 0; background: #fffdf8; color: #1d1a17; } -input:focus, select:focus, textarea:focus, button:focus, .btn:focus { outline: 2px solid #c8541a; outline-offset: 1px; } -input[type=color] { width: 4em; height: 2.3em; padding: 0.1em; margin-top: 0.25em; border: 2px solid #1d1a17; background: #fffdf8; } +label { display: block; margin: 0.9em 0; font-size: 0.875em; font-weight: 500; } +input[type=text], input[type=password], input[type=number], input[type=datetime-local], input[type=url], input[type=search], input:not([type]), input[type=file], select, textarea { + width: 100%; max-width: 100%; padding: 0.5em 0.65em; margin-top: 0.3em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: inherit; font-size: 15px; font-weight: normal; background: var(--surface); color: var(--text); } +input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } +button:focus-visible, .btn:focus-visible, .tabs a:focus-visible, .view:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +input[type=color] { width: 4em; height: 2.4em; padding: 0.15em; margin-top: 0.3em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); } input[type=file] { padding: 0.35em; } -select { -webkit-appearance: none; appearance: none; padding-right: 1.8em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%231d1a17' stroke-width='2'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.6em center; } -textarea { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.95em; line-height: 1.5; } -textarea.editor { min-height: 28em; } -label.check { text-transform: none; letter-spacing: 0; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1em; } -label.check input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } -.row { overflow: hidden; } -.row label { float: left; margin-right: 1.5em; min-width: 12em; } -.row label:last-child { margin-right: 0; } -button, .btn { display: inline-block; font: inherit; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.5em 1.1em; border: 2px solid #1d1a17; background: #c8541a; color: #fffdf8; cursor: pointer; text-decoration: none; box-shadow: 3px 3px 0 #1d1a17; vertical-align: middle; } -button:hover, .btn:hover { background: #a3420f; color: #fffdf8; } -button:active, .btn:active { box-shadow: none; position: relative; top: 3px; left: 3px; } -.btn.secondary, button.secondary { background: #fffdf8; color: #1d1a17; } -.btn.secondary:hover, button.secondary:hover { background: #ebe5d6; color: #1d1a17; } -.btn.small { font-size: 0.75em; padding: 0.4em 0.9em; } -button.mini, .mini { font-size: 0.72em; padding: 0.15em 0.55em; background: #fffdf8; color: #1d1a17; border-width: 1px; box-shadow: none; font-weight: normal; } -button.mini:hover, .mini:hover { background: #1d1a17; color: #fffdf8; } -button.mini:active { top: 0; left: 0; } -button.danger { background: #a12b1e; } -button.danger:hover { background: #7d1f14; } -a.danger { color: #a12b1e; } -button.mini.danger { color: #a12b1e; border-color: #a12b1e; background: #fffdf8; } -button.mini.danger:hover { background: #a12b1e; color: #fffdf8; } -button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; box-shadow: none; text-transform: none; letter-spacing: 0; } -button.linkbtn:active { top: 0; left: 0; } +input[type=search] { -webkit-appearance: none; appearance: none; } +select { -webkit-appearance: none; appearance: none; padding-right: 1.9em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%236b7280' stroke-width='1.8'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.7em center; } +textarea { font-family: var(--mono); font-size: 0.92em; line-height: 1.55; } +textarea.tall { min-height: 28em; } +label.check { font-weight: normal; font-size: 0.95em; } +label.check input { width: auto; margin: 0 0.45em 0 0; vertical-align: middle; accent-color: var(--accent); } +input[type=radio], input[type=checkbox] { accent-color: var(--accent); } +.row { display: flex; flex-wrap: wrap; gap: 0 1.5em; } +.row label { flex: 1 1 12em; min-width: 12em; } +button, .btn { display: inline-block; font: inherit; font-size: 0.9em; font-weight: 600; padding: 0.5em 1.05em; border: 1px solid var(--accent); border-radius: var(--radius-sm); background: var(--accent); color: #fff; cursor: pointer; text-decoration: none; vertical-align: middle; line-height: 1.4; } +button:hover, .btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.btn.secondary, button.secondary { background: var(--surface); color: var(--text); border-color: var(--border-strong); } +.btn.secondary:hover, button.secondary:hover { background: var(--surface-2); color: var(--text); border-color: var(--muted); } +.btn.small, button.small { font-size: 0.8em; padding: 0.4em 0.85em; } +button.mini, .mini { font-size: 0.75em; font-weight: 500; padding: 0.15em 0.55em; background: var(--surface); color: var(--text); border-color: var(--border-strong); } +button.mini:hover, .mini:hover { background: var(--surface-2); color: var(--text); border-color: var(--muted); } +button.danger { background: var(--danger); border-color: var(--danger); } +button.danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); } +a.danger { color: var(--danger); } +button.mini.danger { color: var(--danger); border-color: var(--border-strong); background: var(--surface); } +button.mini.danger:hover { background: var(--danger-soft); border-color: var(--danger); } +button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; } form.inline, .inline { display: inline; } details.inline summary { display: inline; cursor: pointer; } -summary.mini { display: inline-block; list-style: none; } +summary.mini { display: inline-block; list-style: none; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); cursor: pointer; line-height: 1.4; } +summary.mini::-webkit-details-marker { display: none; } .filter { margin: 0 0 1em; } .filter label { display: inline-block; margin: 0; } .filter select { width: auto; margin: 0 0 0 0.4em; } -.actionrow { margin: 1.2em 0 0; } -.actionrow a { margin-left: 0.8em; } +.actionrow { margin: 1.2em 0 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em 0.9em; } +.actionrow a:not(.btn) { font-size: 0.95em; } /* ---- tables & lists ------------------------------------------------------ */ table { border-collapse: collapse; width: 100%; } -th, td { text-align: left; padding: 0.5em 0.6em; border-bottom: 1px solid #ddd6c7; vertical-align: middle; } -th { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; color: #1d1a17; border-bottom: 2px solid #1d1a17; } -tr:nth-child(even) td { background: #f9f6ee; } -tr:hover td { background: #f1ecdf; } -tr.disabled td { color: #8f887c; } +th, td { text-align: left; padding: 0.55em 0.6em; border-bottom: 1px solid var(--border); vertical-align: middle; } +th { font-size: 0.75em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); } +tr:last-child td { border-bottom: 0; } +tr:hover td { background: var(--surface-2); } +tr.disabled td { color: var(--muted); } ul.plain { list-style: none; padding: 0; margin: 0; } -ul.plain li { padding: 0.3em 0; border-bottom: 1px solid #ddd6c7; } +ul.plain li { padding: 0.35em 0; border-bottom: 1px solid var(--border); } ul.plain li:last-child { border-bottom: 0; } -details.help { margin: 0.8em 0 0; border: 2px dashed #1d1a17; padding: 0.5em 1em; background: #fbf8f0; } -details.help summary { cursor: pointer; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.06em; } -details.help[open] summary { margin-bottom: 0.5em; } +details.help { margin: 0.8em 0 0; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); padding: 0.5em 1em; background: var(--surface-2); font-size: 0.95em; } +details.help summary { cursor: pointer; color: var(--muted); font-weight: 500; font-size: 0.9em; } +details.help[open] summary { margin-bottom: 0.5em; color: var(--text); } .cheat td { border: 0; padding: 0.2em 1em 0.2em 0; background: none; } +.cheat tr:hover td { background: none; } -.editor-tools { margin: 0.5em 0 0; font-size: 0.9em; } -.editor-tools .upload { display: inline-block; margin: 0 0.8em 0 0; padding: 0.25em 0.7em; border: 2px solid #1d1a17; background: #fffdf8; cursor: pointer; font-size: 0.8em; } -.editor-tools .upload:hover { background: #ebe5d6; } +/* ---- editor: toolbar, textarea and preview share one frame ------------------ */ +.editor { margin-top: 0.3em; } +.editor textarea { margin-top: 0; display: block; } +.editor.js textarea { border-top-left-radius: 0; border-top-right-radius: 0; } +.ed-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25em; padding: 0.35em 0.4em; background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom: 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0; } +.ed-group { display: inline-flex; gap: 0.1em; padding-right: 0.35em; margin-right: 0.1em; border-right: 1px solid var(--border-strong); } +.ed-group:last-of-type { border-right: 0; } +.ed-toolbar button[data-cmd] { width: 2em; height: 2em; padding: 0; border: 1px solid transparent; background: none; color: var(--text); border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; } +.ed-toolbar button[data-cmd]:hover { background: var(--accent-soft); color: var(--accent-hover); border-color: transparent; } +.ed-toolbar svg { width: 16px; height: 16px; fill: currentColor; } +.ed-modes { margin-left: auto; display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; } +.ed-modes button { font-size: 0.8em; font-weight: 500; padding: 0.25em 0.75em; border: 0; border-radius: 0; background: var(--surface); color: var(--muted); } +.ed-modes button + button { border-left: 1px solid var(--border-strong); } +.ed-modes button:hover { background: var(--surface-2); color: var(--text); } +.ed-modes button[aria-pressed=true] { background: var(--accent); color: #fff; } +.editor.previewing .ed-toolbar button[data-cmd] { opacity: 0.35; pointer-events: none; } +.ed-linkbox { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; padding: 0.5em; background: var(--accent-soft); border: 1px solid var(--border-strong); border-bottom: 0; } +.ed-linkbox input { flex: 1 1 12em; margin: 0; padding: 0.35em 0.5em; font-size: 0.9em; } +.ed-preview { min-height: 10em; padding: 0.8em 1em; border: 1px solid var(--border-strong); border-radius: 0 0 var(--radius-sm) var(--radius-sm); background: var(--surface); overflow-x: auto; } +.ed-preview > :first-child { margin-top: 0; } +.ed-preview > :last-child { margin-bottom: 0; } +.ed-preview h1, .ed-preview h2, .ed-preview h3, .ed-preview h4 { margin: 1em 0 0.4em; } +.ed-preview h1 { font-size: 1.5em; } .ed-preview h2 { font-size: 1.3em; } .ed-preview h3 { font-size: 1.1em; } +.ed-preview img { max-width: 100%; height: auto; } +.ed-preview blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 3px solid var(--border-strong); color: var(--muted); } +.ed-preview pre { padding: 0.7em 0.9em; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow-x: auto; font-size: 0.9em; } +.ed-preview pre code { border: 0; padding: 0; background: none; } +.ed-preview table { width: auto; margin: 0.8em 0; } +.ed-preview th, .ed-preview td { border: 1px solid var(--border); } +.ed-preview hr { border: 0; border-top: 1px solid var(--border-strong); margin: 1.2em 0; } +.editor-tools { margin: 0.5em 0 0; font-size: 0.9em; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3em 0.8em; } +.editor-tools .upload { display: inline-block; margin: 0; padding: 0.25em 0.7em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; font-size: 0.85em; } +.editor-tools .upload:hover { background: var(--surface-2); } .editor-tools .upload input { display: inline-block; width: auto; max-width: 14em; margin: 0 0 0 0.4em; padding: 0; border: 0; background: none; font-size: 0.9em; } -.editor-tools .upload-status { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; margin-right: 0.6em; } +.editor.js .editor-tools .upload { display: none; } /* the toolbar's Insert file button takes over */ +.editor-tools .upload-status { font-size: 0.9em; color: var(--muted); } +.editor-tools .upload-status:empty { display: none; } /* ---- files --------------------------------------------------------------- */ -.dropzone.over { border-style: dashed; background: #ebe5d6; } -.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; } +.dropzone.over { border: 1px dashed var(--accent); background: var(--accent-soft); } +.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-size: 0.9em; color: var(--muted); } .progress:empty { display: none; } -.files-filter { overflow: hidden; } -.files-filter input[type=search] { width: auto; margin: 0 0.3em 0.5em 0; padding: 0.3em 0.5em; vertical-align: middle; } -.kinds { display: inline-block; margin: 0 1em 0.5em 0; vertical-align: middle; } -.kinds a { display: inline-block; padding: 0.2em 0.7em; margin: 0 0.3em 0.3em 0; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } -.kinds a:hover { background: #ebe5d6; } -.kinds a.active { background: #1d1a17; color: #f4efe4; } +.files-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em; } +.files-filter input[type=search] { width: auto; margin: 0; padding: 0.35em 0.6em; } +.kinds { display: inline-flex; flex-wrap: wrap; gap: 0.3em; margin-right: 0.5em; } +.kinds a { display: inline-block; padding: 0.25em 0.8em; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--text); text-decoration: none; font-size: 0.85em; font-weight: 500; } +.kinds a:hover { background: var(--surface-2); } +.kinds a.active { background: var(--accent); color: #fff; border-color: var(--accent); } table.files td.icon { width: 56px; } -table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #ddd6c7; background: #f4efe4; } -table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid #1d1a17; background: #fffdf8; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; } +table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); } +table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--surface); text-align: center; font-family: var(--mono); font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; } table.files td.name { word-break: break-all; } -table.files .copy { width: 100%; min-width: 11em; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; } -.pager { margin: 0 4px 1.6em 0; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; } +table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: 0; font-family: var(--mono); font-size: 0.75em; } +.pager { margin: 0 0 1.4em; text-align: center; font-size: 0.92em; } .pager a, .pager span { margin: 0 0.8em; } -.imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 2px dashed #ddd6c7; } +.imagepick { margin-top: 0.8em; padding-top: 0.6em; border-top: 1px solid var(--border); } .imagepick > label { margin-bottom: 0.3em; } -.imagepick .picks { overflow: hidden; margin: 0.4em 0 0; } -.imagepick .pick { float: left; width: 120px; margin: 0 0.7em 0.7em 0; padding: 0.35em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; font-size: 0.75em; text-transform: none; letter-spacing: 0; cursor: pointer; } -.imagepick .pick:hover { border-color: #1d1a17; } +.imagepick .picks { display: flex; flex-wrap: wrap; gap: 0.7em; margin: 0.4em 0 0; } +.imagepick .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; } +.imagepick .pick:hover { border-color: var(--border-strong); background: var(--surface-2); } .imagepick .pick input { display: block; width: auto; margin: 0 auto 0.3em; } -.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid #ddd6c7; background: #f4efe4; } +.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); } .imagepick .pick img { object-fit: contain; } -.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: #b8b0a3; } +.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: var(--muted); } .imagepick .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border: 3px solid #c8541a; } +.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); } /* ---- design page ---------------------------------------------------------- */ -.jumps { margin: 0 0 1.2em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } -.jumps a { display: inline-block; margin: 0 0.8em 0.3em 0; color: #1d1a17; text-decoration: none; border-bottom: 2px solid #ddd6c7; } -.jumps a:hover { border-color: #c8541a; } -.presets { overflow: hidden; } -.presets .preset { float: left; width: 8.5em; margin: 0 0.9em 0.9em 0; padding: 0.45em; background: #fffdf8; color: #1d1a17; font-size: 0.8em; text-align: center; } -.presets .preset:hover { background: #ebe5d6; color: #1d1a17; } -.presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.25); vertical-align: middle; } +.jumps { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0 0 1.2em; font-size: 0.85em; } +.jumps a { display: inline-block; padding: 0.2em 0.75em; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); text-decoration: none; } +.jumps a:hover { border-color: var(--accent); color: var(--accent); } +.presets { display: flex; flex-wrap: wrap; gap: 0.8em; } +.presets .preset { width: 8.5em; margin: 0; padding: 0.55em 0.45em; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.8em; font-weight: 500; text-align: center; } +.presets .preset:hover { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); } +.presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.15); border-radius: 3px; vertical-align: middle; } .presets .pname { display: block; margin-top: 0.4em; } /* layout page: the column diagram, area cards with their module tables */ -.lay { margin: 0 0 1em; border: 2px solid #1d1a17; background: #f4efe4; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; } -.lay-row { padding: 0.5em; background: #1d1a17; color: #f4efe4; } -.lay-cols { overflow: hidden; padding: 0.5em 0; } -.lay-col { float: left; padding: 1.6em 0.3em; border: 1px dashed #1d1a17; background: #fffdf8; } -.lay-col.lay-main { background: #ebe5d6; } -.lay .off { opacity: 0.4; text-decoration: line-through; } +.lay { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 0.75em; font-weight: 500; text-align: center; overflow: hidden; } +.lay-row { padding: 0.5em; background: var(--text); color: #fff; } +.lay-cols { display: flex; padding: 0.5em; gap: 0.3em; } +.lay-col { padding: 1.6em 0.3em; border: 1px dashed var(--border-strong); border-radius: 4px; background: var(--surface); } +.lay-col.lay-main { background: var(--accent-soft); border-color: var(--accent); border-style: solid; color: var(--accent-hover); } +.lay .off { opacity: 0.45; text-decoration: line-through; } .lay-col.off { text-decoration: none; } .lay-col.empty { background: transparent; } .row input[type=number] { width: 6em; } -.cardhead { overflow: hidden; } -.cardhead h2 { float: left; margin-right: 1em; } -.cardhead .switch { float: right; margin-top: -0.2em; } +.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 1em; } +.cardhead h2 { margin: 0 0 0.6em; } +.cardhead .switch { margin: 0 0 0.6em auto; } .card.area-off { border-style: dashed; box-shadow: none; } .card.area-off .modules, .card.area-off .addmod { opacity: 0.6; } table.modules { margin: 0.5em 0 1em; } table.modules td.order, table.modules td.actions { width: 1%; } table.modules th { width: 10em; } -.addmod { border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } +.addmod { border-top: 1px solid var(--border); padding-top: 0.6em; } .addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; } .addmod select, .add select, .add input { width: auto; min-width: 14em; } .addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; } -.addrow { margin-top: 1em; border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } -.addrow .add { float: left; width: 48%; margin-right: 4%; } -.addrow .add + .add { margin-right: 0; } +.addrow { margin-top: 1em; border-top: 1px solid var(--border); padding-top: 0.6em; margin-bottom: 0; } textarea.mono { font-size: 0.9em; } .hoverpick { margin: 0.8em 0; } .hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } .hoverpick > label.check { display: inline; margin: 0; } .hoverpick .hover-color { margin: 0.4em 0 0 1.6em; } .hoverpick > input:not(:checked) ~ .hover-color { display: none; } -.savebar { position: sticky; bottom: 0; margin: 0 4px 0 0; padding: 0.8em 0; background: #f4efe4; border-top: 2px solid #1d1a17; } -.savebar .btn { margin-left: 0.8em; } +.savebar { position: sticky; bottom: 0; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; gap: 0.8em; align-items: center; } details.reset { margin-top: 1.6em; } /* ---- login --------------------------------------------------------------- */ -.login-brand { text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; text-transform: uppercase; letter-spacing: 0.2em; margin: 2em 0 1em; } +.login-brand { text-align: center; font-weight: 700; font-size: 1.2em; letter-spacing: -0.01em; margin: 2.5em 0 1em; } @media (max-width: 700px) { - .cols .card, .row label { float: none; width: auto; margin-right: 4px; } - .imagepick .pick { width: 46%; margin-right: 4%; } - .presets .preset { width: 46%; margin-right: 4%; } - .addrow .add { float: none; width: auto; margin-right: 0; } - .cardhead h2, .cardhead .switch { float: none; margin-top: 0; } - .cardhead .switch { display: block; margin-bottom: 0.6em; } - .cols .card + .card { margin-right: 4px; } - .topbar .links { float: none; display: block; margin-top: 0.3em; } - .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; } - .blogbar .topbar-inner { overflow: visible; } - .blogbar .blogname { display: block; margin-right: 0; } - .blogbar .view { float: none; display: inline-block; margin: 0 0 0.6em; } - .blogbar .tabs { display: block; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em; padding: 0 1em; } - .pagehead h1, .pagehead .actions { float: none; } - .pagehead .actions { margin-bottom: 0.6em; } - .card { padding: 0.9em 1em; overflow-x: auto; } + body { font-size: 15px; } + .cols { grid-template-columns: 1fr; gap: 1.2em; } + .row { gap: 0; } + .row label { flex-basis: 100%; } + .imagepick .pick { width: 46%; } + .presets .preset { width: 46%; } + .topbar .links { margin-left: 0; flex-basis: 100%; gap: 1em; } + .blogbar .topbar-inner { display: block; } + .blogbar .blogname { display: block; max-width: none; padding-bottom: 0.3em; } + .blogbar .view { display: inline-block; margin: 0 0 0.6em; } + .blogbar .tabs { overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em; padding: 0 1em; } + .pagehead .actions { margin-left: 0; } + .card { padding: 1em 1em; overflow-x: auto; } table { font-size: 0.9em; } - th, td { padding: 0.35em 0.3em; } + th, td { padding: 0.4em 0.35em; } + .ed-modes { margin-left: 0; } } diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 18d1119..775222d 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -8,7 +8,7 @@ <label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br> <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label> - {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index d12d15f..9f188f7 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -15,7 +15,7 @@ <input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label> </div> <label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label> - {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index b401d9c..dc971e5 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -24,7 +24,7 @@ </div> <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p> <label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label> - {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index 8b44e55..40ca33f 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -16,7 +16,7 @@ <div class="blogbar"> <div class="topbar-inner"> <strong class="blogname">{{.Blog.Title}}</strong> - <span class="tabs"> + <nav class="tabs"> <a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>{{t "Overview"}}</a> <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a> <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a> @@ -25,7 +25,7 @@ <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a> <a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a> <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a> - </span> + </nav> <a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> </div> </div> diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html index 4371624..33b2a1c 100644 --- a/internal/web/templates/partials/editor.html +++ b/internal/web/templates/partials/editor.html @@ -1,4 +1,36 @@ -{{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea> +{{define "editor"}}<div class="editor" id="ed-{{.name}}-wrap"> +<div class="ed-toolbar" id="ed-{{.name}}-bar" hidden> + <span class="ed-group"> + <button type="button" data-cmd="bold" title="{{t "Bold"}} (Ctrl+B)"><svg viewBox="0 0 16 16"><path d="M4 2h5a3.5 3.5 0 0 1 2.4 6 3.75 3.75 0 0 1-2.2 6.8H4zM6 4v3.5h3a1.75 1.75 0 0 0 0-3.5zm0 5.5V13h3.2a1.75 1.75 0 0 0 0-3.5z"/></svg></button> + <button type="button" data-cmd="italic" title="{{t "Italic"}} (Ctrl+I)"><svg viewBox="0 0 16 16"><path d="M6.5 2h6v2h-2l-2.5 8h2v2h-6v-2h2l2.5-8h-2z"/></svg></button> + <button type="button" data-cmd="strike" title="{{t "Strikethrough"}}"><svg viewBox="0 0 16 16"><path d="M2 7.5h12V9H2z"/><path d="M8 2c2.3 0 3.8 1 4.1 2.8l-1.9.3C10 4.2 9.2 3.7 8 3.7c-1.3 0-2 .5-2 1.3 0 .5.3.9 1.2 1.2H4.3C4 5.8 4 5.3 4 5c0-1.8 1.6-3 4-3zm3.9 8.4c0 1.9-1.6 3.1-4 3.1-2.5 0-4.1-1.2-4.4-3.2l1.9-.3c.2 1.1 1.1 1.7 2.5 1.7 1.3 0 2-.5 2-1.3 0-.2 0-.4-.1-.5h2c.1.2.1.4.1.5z"/></svg></button> + </span> + <span class="ed-group"> + <button type="button" data-cmd="heading" title="{{t "Heading"}}"><svg viewBox="0 0 16 16"><path d="M3 2h2v5h6V2h2v12h-2V9H5v5H3z"/></svg></button> + <button type="button" data-cmd="quote" title="{{t "Quote"}}"><svg viewBox="0 0 16 16"><path d="M3 4h4v4H5v1.5c0 .6.4 1 1 1h1V12H6a3 3 0 0 1-3-3zm6 0h4v4h-2v1.5c0 .6.4 1 1 1h1V12h-1a3 3 0 0 1-3-3z"/></svg></button> + <button type="button" data-cmd="code" title="{{t "Code"}}"><svg viewBox="0 0 16 16"><path d="M5.5 3.5 1 8l4.5 4.5 1.4-1.4L3.8 8l3.1-3.1zm5 0L9.1 4.9 12.2 8l-3.1 3.1 1.4 1.4L15 8z"/></svg></button> + </span> + <span class="ed-group"> + <button type="button" data-cmd="ul" title="{{t "Bulleted list"}}"><svg viewBox="0 0 16 16"><path d="M2 3h2v2H2zm4 0h8v2H6zM2 7h2v2H2zm4 0h8v2H6zm-4 4h2v2H2zm4 0h8v2H6z"/></svg></button> + <button type="button" data-cmd="ol" title="{{t "Numbered list"}}"><svg viewBox="0 0 16 16"><path d="M2.2 2h1.3v3.5H2.4v-2.3l-.7.4V2.5zM6 3h8v2H6zm-4.1 4.2c0-.8.6-1.3 1.5-1.3s1.4.5 1.4 1.2c0 .5-.3.9-.8 1.3l-.7.6h1.6v1H1.9v-.8L3.3 8c.3-.3.4-.5.4-.7 0-.3-.2-.4-.4-.4-.3 0-.5.2-.5.5zM6 7h8v2H6zm-4.2 4.1c0-.7.6-1.1 1.5-1.1.8 0 1.4.4 1.4 1 0 .4-.3.7-.7.8.5.1.8.5.8.9 0 .7-.6 1.2-1.5 1.2-.9 0-1.5-.4-1.6-1.1h1c0 .2.2.4.5.4s.5-.1.5-.4-.2-.4-.6-.4h-.3v-.8h.3c.3 0 .5-.1.5-.4 0-.2-.2-.3-.4-.3s-.4.1-.4.3zM6 11h8v2H6z"/></svg></button> + <button type="button" data-cmd="hr" title="{{t "Horizontal rule"}}"><svg viewBox="0 0 16 16"><path d="M2 7h12v2H2z"/></svg></button> + </span> + <span class="ed-group"> + <button type="button" data-cmd="link" title="{{t "Link"}} (Ctrl+K)"><svg viewBox="0 0 16 16"><path d="M6.4 9.6a3.5 3.5 0 0 0 4.9 0l2-2a3.5 3.5 0 0 0-4.9-5l-1 1 1.1 1.1 1-1a2 2 0 0 1 2.8 2.9l-2 2a2 2 0 0 1-2.9 0zm3.2-3.2a3.5 3.5 0 0 0-4.9 0l-2 2a3.5 3.5 0 0 0 4.9 5l1-1-1.1-1.1-1 1a2 2 0 0 1-2.8-2.9l2-2a2 2 0 0 1 2.9 0z"/></svg></button> + <button type="button" data-cmd="file" title="{{t "Insert file"}}"><svg viewBox="0 0 16 16"><path d="M10.5 1.5a3.5 3.5 0 0 1 2.5 6l-5.6 5.6a2.25 2.25 0 0 1-3.2-3.2L9.6 4.6 10.7 5.7l-5.4 5.3a.75.75 0 0 0 1.1 1.1L12 6.4a2 2 0 0 0-2.9-2.8L3.6 9.1 2.5 8l5.5-5.5a3.5 3.5 0 0 1 2.5-1z"/></svg></button> + </span> + <span class="ed-modes"> + <button type="button" data-mode="write" aria-pressed="true">{{t "Write"}}</button><button type="button" data-mode="preview" aria-pressed="false">{{t "Preview"}}</button> + </span> +</div> +<div class="ed-linkbox" id="ed-{{.name}}-link" hidden> + <input type="url" placeholder="https://…" aria-label="URL" data-link="url"> + <input type="text" placeholder="{{t "link text"}}" aria-label="{{t "link text"}}" data-link="text"> + <button type="button" class="small" data-link="ok">{{t "Insert link"}}</button> + <button type="button" class="small secondary" data-link="cancel">{{t "Cancel"}}</button> +</div> +<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="tall"{{end}}>{{.value}}</textarea> +<div class="ed-preview" id="ed-{{.name}}-preview" hidden></div> <div class="editor-tools"> <label class="upload">{{t "Insert file"}} <input type="file" name="inline_file" id="ed-{{.name}}-file"></label> <span class="upload-status" id="ed-{{.name}}-status"></span> @@ -6,17 +38,142 @@ <noscript><span class="muted small">{{t "The file is added at the end of the text when you save."}}</span></noscript> </div> {{template "mdhelp"}} +</div> <script> (function () { - var ta = document.getElementById("ed-{{.name}}"), inp = document.getElementById("ed-{{.name}}-file"), st = document.getElementById("ed-{{.name}}-status"); - if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: the file is uploaded on save instead - function insert(text) { - var s = ta.selectionStart, e = ta.selectionEnd, pre = ta.value.slice(0, s), post = ta.value.slice(e); - var chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n"; - ta.value = pre + chunk + post; - ta.selectionStart = ta.selectionEnd = (pre + chunk).length; - ta.focus(); + var id = "ed-{{.name}}", ta = document.getElementById(id), inp = document.getElementById(id + "-file"), st = document.getElementById(id + "-status"), + wrap = document.getElementById(id + "-wrap"), bar = document.getElementById(id + "-bar"), lk = document.getElementById(id + "-link"), pv = document.getElementById(id + "-preview"); + if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: plain textarea, the file is uploaded on save + bar.hidden = false; + wrap.className = "editor js"; + + // ---- text primitives: everything goes through replace() so Ctrl+Z works + function setSel(s, e) { ta.focus(); ta.setSelectionRange(s, e); } + function replace(s, e, text) { + setSel(s, e); + var ok = false; + try { ok = document.execCommand(text ? "insertText" : "delete", false, text); } catch (err) {} + if (!ok || ta.value.slice(s, s + text.length) !== text) ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); + } + function insertLine(text) { // a block on its own line, after the caret + var s = ta.selectionStart, pre = ta.value.slice(0, s), chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n"; + replace(s, ta.selectionEnd, chunk); + setSel(s + chunk.length, s + chunk.length); + } + function wrapSel(before, after, ph) { // wrap the selection, or unwrap it when it already is + var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e), bl = before.length, al = after.length; + if (sel.length >= bl + al && sel.slice(0, bl) === before && sel.slice(-al) === after) { + replace(s, e, sel.slice(bl, sel.length - al)); setSel(s, e - bl - al); return; + } + if (v.slice(s - bl, s) === before && v.slice(e, e + al) === after) { + replace(s - bl, e + al, sel); setSel(s - bl, e - bl); return; + } + var inner = sel || ph; + replace(s, e, before + inner + after); + setSel(s + bl, s + bl + inner.length); + } + function block() { // the whole lines the selection touches + var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd, ls = v.lastIndexOf("\n", s - 1) + 1, le = v.indexOf("\n", e > s ? e - 1 : e); + if (le < 0) le = v.length; + return { s: ls, e: le, text: v.slice(ls, le) }; + } + function prefixLines(strip, own, mark) { // toggle a line marker: off when every line has this one, else replace whatever strip matches + var b = block(), ls = b.text.split("\n"), on = ls.every(function (l) { return own.test(l); }); + var out = ls.map(function (l, i) { l = l.replace(strip, ""); return on ? l : (typeof mark === "function" ? mark(i) : mark) + l; }).join("\n"); + replace(b.s, b.e, out); setSel(b.s, b.s + out.length); + } + var listRe = /^[-*+] (\[[ x]\] )?|^\d+\. /; + var cmds = { + bold: function () { wrapSel("**", "**", {{t "bold"}}); }, + italic: function () { wrapSel("_", "_", {{t "italic"}}); }, + strike: function () { wrapSel("~~", "~~", {{t "text"}}); }, + code: function () { + if (ta.value.slice(ta.selectionStart, ta.selectionEnd).indexOf("\n") >= 0) { var b = block(); replace(b.s, b.e, "```\n" + b.text + "\n```"); setSel(b.s + 4, b.s + 4 + b.text.length); } + else wrapSel("`", "`", {{t "code"}}); + }, + heading: function () { // cycles ## → ### → plain + var b = block(), m = /^(#{1,6}) /.exec(b.text), next = !m ? "## " : m[1].length === 2 ? "### " : ""; + var out = next + b.text.replace(/^#{1,6} /, ""); + replace(b.s, b.e, out); setSel(b.s + next.length, b.s + out.length); + }, + quote: function () { prefixLines(/^> /, /^> /, "> "); }, + ul: function () { prefixLines(listRe, /^[-*+] /, "- "); }, + ol: function () { prefixLines(listRe, /^\d+\. /, function (i) { return (i + 1) + ". "; }); }, + hr: function () { insertLine("\n---\n"); }, + link: openLink, + file: function () { inp.click(); } + }; + bar.addEventListener("click", function (ev) { + var b = ev.target.closest("button"); + if (!b) return; + if (b.getAttribute("data-mode")) setMode(b.getAttribute("data-mode") === "preview"); + else if (cmds[b.getAttribute("data-cmd")]) cmds[b.getAttribute("data-cmd")](); + }); + + // ---- link box: replaces window.prompt, keeps the selection it was opened on + var linkSel = null, lkURL = lk.querySelector("[data-link=url]"), lkText = lk.querySelector("[data-link=text]"); + function openLink() { + linkSel = [ta.selectionStart, ta.selectionEnd]; + lkText.value = ta.value.slice(linkSel[0], linkSel[1]); + lkURL.value = ""; + lk.hidden = false; + lkURL.focus(); + } + function closeLink(insert) { + lk.hidden = true; + if (!insert || !linkSel) { setSel(linkSel ? linkSel[0] : 0, linkSel ? linkSel[1] : 0); return; } + var url = lkURL.value.trim(), text = lkText.value.trim() || url, md = "[" + text + "](" + url + ")"; + replace(linkSel[0], linkSel[1], md); + setSel(linkSel[0] + md.length, linkSel[0] + md.length); + linkSel = null; + } + lk.addEventListener("click", function (ev) { + var b = ev.target.closest("button"); + if (b) closeLink(b.getAttribute("data-link") === "ok" && lkURL.value.trim() !== ""); + }); + lk.addEventListener("keydown", function (ev) { + if (ev.key === "Enter") { ev.preventDefault(); if (lkURL.value.trim()) closeLink(true); } + else if (ev.key === "Escape") { ev.preventDefault(); closeLink(false); } + }); + + // ---- shortcuts and list continuation + ta.addEventListener("keydown", function (ev) { + if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) { + var k = { b: "bold", i: "italic", k: "link" }[ev.key.toLowerCase()]; + if (k) { ev.preventDefault(); cmds[k](); } + return; + } + if (ev.key !== "Enter" || ev.shiftKey || ta.selectionStart !== ta.selectionEnd) return; + var s = ta.selectionStart, v = ta.value, ls = v.lastIndexOf("\n", s - 1) + 1, m = /^(\s*)([-*+] (?:\[[ x]\] )?|(\d+)\. )(.*)$/.exec(v.slice(ls, s)); + if (!m) return; + ev.preventDefault(); + if (!m[4]) { replace(ls, s, ""); return; } // Enter on an empty item ends the list + var mark = m[3] ? (parseInt(m[3], 10) + 1) + ". " : m[2].replace("[x]", "[ ]"); + replace(s, s, "\n" + m[1] + mark); + }); + + // ---- preview: the server renders exactly what a save would + var modes = bar.querySelectorAll("[data-mode]"); + function setMode(preview) { + wrap.className = "editor js" + (preview ? " previewing" : ""); + for (var i = 0; i < modes.length; i++) modes[i].setAttribute("aria-pressed", String((modes[i].getAttribute("data-mode") === "preview") === preview)); + lk.hidden = true; + if (!preview) { pv.hidden = true; ta.hidden = false; ta.focus(); return; } + pv.style.minHeight = ta.offsetHeight + "px"; + pv.innerHTML = ""; + pv.hidden = false; ta.hidden = true; + st.textContent = {{t "Rendering…"}}; + var fd = new FormData(); + fd.append("_csrf", {{.csrf}}); + fd.append("body", ta.value); + fetch({{.preview}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) + .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || r.statusText); return j; }); }) + .then(function (j) { pv.innerHTML = j.html || "<p class=\"muted\">" + {{t "Nothing to show yet."}} + "</p>"; st.textContent = ""; }) + .catch(function (err) { st.textContent = {{t "Could not render:"}} + " " + err.message; setMode(false); }); } + pv.addEventListener("keydown", function (ev) { if (ev.key === "Escape") setMode(false); }); + + // ---- uploads: button, paste and drop all land at the caret function upload(file) { st.textContent = {{t "Uploading"}} + " " + file.name + "…"; var fd = new FormData(); @@ -24,7 +181,7 @@ fd.append("file", file); return fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); }) - .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; }) + .then(function (j) { insertLine(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; }) .catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; }); } function uploadAll(files) { // one after the other, so each lands where the caret is diff --git a/internal/web/web_test.go b/internal/web/web_test.go index e2bd335..9520953 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,6 +1,7 @@ package web import ( + "encoding/json" "io/fs" "net/http" "net/http/httptest" @@ -444,6 +445,24 @@ func TestFileMarkdown(t *testing.T) { } } +func TestPreviewRendersMarkdown(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + req := httptest.NewRequest("POST", "/b/www/preview", strings.NewReader("body=**x**+<script>1</script>")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.handlePreview(rec, req) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("content type %q", ct) + } + var out map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if h := out["html"]; !strings.Contains(h, "<strong>x</strong>") || strings.Contains(h, "<script>") { + t.Errorf("unexpected preview %q", h) + } +} + func TestNormalizeSection(t *testing.T) { sec := &store.Section{Placement: "footer", Style: "<script>"} normalizeSection(sec) |
