feat: page history overlay

scarlett
NGPixel 1 month ago
parent c9bd96a5ba
commit 4ec69a75c0
No known key found for this signature in database

@ -1983,20 +1983,40 @@
"history.action.deleted": "Deleted",
"history.action.moved": "Moved",
"history.action.updated": "Updated",
"history.branchFailed": "Failed to create a page from this version.",
"history.branchOff": "Branch off from here",
"history.branchReason": "Branched off from the version of {date}",
"history.branchSuccess": "New page created from this version.",
"history.changedFields": "Changed: {fields}",
"history.current": "Current",
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
"history.inline": "Inline",
"history.loadFailed": "Failed to load the page history.",
"history.none": "No history has been recorded for this page yet.",
"history.pickA": "Compare from this version",
"history.pickB": "Compare to this version",
"history.restore": "Restore",
"history.restore.confirmButton": "Restore",
"history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.",
"history.restore.confirmTitle": "Restore page version?",
"history.restore.success": "Page version restored succesfully!",
"history.restoreConfirm": "Restore the page content as it was on **{date}**?",
"history.restoreConfirmHint": "The page keeps its current title, tags and settings, and the state it is in now is kept in the history.",
"history.restoreFailed": "Failed to restore this version.",
"history.restoreReason": "Restored the content from {date}",
"history.restoreSuccess": "Page content restored successfully.",
"history.sameContent": "These two versions have the same content — only metadata changed.",
"history.setAsSource": "Set as Differencing Source",
"history.setAsTarget": "Set as Differencing Target",
"history.sideBySide": "Side-by-Side",
"history.sourceCopied": "Source copied to the clipboard.",
"history.title": "Page History",
"history.unknownAuthor": "Unknown",
"history.versionActions": "Version Actions",
"history.versionId": "Version ID {id}",
"history.viewSource": "View Source",
"iconPicker.allSets": "All sets",
"iconPicker.icons": "Icons",
"iconPicker.image": "Image",

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
252 icons.
256 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -40,6 +40,7 @@ export const BUNDLED_ICONS = {
"la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32},
"la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32},
"la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32},
"la:code-branch": {"body":"<path fill=\"currentColor\" d=\"M11 4C9.355 4 8 5.355 8 7c0 1.293.844 2.395 2 2.813v12.374c-1.156.418-2 1.52-2 2.813c0 1.645 1.355 3 3 3s3-1.355 3-3c0-1.27-.816-2.344-1.938-2.781c.145-1.23.622-1.836 1.376-2.344c.898-.605 2.277-.965 3.78-1.313c1.505-.347 3.118-.707 4.47-1.656c1.187-.832 2.085-2.195 2.28-4.093C25.142 12.402 26 11.3 26 10c0-1.645-1.355-3-3-3s-3 1.355-3 3c0 1.277.832 2.352 1.969 2.781c-.137 1.313-.645 1.965-1.407 2.5c-.898.63-2.285 1-3.78 1.344c-1.497.344-3.118.648-4.47 1.563c-.109.074-.21.167-.312.25V9.813c1.156-.418 2-1.52 2-2.813c0-1.645-1.355-3-3-3m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m12 3c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1M11 24c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},
"la:cog": {"body":"<path fill=\"currentColor\" d=\"m13.188 3l-.157.813l-.594 2.968a10 10 0 0 0-2.593 1.532l-2.906-1l-.782-.25l-.406.718l-2 3.438l-.406.719l.594.53l2.25 1.97C6.104 14.948 6 15.46 6 16s.105 1.05.188 1.563l-2.25 1.968l-.594.532l.406.718l2 3.438l.406.718l.782-.25l2.906-1a10 10 0 0 0 2.594 1.532l.593 2.968l.156.813h5.626l.156-.813l.593-2.968a10 10 0 0 0 2.594-1.532l2.907 1l.78.25l.407-.718l2-3.438l.406-.718l-.593-.532l-2.25-1.968C25.895 17.05 26 16.538 26 16c0-.54-.105-1.05-.188-1.563l2.25-1.968l.594-.531l-.406-.72l-2-3.437l-.406-.718l-.782.25l-2.906 1a10 10 0 0 0-2.593-1.532l-.594-2.968L18.812 3zm1.624 2h2.376l.5 2.594l.125.593l.562.188a8 8 0 0 1 3.031 1.75l.438.406l.562-.187l2.532-.875l1.187 2.031l-2 1.781l-.469.375l.157.594c.128.57.187 1.152.187 1.75s-.059 1.18-.188 1.75l-.125.594l.438.375l2 1.781l-1.188 2.031l-2.53-.875l-.563-.187l-.438.406a8 8 0 0 1-3.031 1.75l-.563.188l-.125.593l-.5 2.594h-2.375l-.5-2.594l-.124-.593l-.563-.188a8 8 0 0 1-3.031-1.75l-.438-.406l-.562.187l-2.531.875L5.875 20.5l2-1.781l.469-.375l-.156-.594A8 8 0 0 1 8 16c0-.598.059-1.18.188-1.75l.156-.594l-.469-.375l-2-1.781l1.188-2.031l2.53.875l.563.187l.438-.406a8 8 0 0 1 3.031-1.75l.563-.188l.124-.593zM16 11c-2.75 0-5 2.25-5 5s2.25 5 5 5s5-2.25 5-5s-2.25-5-5-5m0 2c1.668 0 3 1.332 3 3s-1.332 3-3 3s-3-1.332-3-3s1.332-3 3-3\"/>","width":32,"height":32},
"la:comments": {"body":"<path fill=\"currentColor\" d=\"M2 5v16h4v5.094l1.625-1.313L12.344 21H22V5zm2 2h16v12h-8.344l-.281.219L8 21.906V19H4zm20 2v2h4v12h-4v2.906L20.344 23h-7.5l-2.5 2h9.312L26 30.094V25h4V9z\"/>","width":32,"height":32},
"la:copy": {"body":"<path fill=\"currentColor\" d=\"M4 4v20h7v-2H6V6h12v1h2V4zm8 4v20h16V8zm2 2h12v16H14z\"/>","width":32,"height":32},
@ -131,6 +132,7 @@ export const BUNDLED_ICONS = {
"la:traffic-light": {"body":"<path fill=\"currentColor\" d=\"M10 4v3H7v1c0 1.887.746 3.207 1.625 3.969c.441.383.91.656 1.313.812c.023.008.039-.008.062 0V13H7v1c0 1.887.746 3.207 1.625 3.969c.441.383.91.656 1.313.812c.023.008.039-.008.062 0V19H7v1c0 1.887.746 3.207 1.625 3.969c.441.383.91.656 1.313.812c.023.008.039-.008.062 0V28h12v-3.219c.023-.008.04.008.063 0c.402-.156.87-.43 1.312-.812C24.254 23.207 25 21.887 25 20v-1h-3v-.219c.023-.008.04.008.063 0c.402-.156.87-.43 1.312-.812C24.254 17.207 25 15.887 25 14v-1h-3v-.219c.023-.008.04.008.063 0c.402-.156.87-.43 1.312-.812C24.254 11.207 25 9.887 25 8V7h-3V4zm2 2h8v3h2.813c-.172.672-.418 1.184-.75 1.469c-.243.21-.508.355-.72.437c-.21.082-.425.094-.343.094h-1v4h2.813c-.172.672-.418 1.184-.75 1.469c-.243.21-.508.355-.72.437c-.21.082-.425.094-.343.094h-1v4h2.813c-.172.672-.418 1.184-.75 1.469c-.243.21-.508.355-.72.437c-.21.082-.425.094-.343.094h-1v3h-8v-3h-1c.082 0-.133-.012-.344-.094a2.5 2.5 0 0 1-.719-.437c-.332-.285-.578-.797-.75-1.469H12v-4h-1c.082 0-.133-.012-.344-.094a2.5 2.5 0 0 1-.719-.437c-.332-.285-.578-.797-.75-1.469H12v-4h-1c.082 0-.133-.012-.344-.094a2.5 2.5 0 0 1-.719-.437c-.332-.285-.578-.797-.75-1.469H12zm4 2a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:trash": {"body":"<path fill=\"currentColor\" d=\"M14 4c-.523 0-1.059.184-1.438.563C12.184 4.94 12 5.476 12 6v1H5v2h1.094L8 27.094l.094.906h15.812l.094-.906L25.906 9H27V7h-7V6c0-.523-.184-1.059-.563-1.438C19.06 4.184 18.523 4 18 4zm0 2h4v1h-4zM8.125 9h15.75l-1.781 17H9.906zM12 12v11h2V12zm3 0v11h2V12zm3 0v11h2V12z\"/>","width":32,"height":32},
"la:trash-alt": {"body":"<path fill=\"currentColor\" d=\"M15 4c-.523 0-1.059.184-1.438.563C13.184 4.94 13 5.476 13 6v1H7v2h1v16c0 1.645 1.355 3 3 3h12c1.645 0 3-1.355 3-3V9h1V7h-6V6c0-.523-.184-1.059-.563-1.438C20.06 4.184 19.523 4 19 4zm0 2h4v1h-4zm-5 3h14v16c0 .555-.445 1-1 1H11c-.555 0-1-.445-1-1zm2 3v11h2V12zm4 0v11h2V12zm4 0v11h2V12z\"/>","width":32,"height":32},
"la:undo": {"body":"<path fill=\"currentColor\" d=\"m12.781 5.281l-8 8l-.687.719l.687.719l8 8l1.438-1.438L7.938 15H21c2.754 0 5 2.246 5 5v7h2v-7c0-3.844-3.156-7-7-7H7.937l6.282-6.281z\"/>","width":32,"height":32},
"la:undo-alt": {"body":"<path fill=\"currentColor\" d=\"M16 3C12 3 8.4 4.8 6 7.7V4H4v8h8v-2H6.8c2-3 5.3-5 9.2-5c6.1 0 11 4.9 11 11s-4.9 11-11 11S5 22.1 5 16H3c0 7.2 5.8 13 13 13s13-5.8 13-13S23.2 3 16 3\"/>","width":32,"height":32},
"la:unlink": {"body":"<path fill=\"currentColor\" d=\"M21.75 4a6.3 6.3 0 0 0-4.438 1.844l-1.468 1.468a6.22 6.22 0 0 0-1.781 3.625l2 .25A4.2 4.2 0 0 1 17.25 8.72l1.469-1.469a4.253 4.253 0 0 1 6.031 0a4.253 4.253 0 0 1 0 6.031l-1.469 1.469h-.031a4.07 4.07 0 0 1-2.438 1.188l.25 2a6.22 6.22 0 0 0 3.625-1.782l1.47-1.468c2.433-2.434 2.433-6.41 0-8.844A6.22 6.22 0 0 0 21.75 4M7.719 6.281L6.28 7.72l4 4l1.438-1.438zm3.218 7.782a6.22 6.22 0 0 0-3.624 1.78l-1.47 1.47c-2.433 2.433-2.433 6.41 0 8.843c2.434 2.434 6.41 2.434 8.845 0l1.468-1.468a6.22 6.22 0 0 0 1.782-3.625l-2-.25a4.2 4.2 0 0 1-1.188 2.468l-1.469 1.469a4.253 4.253 0 0 1-6.031 0a4.253 4.253 0 0 1 0-6.031l1.469-1.469h.031a4.07 4.07 0 0 1 2.438-1.188zM21.72 20.28l-1.44 1.44l4 4l1.438-1.438z\"/>","width":32,"height":32},
"la:upload": {"body":"<path fill=\"currentColor\" d=\"m16 3.594l-.719.687l-7 7L9.72 12.72L15 7.438V24h2V7.437l5.281 5.282l1.438-1.438l-7-7zM7 26v2h18v-2z\"/>","width":32,"height":32},
@ -220,6 +222,8 @@ export const BUNDLED_ICONS = {
"mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24},
"mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24},
"mdi:letter-a-box": {"body":"<path fill=\"currentColor\" d=\"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm8 2a2 2 0 0 0-2 2v8h2v-4h2v4h2V9a2 2 0 0 0-2-2zm0 2h2v2h-2z\"/>","width":24,"height":24},
"mdi:letter-b-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m10 7.5V9a2 2 0 0 0-2-2H9v10h4a2 2 0 0 0 2-2v-1.5c0-.8-.7-1.5-1.5-1.5c.8 0 1.5-.7 1.5-1.5M13 15h-2v-2h2zm0-4h-2V9h2z\"/>","width":24,"height":24},
"mdi:lightbulb-outline": {"body":"<path fill=\"currentColor\" d=\"M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7M9 21v-1h6v1a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1m3-17a5 5 0 0 0-5 5c0 2.05 1.23 3.81 3 4.58V16h4v-2.42c1.77-.77 3-2.53 3-4.58a5 5 0 0 0-5-5\"/>","width":24,"height":24},
"mdi:line-scan": {"body":"<path fill=\"currentColor\" d=\"M17 22v-2h3v-3h2v3.5c0 .4-.2.7-.5 1s-.7.5-1 .5zM7 22H3.5c-.4 0-.7-.2-1-.5s-.5-.7-.5-1V17h2v3h3zM17 2h3.5c.4 0 .7.2 1 .5s.5.6.5 1V7h-2V4h-3zM7 2v2H4v3H2V3.5c0-.4.2-.7.5-1s.6-.5 1-.5zm12 9H5v2h14z\"/>","width":24,"height":24},
"mdi:link-variant": {"body":"<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>","width":24,"height":24},

@ -3,11 +3,40 @@
<w-header class="card-header px-4 py-2">
<w-icon name="la:history" left size="md" />
<span>{{ t('history.title') }}</span>
<span class="page-history-page ml-3">{{ pageStore.title }}</span>
<!--
Centred on the header itself rather than on the space left between the two groups of
controls, which are nowhere near the same width hence absolute rather than a pair of
spacers. Ignores the pointer so it can overlap nothing it would block.
-->
<span class="page-history-page">{{ pageStore.title }}</span>
<w-space />
<transition name="syncing">
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" />
</transition>
<!--
How the two versions are laid against each other. Up here rather than over the diff, so the
compare bar below can stay exactly two halves lining up with the editor's own two panes.
-->
<w-btn-group class="mr-6">
<w-btn
push
dense
no-caps
:label="t(`history.sideBySide`)"
padding="0.285em sm"
:color="state.inline ? `white` : `secondary`"
:text-color="state.inline ? `black` : `white`"
@click="state.inline = false" />
<w-btn
push
dense
no-caps
:label="t(`history.inline`)"
padding="0.285em sm"
:color="state.inline ? `secondary` : `white`"
:text-color="state.inline ? `white` : `black`"
@click="state.inline = true" />
</w-btn-group>
<w-btn
icon="la:times"
color="pink-2"
@ -55,16 +84,71 @@
<div class="page-history-meta" v-if="version.action === `moved`">
/{{ version.path }}
</div>
<!-- Why, in the author's own words, when the site asks for a reason on save. -->
<div class="page-history-reason" v-if="version.reason">{{ version.reason }}</div>
<div class="page-history-fields" v-if="version.changedFields.length > 0">
{{ t('history.changedFields', { fields: version.changedFields.join(', ') }) }}
</div>
</div>
<!--
Stops the click from also reaching the item, which would move both letters at once.
-->
<div class="page-history-pick" @click.stop>
<w-btn
flat
dense
round
icon="la:ellipsis-h"
color="grey-5"
:aria-label="t(`history.versionActions`)">
<w-menu class="translucent-menu" auto-close anchor="bottom left" self="top left">
<!--
`!min-w-0 !pr-2` on each icon section, and literal colour classes rather than
WIcon's `color` prop both for the same reasons as the profile menu this copies.
-->
<w-list dense padding style="min-width: 260px">
<w-item clickable @click="pick(`a`, version.id)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="mdi:letter-a-box" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.setAsSource') }}</w-item-section>
</w-item>
<w-item clickable @click="pick(`b`, version.id)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="mdi:letter-b-box" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.setAsTarget') }}</w-item-section>
</w-item>
<w-separator class="my-1" />
<w-item clickable @click="viewSource(version)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:code" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.viewSource') }}</w-item-section>
</w-item>
<w-item clickable @click="downloadVersion(version)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:download" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.downloadVersion') }}</w-item-section>
</w-item>
<template v-if="userStore.can(`write:pages`)">
<w-separator class="my-1" />
<!--
Writes over the page, so it reads as the one destructive thing in here the
same red the profile menu gives its one irreversible entry.
-->
<w-item clickable @click="restoreVersion(version)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:undo" class="text-negative" />
</w-item-section>
<w-item-section>{{ t('history.restore') }}</w-item-section>
</w-item>
<w-item clickable @click="branchFrom(version)">
<w-item-section avatar class="!min-w-0 !pr-2">
<w-icon name="la:code-branch" class="text-blue-7" />
</w-item-section>
<w-item-section>{{ t('history.branchOff') }}</w-item-section>
</w-item>
</template>
</w-list>
</w-menu>
</w-btn>
<!-- Not `unelevated`: the push ledge is the point, and that prop would flatten it. -->
<w-btn-group>
<w-btn
@ -89,6 +173,19 @@
@click="pick(`b`, version.id)" />
</w-btn-group>
</div>
<!--
A row of their own, under the buttons rather than beside them: both are prose that runs
on, and the column left over next to the A/B group is too narrow to read either in.
-->
<div
class="page-history-notes"
v-if="version.reason || version.changedFields.length > 0">
<!-- Why, in the author's own words, when the site asks for a reason on save. -->
<div class="page-history-reason" v-if="version.reason">{{ version.reason }}</div>
<div class="page-history-fields" v-if="version.changedFields.length > 0">
{{ t('history.changedFields', { fields: version.changedFields.join(', ') }) }}
</div>
</div>
</div>
</div>
<div class="p-4 text-grey-5" v-else-if="state.loading < 1">{{ t('history.none') }}</div>
@ -109,9 +206,10 @@
<div class="truncate">{{ sideLabel(sideA) }}</div>
<div class="page-history-meta truncate">{{ sideCaption(sideA) }}</div>
</div>
<!-- A literal class, not `color`: that prop builds one at runtime, which Tailwind
never emits. `ml-auto` puts it on the seam between the two panes. -->
<w-icon class="text-grey-6 ml-auto" name="la:arrow-right" />
</div>
<!-- A literal class, not `color`: that prop builds one at runtime, which Tailwind never emits. -->
<w-icon class="text-grey-6" name="la:arrow-right" />
<div class="page-history-side">
<span class="page-history-letter">B</span>
<div class="min-w-0">
@ -135,15 +233,31 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import * as monaco from 'monaco-editor'
import { fileSave } from 'browser-fs-access'
import { MarkdownRenderer } from '@/renderers/markdown'
import { confirm, dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/**
* Everything that ever happened to a page, and the difference between any two moments of it.
@ -157,8 +271,14 @@ import { useSiteStore } from '@/stores/site'
// STORES
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
const router = useRouter()
// I18N
@ -177,7 +297,9 @@ const state = reactive({
/** Shown in place of the diff when there is nothing to show one of. */
notice: '',
/** Set alongside the models rather than computed: the fetched sources are held outside `state`. */
sameContent: false
sameContent: false,
/** One column with the changes marked in place, rather than the two-column default. */
inline: false
})
const thumb = {
@ -233,6 +355,12 @@ const sideB = computed(() => state.versions.find((v) => v.id === state.bId) ?? n
watch(() => [state.aId, state.bId], applyDiff)
// -> A live option, so switching keeps the scroll position and the models rather than rebuilding
watch(
() => state.inline,
(inline) => diffEditor?.updateOptions({ renderSideBySide: !inline })
)
// METHODS
function close() {
@ -334,6 +462,210 @@ async function loadVersion(id) {
return version
}
/** What a version's source is saved as, by the format it was written in. */
const FILE_TYPES = {
markdown: { ext: 'md', mime: 'text/markdown' },
html: { ext: 'html', mime: 'text/html' }
}
/**
* The format a version was written in which decides how it colours, how it renders and what it
* downloads as. Taken from the version rather than from the page, since the page may have been
* converted since.
*/
function contentTypeOf(version) {
return version?.meta?.contentType || version?.meta?.editor || pageStore.editor || 'markdown'
}
/** A version with its source, with the spinner and the error report the menu actions all want. */
async function withVersion(version) {
state.loading++
try {
return await loadVersion(version.id)
} catch (err) {
notify({
type: 'negative',
message: t('history.loadFailed'),
caption: await apiMessage(err)
})
return null
} finally {
state.loading--
}
}
/**
* The HTML for a version's source, produced here for the same reason every save produces it here:
* the markdown pipeline is a frontend one, and the server would otherwise have to drive a headless
* browser an extension most instances do not install.
*/
async function renderOf(version, content) {
if (contentTypeOf(version) !== 'markdown') {
return content
}
// -> The renderer is configured per site (line breaks, typographer, ), and that configuration
// arrives with the editor configs rather than on its own
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
return new MarkdownRenderer(editorStore.editors.markdown ?? {}).render(content)
}
async function viewSource(version) {
const full = await withVersion(version)
if (!full) {
return
}
dialog({
component: defineAsyncComponent(() => import('./PageVersionSourceDialog.vue')),
componentProps: {
content: full.content ?? '',
date: humanizeDate(full.versionDate)
}
})
}
async function downloadVersion(version) {
const full = await withVersion(version)
if (!full) {
return
}
const type = FILE_TYPES[contentTypeOf(full)] ?? { ext: 'txt', mime: 'text/plain' }
// -> Named for the page and the moment, since a folder of `page.md` files says nothing
const name = full.path.split('/').at(-1) || 'page'
const stamp = full.versionDate.slice(0, 19).replace(/[:T]/g, '-')
try {
/*
A bare MIME type, with no `;charset=` on it: the save picker uses this as an `accept` key and
rejects a type carrying parameters outright. Nothing is lost by dropping it a Blob built from
a JS string is UTF-8 already.
*/
await fileSave(new Blob([full.content ?? ''], { type: type.mime }), {
fileName: `${name}-${stamp}.${type.ext}`,
extensions: [`.${type.ext}`]
})
} catch (err) {
// -> Dismissing the file picker is not a failure
if (err.name !== 'AbortError') {
notify({ type: 'negative', message: t('history.downloadFailed'), caption: err.message })
}
}
}
/**
* Put this version's source back on the page.
*
* The source only: the page keeps the title, tags and settings it has now. Restoring those too would
* quietly undo everything done since, and a reader asking for an old version back is asking for the
* text. Nothing is lost either way this is an ordinary edit, so it becomes a version of its own
* with the current state recorded in it.
*/
function restoreVersion(version) {
confirm({
title: t('history.restore'),
message: [
t('history.restoreConfirm', { date: humanizeDate(version.versionDate) }),
t('history.restoreConfirmHint')
],
caption: t('history.versionId', { id: version.id }),
cancel: true,
color: 'negative',
okLabel: t('history.restore')
}).onOk(async () => {
const full = await withVersion(version)
if (!full) {
return
}
state.loading++
try {
const content = full.content ?? ''
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/pages/${pageStore.id}`, {
json: {
content,
render: await renderOf(full, content),
reasonForChange: t('history.restoreReason', { date: humanizeDate(full.versionDate) })
}
}).json()
if (!resp?.page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({ type: 'positive', message: t('history.restoreSuccess') })
// -> The page behind this overlay is now out of date, and so is the timeline: the restore is
// itself a version, and it is the one worth landing on
await pageStore.pageLoad({ id: pageStore.id })
await load()
} catch (err) {
notify({
type: 'negative',
message: t('history.restoreFailed'),
caption: await apiMessage(err)
})
} finally {
state.loading--
}
})
}
/**
* Start a new page from this version, leaving this one alone.
*
* What to do with an old version that is worth keeping but not worth reverting to. The same path
* picker as duplicating a page, because that is what this is a duplicate of a page as it was.
*/
function branchFrom(version) {
dialog({
component: defineAsyncComponent(() => import('./TreeBrowserDialog.vue')),
componentProps: {
mode: 'duplicatePage',
folderPath: '',
itemId: pageStore.id,
itemTitle: version.title,
itemFileName: pageStore.path
}
}).onOk(async (target) => {
const full = await withVersion(version)
if (!full) {
return
}
state.loading++
try {
const content = full.content ?? ''
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: {
path: target.path,
title: target.title,
locale: pageStore.locale,
editor: full.meta?.editor || pageStore.editor,
content,
render: await renderOf(full, content),
description: full.meta?.description ?? '',
icon: full.meta?.icon ?? '',
tags: full.meta?.tags ?? [],
// -> A version that was scheduled carries dates this new page has not got, and the API
// rightly refuses that combination
publishState: full.meta?.publishState === 'published' ? 'published' : 'draft',
reasonForChange: t('history.branchReason', { date: humanizeDate(full.versionDate) })
}
}).json()
const page = resp?.page
if (!page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({ type: 'positive', message: t('history.branchSuccess') })
close()
router.push(`/${page.path}`)
} catch (err) {
notify({
type: 'negative',
message: t('history.branchFailed'),
caption: await apiMessage(err)
})
} finally {
state.loading--
}
})
}
/** The editor is built on first use, since the container only exists once there is history to show. */
async function mountEditor() {
await nextTick()
@ -357,9 +689,9 @@ async function mountEditor() {
diffEditor = monaco.editor.createDiffEditor(diffEl.value, {
automaticLayout: true,
fontSize: 14,
// -> Side by side: this exists to compare the two, and an inline diff of prose reads as a jumble
// of half-lines
renderSideBySide: true,
// -> Side by side by default: this exists to compare the two, and an inline diff of prose reads
// as a jumble of half-lines. The header offers the other way for anyone who prefers it.
renderSideBySide: !state.inline,
originalEditable: false,
// -> A reader, not an editor. Restoring a version is its own action, and is not implemented yet.
readOnly: true,
@ -371,8 +703,7 @@ async function mountEditor() {
/** The format the page was written in at the time, which is what colours the two sides. */
function languageOf(version) {
const kind = version?.meta?.contentType || version?.meta?.editor
return kind === 'html' ? 'html' : 'markdown'
return contentTypeOf(version) === 'html' ? 'html' : 'markdown'
}
async function applyDiff() {
@ -418,6 +749,7 @@ function disposeEditor() {
async function load() {
state.loading++
state.notice = ''
try {
state.versions =
(await API_CLIENT.get(`sites/${siteStore.id}/pages/${pageStore.id}/history`).json()) ?? []
@ -454,7 +786,21 @@ $timeline-line: rgba(#fff, 0.12);
$timeline-turn: 16px;
.page-history {
/* -> The header is the positioning context for the page title below */
.card-header {
position: relative;
}
&-page {
position: absolute;
left: 50%;
transform: translateX(-50%);
/* -> Never wide enough to reach either group of controls; a long title is cut instead */
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
font-size: 0.8rem;
opacity: 0.6;
}
@ -510,8 +856,11 @@ $timeline-turn: 16px;
&-item {
position: relative;
display: flex;
/* -> Wraps so the notes below can claim a row of their own; no row gap, since they bring their
own margin */
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
gap: 0 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
@ -554,6 +903,13 @@ $timeline-turn: 16px;
color: rgba(#fff, 0.6);
}
/* -> Full width, indented to sit under the entry's text rather than under its dot */
&-notes {
flex: 0 0 100%;
min-width: 0;
padding-left: calc(28px + 0.75rem);
}
&-reason {
margin-top: 0.25rem;
font-size: 0.78rem;
@ -571,24 +927,30 @@ $timeline-turn: 16px;
&-pick {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.25rem;
}
&-compare {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
/* -> No gap: each side owns exactly half the width, and its own padding keeps the two apart */
padding: 0.75rem 0;
border-bottom: 1px solid rgba(#fff, 0.1);
font-size: 0.85rem;
}
/* -> Half each, so B starts on the divider between the editor's two panes rather than wherever
the row's other contents happen to leave it */
&-side {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 0;
flex: 0 0 50%;
min-width: 0;
padding: 0 1rem;
}
&-letter {

@ -15,10 +15,14 @@
flat
:disable="!state.content"
@click="download">
<w-tooltip anchor="bottom middle" self="top middle">{{t(`common.actions.download`)}}</w-tooltip>
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.download`)
}}</w-tooltip>
</w-btn>
<w-btn icon="la:times" color="pink-2" dense flat @click="close">
<w-tooltip anchor="bottom middle" self="top middle">{{t(`common.actions.close`)}}</w-tooltip>
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.close`)
}}</w-tooltip>
</w-btn>
</w-header>
<w-page-container>
@ -27,7 +31,7 @@
:thumb-style="thumb"
:bar-style="bar"
:horizontal-thumb-style="{ height: `5px` }"
style="width: 100%; height: calc(100vh - 100px);">
style="width: 100%; height: calc(100vh - 100px)">
<div class="p-4 text-grey-5" v-if="state.notice">{{ state.notice }}</div>
<!-- -> `pt-4` so the first line clears the header rather than sitting against it -->
<pre class="px-4 pt-4" v-else v-text="state.content"></pre>
@ -49,7 +53,6 @@ import { useSiteStore } from '@/stores/site'
import { fileSave } from 'browser-fs-access'
// STORES
const pageStore = usePageStore()
@ -96,7 +99,9 @@ const contentTypes = {
function download() {
const fileType = contentTypes[state.contentType] ?? { ext: 'txt', mime: 'text/plain' }
fileSave(new Blob([state.content], { type: `${fileType.mime};charset=UTF-8` }), {
// -> No `;charset=` on the type: the save picker uses it as an `accept` key and rejects a type
// with parameters. A Blob built from a JS string is UTF-8 regardless.
fileSave(new Blob([state.content], { type: fileType.mime }), {
fileName: `page.${fileType.ext}`,
extensions: [`.${fileType.ext}`]
})

@ -0,0 +1,125 @@
<template>
<!-- Not `persistent`: nothing here is being filled in, so clicking away should just close it. -->
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="page-version-source" style="width: 1200px; max-width: 94vw">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-code.svg" left size="sm" />
<span>{{ t('history.viewSource') }}</span>
<w-space />
<span class="page-version-source-date">{{ date }}</span>
</w-card-section>
<w-card-section class="page-version-source-body">
<pre v-text="content" />
</w-card-section>
<!--
Its own class rather than the shared `card-actions`: that one follows the app theme, and this
dialog is dark whichever theme is on a light toolbar under a black pane reads as a different
component bolted to the bottom. Mirrors what `card-header` does at the top.
-->
<w-card-actions class="page-version-source-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
icon="la:copy"
color="grey-5"
padding="xs md"
:label="t(`common.actions.copy`)"
@click="copy" />
<w-btn
unelevated
color="primary"
padding="xs md"
:label="t(`common.actions.close`)"
@click="onDialogOK" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { copyToClipboard } from '@/helpers/clipboard'
import { notify } from '@/composables/notify'
/**
* One version's source, as it stood.
*
* A dialog rather than the page source overlay, which shows the page as it is now: only one overlay
* can be open at a time, and taking over the screen would close the history this was opened from.
*/
// PROPS
const props = defineProps({
/** The source itself. */
content: {
type: String,
default: ''
},
/** When this version was written, already formatted for reading. */
date: {
type: String,
default: ''
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK } = useDialogComponent()
// I18N
const { t } = useI18n()
// METHODS
async function copy() {
try {
await copyToClipboard(props.content)
notify({ type: 'positive', message: t('history.sourceCopied') })
} catch (err) {
notify({ type: 'negative', message: err.message })
}
}
</script>
<style lang="scss">
.page-version-source {
&-date {
font-size: 0.8rem;
opacity: 0.7;
}
/* -> Colour only: WCardActions already lays the bar out */
&-actions {
background-color: $dark-3;
background-image: radial-gradient(at top left, $dark-3, $dark-5);
border-top: 1px solid #000;
box-shadow: 0 -1px 0 0 rgba(#fff, 0.06);
color: #fff;
}
&-body {
padding: 0;
background-color: $dark-6;
color: #fff;
pre {
/* -> Tall enough to be worth opening, short enough to leave the dialog on screen */
max-height: 60vh;
overflow: auto;
padding: 1rem;
font-family: 'Roboto Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
}
}
}
</style>

@ -14,7 +14,7 @@
<span>{{ t('pageRenameDialog.title') }}</span>
</w-card-section>
<div class="page-save-dialog-browser flex flex-nowrap">
<div class="w-1/3">
<div class="page-save-dialog-tree w-1/3">
<w-scroll-area style="height: 300px">
<!-- -> No side padding: the rows carry their own 12px and span the column, as in the
File Manager. Padding here would inset the highlight band as well. -->
@ -33,22 +33,29 @@
</w-scroll-area>
</div>
<div class="w-2/3">
<w-list class="page-save-dialog-filelist" dense>
<w-item
v-for="item of files"
:key="item.id"
clickable
active-class="active"
:active="item.id === state.currentFileId"
@click="selectItem(item)">
<w-item-section side>
<w-icon :name="item.icon" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
<!--
Scrolls on its own, as the tree beside it does: this row is a fixed 300px, and a folder
with more entries than that holds simply drew straight over the path bar, the two fields
and the buttons underneath.
-->
<w-scroll-area style="height: 300px">
<w-list class="page-save-dialog-filelist" dense>
<w-item
v-for="item of files"
:key="item.id"
clickable
active-class="active"
:active="item.id === state.currentFileId"
@click="selectItem(item)">
<w-item-section side>
<w-icon :name="item.icon" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-scroll-area>
</div>
</div>
<div class="page-save-dialog-path font-robotomono">{{ currentFolderPath }}</div>
@ -514,9 +521,25 @@ onMounted(async () => {
@use 'sass:color';
.page-save-dialog {
/*
The header draws its separator as an OUTSET box-shadow, which is painted with the header's own
background -- and a later sibling's background is painted after it. So the tinted tree column
covered that 1px line while the untinted file list left it showing, and the two columns looked as
though they started at different heights.
Positioning the header puts it above both: a positioned element paints over its in-flow siblings,
so the line survives across the full width.
*/
.card-header {
position: relative;
}
&-browser {
height: 300px;
max-height: 90vh;
/* -> Belt and braces with the scroll areas inside: whatever either column ends up holding, the
browser cannot spill over the fields and buttons below it */
overflow: hidden;
border-bottom: 1px solid #fff;
@at-root .body--light & {
@ -525,16 +548,20 @@ onMounted(async () => {
@at-root .body--dark & {
border-bottom-color: $dark-3;
}
}
> .col-4 {
height: 300px;
/*
Tinted so the tree reads as a column of its own rather than running into the file list beside it.
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
This was a `> .col-4` rule, which the layout migration left pointing at a class that no longer
exists -- the columns are Tailwind fractions now -- so the pane had been plain white since.
*/
&-tree {
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}

@ -5,7 +5,20 @@
<span>{{ title }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">{{ message }}</div>
<div
v-for="(paragraph, pIdx) of paragraphs"
:key="pIdx"
class="text-body2"
:class="pIdx > 0 ? 'mt-3' : ''">
<template v-for="(run, rIdx) of runs(paragraph)" :key="rIdx">
<strong v-if="run.strong">{{ run.text }}</strong>
<template v-else>{{ run.text }}</template>
</template>
</div>
<!-- An identifier, a path, a count: the quiet detail under the question, as the page
deletion dialog shows the page's ID. -->
<div v-if="caption" class="text-caption text-grey mt-2">{{ caption }}</div>
<!--
The one prompting variant in the codebase: pick one of a few named choices. `onOk`
@ -43,7 +56,7 @@
</template>
<script setup>
import { ref, useId } from 'vue'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
@ -61,7 +74,17 @@ const props = defineProps({
type: String,
default: ''
},
/**
* What is being confirmed. An array is rendered as one paragraph per entry, and `**like this**`
* marks a run as bold the one bit of emphasis these dialogs have ever needed, and cheaper than
* a bespoke component per message.
*/
message: {
type: [String, Array],
default: ''
},
/** A supporting detail, set smaller and greyer under the message. */
caption: {
type: String,
default: ''
},
@ -103,6 +126,21 @@ const { t } = useI18n()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const paragraphs = computed(() => (Array.isArray(props.message) ? props.message : [props.message]))
/**
* A paragraph split into plain and bold runs.
*
* Split on `**`, so every odd piece is what sat between a pair. Returned as data for the template to
* render as real elements rather than as markup handed to `v-html`: a confirmation message is often
* built from a page title or a file name, and none of those can become HTML this way.
*/
function runs(paragraph) {
return String(paragraph)
.split('**')
.map((text, idx) => ({ text, strong: idx % 2 === 1 }))
}
/** Radios need a name unique to this instance, or two open dialogs would share a group. */
const groupName = useId()
const choice = ref(props.options?.model ?? null)

Loading…
Cancel
Save