mirror of
https://github.com/requarks/wiki.git
synced 2026-10-13 14:01:58 -05:00
feat: page history overlay (wip)
This commit is contained in:
15 files changed
+6232
-28
No files matched your search
@@ -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);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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 ?? ''
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in new issue
Block a user