1182 lines
51 KiB
HTML
1182 lines
51 KiB
HTML
{{define "title"}}{{if .IsNew}}{{ tr "New post" }}{{else}}{{ tr "Edit post" }}{{end}} · {{ tr "Volumen admin" }}{{end}}
|
|||
|
|
{{define "content"}}
|
||
|
|
<div class="page-head">
|
||
|
|
<div class="page-head__text">
|
||
|
|
<h1>{{if .IsNew}}{{ tr "New post" }}{{else}}{{ tr "Edit post" }}{{end}}</h1>
|
||
|
|
<p class="page-head__lede">{{if .IsNew}}{{ tr "Draft a new article in Markdown." }}{{else}}{{ tr "Update and publish this post." }}{{end}}</p>
|
||
|
|
<p>
|
||
|
|
{{if and .IsEdit .Post.Draft}}
|
||
|
|
<span class="tag tag--warn">{{ tr "Draft, not visible to the public" }}</span>
|
||
|
|
{{else if and .IsEdit .Post.Scheduled}}
|
||
|
|
<span class="tag tag--accent">{{if .Post.PublishAt}}{{ trf "Scheduled for %s" .Post.PublishAt }}{{else}}{{ tr "Scheduled for later" }}{{end}}</span>
|
||
|
|
{{end}}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div class="page-head__actions">
|
||
|
|
{{if .IsEdit}}
|
||
|
|
<a class="btn btn--ghost" href="/api/volumen/posts/{{ .Post.Slug }}{{if .PreviewToken}}?preview_token={{ .PreviewToken }}{{end}}" target="_blank">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#external-link"></use></svg>
|
||
|
|
API
|
||
|
|
</a>
|
||
|
|
<a class="btn btn--ghost" href="/admin/posts/{{ .Post.Slug }}/download">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#download"></use></svg>
|
||
|
|
{{ tr "Download" }}
|
||
|
|
</a>
|
||
|
|
<a class="btn btn--ghost" href="/admin/posts/{{ .Post.Slug }}/history">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#clock"></use></svg>
|
||
|
|
{{ tr "History" }}
|
||
|
|
</a>
|
||
|
|
<form method="post" action="/admin/posts/{{ .Post.Slug }}/duplicate" class="form-inline"
|
||
|
|
data-confirm="{{ tr "Duplicate this post as a draft?" }}">
|
||
|
|
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||
|
|
<button type="submit" class="btn btn--ghost" title="{{ tr "Duplicate as draft" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#copy"></use></svg>
|
||
|
|
{{ tr "Duplicate" }}
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
{{end}}
|
||
|
|
<a class="btn btn--ghost" href="/admin/">{{ tr "Cancel" }}</a>
|
||
|
|
<button type="button" class="icon-btn" id="show-shortcuts"
|
||
|
|
title="{{ tr "Keyboard shortcuts" }}" aria-label="{{ tr "Keyboard shortcuts" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#keyboard"></use></svg>
|
||
|
|
</button>
|
||
|
|
<button type="submit" form="post-form" class="btn btn--primary" title="{{ tr "Save (Ctrl+S)" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#save"></use></svg>
|
||
|
|
{{ tr "Save" }}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{{if .Error}}
|
||
|
|
<div class="status status--error" role="alert">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||
|
|
<div>{{ .Error }}</div>
|
||
|
|
</div>
|
||
|
|
{{end}}
|
||
|
|
|
||
|
|
{{if .Restored}}
|
||
|
|
<div class="status status--ok" role="status">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check-circle"></use></svg>
|
||
|
|
<div>{{ tr "Revision restored. The previous content was archived; see" }} <a href="/admin/posts/{{ .Post.Slug }}/history">{{ tr "History" }}</a>.</div>
|
||
|
|
</div>
|
||
|
|
{{end}}
|
||
|
|
|
||
|
|
{{if .Duplicated}}
|
||
|
|
<div class="status status--ok" role="status">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check-circle"></use></svg>
|
||
|
|
<div>{{ tr "Duplicated as draft. The new slug is" }} <code>{{ .Post.Slug }}</code>{{ tr ", adjust the title and body, then publish." }}</div>
|
||
|
|
</div>
|
||
|
|
{{end}}
|
||
|
|
|
||
|
|
{{if and .IsNew .PostTemplates}}
|
||
|
|
<div class="field narrow-start">
|
||
|
|
<label class="field__label" for="tpl-select">{{ tr "Template" }}</label>
|
||
|
|
<select id="tpl-select" class="control">
|
||
|
|
<option value="">{{ tr "Blank" }}</option>
|
||
|
|
{{range $i, $t := .PostTemplates}}
|
||
|
|
<option value="{{ $i }}">{{ $t.Name }}</option>
|
||
|
|
{{end}}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<script nonce="{{ .CSPNonce }}">
|
||
|
|
var TEMPLATES = {{ .TemplatesJSON }};
|
||
|
|
document.getElementById("tpl-select").addEventListener("change", function () {
|
||
|
|
var idx = parseInt(this.value, 10);
|
||
|
|
if (isNaN(idx)) return;
|
||
|
|
var tpl = TEMPLATES[idx];
|
||
|
|
if (!tpl) return;
|
||
|
|
var f = document.getElementById("post-form");
|
||
|
|
if (tpl.title) f.querySelector("[name=title]").value = tpl.title;
|
||
|
|
if (tpl.slug) f.querySelector("[name=slug]").value = tpl.slug;
|
||
|
|
if (tpl.tags) f.querySelector("[name=tags]").value = tpl.tags.join(", ");
|
||
|
|
if (tpl.body) f.querySelector("[name=body]").value = tpl.body;
|
||
|
|
// The scientific extras (doi, orcid, series, author, …) ride in
|
||
|
|
// fields: fill any editor input the template names.
|
||
|
|
if (tpl.fields) {
|
||
|
|
for (var name in tpl.fields) {
|
||
|
|
var input = f.querySelector('[name="' + name + '"]');
|
||
|
|
if (input) input.value = tpl.fields[name];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
window.__volumenSlugTouched = true;
|
||
|
|
if (f.dispatchEvent) f.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
{{end}}
|
||
|
|
|
||
|
|
<div class="status status--warn" id="autosave-bar" hidden>
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#clock"></use></svg>
|
||
|
|
<span id="autosave-msg"></span>
|
||
|
|
<span class="status__actions">
|
||
|
|
<button type="button" class="btn btn--sm btn--primary" id="autosave-restore">{{ tr "Restore" }}</button>
|
||
|
|
<button type="button" class="btn btn--sm btn--ghost" id="autosave-discard">{{ tr "Discard" }}</button>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form id="post-form" method="post"
|
||
|
|
action="{{if .IsNew}}/admin/posts{{else}}/admin/posts/{{ .Post.Slug }}{{end}}">
|
||
|
|
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||
|
|
|
||
|
|
<section class="card">
|
||
|
|
<div class="card__head">
|
||
|
|
<h2>{{ tr "Details" }}</h2>
|
||
|
|
<p class="card__note">{{ tr "Title, slug, and publishing options" }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="title">{{ tr "Title" }}</label>
|
||
|
|
<input id="title" class="control" type="text" name="title" value="{{ .Post.Title }}"
|
||
|
|
placeholder="{{ tr "A clear, descriptive title" }}" required
|
||
|
|
{{if .IsNew}}autofocus{{end}}>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="grid-2">
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="slug">{{ tr "Slug" }}</label>
|
||
|
|
<input id="slug" class="control mono" type="text" name="slug" value="{{ .Post.Slug }}"
|
||
|
|
{{if .IsNew}}required{{else}}readonly{{end}} placeholder="{{ tr "my-post-slug" }}"
|
||
|
|
data-exists-url="/admin/posts/exists">
|
||
|
|
<p class="field__hint is-live" id="slug-help" hidden></p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="lang">{{ tr "Language" }}</label>
|
||
|
|
<input id="lang" class="control" type="text" name="lang" value="{{ .Post.Lang }}" placeholder="en">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="author">{{ tr "Author" }}</label>
|
||
|
|
<input id="author" class="control" type="text" name="author" value="{{ .Post.Author }}"
|
||
|
|
placeholder="{{ .AuthorPlaceholder }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="fediverse_creator">{{ tr "Fediverse creator" }}</label>
|
||
|
|
<input id="fediverse_creator" class="control" type="text" name="fediverse_creator"
|
||
|
|
value="{{ .Post.FediverseCreator }}" placeholder="{{ tr "@user@instance.tld" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="doi">{{ tr "DOI" }}</label>
|
||
|
|
<input id="doi" class="control" type="text" name="doi" value="{{ .Post.DOI }}"
|
||
|
|
placeholder="{{ tr "10.5281/zenodo.1234567" }}">
|
||
|
|
<p class="field__hint">{{ tr "Digital Object Identifier; a doi.org URL is stored as the bare 10.… form." }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="orcid">{{ tr "Author ORCID" }}</label>
|
||
|
|
<input id="orcid" class="control" type="text" name="orcid" value="{{ .Post.ORCID }}"
|
||
|
|
placeholder="{{ tr "0000-0002-1825-0097" }}">
|
||
|
|
<p class="field__hint">{{ tr "The author's Open Researcher and Contributor ID, checked against its own digit." }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="date">{{ tr "Publish date" }}</label>
|
||
|
|
<input id="date" class="control" type="date" name="date" value="{{ .Post.Date }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="publish_at">{{ tr "Schedule for" }}</label>
|
||
|
|
<input id="publish_at" class="control" type="date" name="publish_at"
|
||
|
|
value="{{ .Post.PublishAt }}"
|
||
|
|
data-warn-today="{{ tr "Will be published immediately (date is today or earlier)." }}">
|
||
|
|
<p class="field__hint">{{ tr "Leave empty to publish immediately. Date is interpreted as your local timezone." }}</p>
|
||
|
|
<p class="field__hint hint--warn" id="publish-at-warn" hidden></p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="tags">{{ tr "Tags" }}</label>
|
||
|
|
<input id="tags" class="control" type="text" name="tags" value="{{ .Post.TagsCSV }}"
|
||
|
|
placeholder="{{ tr "comma, separated" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="series">{{ tr "Series" }}</label>
|
||
|
|
<input id="series" class="control mono" type="text" name="series" value="{{ .Post.Series }}"
|
||
|
|
placeholder="{{ tr "e.g. rust-tutorial" }}">
|
||
|
|
<p class="field__hint">{{ tr "Group multi-part posts; leave empty for standalone posts" }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="series_order">{{ tr "Part number" }}</label>
|
||
|
|
<input id="series_order" class="control" type="number" name="series_order" min="1"
|
||
|
|
value="{{ .Post.SeriesOrder }}" placeholder="1">
|
||
|
|
<p class="field__hint">{{ tr "Order within the series" }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label class="field__label" for="excerpt">{{ tr "Excerpt" }}</label>
|
||
|
|
<input id="excerpt" class="control" type="text" name="excerpt" value="{{ .Post.Excerpt }}"
|
||
|
|
placeholder="{{ .Post.ExcerptPlaceholder }}">
|
||
|
|
<p class="field__hint">{{ tr "Auto-generated from the first paragraph if left empty" }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="check-row">
|
||
|
|
<label class="check">
|
||
|
|
<input type="checkbox" name="draft" {{if .Post.Draft}}checked{{end}}>
|
||
|
|
<span>{{ tr "Draft" }}</span>
|
||
|
|
</label>
|
||
|
|
<label class="check">
|
||
|
|
<input type="checkbox" name="all_langs" {{if .Post.AllLangs}}checked{{end}}>
|
||
|
|
<span>{{ tr "Available in all languages" }}</span>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="card">
|
||
|
|
<div class="card__head">
|
||
|
|
<h2>{{ tr "Cover image" }}</h2>
|
||
|
|
<p class="card__note">{{ tr "Header image shown on the article page" }}</p>
|
||
|
|
</div>
|
||
|
|
<div class="cover-row">
|
||
|
|
<input id="cover-input" class="control mono" type="text" name="cover" value="{{ .Post.Cover }}"
|
||
|
|
placeholder="{{ tr "/media/… or https://…" }}">
|
||
|
|
<button type="button" id="cover-upload" class="btn">{{ tr "Upload" }}</button>
|
||
|
|
<button type="button" id="cover-clear" class="btn btn--ghost">{{ tr "Clear" }}</button>
|
||
|
|
</div>
|
||
|
|
<div class="grid-2">
|
||
|
|
<input id="cover-alt-input" class="control" type="text" name="cover_alt" value="{{ .Post.CoverAlt }}"
|
||
|
|
placeholder="{{ tr "ALT text (for accessibility)" }}">
|
||
|
|
<input id="cover-caption-input" class="control" type="text" name="cover_caption" value="{{ .Post.CoverCaption }}"
|
||
|
|
placeholder="{{ tr "Caption (e.g. 'Generated by AI')" }}">
|
||
|
|
</div>
|
||
|
|
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
||
|
|
<input type="file" id="cover-file" accept="image/webp,image/avif,image/svg+xml" hidden>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="card">
|
||
|
|
<div class="tabs" role="tablist" aria-label="{{ tr "Editor" }}">
|
||
|
|
<button type="button" role="tab" id="tab-markdown" data-tab="markdown"
|
||
|
|
aria-selected="true" aria-controls="markdown-view">{{ tr "Markdown" }}</button>
|
||
|
|
<button type="button" role="tab" id="tab-visual" data-tab="visual"
|
||
|
|
aria-selected="false" aria-controls="visual-view">{{ tr "Visual" }}</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="visual-view" hidden role="tabpanel" aria-labelledby="tab-visual">
|
||
|
|
<div class="editor">
|
||
|
|
<div class="editor__pane">
|
||
|
|
<div class="editor__head">
|
||
|
|
<span class="editor__label">{{ tr "Editor" }}</span>
|
||
|
|
<span class="flex"></span>
|
||
|
|
<button type="button" class="tool tool--bold" data-rich="bold" title="{{ tr "Bold (Ctrl+B)" }}">B</button>
|
||
|
|
<button type="button" class="tool tool--italic" data-rich="italic" title="{{ tr "Italic (Ctrl+I)" }}">I</button>
|
||
|
|
<button type="button" class="tool" data-rich="h2" title="{{ tr "Heading 2" }}">H2</button>
|
||
|
|
<button type="button" class="tool" data-rich="h3" title="{{ tr "Heading 3" }}">H3</button>
|
||
|
|
<button type="button" class="tool tool--wide" data-rich="link" title="{{ tr "Link (Ctrl+K)" }}">{{ tr "Link" }}</button>
|
||
|
|
<button type="button" class="tool" data-rich="ul" title="{{ tr "Bullet list" }}">•</button>
|
||
|
|
<button type="button" class="tool" data-rich="ol" title="{{ tr "Numbered list" }}">1.</button>
|
||
|
|
<button type="button" class="tool" data-rich="quote" title="{{ tr "Quote" }}">”</button>
|
||
|
|
<button type="button" class="tool" data-rich="code" title="{{ tr "Code" }}"></></button>
|
||
|
|
<button type="button" class="tool tool--wide" data-rich="image" title="{{ tr "Image" }}">IMG</button>
|
||
|
|
</div>
|
||
|
|
<div id="wysiwyg" class="prose editor__preview" contenteditable="true"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="markdown-view" role="tabpanel" aria-labelledby="tab-markdown">
|
||
|
|
<div class="editor" id="editor-grid">
|
||
|
|
<div class="editor__pane" id="markdown-pane">
|
||
|
|
<div class="editor__head">
|
||
|
|
<span class="editor__label">{{ tr "Markdown" }}</span>
|
||
|
|
<span class="flex"></span>
|
||
|
|
<button type="button" class="tool tool--bold" data-md="bold" title="{{ tr "Bold (Ctrl+B)" }}">B</button>
|
||
|
|
<button type="button" class="tool tool--italic" data-md="italic" title="{{ tr "Italic (Ctrl+I)" }}">I</button>
|
||
|
|
<button type="button" class="tool" data-md="h2" title="{{ tr "Heading 2" }}">H2</button>
|
||
|
|
<button type="button" class="tool" data-md="h3" title="{{ tr "Heading 3" }}">H3</button>
|
||
|
|
<button type="button" class="tool" data-md="link" title="{{ tr "Link (Ctrl+K)" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#link"></use></svg>
|
||
|
|
</button>
|
||
|
|
<button type="button" class="tool" data-md="ul" title="{{ tr "Bullet list" }}">•</button>
|
||
|
|
<button type="button" class="tool" data-md="ol" title="{{ tr "Numbered list" }}">1.</button>
|
||
|
|
<button type="button" class="tool" data-md="quote" title="{{ tr "Quote" }}">”</button>
|
||
|
|
<button type="button" class="tool" data-md="code" title="{{ tr "Code" }}"></></button>
|
||
|
|
<button type="button" class="tool tool--wide" data-md="image" title="{{ tr "Image" }}">IMG</button>
|
||
|
|
<button type="button" class="tool tool--wide" id="toggle-preview"
|
||
|
|
aria-pressed="false" title="{{ tr "Toggle preview" }}">{{ tr "Preview" }}</button>
|
||
|
|
</div>
|
||
|
|
<textarea id="body" name="body" class="editor__input" rows="22"
|
||
|
|
placeholder="{{ tr "Write your post in Markdown…" }}">{{ .Post.Body }}</textarea>
|
||
|
|
<div class="drop-overlay" id="body-drop" hidden>
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||
|
|
{{ tr "Drop image to upload" }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="editor__pane" id="preview-pane">
|
||
|
|
<div class="editor__head">
|
||
|
|
<span class="editor__label">{{ tr "Preview" }}</span>
|
||
|
|
</div>
|
||
|
|
<div id="preview" class="prose editor__preview"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="editor__status" id="editor-status">
|
||
|
|
<span id="status-words">0</span>
|
||
|
|
<span id="status-read">1</span>
|
||
|
|
<span id="status-chars">0</span>
|
||
|
|
<span class="flex"></span>
|
||
|
|
<span class="editor__saved" id="status-saved">{{ tr "✓ saved" }}</span>
|
||
|
|
<button type="submit" class="btn btn--primary btn--sm" title="{{ tr "Save (Ctrl+S)" }}">{{ tr "Save" }}</button>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="card" id="refs-card">
|
||
|
|
<div class="card__head">
|
||
|
|
<h2>{{ tr "Bibliography" }}</h2>
|
||
|
|
<p class="card__note"><span id="refs-count">{{ trn .Post.RefsCount "refs.count" }}</span> · {{ tr "The numbered reference list; cite entries as [n] in the body" }}</p>
|
||
|
|
</div>
|
||
|
|
<input type="hidden" name="refs" id="refs-json" value="">
|
||
|
|
<div class="refs-scroll"><div id="refs-list" aria-live="polite"></div></div>
|
||
|
|
<p class="field__hint" id="refs-empty" hidden>{{ tr "No references yet." }}</p>
|
||
|
|
<div class="check-row">
|
||
|
|
<button type="button" class="btn" id="ref-add">{{ tr "Add reference" }}</button>
|
||
|
|
<button type="button" class="btn btn--ghost" id="ref-marker">{{ tr "Insert [[refs]] marker into the body" }}</button>
|
||
|
|
</div>
|
||
|
|
<p class="field__hint">{{ tr "Fill the verbatim citation line, or the structured fields; the marker places the list in the body and without it the list is appended." }}</p>
|
||
|
|
<template id="ref-row-template">
|
||
|
|
<div class="ref-row">
|
||
|
|
<div class="ref-row__head">
|
||
|
|
<span class="tag tag--accent ref-row__num" aria-hidden="true">1</span>
|
||
|
|
<span class="flex"></span>
|
||
|
|
<button type="button" class="icon-btn" data-ref="up" aria-label="{{ tr "Move up" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#chevron-up"></use></svg>
|
||
|
|
</button>
|
||
|
|
<button type="button" class="icon-btn" data-ref="down" aria-label="{{ tr "Move down" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#chevron-down"></use></svg>
|
||
|
|
</button>
|
||
|
|
<button type="button" class="icon-btn" data-ref="remove" aria-label="{{ tr "Remove reference" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#x"></use></svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "Verbatim citation" }}</span>
|
||
|
|
<input class="control" data-ref-field="raw" placeholder="{{ tr "Author, title, venue, year" }}"
|
||
|
|
aria-label="{{ tr "Verbatim citation" }}">
|
||
|
|
</div>
|
||
|
|
<details>
|
||
|
|
<summary class="btn btn--ghost btn--sm">{{ tr "Structured fields" }}</summary>
|
||
|
|
<div class="grid-2 gap-top">
|
||
|
|
<div class="field field--wide">
|
||
|
|
<span class="field__label">{{ tr "Authors" }}</span>
|
||
|
|
<textarea class="control" data-ref-field="authors" rows="2"
|
||
|
|
placeholder="{{ tr "One author per line, optionally with [orcid:0000-0002-1825-0097]" }}"
|
||
|
|
aria-label="{{ tr "Authors" }}"></textarea>
|
||
|
|
</div>
|
||
|
|
<div class="field field--wide">
|
||
|
|
<span class="field__label">{{ tr "Title" }}</span>
|
||
|
|
<input class="control" data-ref-field="title" aria-label="{{ tr "Title" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "Venue" }}</span>
|
||
|
|
<input class="control" data-ref-field="venue" placeholder="{{ tr "Journal or proceedings" }}"
|
||
|
|
aria-label="{{ tr "Venue" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "Year" }}</span>
|
||
|
|
<input class="control" data-ref-field="year" inputmode="numeric" placeholder="1998"
|
||
|
|
aria-label="{{ tr "Year" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "Volume" }}</span>
|
||
|
|
<input class="control" data-ref-field="volume" placeholder="116" aria-label="{{ tr "Volume" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "Pages" }}</span>
|
||
|
|
<input class="control" data-ref-field="pages" placeholder="1009" aria-label="{{ tr "Pages" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "DOI" }}</span>
|
||
|
|
<input class="control mono" data-ref-field="doi" placeholder="10.5281/zenodo.1234567"
|
||
|
|
aria-label="{{ tr "DOI" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "arXiv" }}</span>
|
||
|
|
<input class="control mono" data-ref-field="arxiv" placeholder="2401.12345"
|
||
|
|
aria-label="{{ tr "arXiv" }}">
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<span class="field__label">{{ tr "URL" }}</span>
|
||
|
|
<input class="control mono" data-ref-field="url" placeholder="https://…"
|
||
|
|
aria-label="{{ tr "URL" }}">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<input type="file" id="image-input" accept="image/webp,image/avif,image/svg+xml" hidden>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<dialog id="shortcut-help">
|
||
|
|
<form method="dialog">
|
||
|
|
<div class="dialog__head">
|
||
|
|
<h2>{{ tr "Keyboard shortcuts" }}</h2>
|
||
|
|
<button type="submit" value="cancel" class="icon-btn" aria-label="{{ tr "Close modal" }}">
|
||
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#x"></use></svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="dialog__body">
|
||
|
|
<table class="table">
|
||
|
|
<tbody>
|
||
|
|
<tr><td>{{ tr "Bold" }}</td><td class="table__actions"><kbd>Ctrl</kbd>+<kbd>B</kbd></td></tr>
|
||
|
|
<tr><td>{{ tr "Italic" }}</td><td class="table__actions"><kbd>Ctrl</kbd>+<kbd>I</kbd></td></tr>
|
||
|
|
<tr><td>{{ tr "Link" }}</td><td class="table__actions"><kbd>Ctrl</kbd>+<kbd>K</kbd></td></tr>
|
||
|
|
<tr><td>{{ tr "Save post" }}</td><td class="table__actions"><kbd>Ctrl</kbd>+<kbd>S</kbd></td></tr>
|
||
|
|
<tr><td>{{ tr "Show this help" }}</td><td class="table__actions"><kbd>?</kbd></td></tr>
|
||
|
|
<tr><td>{{ tr "Close modal" }}</td><td class="table__actions"><kbd>Esc</kbd></td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
<p class="muted">{{ trh "On macOS use <kbd>Cmd</kbd> instead of <kbd>Ctrl</kbd>." }}</p>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</dialog>
|
||
|
|
|
||
|
|
<script nonce="{{ .CSPNonce }}">
|
||
|
|
(() => {
|
||
|
|
const form = document.getElementById("post-form");
|
||
|
|
const textarea = document.getElementById("body");
|
||
|
|
const preview = document.getElementById("preview");
|
||
|
|
const wysiwyg = document.getElementById("wysiwyg");
|
||
|
|
const visualView = document.getElementById("visual-view");
|
||
|
|
const markdownView = document.getElementById("markdown-view");
|
||
|
|
const imageInput = document.getElementById("image-input");
|
||
|
|
const coverInput = document.getElementById("cover-input");
|
||
|
|
const coverFile = document.getElementById("cover-file");
|
||
|
|
const coverPreview = document.getElementById("cover-preview");
|
||
|
|
const previewUrl = "/admin/preview";
|
||
|
|
const uploadUrl = "/admin/uploads";
|
||
|
|
const csrf = "{{ .CSRFToken }}";
|
||
|
|
let timer = null;
|
||
|
|
|
||
|
|
// --- Markdown <-> HTML --------------------------------------------------
|
||
|
|
|
||
|
|
const mdToHtml = (md) => {
|
||
|
|
const data = new URLSearchParams({ body: md, _csrf: csrf });
|
||
|
|
// The saved post supplies the reference list to the preview: refs
|
||
|
|
// live in the frontmatter, which the body-only render cannot see.
|
||
|
|
const slugInput = document.getElementById("slug");
|
||
|
|
const langInput = document.getElementById("lang");
|
||
|
|
if (slugInput) data.set("slug", slugInput.value);
|
||
|
|
if (langInput) data.set("lang", langInput.value);
|
||
|
|
return fetch(previewUrl, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
|
|
body: data.toString(),
|
||
|
|
}).then((response) => response.text());
|
||
|
|
};
|
||
|
|
|
||
|
|
const inlineToMd = (node) =>
|
||
|
|
Array.from(node.childNodes).map(inlineNode).join("");
|
||
|
|
|
||
|
|
function inlineNode(node) {
|
||
|
|
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue.replace(/\s+/g, " ");
|
||
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||
|
|
const tag = node.tagName.toLowerCase();
|
||
|
|
const inner = inlineToMd(node);
|
||
|
|
switch (tag) {
|
||
|
|
case "strong": case "b": return "**" + inner.trim() + "**";
|
||
|
|
case "em": case "i": return "*" + inner.trim() + "*";
|
||
|
|
case "code": return "`" + node.textContent + "`";
|
||
|
|
case "a": return "[" + inner + "](" + (node.getAttribute("href") || "") + ")";
|
||
|
|
case "img": {
|
||
|
|
const title = node.getAttribute("title");
|
||
|
|
let md = " || "") + ")";
|
||
|
|
if (title) md = md.replace(")", ' "' + title + '")');
|
||
|
|
return md;
|
||
|
|
}
|
||
|
|
case "br": return "\n";
|
||
|
|
default: return inner;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function listToMd(list, ordered) {
|
||
|
|
let index = 1;
|
||
|
|
return Array.from(list.children)
|
||
|
|
.filter((li) => li.tagName.toLowerCase() === "li")
|
||
|
|
.map((li) => (ordered ? index++ + ". " : "- ") + inlineToMd(li).trim())
|
||
|
|
.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
function htmlToMd(root) {
|
||
|
|
const blocks = [];
|
||
|
|
root.childNodes.forEach((node) => {
|
||
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
||
|
|
const text = node.nodeValue.trim();
|
||
|
|
if (text) blocks.push(text);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||
|
|
const tag = node.tagName.toLowerCase();
|
||
|
|
if (/^h[1-6]$/.test(tag)) {
|
||
|
|
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
|
||
|
|
} else if (tag === "ul" || tag === "ol") {
|
||
|
|
blocks.push(listToMd(node, tag === "ol"));
|
||
|
|
} else if (tag === "blockquote") {
|
||
|
|
blocks.push(inlineToMd(node).trim().split("\n").map((l) => "> " + l).join("\n"));
|
||
|
|
} else if (tag === "pre") {
|
||
|
|
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
|
||
|
|
} else if (tag === "hr") {
|
||
|
|
blocks.push("---");
|
||
|
|
} else {
|
||
|
|
const content = inlineToMd(node).trim();
|
||
|
|
if (content) blocks.push(content);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Replace the content through a parse-and-move: a script inserted via
|
||
|
|
DOMParser carries the "already started" flag and never runs, so the
|
||
|
|
server sanitisation gets defence in depth. */
|
||
|
|
function setSafeHtml(target, html) {
|
||
|
|
target.replaceChildren();
|
||
|
|
const doc = new DOMParser().parseFromString("<div>" + html + "</div>", "text/html");
|
||
|
|
const source = doc.body.firstChild;
|
||
|
|
while (source?.firstChild) target.append(source.firstChild);
|
||
|
|
}
|
||
|
|
|
||
|
|
const renderPreview = () =>
|
||
|
|
mdToHtml(textarea.value).then((html) => setSafeHtml(preview, html));
|
||
|
|
|
||
|
|
function schedule() {
|
||
|
|
clearTimeout(timer);
|
||
|
|
timer = setTimeout(renderPreview, 250);
|
||
|
|
updateCounts();
|
||
|
|
scheduleAutosave();
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Image upload --------------------------------------------------------
|
||
|
|
|
||
|
|
async function uploadImage(file, onSuccess) {
|
||
|
|
const data = new FormData();
|
||
|
|
data.append("file", file);
|
||
|
|
data.append("_csrf", csrf);
|
||
|
|
let message;
|
||
|
|
try {
|
||
|
|
const response = await fetch(uploadUrl, { method: "POST", body: data });
|
||
|
|
const json = await response.json();
|
||
|
|
if (response.ok && json.url) {
|
||
|
|
onSuccess(json.url);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
message = json.message || json.error || "error";
|
||
|
|
} catch (error) {
|
||
|
|
message = String(error);
|
||
|
|
}
|
||
|
|
window.__volumen.toast(window.__f("upload.prefix", message), { kind: "error" });
|
||
|
|
}
|
||
|
|
|
||
|
|
imageInput.addEventListener("change", () => {
|
||
|
|
const file = imageInput.files[0];
|
||
|
|
if (!file) return;
|
||
|
|
uploadImage(file, (url) => {
|
||
|
|
if (visualView.hidden) insertIntoTextarea("");
|
||
|
|
else exec("insertImage", url);
|
||
|
|
});
|
||
|
|
imageInput.value = "";
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- Cover image -----------------------------------------------------------
|
||
|
|
|
||
|
|
const refreshCoverPreview = () => {
|
||
|
|
if (coverInput.value) {
|
||
|
|
coverPreview.src = coverInput.value;
|
||
|
|
coverPreview.hidden = false;
|
||
|
|
} else {
|
||
|
|
coverPreview.hidden = true;
|
||
|
|
coverPreview.removeAttribute("src");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
document.getElementById("cover-upload").addEventListener("click", () => coverFile.click());
|
||
|
|
document.getElementById("cover-clear").addEventListener("click", () => {
|
||
|
|
coverInput.value = "";
|
||
|
|
refreshCoverPreview();
|
||
|
|
});
|
||
|
|
coverFile.addEventListener("change", () => {
|
||
|
|
const file = coverFile.files[0];
|
||
|
|
if (!file) return;
|
||
|
|
uploadImage(file, (url) => {
|
||
|
|
coverInput.value = url;
|
||
|
|
refreshCoverPreview();
|
||
|
|
});
|
||
|
|
coverFile.value = "";
|
||
|
|
});
|
||
|
|
coverInput.addEventListener("input", refreshCoverPreview);
|
||
|
|
refreshCoverPreview();
|
||
|
|
|
||
|
|
// --- Bibliography -------------------------------------------------------
|
||
|
|
|
||
|
|
const refsList = document.getElementById("refs-list");
|
||
|
|
const refsEmpty = document.getElementById("refs-empty");
|
||
|
|
const refsHidden = document.getElementById("refs-json");
|
||
|
|
const refRowTemplate = document.getElementById("ref-row-template");
|
||
|
|
const refFields = ["raw", "title", "venue", "year", "volume", "pages", "doi", "arxiv", "url"];
|
||
|
|
|
||
|
|
const authorLines = (authors) => (authors || []).map((author) => {
|
||
|
|
if (typeof author === "string") return author;
|
||
|
|
if (author.orcid) return author.name + " [orcid:" + author.orcid + "]";
|
||
|
|
return author.name || "";
|
||
|
|
}).join("\n");
|
||
|
|
|
||
|
|
function refEntriesFrom(tables) {
|
||
|
|
return (tables || []).map((table) => {
|
||
|
|
const entry = { authorsText: authorLines(table.authors) };
|
||
|
|
refFields.forEach((name) => {
|
||
|
|
const value = table[name];
|
||
|
|
if (value !== undefined && value !== null && String(value) !== "") entry[name] = String(value);
|
||
|
|
});
|
||
|
|
const num = table.num;
|
||
|
|
if (num !== undefined && num !== null && String(num) !== "") entry.num = String(num);
|
||
|
|
return entry;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let refEntries = refEntriesFrom({{ .Post.RefsJSON }});
|
||
|
|
|
||
|
|
function serializeRefs() {
|
||
|
|
refsHidden.value = JSON.stringify(refEntries.map((entry) => {
|
||
|
|
const out = {};
|
||
|
|
refFields.forEach((name) => { if (entry[name]) out[name] = entry[name]; });
|
||
|
|
const authors = (entry.authorsText || "").split("\n")
|
||
|
|
.map((line) => line.trim()).filter(Boolean)
|
||
|
|
.map((line) => {
|
||
|
|
const withOrcid = line.match(/^(.*?)\s*\[orcid:\s*([0-9Xx-]+)\]\s*$/);
|
||
|
|
return withOrcid ? { name: withOrcid[1].trim(), orcid: withOrcid[2] } : line;
|
||
|
|
});
|
||
|
|
if (authors.length) out.authors = authors;
|
||
|
|
if (entry.num) out.num = Number(entry.num);
|
||
|
|
return out;
|
||
|
|
}));
|
||
|
|
refsEmpty.hidden = refEntries.length > 0;
|
||
|
|
const count = document.getElementById("refs-count");
|
||
|
|
if (count) count.textContent = window.__n("refs.count", refEntries.length);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderRefs() {
|
||
|
|
refsList.replaceChildren();
|
||
|
|
refEntries.forEach((entry, index) => {
|
||
|
|
const row = refRowTemplate.content.firstElementChild.cloneNode(true);
|
||
|
|
row.querySelector(".ref-row__num").textContent = index + 1;
|
||
|
|
row.querySelector('[data-ref-field="raw"]').value = entry.raw || "";
|
||
|
|
row.querySelector('[data-ref-field="authors"]').value = entry.authorsText || "";
|
||
|
|
refFields.slice(1).forEach((name) => {
|
||
|
|
row.querySelector('[data-ref-field="' + name + '"]').value = entry[name] || "";
|
||
|
|
});
|
||
|
|
if (refFields.slice(1).some((name) => entry[name])) row.querySelector("details").open = true;
|
||
|
|
refsList.append(row);
|
||
|
|
});
|
||
|
|
serializeRefs();
|
||
|
|
}
|
||
|
|
|
||
|
|
function readRefs() {
|
||
|
|
refEntries = Array.from(refsList.children).map((row, index) => {
|
||
|
|
const previous = refEntries[index] || {};
|
||
|
|
const entry = { authorsText: row.querySelector('[data-ref-field="authors"]').value };
|
||
|
|
refFields.forEach((name) => {
|
||
|
|
if (name === "authors") return;
|
||
|
|
const value = row.querySelector('[data-ref-field="' + name + '"]').value.trim();
|
||
|
|
if (value) entry[name] = value;
|
||
|
|
});
|
||
|
|
if (previous.num) entry.num = previous.num;
|
||
|
|
return entry;
|
||
|
|
});
|
||
|
|
serializeRefs();
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById("ref-add").addEventListener("click", () => {
|
||
|
|
refEntries.push({ authorsText: "" });
|
||
|
|
renderRefs();
|
||
|
|
const last = refsList.lastElementChild;
|
||
|
|
if (last) last.querySelector('[data-ref-field="raw"]').focus();
|
||
|
|
});
|
||
|
|
document.getElementById("ref-marker").addEventListener("click", () => {
|
||
|
|
insertIntoTextarea("\n[[refs]]\n");
|
||
|
|
});
|
||
|
|
refsList.addEventListener("input", readRefs);
|
||
|
|
refsList.addEventListener("click", (event) => {
|
||
|
|
const button = event.target.closest("[data-ref]");
|
||
|
|
if (!button) return;
|
||
|
|
const row = button.closest(".ref-row");
|
||
|
|
const index = Array.prototype.indexOf.call(refsList.children, row);
|
||
|
|
const moved = refEntries.splice(index, 1)[0];
|
||
|
|
if (button.dataset.ref === "up" && index > 0) {
|
||
|
|
refEntries.splice(index - 1, 0, moved);
|
||
|
|
} else if (button.dataset.ref === "down" && index < refEntries.length) {
|
||
|
|
refEntries.splice(index + 1, 0, moved);
|
||
|
|
} else if (button.dataset.ref !== "remove") {
|
||
|
|
// An edge move (first up, last down): the entry goes back.
|
||
|
|
refEntries.splice(index, 0, moved);
|
||
|
|
}
|
||
|
|
renderRefs();
|
||
|
|
});
|
||
|
|
renderRefs();
|
||
|
|
|
||
|
|
// --- Markdown toolbar --------------------------------------------------
|
||
|
|
|
||
|
|
function insertIntoTextarea(text) {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
|
||
|
|
textarea.focus();
|
||
|
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||
|
|
schedule();
|
||
|
|
}
|
||
|
|
|
||
|
|
function wrap(before, after) {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
textarea.value = textarea.value.slice(0, start) + before + textarea.value.slice(start, end)
|
||
|
|
+ after + textarea.value.slice(end);
|
||
|
|
textarea.focus();
|
||
|
|
textarea.selectionStart = start + before.length;
|
||
|
|
textarea.selectionEnd = end + before.length;
|
||
|
|
schedule();
|
||
|
|
}
|
||
|
|
|
||
|
|
function linePrefix(prefix) {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
|
||
|
|
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
||
|
|
textarea.focus();
|
||
|
|
schedule();
|
||
|
|
}
|
||
|
|
|
||
|
|
const mdActions = {
|
||
|
|
bold: () => wrap("**", "**"),
|
||
|
|
italic: () => wrap("*", "*"),
|
||
|
|
code: () => wrap("`", "`"),
|
||
|
|
link: () => wrap("[", "](https://)"),
|
||
|
|
h2: () => linePrefix("## "),
|
||
|
|
h3: () => linePrefix("### "),
|
||
|
|
ul: () => linePrefix("- "),
|
||
|
|
ol: () => linePrefix("1. "),
|
||
|
|
quote: () => linePrefix("> "),
|
||
|
|
image: () => imageInput.click(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Visual toolbar -------------------------------------------------------
|
||
|
|
|
||
|
|
function exec(command, value) {
|
||
|
|
document.execCommand(command, false, value ?? null);
|
||
|
|
wysiwyg.focus();
|
||
|
|
updateToolbarState();
|
||
|
|
}
|
||
|
|
|
||
|
|
function wrapInline(tagName) {
|
||
|
|
const selection = window.getSelection();
|
||
|
|
if (!selection.rangeCount) return;
|
||
|
|
const element = document.createElement(tagName);
|
||
|
|
try { selection.getRangeAt(0).surroundContents(element); } catch (error) {}
|
||
|
|
wysiwyg.focus();
|
||
|
|
updateToolbarState();
|
||
|
|
}
|
||
|
|
|
||
|
|
const richActions = {
|
||
|
|
bold: () => exec("bold"),
|
||
|
|
italic: () => exec("italic"),
|
||
|
|
h2: () => exec("formatBlock", "H2"),
|
||
|
|
h3: () => exec("formatBlock", "H3"),
|
||
|
|
quote: () => exec("formatBlock", "BLOCKQUOTE"),
|
||
|
|
ul: () => exec("insertUnorderedList"),
|
||
|
|
ol: () => exec("insertOrderedList"),
|
||
|
|
code: () => wrapInline("code"),
|
||
|
|
image: () => imageInput.click(),
|
||
|
|
link: async () => {
|
||
|
|
const url = await window.__volumen.prompt(window.__("link.prompt"), "https://");
|
||
|
|
if (url) exec("createLink", url);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Toolbar active state --------------------------------------------------
|
||
|
|
|
||
|
|
const queryState = (command) => {
|
||
|
|
try { return document.queryCommandState(command); } catch (error) { return false; }
|
||
|
|
};
|
||
|
|
|
||
|
|
const currentBlock = () => {
|
||
|
|
try {
|
||
|
|
return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, "");
|
||
|
|
} catch (error) { return ""; }
|
||
|
|
};
|
||
|
|
|
||
|
|
function selectionInEditor() {
|
||
|
|
let node = window.getSelection().anchorNode;
|
||
|
|
while (node) {
|
||
|
|
if (node === wysiwyg) return true;
|
||
|
|
node = node.parentNode;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isInside(tagName) {
|
||
|
|
let node = window.getSelection().anchorNode;
|
||
|
|
while (node && node !== wysiwyg) {
|
||
|
|
if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === tagName) return true;
|
||
|
|
node = node.parentNode;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateToolbarState() {
|
||
|
|
if (visualView.hidden) return;
|
||
|
|
let active = {};
|
||
|
|
if (selectionInEditor()) {
|
||
|
|
const block = currentBlock();
|
||
|
|
active = {
|
||
|
|
bold: queryState("bold"),
|
||
|
|
italic: queryState("italic"),
|
||
|
|
ul: queryState("insertUnorderedList"),
|
||
|
|
ol: queryState("insertOrderedList"),
|
||
|
|
h2: block === "h2",
|
||
|
|
h3: block === "h3",
|
||
|
|
quote: block === "blockquote",
|
||
|
|
link: isInside("a"),
|
||
|
|
code: isInside("code"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
document.querySelectorAll("[data-rich]").forEach((button) => {
|
||
|
|
button.classList.toggle("is-active", !!active[button.dataset.rich]);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Mode switching ------------------------------------------------------
|
||
|
|
|
||
|
|
const setActiveTab = (name) => document.querySelectorAll(".tabs [role='tab']").forEach((button) => {
|
||
|
|
button.setAttribute("aria-selected", String(button.dataset.tab === name));
|
||
|
|
});
|
||
|
|
|
||
|
|
const showVisual = () => {
|
||
|
|
mdToHtml(textarea.value).then((html) => setSafeHtml(wysiwyg, html));
|
||
|
|
visualView.hidden = false;
|
||
|
|
markdownView.hidden = true;
|
||
|
|
setActiveTab("visual");
|
||
|
|
};
|
||
|
|
|
||
|
|
const showMarkdown = () => {
|
||
|
|
textarea.value = htmlToMd(wysiwyg);
|
||
|
|
renderPreview();
|
||
|
|
visualView.hidden = true;
|
||
|
|
markdownView.hidden = false;
|
||
|
|
setActiveTab("markdown");
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Keyboard shortcuts ------------------------------------------------------
|
||
|
|
|
||
|
|
const shortcut = (actions) => (event) => {
|
||
|
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||
|
|
const key = event.key.toLowerCase();
|
||
|
|
if (key === "b") { event.preventDefault(); actions.bold(); }
|
||
|
|
else if (key === "i") { event.preventDefault(); actions.italic(); }
|
||
|
|
else if (key === "k") { event.preventDefault(); actions.link(); }
|
||
|
|
};
|
||
|
|
|
||
|
|
// --- Wiring ------------------------------------------------------------
|
||
|
|
|
||
|
|
document.querySelectorAll("[data-md]").forEach((button) =>
|
||
|
|
button.addEventListener("click", () => mdActions[button.dataset.md]()));
|
||
|
|
document.querySelectorAll("[data-rich]").forEach((button) =>
|
||
|
|
button.addEventListener("click", () => richActions[button.dataset.rich]()));
|
||
|
|
document.querySelectorAll(".tabs [role='tab']").forEach((button) => {
|
||
|
|
button.addEventListener("click", () => {
|
||
|
|
if (button.dataset.tab === "visual") showVisual();
|
||
|
|
else showMarkdown();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
wysiwyg.addEventListener("paste", (event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
||
|
|
});
|
||
|
|
wysiwyg.addEventListener("keydown", shortcut(richActions));
|
||
|
|
wysiwyg.addEventListener("keyup", updateToolbarState);
|
||
|
|
wysiwyg.addEventListener("mouseup", updateToolbarState);
|
||
|
|
document.addEventListener("selectionchange", updateToolbarState);
|
||
|
|
|
||
|
|
textarea.addEventListener("input", schedule);
|
||
|
|
textarea.addEventListener("keydown", shortcut(mdActions));
|
||
|
|
|
||
|
|
form.addEventListener("submit", () => {
|
||
|
|
if (!visualView.hidden) textarea.value = htmlToMd(wysiwyg);
|
||
|
|
clearAutosave();
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("keydown", (event) => {
|
||
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||
|
|
event.preventDefault();
|
||
|
|
form.requestSubmit();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- Smart slug from title, and its live collision check --------------------
|
||
|
|
|
||
|
|
const titleInput = document.getElementById("title");
|
||
|
|
const slugInput = document.getElementById("slug");
|
||
|
|
let slugTouched = window.__volumenSlugTouched === true || slugInput.readOnly || slugInput.value !== "";
|
||
|
|
|
||
|
|
const slugify = (text) => text
|
||
|
|
.toLowerCase()
|
||
|
|
.normalize("NFKD")
|
||
|
|
.replace(/[\u0300-\u036f]/g, "")
|
||
|
|
.replace(/[^a-z0-9]+/g, "-")
|
||
|
|
.replace(/^-+|-+$/g, "")
|
||
|
|
.slice(0, 200);
|
||
|
|
|
||
|
|
titleInput.addEventListener("input", () => {
|
||
|
|
if (slugTouched || window.__volumenSlugTouched === true) return;
|
||
|
|
slugInput.value = slugify(titleInput.value);
|
||
|
|
});
|
||
|
|
slugInput.addEventListener("input", () => { slugTouched = true; });
|
||
|
|
|
||
|
|
const slugHelp = document.getElementById("slug-help");
|
||
|
|
let slugCheckTimer = null;
|
||
|
|
const slugExclude = slugInput.readOnly ? slugInput.value : "";
|
||
|
|
const slugCheckUrl = slugInput.dataset.existsUrl || "/admin/posts/exists";
|
||
|
|
|
||
|
|
async function checkSlug() {
|
||
|
|
const value = (slugInput.value || "").trim();
|
||
|
|
if (!value) {
|
||
|
|
slugHelp.hidden = true;
|
||
|
|
slugInput.classList.remove("is-invalid", "is-ok");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const url = slugCheckUrl + "?slug=" + encodeURIComponent(value)
|
||
|
|
+ (slugExclude ? "&exclude=" + encodeURIComponent(slugExclude) : "");
|
||
|
|
try {
|
||
|
|
const response = await fetch(url, { credentials: "same-origin" });
|
||
|
|
const data = response.ok ? await response.json() : null;
|
||
|
|
if (!data) return;
|
||
|
|
if (data.available) {
|
||
|
|
slugInput.classList.remove("is-invalid");
|
||
|
|
slugInput.classList.add("is-ok");
|
||
|
|
slugHelp.className = "field__hint is-live hint--ok";
|
||
|
|
slugHelp.textContent = window.__("slug.ok");
|
||
|
|
} else {
|
||
|
|
slugInput.classList.remove("is-ok");
|
||
|
|
slugInput.classList.add("is-invalid");
|
||
|
|
slugHelp.className = "field__hint is-live hint--error";
|
||
|
|
slugHelp.textContent = window.__f("slug.taken", data.title || data.slug);
|
||
|
|
}
|
||
|
|
slugHelp.hidden = false;
|
||
|
|
} catch (error) {
|
||
|
|
slugHelp.hidden = true;
|
||
|
|
slugInput.classList.remove("is-invalid", "is-ok");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
slugInput.addEventListener("input", () => {
|
||
|
|
clearTimeout(slugCheckTimer);
|
||
|
|
slugCheckTimer = setTimeout(checkSlug, 300);
|
||
|
|
});
|
||
|
|
if (slugInput.value && !slugInput.readOnly) checkSlug();
|
||
|
|
|
||
|
|
// --- publish_at warning when the date is today or earlier ----------------------
|
||
|
|
|
||
|
|
const publishAt = document.getElementById("publish_at");
|
||
|
|
const publishWarn = document.getElementById("publish-at-warn");
|
||
|
|
function checkPublishAt() {
|
||
|
|
const value = publishAt.value;
|
||
|
|
if (!value) { publishWarn.hidden = true; return; }
|
||
|
|
const today = new Date();
|
||
|
|
const ymd = today.getFullYear() + "-"
|
||
|
|
+ String(today.getMonth() + 1).padStart(2, "0") + "-"
|
||
|
|
+ String(today.getDate()).padStart(2, "0");
|
||
|
|
publishWarn.textContent = publishAt.dataset.warnToday;
|
||
|
|
publishWarn.hidden = value > ymd;
|
||
|
|
}
|
||
|
|
publishAt.addEventListener("input", checkPublishAt);
|
||
|
|
checkPublishAt();
|
||
|
|
|
||
|
|
// --- Word / reading-time counters -----------------------------------------------
|
||
|
|
|
||
|
|
const statusWords = document.getElementById("status-words");
|
||
|
|
const statusRead = document.getElementById("status-read");
|
||
|
|
const statusChars = document.getElementById("status-chars");
|
||
|
|
|
||
|
|
function updateCounts() {
|
||
|
|
const source = visualView.hidden ? textarea.value : wysiwyg.innerText;
|
||
|
|
const trimmed = source.trim();
|
||
|
|
const words = trimmed ? trimmed.split(/\s+/).length : 0;
|
||
|
|
const mins = Math.max(1, Math.floor((words + 199) / 200));
|
||
|
|
statusWords.textContent = window.__n("editor.words", words);
|
||
|
|
statusRead.textContent = window.__n("editor.reading", mins);
|
||
|
|
statusChars.textContent = window.__n("editor.chars", source.length);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Autosave to localStorage ----------------------------------------------------
|
||
|
|
|
||
|
|
const AUTOSAVE_KEY = "volumen.autosave:" + (slugInput.value || "new");
|
||
|
|
const AUTOSAVE_FIELDS = [
|
||
|
|
"title", "slug", "lang", "author", "fediverse_creator", "doi", "orcid", "date",
|
||
|
|
"publish_at", "tags", "excerpt", "cover", "cover_alt", "cover_caption",
|
||
|
|
"series", "series_order", "refs",
|
||
|
|
];
|
||
|
|
let autosaveTimer = null;
|
||
|
|
let savedFlashTimer = null;
|
||
|
|
const statusSaved = document.getElementById("status-saved");
|
||
|
|
const autosaveBar = document.getElementById("autosave-bar");
|
||
|
|
const autosaveMsg = document.getElementById("autosave-msg");
|
||
|
|
|
||
|
|
function gatherForm() {
|
||
|
|
const data = {};
|
||
|
|
AUTOSAVE_FIELDS.forEach((name) => {
|
||
|
|
data[name] = form.elements[name]?.value || "";
|
||
|
|
});
|
||
|
|
data.draft = form.elements.draft.checked;
|
||
|
|
data.all_langs = form.elements.all_langs.checked;
|
||
|
|
data.body = visualView.hidden ? textarea.value : htmlToMd(wysiwyg);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyForm(data) {
|
||
|
|
AUTOSAVE_FIELDS.forEach((name) => {
|
||
|
|
const el = form.elements[name];
|
||
|
|
if (el && Object.hasOwn(data, name)) el.value = data[name];
|
||
|
|
});
|
||
|
|
if (Object.hasOwn(data, "refs")) {
|
||
|
|
try { refEntries = refEntriesFrom(JSON.parse(data.refs || "[]")); }
|
||
|
|
catch (error) { refEntries = []; }
|
||
|
|
}
|
||
|
|
form.elements.draft.checked = !!data.draft;
|
||
|
|
form.elements.all_langs.checked = !!data.all_langs;
|
||
|
|
textarea.value = data.body || "";
|
||
|
|
if (data.slug) slugTouched = true;
|
||
|
|
renderRefs();
|
||
|
|
refreshCoverPreview();
|
||
|
|
renderPreview();
|
||
|
|
updateCounts();
|
||
|
|
}
|
||
|
|
|
||
|
|
const clearAutosave = () => {
|
||
|
|
try { localStorage.removeItem(AUTOSAVE_KEY); } catch (error) {}
|
||
|
|
};
|
||
|
|
|
||
|
|
const flashSaved = () => {
|
||
|
|
statusSaved.style.opacity = "1";
|
||
|
|
clearTimeout(savedFlashTimer);
|
||
|
|
savedFlashTimer = setTimeout(() => { statusSaved.style.opacity = "0"; }, 1500);
|
||
|
|
};
|
||
|
|
|
||
|
|
function scheduleAutosave() {
|
||
|
|
clearTimeout(autosaveTimer);
|
||
|
|
autosaveTimer = setTimeout(() => {
|
||
|
|
try {
|
||
|
|
const data = gatherForm();
|
||
|
|
data._savedAt = Date.now();
|
||
|
|
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||
|
|
flashSaved();
|
||
|
|
} catch (error) {}
|
||
|
|
}, 800);
|
||
|
|
}
|
||
|
|
|
||
|
|
(() => {
|
||
|
|
let raw = null;
|
||
|
|
try { raw = localStorage.getItem(AUTOSAVE_KEY); } catch (error) {}
|
||
|
|
if (!raw) return;
|
||
|
|
let saved;
|
||
|
|
try { saved = JSON.parse(raw); } catch (error) { clearAutosave(); return; }
|
||
|
|
const current = gatherForm();
|
||
|
|
const differs = AUTOSAVE_FIELDS.concat(["body"]).some((key) =>
|
||
|
|
String(saved[key] || "") !== String(current[key] || ""))
|
||
|
|
|| !!saved.draft !== current.draft || !!saved.all_langs !== current.all_langs;
|
||
|
|
if (!differs) { clearAutosave(); return; }
|
||
|
|
const when = new Date(saved._savedAt || Date.now());
|
||
|
|
autosaveMsg.textContent = window.__f("autosave.available", when.toLocaleTimeString());
|
||
|
|
autosaveBar.hidden = false;
|
||
|
|
document.getElementById("autosave-restore").addEventListener("click", () => {
|
||
|
|
applyForm(saved);
|
||
|
|
clearAutosave();
|
||
|
|
autosaveBar.hidden = true;
|
||
|
|
});
|
||
|
|
document.getElementById("autosave-discard").addEventListener("click", () => {
|
||
|
|
clearAutosave();
|
||
|
|
autosaveBar.hidden = true;
|
||
|
|
});
|
||
|
|
})();
|
||
|
|
|
||
|
|
form.addEventListener("input", scheduleAutosave);
|
||
|
|
wysiwyg.addEventListener("input", () => { updateCounts(); scheduleAutosave(); });
|
||
|
|
|
||
|
|
// --- Drag & drop / paste images into the editor ------------------------------------
|
||
|
|
|
||
|
|
const mdPane = document.getElementById("markdown-pane");
|
||
|
|
const dropOverlay = document.getElementById("body-drop");
|
||
|
|
let dropDepth = 0;
|
||
|
|
|
||
|
|
const isUploadable = (file) => /\.(webp|avif|svg)$/i.test(file.name)
|
||
|
|
|| file.type === "image/webp" || file.type === "image/avif" || file.type === "image/svg+xml";
|
||
|
|
|
||
|
|
mdPane.addEventListener("dragenter", (event) => {
|
||
|
|
if (!event.dataTransfer?.types.includes("Files")) return;
|
||
|
|
dropDepth += 1;
|
||
|
|
mdPane.classList.add("is-drop");
|
||
|
|
dropOverlay.hidden = false;
|
||
|
|
});
|
||
|
|
mdPane.addEventListener("dragover", (event) => event.preventDefault());
|
||
|
|
mdPane.addEventListener("dragleave", () => {
|
||
|
|
dropDepth = Math.max(0, dropDepth - 1);
|
||
|
|
if (dropDepth === 0) {
|
||
|
|
mdPane.classList.remove("is-drop");
|
||
|
|
dropOverlay.hidden = true;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
mdPane.addEventListener("drop", (event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
dropDepth = 0;
|
||
|
|
mdPane.classList.remove("is-drop");
|
||
|
|
dropOverlay.hidden = true;
|
||
|
|
Array.from(event.dataTransfer?.files || []).forEach((file) => {
|
||
|
|
if (!isUploadable(file)) {
|
||
|
|
window.__volumen.toast(window.__f("upload.rejected", file.name), { kind: "error" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const alt = file.name.replace(/\.(webp|avif|svg)$/i, "");
|
||
|
|
uploadImage(file, (url) => insertIntoTextarea("\n"));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
textarea.addEventListener("paste", (event) => {
|
||
|
|
for (const item of event.clipboardData?.items || []) {
|
||
|
|
if (item.type.startsWith("image/")) {
|
||
|
|
event.preventDefault();
|
||
|
|
uploadImage(item.getAsFile(), (url) => insertIntoTextarea("\n"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- Preview toggle -------------------------------------------------------------
|
||
|
|
|
||
|
|
const editorGrid = document.getElementById("editor-grid");
|
||
|
|
const togglePreviewBtn = document.getElementById("toggle-preview");
|
||
|
|
|
||
|
|
function applyPreviewPreference() {
|
||
|
|
let on = false;
|
||
|
|
try { on = localStorage.getItem("volumen.preview") === "on"; } catch (error) {}
|
||
|
|
editorGrid.classList.toggle("is-preview", on);
|
||
|
|
document.getElementById("preview-pane").hidden = !on;
|
||
|
|
togglePreviewBtn.setAttribute("aria-pressed", String(on));
|
||
|
|
if (on) renderPreview();
|
||
|
|
}
|
||
|
|
|
||
|
|
togglePreviewBtn.addEventListener("click", () => {
|
||
|
|
const turningOn = !editorGrid.classList.contains("is-preview");
|
||
|
|
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (error) {}
|
||
|
|
applyPreviewPreference();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Start in Markdown mode (primary), preview OFF by default.
|
||
|
|
applyPreviewPreference();
|
||
|
|
|
||
|
|
// Fill the counters for the content the post already carries.
|
||
|
|
updateCounts();
|
||
|
|
|
||
|
|
// --- "?" keyboard shortcut dialog -------------------------------------------------
|
||
|
|
|
||
|
|
const helpDialog = document.getElementById("shortcut-help");
|
||
|
|
const openHelp = () => helpDialog.showModal();
|
||
|
|
document.getElementById("show-shortcuts").addEventListener("click", openHelp);
|
||
|
|
document.addEventListener("keydown", (event) => {
|
||
|
|
const tag = event.target.tagName || "";
|
||
|
|
if (event.key === "?" && !event.ctrlKey && !event.metaKey && !event.altKey
|
||
|
|
&& !["INPUT", "TEXTAREA"].includes(tag) && !event.target.isContentEditable) {
|
||
|
|
event.preventDefault();
|
||
|
|
openHelp();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
{{end}}
|