feat: page history overlay (wip)

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

@ -95,6 +95,23 @@ export function unlockedFor(req: FastifyRequest, pageId: string): boolean {
return mayBypassPassword(req) || Boolean(req.session?.unlockedPages?.includes(pageId))
}
/**
* A page, as this requester is allowed to see it or null when they are not allowed to see it at all.
*
* The gate for anything that hangs off a page but is not the page itself. An anonymous requester only
* ever reaches a published page, and a password-protected one comes back with `isLocked` set until the
* session has satisfied the unlock, which the caller is expected to refuse on.
*/
async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req)
return WIKI.models.pages.getPage({
siteId,
id: pageId,
publicOnly: !actor,
unlocked: (id: string) => unlockedFor(req, id)
})
}
/**
* Pages API Routes
*/
@ -722,6 +739,93 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PAGE HISTORY
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/history',
{
schema: {
summary: "Get a page's version history",
description:
'Every recorded version of the page, newest first — the first entry is the page as it stands now.\n\nGated on being able to read the page, no more: history is part of a page, so whoever may read the page may read what it used to say. That means an anonymous reader sees the history of a published page and nothing of a draft, and that a password-protected page answers only once the session has satisfied `POST …/unlock`.',
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Versions of this page, newest first',
type: 'array',
items: { $ref: 'PageHistoryEntry#' }
}
}
}
},
async (req, reply) => {
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
return WIKI.models.pageHistory.list(req.params.siteId, req.params.pageId)
}
)
/**
* PAGE HISTORY VERSION
*/
app.get<{ Params: { siteId: string; pageId: string; versionId: string } }>(
'/sites/:siteId/pages/:pageId/history/:versionId',
{
schema: {
summary: 'Get a single version of a page',
description:
'One version in full, source included — one side of a comparison. Readable by whoever may read the page, on the same terms as the history list.',
tags: ['Pages'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
pageId: {
type: 'string',
format: 'uuid'
},
versionId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'pageId', 'versionId']
},
response: {
200: { $ref: 'PageHistoryVersion#' }
}
}
},
async (req, reply) => {
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
const version = await WIKI.models.pageHistory.getVersion(
req.params.siteId,
req.params.pageId,
req.params.versionId
)
if (!version) {
return reply.notFound('This version does not exist.')
}
return version
}
)
/**
* RESOLVE ALIAS
*/

@ -1,3 +1,4 @@
import { pageHistoryActions } from '../../models/pageHistory.ts'
import type { FastifyInstance } from 'fastify'
/**
@ -119,6 +120,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
scriptCss: {
type: 'string',
description: 'Requires the `write:styles` permission. Ignored without it.'
},
reasonForChange: {
type: 'string',
maxLength: 255,
description:
"Why this save is being made, as the editor's reason-for-change prompt collected it. Not stored on the page: it is recorded on the history version this save produces."
}
}
})
@ -227,4 +234,90 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
}
})
/**
* PAGE HISTORY ENTRY - One version of a page, as the history timeline lists it
*/
app.addSchema({
$id: 'PageHistoryEntry',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
action: {
type: 'string',
enum: [...pageHistoryActions],
description: 'What happened to the page. `moved` is a change of path or title.'
},
changedFields: {
type: 'array',
description:
'Which page fields the change touched, named as the page stores them. Empty for a creation or a deletion, where the whole page is the change.',
items: {
type: 'string'
}
},
reason: {
type: 'string',
description:
"Why the change was made, in the author's words. Empty when the site does not ask for a reason — see the `reasonForChange` site feature — or asked and was not answered."
},
versionDate: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
path: {
type: 'string',
description: 'Where the page was at the time, which is not necessarily where it is now.'
},
title: {
type: 'string'
},
author: {
type: 'object',
description: 'Who made the change. Null id and empty name once that account is deleted.',
properties: {
id: {
type: ['string', 'null'],
format: 'uuid'
},
name: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
})
/**
* PAGE HISTORY VERSION - The same, with the source it held: one side of a diff
*/
app.addSchema({
$id: 'PageHistoryVersion',
type: 'object',
allOf: [
{ $ref: 'PageHistoryEntry#' },
{
type: 'object',
properties: {
content: {
type: 'string',
description: 'The page source as of this version.'
},
meta: {
type: 'object',
additionalProperties: true,
description:
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.'
}
}
}
]
})
}

@ -0,0 +1 @@
ALTER TABLE "pageHistory" ADD COLUMN "reason" varchar(255);

@ -428,6 +428,11 @@ export const pageHistory = pgTable(
* field added to a page does not have to be added here too.
*/
meta: jsonb().notNull().default({}),
/**
* Why the change was made, in the author's words, as the editor's reason-for-change prompt
* collected it. Null when the site does not ask for one, or asks and is not answered.
*/
reason: varchar({ length: 255 }),
versionDate: timestamp().notNull().defaultNow(),
// -> Null once the account is gone, rather than holding the account hostage: a history row is a
// record of what happened to the page, and requiring its author to exist for ever would mean

@ -1979,10 +1979,24 @@
"folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.",
"folderDeleteDialog.folderId": "Folder ID {id}",
"folderDeleteDialog.title": "Confirm Delete Folder",
"history.action.created": "Created",
"history.action.deleted": "Deleted",
"history.action.moved": "Moved",
"history.action.updated": "Updated",
"history.changedFields": "Changed: {fields}",
"history.current": "Current",
"history.emptyPage": "Nothing",
"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.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.sameContent": "These two versions have the same content — only metadata changed.",
"history.title": "Page History",
"history.unknownAuthor": "Unknown",
"iconPicker.allSets": "All sets",
"iconPicker.icons": "Icons",
"iconPicker.image": "Image",

@ -1,5 +1,10 @@
import { eq } from 'drizzle-orm'
import { pageHistory as pageHistoryTable, pages as pagesTable } from '../db/schema.ts'
import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq } from 'drizzle-orm'
import {
pageHistory as pageHistoryTable,
pages as pagesTable,
users as usersTable
} from '../db/schema.ts'
/**
* The kinds of change a history row records.
@ -64,12 +69,38 @@ const NOT_REPORTED_AS_CHANGED = new Set([
'isSearchableComputed'
])
/** Who a version is attributed to. Null once that account is gone; the version stays. */
export type PageHistoryAuthor = {
id: string | null
name: string
email: string
}
/** A version as a timeline shows it: what happened, when, and to whom — but not the source. */
export type PageHistoryEntry = {
id: string
action: string
changedFields: string[]
/** Empty when the site does not ask for a reason, or asked and was not answered. */
reason: string
versionDate: Date
path: string
title: string
author: PageHistoryAuthor
}
/** A version in full, source included. */
export type PageHistoryVersion = PageHistoryEntry & {
content: string
meta: Record<string, any>
}
/**
* Page history model
*
* Records a version of a page every time one changes. Nothing reads it back yet displaying the
* history, comparing versions and restoring one are the next step so this is deliberately only the
* recording side.
* Records a version of a page every time one changes, and reads those versions back for the history
* view which lists them and diffs any two against each other. Restoring one, and recovering a page
* that was deleted, are still to come.
*/
class PageHistory {
/**
@ -86,6 +117,7 @@ class PageHistory {
* point the version survives with no author rather than blocking the deletion.
* @param changedFields Which fields the change touched. Empty for a creation or a deletion, where
* the whole page is the change.
* @param reason Why, in the author's words, when the site asks for one.
* @returns The version's ID, or null when nothing was recorded
*/
async record({
@ -93,13 +125,15 @@ class PageHistory {
pageId,
action,
authorId,
changedFields = []
changedFields = [],
reason
}: {
siteId: string
pageId: string
action: PageHistoryAction
authorId: string
changedFields?: string[]
reason?: string | null
}): Promise<string | null> {
try {
const rows = await WIKI.db.select().from(pagesTable).where(eq(pagesTable.id, pageId)).limit(1)
@ -124,6 +158,8 @@ class PageHistory {
authorId,
action,
changedFields,
// -> An unanswered optional prompt sends an empty string; a version simply has no reason
reason: reason?.trim() || null,
locale: page.locale,
path: page.path,
title: page.title,
@ -139,6 +175,107 @@ class PageHistory {
}
}
/**
* A page's versions, newest first the order a timeline reads in.
*
* The newest row is the page as it stands: it was written after the change that produced the state
* the page is in now. No content here; a list of forty versions has no business carrying forty
* copies of the page.
*/
async list(siteId: string, pageId: string): Promise<PageHistoryEntry[]> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.pageId, pageId)))
.orderBy(desc(pageHistoryTable.versionDate), desc(pageHistoryTable.id))
return rows.map((row: any) => ({
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
author: {
// -> Null once the account is gone: the version outlives it, see the column's own note
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}))
}
/**
* One version, with the source it held the side of a diff.
*
* @returns The version, or null when this page has no such version
*/
async getVersion(
siteId: string,
pageId: string,
versionId: string
): Promise<PageHistoryVersion | null> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.id, versionId)
)
)
.limit(1)
const row: any = rows[0]
if (!row) {
return null
}
return {
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
author: {
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}
}
/**
* Which of a page's fields a patch actually changes.
*
@ -158,9 +295,17 @@ class PageHistory {
if (value === undefined || !(key in existing) || NOT_REPORTED_AS_CHANGED.has(key)) {
continue
}
// -> JSON rather than `===`: tags, relations and the config blobs are arrays and objects, and
// comparing those by reference reports every save as a change to all of them
if (JSON.stringify(existing[key]) !== JSON.stringify(value)) {
/*
Deep rather than `===`: tags, relations and the config blobs are arrays and objects, and
comparing those by reference reports every save as a change to all of them.
Not `JSON.stringify` either, which was the same bug one level down. Postgres stores a `jsonb`
column with its keys in its own order by length, then bytewise so `config` came back as
`showToc, showTags, tocDepth, …` while `buildConfig` produces them in its own fixed order.
Two identical objects, two different strings, and `config` and `scripts` were therefore
reported as changed on every single save.
*/
if (!isEqual(existing[key], value)) {
changed.push(key)
}
}

@ -103,6 +103,11 @@ export interface PageInput {
scriptJsLoad?: string
scriptJsUnload?: string
scriptCss?: string
/**
* Why this save is being made, as the editor's reason-for-change prompt collected it. Not a page
* field: it belongs to the version this save produces, and is recorded on the history row.
*/
reasonForChange?: string
}
/** Who is saving, and what they are allowed to put in a page. */
@ -410,7 +415,7 @@ class Pages {
isBrowsable: input.isBrowsable ?? true,
isSearchable: input.isSearchable ?? true,
locale,
password: input.password ?? null,
password: input.password || null,
path,
publishState: input.publishState ?? 'published',
publishStartDate: input.publishStartDate ? new Date(input.publishStartDate) : null,
@ -450,7 +455,8 @@ class Pages {
siteId,
pageId: page.id,
action: 'created',
authorId: actor.id
authorId: actor.id,
reason: input.reasonForChange
})
await WIKI.models.search.indexPage(page.id, locale)
@ -581,7 +587,8 @@ class Pages {
pageId: id,
action: 'updated',
authorId: actor.id,
changedFields
changedFields,
reason: patch.reasonForChange
})
if (treeTitle !== null || patch.tags !== undefined) {

@ -33,6 +33,10 @@ const overlays = {
loader: () => import('./NavEditOverlay.vue'),
loadingComponent: LoadingGeneric
}),
PageHistory: defineAsyncComponent({
loader: () => import('./PageHistoryOverlay.vue'),
loadingComponent: LoadingGeneric
}),
PageSource: defineAsyncComponent({
loader: () => import('./PageSourceOverlay.vue'),
loadingComponent: LoadingGeneric

@ -94,7 +94,7 @@
icon="la:history"
:color="editorStore.isActive ? `white` : `grey`"
aria-label="Page History"
@click="notImplemented">
@click="viewPageHistory">
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn>
<w-btn
@ -246,6 +246,10 @@ function togglePageData() {
})
}
function viewPageHistory() {
siteStore.$patch({ overlay: 'PageHistory', overlayOpts: {} })
}
function viewPageSource() {
siteStore.$patch({ overlay: 'PageSource', overlayOpts: {} })
}
@ -337,13 +341,6 @@ function removePendingAsset(item) {
menuPendingAssets.value.hide()
}
}
function notImplemented() {
notify({
type: 'negative',
message: 'Not implemented'
})
}
</script>
<style lang="scss">

@ -0,0 +1,620 @@
<template>
<w-layout class="page-history" view="hHh lpR fFf" container>
<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>
<w-space />
<transition name="syncing">
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" />
</transition>
<w-btn
icon="la:times"
color="pink-2"
dense
flat
:aria-label="t(`common.actions.close`)"
@click="close">
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.close`)
}}</w-tooltip>
</w-btn>
</w-header>
<!-- ----------------------------------------------------- -->
<!-- TIMELINE -->
<!-- ----------------------------------------------------- -->
<w-drawer class="page-history-sidebar" :model-value="true" :width="380">
<w-scroll-area :thumb-style="thumb" :bar-style="bar" style="height: 100%">
<div class="page-history-timeline" v-if="state.versions.length > 0">
<div
class="page-history-item"
v-for="(version, idx) of state.versions"
:key="version.id"
:class="{ 'is-picked': version.id === state.aId || version.id === state.bId }"
role="button"
tabindex="0"
@click="selectVersion(idx)"
@keydown.enter="selectVersion(idx)">
<!-- The subway stop: the line itself is drawn by the item, this is the dot on it. -->
<div class="page-history-dot" :class="actionStyle(version.action).dot">
<w-icon :name="actionStyle(version.action).icon" size="15px" />
</div>
<div class="page-history-body">
<div class="flex items-center gap-2">
<strong>{{ actionLabel(version.action) }}</strong>
<w-badge v-if="idx === 0" color="primary" rounded>
{{ t('history.current') }}
</w-badge>
</div>
<div class="page-history-meta">{{ humanizeDate(version.versionDate) }}</div>
<div class="page-history-meta">
{{ version.author.name || t('history.unknownAuthor') }}
</div>
<!-- Where it went, which is the whole point of telling a move apart from an edit. -->
<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>
<!-- Not `unelevated`: the push ledge is the point, and that prop would flatten it. -->
<w-btn-group>
<w-btn
push
glossy
dense
no-caps
label="A"
padding="0.285em sm"
:color="version.id === state.aId ? `pink-6` : `dark-3`"
:aria-label="t(`history.pickA`)"
@click="pick(`a`, version.id)" />
<w-btn
push
glossy
dense
no-caps
label="B"
padding="0.285em sm"
:color="version.id === state.bId ? `pink-6` : `dark-3`"
:aria-label="t(`history.pickB`)"
@click="pick(`b`, version.id)" />
</w-btn-group>
</div>
</div>
</div>
<div class="p-4 text-grey-5" v-else-if="state.loading < 1">{{ t('history.none') }}</div>
</w-scroll-area>
</w-drawer>
<!-- ----------------------------------------------------- -->
<!-- DIFF -->
<!-- ----------------------------------------------------- -->
<w-page-container>
<w-page class="page-history-main">
<div class="p-4 text-grey-5" v-if="state.notice">{{ state.notice }}</div>
<template v-else-if="state.versions.length > 0">
<div class="page-history-compare">
<div class="page-history-side">
<span class="page-history-letter">A</span>
<div class="min-w-0">
<div class="truncate">{{ sideLabel(sideA) }}</div>
<div class="page-history-meta truncate">{{ sideCaption(sideA) }}</div>
</div>
</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">
<div class="truncate">{{ sideLabel(sideB) }}</div>
<div class="page-history-meta truncate">{{ sideCaption(sideB) }}</div>
</div>
</div>
</div>
<!--
An identical diff looks like a failure otherwise: a metadata-only edit leaves the source
untouched, and the timeline entry is where what actually changed is listed.
-->
<div class="page-history-same" v-if="state.sameContent">
{{ t('history.sameContent') }}
</div>
<div ref="diffEl" class="page-history-diff" />
</template>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import * as monaco from 'monaco-editor'
import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
/**
* Everything that ever happened to a page, and the difference between any two moments of it.
*
* The timeline is the record; A and B are a pair of cursors over it. They are deliberately not "the
* selected item" comparing a version against the one immediately before it is only the most common
* question, not the only one, so clicking an entry sets that up and the two letters then move
* independently. What the right-hand side shows is always A on the left and B on the right, whichever
* way round in time they happen to be.
*/
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
loading: 0,
/** Newest first, as the API returns them: the first entry is the page as it stands. */
versions: [],
/** The left-hand side. Null against the very first version, where there is nothing to compare to. */
aId: null,
/** The right-hand side. Never null once there is any history at all. */
bId: null,
/** 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
})
const thumb = {
right: '2px',
borderRadius: '5px',
backgroundColor: '#FFF',
width: '5px',
opacity: 0.25
}
const bar = {
backgroundColor: '#000',
width: '9px',
opacity: 0.25
}
/**
* How each kind of change reads on the line. Both halves are literals on purpose: an icon name built
* at runtime is not inlined by the icon generator, and a class built at runtime is not emitted by
* Tailwind.
*/
const ACTION_STYLES = {
created: { icon: 'la:plus', dot: 'bg-positive' },
updated: { icon: 'la:pen', dot: 'bg-blue-7' },
moved: { icon: 'la:share', dot: 'bg-warning' },
deleted: { icon: 'la:trash', dot: 'bg-negative' }
}
const ACTION_FALLBACK = { icon: 'la:circle', dot: 'bg-grey-7' }
// REFS
const diffEl = ref(null)
/*
The Monaco instances, deliberately outside `state`: they are large objects with their own internals,
and making them reactive buys nothing and costs a lot.
*/
let diffEditor = null
let originalModel = null
let modifiedModel = null
/** The versions whose source has been fetched, keyed by id. Kept out of `state` for the same reason. */
const contents = new Map()
/** Guards against an out-of-order fetch: only the newest comparison may touch the editor. */
let applyToken = 0
// COMPUTED
const sideA = computed(() => state.versions.find((v) => v.id === state.aId) ?? null)
const sideB = computed(() => state.versions.find((v) => v.id === state.bId) ?? null)
// WATCHERS
watch(() => [state.aId, state.bId], applyDiff)
// METHODS
function close() {
siteStore.$patch({ overlay: '' })
}
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
function actionStyle(action) {
return ACTION_STYLES[action] ?? ACTION_FALLBACK
}
function actionLabel(action) {
return ACTION_STYLES[action] ? t(`history.action.${action}`) : action
}
function sideLabel(version) {
return version ? humanizeDate(version.versionDate) : t('history.emptyPage')
}
/** Who, and why if they said — the same line the timeline entry carries, on one row. */
function sideCaption(version) {
if (!version) {
return ''
}
const author = version.author.name || t('history.unknownAuthor')
return version.reason ? `${author}${version.reason}` : author
}
/**
* What one entry changed: itself as B, and whatever came before it as A.
*
* The oldest entry has nothing before it, so A goes empty and the diff shows the page arriving.
*/
function selectVersion(idx) {
state.bId = state.versions[idx]?.id ?? null
state.aId = state.versions[idx + 1]?.id ?? null
}
/**
* Move one of the two letters onto a version.
*
* The pair can never land on the same entry, so a letter arriving where the other one sits displaces
* it: normally to the position being vacated, which is a straight swap. The one case that cannot swap
* is A landing on B while A is nowhere comparing against the empty page and there B steps to the
* next newer entry instead, or the click does nothing if there is no such entry.
*/
function pick(slot, id) {
const idx = state.versions.findIndex((v) => v.id === id)
if (slot === 'a') {
if (state.bId === id) {
const displaced = state.aId ?? state.versions[idx - 1]?.id
if (!displaced) {
return
}
state.bId = displaced
}
state.aId = id
} else {
if (state.aId === id) {
state.aId = state.bId
}
state.bId = id
}
}
/**
* A version's source, fetched once.
*
* Cached because the two letters walk back and forth over the same handful of entries, and because a
* version is immutable there is no state in which a second fetch would answer differently.
*/
async function loadVersion(id) {
if (!id) {
return null
}
if (contents.has(id)) {
return contents.get(id)
}
const version = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${pageStore.id}/history/${id}`
).json()
contents.set(id, version)
return version
}
/** The editor is built on first use, since the container only exists once there is history to show. */
async function mountEditor() {
await nextTick()
if (diffEditor || !diffEl.value) {
return
}
// -> The markdown editor's theme, defined again here because that component may never have mounted
monaco.editor.defineTheme('wikijs', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#070a0d',
'editor.lineHighlightBackground': '#0d1117',
'editorLineNumber.foreground': '#546e7a',
'editorGutter.background': '#0d1117'
}
})
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,
originalEditable: false,
// -> A reader, not an editor. Restoring a version is its own action, and is not implemented yet.
readOnly: true,
scrollBeyondLastLine: false,
theme: 'wikijs',
wordWrap: 'on'
})
}
/** 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'
}
async function applyDiff() {
const token = ++applyToken
state.loading++
try {
const [a, b] = await Promise.all([loadVersion(state.aId), loadVersion(state.bId)])
await mountEditor()
// -> A newer comparison started while this one was in flight, and owns the editor now
if (token !== applyToken || !diffEditor) {
return
}
state.sameContent = Boolean(a && b && a.content === b.content)
const previous = [originalModel, modifiedModel]
originalModel = monaco.editor.createModel(a?.content ?? '', languageOf(a ?? b))
modifiedModel = monaco.editor.createModel(b?.content ?? '', languageOf(b))
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
// -> After the swap, not before: disposing a model the editor still holds blanks the pane
for (const model of previous) {
model?.dispose()
}
} catch (err) {
notify({
type: 'negative',
message: t('history.loadFailed'),
caption: await apiMessage(err)
})
} finally {
state.loading--
}
}
function disposeEditor() {
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
diffEditor = null
originalModel = null
modifiedModel = null
}
async function load() {
state.loading++
try {
state.versions =
(await API_CLIENT.get(`sites/${siteStore.id}/pages/${pageStore.id}/history`).json()) ?? []
// -> The timeline says so itself; repeating it in the diff pane would say it twice
if (state.versions.length < 1) {
return
}
// -> The live version against the one before it: the change the page is carrying right now
state.bId = state.versions[0].id
state.aId = state.versions[1]?.id ?? null
} catch (err) {
const caption = await apiMessage(err)
state.notice = caption
notify({
type: 'negative',
message: t('history.loadFailed'),
caption
})
} finally {
state.loading--
}
}
// MOUNTED
onMounted(load)
onBeforeUnmount(disposeEditor)
</script>
<style lang="scss">
/** The subway line: its colour, and the radius of the turn it makes at the end. */
$timeline-line: rgba(#fff, 0.12);
$timeline-turn: 16px;
.page-history {
&-page {
font-size: 0.8rem;
opacity: 0.6;
}
&-sidebar {
background-color: $dark-5;
color: #fff;
border-right: 1px solid rgba(#fff, 0.08);
}
&-main {
display: flex;
flex-direction: column;
background-color: $dark-6;
color: #fff;
/* -> The grid cell already has a height; this claims it so the diff can fill what is left */
height: 100%;
min-height: 0;
}
/* The subway line: one continuous rule behind the dots, drawn by the list rather than the items. */
&-timeline {
position: relative;
padding: 1rem 0;
/*
The line: down behind the dots, then a quarter turn out to the left edge rather than stopping
in mid-air.
Both halves are ONE border of ONE box -- the right and bottom edges of an invisible rectangle,
joined by a corner radius -- rather than a straight element meeting a curved one. Two elements
cannot be made to match under fractional display scaling: each snaps to the device pixel grid
from its own layout box, so at 125% or 150% one lands on a whole device pixel and the other
straddles two, and the seam shows as a change of thickness. As a single border there is nothing
to line up: the browser rasterises the straight stretch and the curve as one path.
The box's right edge sits under the middle of the dots: 1rem of padding, half of the 28px dot,
half of the 2px line.
*/
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: calc(1rem + 14px + 1px);
border-right: 2px solid $timeline-line;
border-bottom: 2px solid $timeline-line;
border-bottom-right-radius: $timeline-turn;
}
}
&-item {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
&:hover {
background-color: rgba(#fff, 0.04);
}
/*
An inset shadow rather than a `border-left`, which is what this was: a border is part of the
box, so it pushed the row's contents 3px across and took the dot of every picked entry off the
line while the unpicked ones stayed on it.
*/
&.is-picked {
background-color: rgba($primary, 0.16);
box-shadow: inset 3px 0 0 $primary;
}
}
&-dot {
flex: 0 0 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
/* -> A ring in the sidebar's own colour, so the line appears to pass behind the dot */
box-shadow: 0 0 0 3px $dark-5;
}
&-body {
flex: 1 1 auto;
min-width: 0;
font-size: 0.85rem;
line-height: 1.35;
}
&-meta {
font-size: 0.75rem;
color: rgba(#fff, 0.6);
}
&-reason {
margin-top: 0.25rem;
font-size: 0.78rem;
font-style: italic;
color: rgba(#fff, 0.8);
word-break: break-word;
}
&-fields {
margin-top: 0.25rem;
font-size: 0.7rem;
color: rgba(#fff, 0.45);
word-break: break-word;
}
&-pick {
flex: 0 0 auto;
}
&-compare {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(#fff, 0.1);
font-size: 0.85rem;
}
&-side {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 0;
min-width: 0;
}
&-letter {
flex: 0 0 24px;
height: 24px;
border-radius: 4px;
background-color: $primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
}
&-same {
flex: 0 0 auto;
padding: 0.5rem 1rem;
font-size: 0.8rem;
color: rgba(#fff, 0.6);
background-color: rgba(#fff, 0.04);
}
&-diff {
flex: 1 1 auto;
min-height: 0;
}
}
</style>

@ -24,7 +24,7 @@
<div
role="dialog"
aria-modal="true"
class="w-dialog-panel pointer-events-auto flex flex-col shadow-dialog"
class="w-dialog-panel pointer-events-auto flex flex-col overflow-auto shadow-dialog"
:class="panelClasses"
:style="panelStyle"
@click.stop>
@ -113,10 +113,11 @@ const transitionName = computed(() => TRANSITIONS[props.position] ?? TRANSITIONS
const viewportClasses = computed(() => VIEWPORTS[props.position] ?? VIEWPORTS.standard)
const panelClasses = computed(() => [
// -> `rounded`, not `rounded-none`: the panel no longer touches the window, see VIEWPORTS above
props.position === 'right' ? 'h-full rounded' : '',
props.position === 'bottom' ? 'rounded-b-none max-h-full rounded-t' : '',
props.position === 'standard' ? 'rounded max-h-full' : '',
// -> Rounded, not square: the panel no longer touches the window, see VIEWPORTS above. A panel
// against the bottom edge keeps its own bottom corners square, since they sit on that edge.
props.position === 'right' ? 'h-full rounded-lg' : '',
props.position === 'bottom' ? 'rounded-b-none max-h-full rounded-t-lg' : '',
props.position === 'standard' ? 'rounded-lg max-h-full' : '',
props.fullHeight && props.position === 'standard' ? 'h-full' : '',
props.fullWidth ? 'w-full' : ''
])
@ -186,6 +187,28 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/*
The panel clips what is put inside it, which is what actually rounds a dialog.
Every dialog fills its panel with something opaque -- a `WCard`, or a whole `WLayout` for the
full-screen overlays -- and those surfaces carry bands with backgrounds of their own: a header, a row
of actions. Left to paint themselves, they cover the panel's corners and the dialog reads as square,
which it did. Clipping here rounds all of them at once, however deeply the band is nested.
`auto` rather than `hidden`: both clip, but a dialog whose content outgrows the screen stays
reachable instead of being cut off. The viewport behind it scrolls too, so nothing is trapped.
*/
/*
The surface inside takes the panel's shape. Without this its own smaller radius shows through at the
corners as four notches of backdrop, since the panel itself has no background of its own.
Written flat rather than nested: nesting `> :deep(*)` inside the panel's own rule compiles to a
DESCENDANT selector, which matches the wrong elements entirely.
*/
.w-dialog-panel > :deep(*) {
border-radius: inherit;
}
.w-dialog-backdrop-enter-active,
.w-dialog-backdrop-leave-active {
transition: opacity 0.2s var(--ease-standard);

@ -601,8 +601,9 @@ onMounted(async () => {
padding: 0;
}
// -> The radius is WDialog's, and the panel clips to it there; this only adds the depth and the
// title-bar strip an overlay wants on top of it
> .w-dialog-panel {
border-radius: 6px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
@at-root .body--light & {

@ -291,8 +291,9 @@ body.body--dark {
padding: 0;
}
// -> The radius is WDialog's, and the panel clips to it there; this only adds the depth and the
// title-bar strip an overlay wants on top of it
> .w-dialog-panel {
border-radius: 6px;
box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.3);
@at-root .body--light & {

@ -538,7 +538,13 @@ export const usePageStore = defineStore('page', {
'tags',
'title',
'tocDepth'
])
]),
/*
Not a page field: it describes the save rather than the page, and the server records it on
the history version this save produces. Collected by the reason-for-change dialog before
`pageSave` is called, and cleared below once it has gone up.
*/
reasonForChange: editorStore.reasonForChange ?? ''
}
/*

Loading…
Cancel
Save