mirror of https://github.com/requarks/wiki
parent
c36eab6729
commit
c9bd96a5ba
@ -0,0 +1 @@
|
||||
ALTER TABLE "pageHistory" ADD COLUMN "reason" varchar(255);
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
Loading…
Reference in new issue