mirror of https://github.com/requarks/wiki
parent
acb555eaa7
commit
50adab2eac
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="page-header flex flex-wrap">
|
||||
<!-- PAGE ICON -->
|
||||
<!--
|
||||
Never a button, unlike `PageHeader`'s: there is no editing surface on a snapshot, so the icon is
|
||||
the drawing and nothing else. Same size and same column so the two headers line up exactly --
|
||||
walking from a page to one of its versions should move the title bar's contents nowhere.
|
||||
-->
|
||||
<div class="flex-none pl-4 flex items-center">
|
||||
<w-icon class="rounded" :name="icon" :size="iconSize" color="primary" />
|
||||
</div>
|
||||
<!-- PAGE HEADING -->
|
||||
<!--
|
||||
Centred rather than top-aligned, as in `PageHeader`: with no description the title is the only
|
||||
line in this column and would otherwise sit above the middle of the icon beside it.
|
||||
-->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-center p-2 sm:p-4">
|
||||
<div class="text-h4 page-header-title">{{ title }}</div>
|
||||
<div class="text-subtitle2 page-header-subtitle">{{ description }}</div>
|
||||
</div>
|
||||
<!-- VERSION ACTIONS -->
|
||||
<!--
|
||||
What this header has in place of Watch / Print / Edit: the three things there are to do with a
|
||||
snapshot. Two are icons -- taking a copy of it, in either of two forms -- and the third is
|
||||
labelled, because it is the one that WRITES, and a button that overwrites the live page should
|
||||
not be a glyph somebody presses to find out what it does. Its ellipsis is doing the same work:
|
||||
restoring asks first.
|
||||
|
||||
Download, Restore and Branch off each do the same as their entry in the history overlay's version
|
||||
menu, through the same code. Export to PDF is the one still disabled, because it is not wired up
|
||||
yet -- disabled rather than inert on purpose: a button that silently does nothing when pressed
|
||||
reads as a bug, where a dimmed one reads as not-yet.
|
||||
|
||||
Restore is the only one that WRITES to the live page, which is why it keeps the orange this app
|
||||
gives an action that changes a page, and why it asks before doing it. Branch off creates a page
|
||||
instead of overwriting one, so it sits with the harmless ones.
|
||||
|
||||
They stay on a phone, where `PageHeader` drops its whole row: that row is icons for things
|
||||
reachable elsewhere -- Print is the browser's own menu -- while these three are the only actions
|
||||
this view offers at all, so hiding them would leave the screen with none.
|
||||
|
||||
What they do instead is take a row of their own, which is what `w-full` at phone widths buys: the
|
||||
bar already wraps, but this block is `flex-none` and about 230px wide, so beside a 32px icon it
|
||||
left the title column ~60px and the description came out one word per line. Full width wraps it
|
||||
under the title, and `.page-header` is `height: auto` on the same breakpoint so the bar grows by
|
||||
the row rather than squeezing it.
|
||||
-->
|
||||
<!--
|
||||
`ml-2` throughout, which is the editor's own action row (see `PageHeader`): 8px between buttons
|
||||
rather than 16. Every one of them is acrylic and flat there too, except View Live, which is the
|
||||
one filled button -- see its own note below.
|
||||
|
||||
The icon-only pair takes the same treatment as View Documentation in that row: acrylic, flat,
|
||||
grey, and NOT `dense`, so a glyph-only button is the same height as the labelled ones beside it
|
||||
instead of a smaller target floating in the middle of them.
|
||||
-->
|
||||
<div
|
||||
class="page-header-actions w-full sm:w-auto flex-none px-4 pb-4 sm:p-4 flex items-center justify-end">
|
||||
<w-btn
|
||||
class="acrylic-btn ml-2"
|
||||
flat
|
||||
icon="la:download"
|
||||
color="grey"
|
||||
:aria-label="t(`history.downloadVersion`)"
|
||||
@click="emit(`download`)">
|
||||
<w-tooltip>{{ t('history.downloadVersion') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="acrylic-btn ml-2"
|
||||
flat
|
||||
icon="la:file-pdf"
|
||||
color="grey"
|
||||
disable
|
||||
:aria-label="t(`history.exportPdf`)">
|
||||
<w-tooltip>{{ t('history.exportPdf') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<!--
|
||||
Branch off before Restore: it reads as the gentler of the two, and Restore stays next to the
|
||||
confirmation it raises.
|
||||
|
||||
Indigo, which is the colour this app gives history -- the Schedule tab's calendar, the version
|
||||
timeline's dots, the bar at the top of this very screen. It also tells this button apart from
|
||||
the disabled Export to PDF beside it, which grey did not.
|
||||
|
||||
Two shades, because one will not do: as a LABEL, `indigo` measures 6.3:1 on the light header
|
||||
and 2.5:1 on the dark one, while `indigo-4` is 5.0:1 dark and 3.2:1 light. So each theme takes
|
||||
the shade that is legible in it. Bound rather than left to a `dark:` class because `WBtn`
|
||||
writes the colour as an inline style (`--w-btn-color`, and `color` with it), which no
|
||||
stylesheet outranks without `!important`.
|
||||
-->
|
||||
<w-btn
|
||||
class="acrylic-btn ml-2"
|
||||
flat
|
||||
icon="la:code-branch"
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
no-caps
|
||||
:label="t(`history.branchOffShort`)"
|
||||
:aria-label="t(`history.branchOffShort`)"
|
||||
@click="emit(`branch`)" />
|
||||
<!-- -> The same orange every page header gives the action that changes the page -->
|
||||
<w-btn
|
||||
class="acrylic-btn ml-2"
|
||||
flat
|
||||
icon="la:undo"
|
||||
color="deep-orange-9"
|
||||
no-caps
|
||||
:label="t(`history.restore`)"
|
||||
:aria-label="t(`history.restore`)"
|
||||
@click="emit(`restore`)" />
|
||||
<!--
|
||||
The one live control on this row, and the only way out of the snapshot that does not go
|
||||
backwards: everything else here acts on the version, while this leaves it for the page as it
|
||||
stands. In the site's own colour rather than the orange beside it, because it is not a change
|
||||
to anything -- it is navigation.
|
||||
|
||||
A `to`, not a click handler: it is a link to a path, so it should behave like one — middle-click
|
||||
and ctrl-click open the live page in a tab, and the status bar shows where it goes.
|
||||
|
||||
Solid rather than acrylic, and the only filled button in the row: it is where a reader goes
|
||||
when they are done here, so it carries the weight. `unelevated` because that is how this app
|
||||
does a solid primary button everywhere else -- the unlock and create buttons in `Index.vue`
|
||||
-- and a raised one would be the only shadow in a flat header.
|
||||
|
||||
No `acrylic-btn`, which paints a 10% tint of the button colour and would be a second, weaker
|
||||
background under the fill. And no `dark:` variant either: `primary-light` is right for
|
||||
primary as TEXT on a dark page, but as a FILL behind white it measures 2.4:1 where plain
|
||||
`primary` gives 4.6:1. A fill carries its own contrast, so one colour serves both themes.
|
||||
|
||||
Rendered only with a path to go to. In practice there is always one, since the endpoint behind
|
||||
this view refuses a version whose page has been deleted (page rules need a page to be checked
|
||||
against), but a button whose target is empty would navigate to the site root and quietly look
|
||||
like it had worked.
|
||||
-->
|
||||
<w-btn
|
||||
class="ml-2"
|
||||
v-if="livePath"
|
||||
unelevated
|
||||
icon="la:eye"
|
||||
color="primary"
|
||||
no-caps
|
||||
:to="livePath"
|
||||
:label="t(`history.viewLive`)"
|
||||
:aria-label="t(`history.viewLive`)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMinWidth } from '@/composables/screen'
|
||||
|
||||
const emit = defineEmits(['download', 'restore', 'branch'])
|
||||
|
||||
defineProps({
|
||||
/** The page's icon as the version recorded it, an Iconify reference. */
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Where the live page sits now, ready to route to — locale prefix and all. Empty when there is
|
||||
* nowhere to send the reader, which hides the View Live button rather than pointing it at nothing.
|
||||
*/
|
||||
livePath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* The page icon, halved on a phone — the same call, and the same reason, as `PageHeader`: `WIcon`
|
||||
* renders `size` as an inline `font-size`, which no stylesheet can outrank without `!important`, so it
|
||||
* has to be bound rather than left to a media query.
|
||||
*/
|
||||
const isAtLeastSm = useMinWidth(600)
|
||||
const iconSize = computed(() => (isAtLeastSm.value ? '64px' : '32px'))
|
||||
</script>
|
||||
@ -0,0 +1,416 @@
|
||||
/*
|
||||
The chrome a page view is drawn in: the breadcrumb bar, the title header, and the column the article
|
||||
and its footer scroll inside. Plus the placeholder screen that stands in for the article when there
|
||||
is nothing to draw there.
|
||||
|
||||
Here rather than in `pages/Index.vue`, where all of it began, because there is now more than one view
|
||||
wearing it -- `pages/PageVersion.vue` draws a historical snapshot in the same chrome. An SFC's
|
||||
unscoped styles ship in that component's own lazily-imported chunk, so a second view reusing these
|
||||
class names got the markup and none of the rules, which is the way this goes wrong invisibly: the
|
||||
header renders, and it is a plain box with no gradient, no border and no height.
|
||||
|
||||
The contents column is here too, panel behaviour and all: both views have one, and the version view's
|
||||
is the same column drawing the same list from the same `toc`. What stayed behind in `Index.vue` is
|
||||
only what a snapshot has no use for -- the toggle on the tags heading, tags being editable on a page
|
||||
and not on a record of one.
|
||||
*/
|
||||
|
||||
/*
|
||||
The Sass variables, loaded explicitly. `vite.config.js` injects both of these into every SFC through
|
||||
`additionalData`, but that reaches only the files Vite itself hands to Sass -- a partial pulled in by
|
||||
`@use` from `app.scss` is resolved by Sass and arrives without it. Same note as `_page-contents.scss`,
|
||||
which is here for the same reason.
|
||||
|
||||
Both, not just the palette: the greys and the breakpoint come from `_palette.scss`, the `$dark-*` ramp
|
||||
these gradients are built on from `_theme.scss`.
|
||||
*/
|
||||
@use 'palette' as *;
|
||||
@use 'theme' as *;
|
||||
|
||||
/*
|
||||
Where the contents column stops being able to afford 300px. A page view's own threshold, not one of the
|
||||
app's -- `_palette.scss` is for the breakpoints the whole app shares, and this one is a function of a
|
||||
page's two sidebars. Stated as a `max` value just under 1400px, the way the shared ones are.
|
||||
*/
|
||||
$toc-narrow-max: 1399.98px;
|
||||
|
||||
/*
|
||||
...and where it stops being a column at all and becomes a panel over the article. The same boundary as
|
||||
the 750px `useMinWidth` in each view, which decides whether that view renders the opener, and as the
|
||||
one each layout uses to stand scroll-to-top down from this corner. They all have to agree.
|
||||
*/
|
||||
$toc-overlay-max: 749.98px;
|
||||
|
||||
/*
|
||||
The column in place of the article: the lock screen, the page that does not exist, and the
|
||||
redirection on its way somewhere else. All three are the same shape -- a large faint icon, a
|
||||
sentence, and the one button that does something about it -- and share the styling so they cannot
|
||||
drift apart. `PageRedirect.vue` draws its own screens with these classes for that reason.
|
||||
*/
|
||||
.page-placeholder {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* -> Off dead centre: the text reads better a little above the middle of the column */
|
||||
padding: 0 24px 10vh;
|
||||
text-align: center;
|
||||
|
||||
/*
|
||||
Stated per theme, as everything else in this column is: the article's own colours come from
|
||||
`_page-contents.scss`, so a plain block dropped in beside it inherits the document's black and
|
||||
goes invisible on the dark surface. The icon below takes its colour from here as well.
|
||||
*/
|
||||
@at-root .body--light & {
|
||||
color: $grey-9;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the
|
||||
sentence under it is what the reader is here to read.
|
||||
*/
|
||||
.page-placeholder-icon {
|
||||
margin-bottom: 24px;
|
||||
font-size: 96px;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.page-breadcrumbs {
|
||||
@at-root .body--light & {
|
||||
background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%);
|
||||
border-bottom: 1px solid $grey-4;
|
||||
}
|
||||
/*
|
||||
The bar sets a background per theme, so it owes a foreground too: the LAST crumb -- the current
|
||||
page -- deliberately inherits rather than taking `active-color`, and what it was inheriting in
|
||||
dark mode was the document's black.
|
||||
*/
|
||||
@at-root .body--light & {
|
||||
color: var(--color-black);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background: linear-gradient(to bottom, $dark-3 0%, $dark-4 100%);
|
||||
border-bottom: 1px solid $dark-3;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
/*
|
||||
A point off the trail on a phone, on the bar rather than on the crumbs: `WBreadcrumbs` sets no size
|
||||
of its own and its icons are 125% of whatever it inherits, so one declaration here takes the text and
|
||||
the icons down together and keeps the two in proportion.
|
||||
|
||||
13px is where it stops. The trail is how a reader gets back out, and it is already the smallest type
|
||||
on the screen -- what is wanted is a bar that gives way to the page under it, not one nobody can read.
|
||||
*/
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The version view's bar, which says which snapshot is on screen rather than where a page sits.
|
||||
|
||||
Indigo and not the page view's grey, because it is not the same statement: the trail on a page is
|
||||
chrome a reader looks past, while this one is the only thing on screen saying that what is under it
|
||||
is a RECORD of a page and not the page. A coloured band is what carries that at a glance, and indigo
|
||||
is the colour the interface already gives history -- the Schedule tab's calendar, the version
|
||||
timeline's own dots.
|
||||
|
||||
Both themes take the same treatment rather than the light one being tinted and the dark one left
|
||||
dark: the point is a band that stands out from the chrome above and below it, and in the dark theme
|
||||
a grey bar between a dark header and a dark article is exactly what does not.
|
||||
|
||||
Declared after the block above and overriding it at equal specificity, so source order is what
|
||||
settles it -- one file, one place to look, and nothing depending on which chunk loaded last.
|
||||
|
||||
The foreground is stated once for everything in the bar, which is why the markup carries no colour
|
||||
of its own: the icon paints with `currentColor` and the date inherits. `<strong>` is what marks the
|
||||
date out, as it did when the bar was grey.
|
||||
*/
|
||||
.page-breadcrumbs--version {
|
||||
@at-root .body--light & {
|
||||
background: linear-gradient(to bottom, var(--color-indigo-6) 0%, var(--color-indigo-7) 100%);
|
||||
border-bottom: 1px solid var(--color-indigo-9);
|
||||
color: #fff;
|
||||
}
|
||||
/* -> A step down the same ramp, so the band reads as deliberate against a dark page rather than as
|
||||
the light theme's bar left switched on */
|
||||
@at-root .body--dark & {
|
||||
background: linear-gradient(to bottom, var(--color-indigo-7) 0%, var(--color-indigo-9) 100%);
|
||||
border-bottom: 1px solid var(--color-indigo-9);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The frame round the whole version view: 5px down both sides and along the bottom, with the bar above
|
||||
closing the fourth. Together they box the screen in, which is the point -- the indigo says "what you
|
||||
are reading is a RECORD of a page", and that is true of the whole view rather than of its first 30px.
|
||||
|
||||
Its colour is the DARK end of that bar's gradient in each theme, which is what makes the top corners
|
||||
corners: the bar finishes on this exact value, so where the two meet there is no seam and the eye
|
||||
reads one shape bent round the content. Picking the light end instead would draw a line across the
|
||||
join.
|
||||
|
||||
No top width, since the bar is already there -- a border under it would be a second line saying the
|
||||
same thing, and the bar has an underline of its own.
|
||||
|
||||
On the page element, which is what makes all three sides land where they should: it is a flex item in
|
||||
`WPageContainer` that also claims `h-full`, so it is exactly as tall as the content cell, and the
|
||||
bottom edge sits at the bottom of the window rather than below the fold. The article scrolls inside
|
||||
its own box further in (`.page-container-scrl`), so the frame holds still while the content moves
|
||||
behind it.
|
||||
|
||||
`box-sizing: border-box` is global (Tailwind's reset), so all 10px of the sides come out of the
|
||||
content box and the view does not grow a scrollbar in either direction. That reset also declares
|
||||
`border: 0 solid`, which is why a width and a colour are the whole of this -- and why the widths can
|
||||
be stated once for both themes with only the colour switching.
|
||||
*/
|
||||
.page-version {
|
||||
border-style: solid;
|
||||
border-width: 0 5px 5px;
|
||||
|
||||
@at-root .body--light & {
|
||||
border-color: var(--color-indigo-7);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
border-color: var(--color-indigo-9);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
height: 95px;
|
||||
|
||||
/*
|
||||
Sized by its contents on a phone instead, which comes out around 70px: the 95px is pitched for a 64px
|
||||
icon beside 34px display type, and holding it under the halved icon and title of the phone layout left
|
||||
a band of empty gradient under the description.
|
||||
|
||||
`auto` rather than a smaller fixed height, because a fixed one is what the desktop bar can only just
|
||||
afford: a title long enough to wrap has nowhere to go in it. Here the bar grows by a line instead, and
|
||||
a page with no description gets a bar shorter still.
|
||||
*/
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@at-root .body--light & {
|
||||
background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);
|
||||
border-bottom: 1px solid $grey-4;
|
||||
border-top: 1px solid #fff;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background: linear-gradient(to bottom, $dark-4 0%, $dark-3 100%);
|
||||
// border-bottom: 1px solid $dark-5;
|
||||
border-top: 1px solid $dark-6;
|
||||
}
|
||||
|
||||
.no-height .q-field__control {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&-title {
|
||||
/*
|
||||
`text-h4` is a 34px display size written for a header the width of a desktop window: at 390px a
|
||||
title of any length wrapped and pushed the description out of the bar. 24px is the step
|
||||
`text-h5` takes, written as a value rather than as that class so the size sits beside the
|
||||
breakpoint asking for it.
|
||||
|
||||
Here rather than in a header component's scoped block, where it started: scoped rules carry a
|
||||
data attribute and reach only that one component's markup, so the second header wearing these
|
||||
classes rendered a 34px title on a phone. The header's own `@media` block keeps what is its own
|
||||
business -- which of ITS actions survive the narrow layout.
|
||||
*/
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
@at-root .body--light & {
|
||||
color: $grey-9;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
&-subtitle {
|
||||
@at-root .body--light & {
|
||||
color: $grey-7;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
The article and the footer under it, stacked inside the one box that scrolls.
|
||||
|
||||
`flex: 1 0 auto` on the article is what keeps the footer at the BOTTOM of a short page instead of
|
||||
leaving it hanging under two lines of content: the article takes the leftover height, and past that
|
||||
grows with its own content and pushes the footer out of view until the reader gets there. It must
|
||||
not shrink either, or a long article would be squeezed to make room rather than scrolling.
|
||||
*/
|
||||
.page-container-scrl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-container-body {
|
||||
flex: 1 0 auto;
|
||||
|
||||
/*
|
||||
The other half of the padding change each view makes on a phone, where the article column pads by
|
||||
8px a side instead of 16.
|
||||
|
||||
`--content-bleed` is how far the rule under an h1 reaches BACK through the padding of whatever holds
|
||||
the content, so that it starts at the sidebar rather than at the text -- so it is a statement about
|
||||
this surface's padding, and left at 1rem against 0.5rem of it the rule overhung the column by 8px.
|
||||
`_page-contents.scss` declares the property expecting exactly this: a surface that pads differently
|
||||
overrides the one property rather than the rule.
|
||||
|
||||
On the `.page-contents` element rather than here, because that is where the default is declared and a
|
||||
custom property set on the parent would simply be shadowed by it. The editor's preview pane carries
|
||||
the class itself and still pads 1rem, so it keeps the default.
|
||||
*/
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
.page-contents {
|
||||
--content-bleed: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A hairline of the page's OWN background between the header and whatever the column starts with, in
|
||||
each theme's colour -- so it is invisible against the article, which is that colour, and reads as one
|
||||
pixel of daylight under anything that starts flush to the top of the column. A site banner does
|
||||
exactly that, and against the header's bottom border it needs the gap.
|
||||
|
||||
Both themes: with the dark one left out the banner butted straight into the header there and not in
|
||||
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
|
||||
*/
|
||||
.page-container {
|
||||
@at-root .body--light & {
|
||||
border-top: 1px solid #fff;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
border-top: 1px solid $dark-6;
|
||||
}
|
||||
}
|
||||
|
||||
.page-sidebar {
|
||||
flex: 0 0 300px;
|
||||
|
||||
/*
|
||||
Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and
|
||||
by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few
|
||||
words per line -- the contents list wraps rather than truncating (see `PageToc`) -- and hands the
|
||||
article the other 100px.
|
||||
|
||||
1400px is this view's own threshold rather than one of the app's `--breakpoint-*`: it is where THIS
|
||||
column starts crowding the article, which depends on its own width and the nav's.
|
||||
*/
|
||||
@media (max-width: $toc-narrow-max) {
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
|
||||
/*
|
||||
And below 750px it stops being a column at all: even at 200px it is a third of a 600px window, and an
|
||||
article is what the reader came for. It becomes a panel the width of the wide column, parked off the
|
||||
right edge and slid in when asked for -- the same shape as the nav drawer on a narrow screen, and for
|
||||
the same reason, so the two behave alike from opposite sides.
|
||||
|
||||
`position: fixed` is what takes it out of the row, so the article gets the whole width whether the
|
||||
panel is open or not; the reader is never made to choose between the two, only to look at one at a
|
||||
time. `transform` is what animates, being the one property that moves a box without laying anything
|
||||
out again -- and the panel is out of flow, so there is nothing behind it to reflow anyway.
|
||||
|
||||
Right regardless of `tocPosition`: the opener is in the bottom-RIGHT corner, and a panel arriving from
|
||||
the far side of the screen from the button that summoned it reads as something else appearing.
|
||||
*/
|
||||
@media (max-width: $toc-overlay-max) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
/* -> The wide column's width, capped so it cannot take the whole of a small screen */
|
||||
width: 300px;
|
||||
max-width: 85vw;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s var(--ease-standard);
|
||||
box-shadow: -2px 0 12px rgb(0 0 0 / 0.3);
|
||||
|
||||
&.is-open {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-2;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-5;
|
||||
}
|
||||
|
||||
// A light rule on the light sidebar, near-black on the dark one -- it reads as the bevel between
|
||||
// two panels rather than as a drawn line.
|
||||
//
|
||||
// The original set a background-colour here as well as a border. It never showed: the element is
|
||||
// 1px tall with `box-sizing: border-box`, so the content box is 0px and the opaque border covers
|
||||
// it completely. Only the border colour is carried across.
|
||||
.w-separator {
|
||||
--w-hairline-color: #fff;
|
||||
}
|
||||
@at-root .body--dark & .w-separator {
|
||||
--w-hairline-color: #070a0d;
|
||||
}
|
||||
|
||||
/*
|
||||
The column is the height of the shell, so its own content scrolls when there is more of it than
|
||||
there is room -- a long contents list, in practice. Nothing sticky is involved: the shell holds
|
||||
still on its own, and the article beside this scrolls in its own box.
|
||||
*/
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(102 102 102 / 0.5) transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
Behind the panel, and under it: the same tint and the same z-index as the nav drawer's scrim, one step
|
||||
below the panel it dims. The opener is at z-30 as well and is not rendered while the panel is open, so
|
||||
the two never overlap.
|
||||
*/
|
||||
.page-sidebar-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background-color: rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.page-sidebar-scrim-enter-active,
|
||||
.page-sidebar-scrim-leave-active,
|
||||
.toc-open-btn-enter-active,
|
||||
.toc-open-btn-leave-active {
|
||||
transition: opacity 0.2s var(--ease-standard);
|
||||
}
|
||||
.page-sidebar-scrim-enter-from,
|
||||
.page-sidebar-scrim-leave-to,
|
||||
.toc-open-btn-enter-from,
|
||||
.toc-open-btn-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-sidebar,
|
||||
.page-sidebar-scrim-enter-active,
|
||||
.page-sidebar-scrim-leave-active,
|
||||
.toc-open-btn-enter-active,
|
||||
.toc-open-btn-leave-active {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
@use 'base';
|
||||
@use 'animation';
|
||||
@use 'page-chrome';
|
||||
@use 'page-contents';
|
||||
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { fileSave } from 'browser-fs-access'
|
||||
|
||||
import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
|
||||
/**
|
||||
* What a recorded page version is, to the two screens that show one.
|
||||
*
|
||||
* The history overlay and the version view both have to answer the same questions about a version --
|
||||
* what format was it written in, what does its source save as, and what HTML does it become -- and
|
||||
* they used to answer them separately. The format question alone is asked six times between them
|
||||
* (colouring a diff, two renders, two downloads, a restore), so it lives here once.
|
||||
*
|
||||
* Nothing here touches a store or the i18n catalogue, which is what keeps it a helper: a caller
|
||||
* supplies the renderer config it already holds, and reports failures in its own words.
|
||||
*/
|
||||
|
||||
/** What a version's source is saved as, by the format it was written in. */
|
||||
const FILE_TYPES = {
|
||||
markdown: { ext: 'md', mime: 'text/markdown' },
|
||||
html: { ext: 'html', mime: 'text/html' }
|
||||
}
|
||||
|
||||
/** For a format nothing here knows: plain text saves and reads as itself. */
|
||||
const FALLBACK_TYPE = { ext: 'txt', mime: 'text/plain' }
|
||||
|
||||
/**
|
||||
* The format a version was written in -- which decides how it colours, how it renders and what it
|
||||
* downloads as.
|
||||
*
|
||||
* Read off the VERSION and never off the page it belongs to: a page converted from markdown to HTML
|
||||
* since is still a markdown version, and asking the page would render it as the wrong thing.
|
||||
*
|
||||
* `meta.contentType` is what `pageHistory.record` writes for every version, so the rest is belt and
|
||||
* braces for a row that somehow lacks it.
|
||||
*
|
||||
* @param {object} version A version as the API returns one.
|
||||
* @returns {string} `markdown`, `html`, or whatever the version claims.
|
||||
*/
|
||||
export function versionContentType(version) {
|
||||
return version?.meta?.contentType || version?.meta?.editor || 'markdown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a version's source to a file the reader picks.
|
||||
*
|
||||
* Named for the page and the moment -- `notes-2026-09-07-03-30-38.md` -- because a folder of
|
||||
* `page.md` files says nothing about which page or which version each one is.
|
||||
*
|
||||
* @param {object} version A version WITH its `content`; the history list alone does not carry it.
|
||||
* @returns {Promise<boolean>} True once written, false if the reader dismissed the picker. Anything
|
||||
* that actually went wrong is thrown, for the caller to report in its own words.
|
||||
*/
|
||||
export async function saveVersionSource(version) {
|
||||
const type = FILE_TYPES[versionContentType(version)] ?? FALLBACK_TYPE
|
||||
const name = version.path?.split('/').at(-1) || 'page'
|
||||
const stamp = (version.versionDate ?? '').slice(0, 19).replace(/[:T]/g, '-')
|
||||
try {
|
||||
/*
|
||||
A bare MIME type, with no `;charset=` on it: the save picker uses this as an `accept` key and
|
||||
rejects a type carrying parameters outright. Nothing is lost by dropping it -- a Blob built from
|
||||
a JS string is UTF-8 already.
|
||||
*/
|
||||
await fileSave(new Blob([version.content ?? ''], { type: type.mime }), {
|
||||
fileName: `${name}-${stamp}.${type.ext}`,
|
||||
extensions: [`.${type.ext}`]
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
// -> Dismissing the file picker is not a failure, and nothing should be said about it
|
||||
if (err.name === 'AbortError') {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A version's source as the HTML a page stores.
|
||||
*
|
||||
* Produced on this side for the same reason every save produces it here: the markdown pipeline is a
|
||||
* frontend one, and the server would otherwise have to drive a headless browser. A version records
|
||||
* the source it held and not the render, so there is nothing stored to use instead.
|
||||
*
|
||||
* The config is passed IN rather than read from `stores/editor` here, because no other helper in this
|
||||
* directory reaches for a store and this is not the file to start in. The caller has it already, and
|
||||
* has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking.
|
||||
*
|
||||
* @param {object} version A version WITH its `content`.
|
||||
* @param {object} options
|
||||
* @param {object} options.markdownConfig `editorStore.editors.markdown` — per-site renderer settings
|
||||
* (line breaks, typographer, …).
|
||||
* @param {string} options.pagePath The page this HTML is FOR, which is what a relative image in it
|
||||
* resolves against. Not always the version's own `path`: content being restored onto a page that
|
||||
* has since moved belongs to where that page is now.
|
||||
* @returns {string} The HTML, or the source unchanged for a format this does not render.
|
||||
*/
|
||||
export function renderVersionSource(version, { markdownConfig, pagePath }) {
|
||||
const content = version?.content ?? ''
|
||||
if (versionContentType(version) !== 'markdown') {
|
||||
return content
|
||||
}
|
||||
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<w-layout>
|
||||
<w-header class="site-header-wrap">
|
||||
<header-nav />
|
||||
</w-header>
|
||||
<!--
|
||||
No `<w-drawer>`, which is the whole of what separates this from `MainLayout`: a snapshot is
|
||||
reached from a link rather than browsed to, and the navigation tree is about where pages are NOW
|
||||
-- every entry in it would lead out of the version being read without saying so.
|
||||
|
||||
What follows from that is the simple part of this file. There is no sidebar, so there is no
|
||||
opener for one on a narrow viewport, and no column for scroll-to-top to end at: it sits in the
|
||||
corner at every width, which is the `scrollerAnchorX: null` case `MainLayout` reaches when a site
|
||||
has its sidebar off.
|
||||
-->
|
||||
<!--
|
||||
No `<w-footer>` here, for the same reason as `MainLayout`: this shell holds still and the article
|
||||
column scrolls inside it, so a footer at this level would be pinned to the window. The version
|
||||
view puts one at the end of that scrolling column instead.
|
||||
-->
|
||||
<w-page-container>
|
||||
<router-view />
|
||||
<!--
|
||||
Below 750px the version view turns its contents column into a panel and takes this corner for
|
||||
the opener, exactly as the page view does -- so this stands down there, and the two never
|
||||
overlap. `.page-container-scrl` is the article column, which is what actually scrolls.
|
||||
-->
|
||||
<w-page-scroller
|
||||
v-if="isAtLeastTocPanelWidth"
|
||||
:scroll-offset="150"
|
||||
:anchor-x="null"
|
||||
target=".page-container-scrl">
|
||||
<w-btn
|
||||
class="corner-btn corner-btn--right"
|
||||
icon="la:arrow-up"
|
||||
color="primary"
|
||||
round
|
||||
size="md" />
|
||||
</w-page-scroller>
|
||||
</w-page-container>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useMinWidth } from '@/composables/screen'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
// COMPONENTS
|
||||
|
||||
import HeaderNav from '@/components/HeaderNav.vue'
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// META
|
||||
|
||||
/*
|
||||
The same title template as `MainLayout`, and a getter for the same reason: the site config is
|
||||
fetched, so a template closing over `siteStore.title` and registered once would keep whatever the
|
||||
store held at mount. The version view supplies the title half.
|
||||
*/
|
||||
useMeta(() => {
|
||||
const siteTitle = siteStore.title
|
||||
return {
|
||||
titleTemplate: (title) => (title ? `${title} - ${siteTitle}` : siteTitle)
|
||||
}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* At or above 750px, which is where scroll-to-top keeps the bottom-right corner. Below it the version
|
||||
* view turns its contents column into a panel and puts that panel's opener here instead. The view owns
|
||||
* the threshold (`$toc-overlay-max` and the 750px `useMinWidth` in `pages/PageVersion.vue`); this is
|
||||
* the same number from the side that has to get out of the way.
|
||||
*/
|
||||
const isAtLeastTocPanelWidth = useMinWidth(750)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
The window behind the shell, in the dark theme.
|
||||
|
||||
Every layout that holds a page-shaped view declares this for itself -- `MainLayout`, `InboxLayout`
|
||||
and `ProfileLayout` all carry the identical rule -- because it paints `body`, which is outside the
|
||||
app's own markup and so cannot be reached by anything scoped. Without it the article column sits on
|
||||
the browser's default white while the header, the contents column and the footer are all dark, and
|
||||
the body text, which IS white in that theme, disappears into it.
|
||||
*/
|
||||
body.body--dark {
|
||||
background-color: $dark-6;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<!--
|
||||
`h-full min-h-0`, as in the page view: the shell hands this a definite height and it has to CLAIM
|
||||
it, or the article column below cannot scroll on its own and the whole page scrolls in the shell.
|
||||
-->
|
||||
<w-page class="page-version flex flex-col h-full min-h-0">
|
||||
<!--
|
||||
Where the page view puts the breadcrumb trail. A snapshot has no trail to draw: the crumbs lead
|
||||
to where each folder is NOW, which is a walk out of the version and into the live wiki without
|
||||
saying so -- and the version's own path is already the title bar's business. What identifies this
|
||||
screen is the version, so the version is what the bar says.
|
||||
-->
|
||||
<div
|
||||
class="page-breadcrumbs page-breadcrumbs--version py-1 px-4 sm:py-2 flex flex-wrap"
|
||||
v-if="state.version">
|
||||
<div class="min-w-0 flex-1 flex items-center">
|
||||
<!-- -> No `color` on the icon and no grey on the date: the indigo bar states one foreground
|
||||
for everything in it, and `WIcon` paints with `currentColor`, so both inherit it -->
|
||||
<w-icon class="mr-2" name="la:history" size="sm" />
|
||||
<!-- -> `shrink-0`, so it is the ID that truncates on a narrow bar and never the words saying
|
||||
what the ID is -->
|
||||
<span class="text-caption shrink-0 mr-1">{{ t('history.viewingVersionId') }}</span>
|
||||
<!-- -> Monospaced and selectable: it is an identifier, and the reason to show one in full is
|
||||
so it can be read off and quoted -->
|
||||
<span class="text-caption font-robotomono select-all truncate">{{ state.version.id }}</span>
|
||||
</div>
|
||||
<!--
|
||||
Off on a phone, as the page view's date is: on a 390px screen it takes a whole line of its own
|
||||
under the identifier.
|
||||
-->
|
||||
<div class="flex-none items-center justify-end hidden sm:flex">
|
||||
<div class="text-caption">
|
||||
{{ t('history.snapshotFrom') }} <strong>{{ snapshotFrom }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<page-version-header
|
||||
v-if="state.version"
|
||||
:icon="versionIcon"
|
||||
:title="state.version.title"
|
||||
:description="versionDescription"
|
||||
:live-path="livePath"
|
||||
@download="downloadVersion"
|
||||
@restore="restoreVersion"
|
||||
@branch="branchFrom" />
|
||||
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
|
||||
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%">
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
|
||||
<!--
|
||||
The same placeholder column the page view uses for a page that is not there, and for the
|
||||
same reason: this is a state of the view rather than an error screen. Nothing here offers a
|
||||
way to fix it -- a version either exists and may be read, or it does not -- so the way out is
|
||||
the way they came.
|
||||
-->
|
||||
<div v-if="state.failed" class="page-placeholder">
|
||||
<w-icon class="page-placeholder-icon" name="la:history" />
|
||||
<div class="text-h6">{{ t('history.versionUnavailable') }}</div>
|
||||
<div class="text-body2 mt-1 opacity-60">{{ t('history.versionUnavailableHint') }}</div>
|
||||
<w-btn
|
||||
class="mt-6"
|
||||
outline
|
||||
icon="la:arrow-left"
|
||||
color="primary"
|
||||
padding="xs lg"
|
||||
:label="t(`common.newpage.goback`)"
|
||||
@click="goBack" />
|
||||
</div>
|
||||
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
|
||||
<!-- -> Half the padding on a phone, matching the page view; `--content-bleed` follows in
|
||||
`_page-chrome.scss` -->
|
||||
<div class="page-container-body p-2 sm:p-4">
|
||||
<!--
|
||||
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
|
||||
nothing here to put a handler on.
|
||||
-->
|
||||
<div
|
||||
class="page-contents"
|
||||
ref="pageContents"
|
||||
v-html="state.render"
|
||||
@click="onContentClick" />
|
||||
</div>
|
||||
<!-- -> Inside the scrolling column and last, so it is the bottom of the page rather than
|
||||
something sitting over the article -->
|
||||
<w-footer>
|
||||
<footer-nav />
|
||||
</w-footer>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
<!-- -> The scrim behind the contents panel while it overlays the article, and how it is
|
||||
dismissed without picking a heading -->
|
||||
<transition name="page-sidebar-scrim">
|
||||
<div v-if="tocPanelIsOpen" class="page-sidebar-scrim" @click="closeTocPanel" />
|
||||
</transition>
|
||||
<!--
|
||||
The contents column, drawn from the `toc` the version recorded -- see `models/pageHistory.ts`,
|
||||
where `toc` is kept in a version's `meta` for exactly this. Below 750px it stops being a column
|
||||
and slides in over the article instead, which is why it stays mounted at every width and
|
||||
`is-open` is what decides whether it is on screen.
|
||||
|
||||
Contents and nothing else. The page view's tags and rating are beside them there because both
|
||||
are things to DO to the page in front of the reader, and neither is a thing to do to a record
|
||||
of what it once said -- editing tags writes the live page, and a rating is about the page as it
|
||||
stands.
|
||||
-->
|
||||
<div
|
||||
class="page-sidebar"
|
||||
v-if="showToc"
|
||||
:class="{ 'is-open': tocPanelIsOpen }"
|
||||
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
|
||||
@click="onSidebarClick">
|
||||
<div class="p-4 flex items-center">
|
||||
<w-icon class="mr-2" name="la:stream" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-2">
|
||||
<page-toc
|
||||
:nodes="versionToc"
|
||||
:min-depth="tocDepth.min"
|
||||
:max-depth="tocDepth.max"
|
||||
v-model:selected="state.tocSelected" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
What opens that panel, in the bottom-right corner -- the corner `VersionLayout` gives to
|
||||
scroll-to-top, which stands down below 750px so that this can have it. Same position and the same
|
||||
`.corner-btn` shape, so the two read as one button that changes what it does.
|
||||
-->
|
||||
<transition name="toc-open-btn">
|
||||
<div v-if="showTocPanelBtn" class="fixed bottom-0 right-0 z-30">
|
||||
<w-btn
|
||||
class="corner-btn corner-btn--right"
|
||||
icon="mdi:file-tree"
|
||||
color="primary"
|
||||
round
|
||||
size="md"
|
||||
:aria-label="t(`common.page.contents`)"
|
||||
:aria-expanded="tocPanelIsOpen"
|
||||
@click="openTocPanel" />
|
||||
</div>
|
||||
</transition>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { useMinWidth } from '@/composables/screen'
|
||||
import { confirm, dialog } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { loading } from '@/composables/loading'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { scrollToAnchor } from '@/helpers/anchors'
|
||||
import { enhanceRenderedContent, resolveContentClick } from '@/helpers/renderedContent'
|
||||
import { renderVersionSource, saveVersionSource } from '@/helpers/pageVersions'
|
||||
import { flattenToc } from '@/helpers/toc'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import FooterNav from '@/components/FooterNav.vue'
|
||||
import PageToc from '@/components/PageToc.vue'
|
||||
import PageVersionHeader from '@/components/PageVersionHeader.vue'
|
||||
|
||||
// STORES
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// ROUTER
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
/** The version as the API answered it, or null while it is on its way — and after it fails. */
|
||||
version: null,
|
||||
/** The version's source, rendered here. See `renderFor`. */
|
||||
render: '',
|
||||
/**
|
||||
* Whether the version could not be loaded — gone, never there, or not this reader's to see. One flag
|
||||
* for all three: the API deliberately does not tell them apart (a version on a page you cannot read
|
||||
* is indistinguishable from one that does not exist), so neither can this.
|
||||
*/
|
||||
failed: false,
|
||||
tocSelected: null,
|
||||
/** Whether the contents panel has been slid open. Only consulted below 750px. */
|
||||
tocPanelOpen: false
|
||||
})
|
||||
|
||||
const pageContents = ref(null)
|
||||
|
||||
// META
|
||||
|
||||
/*
|
||||
A getter, for the same reason as the page view's: the title is not known when this runs -- the view
|
||||
mounts for the version ID and the snapshot arrives a moment later.
|
||||
|
||||
Below `state` and not above it, which is not a matter of taste: `useMeta` runs the getter straight
|
||||
away, so declared first it read `state` from inside its temporal dead zone and the whole view failed
|
||||
to mount with "Cannot access 'state' before initialization".
|
||||
*/
|
||||
useMeta(() => ({
|
||||
title: state.version?.title ?? ''
|
||||
}))
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/** Below 750px, where the contents stop being a column beside the article and become a panel over it. */
|
||||
const isAtLeast750 = useMinWidth(750)
|
||||
const tocIsPanel = computed(() => !isAtLeast750.value)
|
||||
|
||||
const tocPanelIsOpen = computed(() => tocIsPanel.value && showToc.value && state.tocPanelOpen)
|
||||
|
||||
const showTocPanelBtn = computed(() => tocIsPanel.value && showToc.value && !state.tocPanelOpen)
|
||||
|
||||
/**
|
||||
* The page's own fields as the version recorded them.
|
||||
*
|
||||
* Everything but the path, the title and the date lives in `meta` — those four have columns of their
|
||||
* own on `pageHistory`. Read off the VERSION rather than off the live page throughout: the point of
|
||||
* this screen is what the page said then, and its icon and description are part of that.
|
||||
*/
|
||||
const versionIcon = computed(() => state.version?.meta?.icon || 'la:file-alt')
|
||||
const versionDescription = computed(() => state.version?.meta?.description ?? '')
|
||||
const versionToc = computed(() => state.version?.meta?.toc ?? [])
|
||||
const tocDepth = computed(() => state.version?.meta?.config?.tocDepth ?? { min: 1, max: 2 })
|
||||
|
||||
/**
|
||||
* Where the live page is, for the View Live button.
|
||||
*
|
||||
* Built from `pagePath` / `pageLocale` — the page as it stands — and NOT from `version.path`, which is
|
||||
* where the page was when this snapshot was written: a page that has since moved would send the reader
|
||||
* to a path that no longer holds anything.
|
||||
*
|
||||
* Prefixed the way every other in-app link to a page is, so a site that brackets its URLs by locale
|
||||
* lands on the right one rather than being redirected to the primary locale's copy.
|
||||
*/
|
||||
const livePath = computed(() => {
|
||||
const path = state.version?.pagePath
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
return `${siteStore.localeUrlPrefix(state.version.pageLocale)}/${path}`
|
||||
})
|
||||
|
||||
/*
|
||||
Whether there is a contents section to draw, rather than whether the page asked for one: a version
|
||||
with no headings, or whose depth settings leave nothing to list, would otherwise show "Contents" over
|
||||
an empty space. Asked of the same helper the list itself draws from, so the two cannot disagree.
|
||||
|
||||
`showToc` on the version is the page's own setting as it stood, and the site's `tocPosition` is the
|
||||
live one — there being no historical copy of a site's theme to consult.
|
||||
*/
|
||||
const showToc = computed(() => {
|
||||
if (!state.version || siteStore.theme.tocPosition === 'off') {
|
||||
return false
|
||||
}
|
||||
if (state.version.meta?.config?.showToc === false) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
flattenToc(versionToc.value, {
|
||||
minDepth: tocDepth.value.min,
|
||||
maxDepth: tocDepth.value.max
|
||||
}).length > 0
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* When the snapshot was taken — what stands where the page view says "Last modified on".
|
||||
*
|
||||
* The same fields that view formats, so the two bars read alike.
|
||||
*/
|
||||
const snapshotFrom = computed(() => {
|
||||
return state.version?.versionDate
|
||||
? Temporal.Instant.from(state.version.versionDate).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'N/A'
|
||||
})
|
||||
|
||||
// WATCHERS
|
||||
|
||||
/*
|
||||
The copy buttons on code blocks are part of the content, so they are re-added whenever the content
|
||||
is. Keyed on the render rather than on the route, since it arrives after this has already mounted.
|
||||
*/
|
||||
watch(
|
||||
() => state.render,
|
||||
() => {
|
||||
nextTick(() => enhanceRenderedContent(pageContents.value))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/*
|
||||
The version in the URL is what this view is OF, so it is what drives the load — immediately, since
|
||||
arriving here IS the request. The router reuses this component when only the parameter changes,
|
||||
which is what walking from one version to another would do.
|
||||
*/
|
||||
watch(() => route.params.versionId, loadVersion, { immediate: true })
|
||||
|
||||
// METHODS
|
||||
|
||||
async function loadVersion(versionId) {
|
||||
if (!versionId) {
|
||||
state.failed = true
|
||||
return
|
||||
}
|
||||
loading.show()
|
||||
try {
|
||||
const version = await API_CLIENT.get(`sites/${siteStore.id}/versions/${versionId}`).json()
|
||||
if (!version?.id) {
|
||||
throw new Error('ERR_VERSION_NOT_FOUND')
|
||||
}
|
||||
state.render = await renderFor(version, version.path)
|
||||
state.version = version
|
||||
state.failed = false
|
||||
state.tocPanelOpen = false
|
||||
} catch (err) {
|
||||
state.version = null
|
||||
state.render = ''
|
||||
state.failed = true
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: apiErrorMessage(err, t('history.versionLoadFailed'))
|
||||
})
|
||||
} finally {
|
||||
loading.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML for a version's source.
|
||||
*
|
||||
* @param version The version to render.
|
||||
* @param pagePath The page the HTML is FOR, which is what a relative image in it resolves against.
|
||||
* Reading a snapshot, that is the path the version was written at -- it is the page as it was, so
|
||||
* its links should resolve as they did. Restoring or branching, it is where the content is GOING,
|
||||
* because that is the page the reader will follow those links from.
|
||||
*/
|
||||
async function renderFor(version, pagePath) {
|
||||
// -> The renderer is configured per site (line breaks, typographer, …), and that configuration
|
||||
// arrives with the editor configs rather than on its own
|
||||
if (!editorStore.configIsLoaded) {
|
||||
await editorStore.fetchConfigs()
|
||||
}
|
||||
return renderVersionSource(version, {
|
||||
markdownConfig: editorStore.editors.markdown,
|
||||
pagePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save this version's source, the same as the history overlay's Download.
|
||||
*
|
||||
* No fetch first, unlike there: the overlay downloads from a timeline that carries no source, while
|
||||
* this screen already has the whole version in hand -- it is what is being read.
|
||||
*/
|
||||
async function downloadVersion() {
|
||||
if (!state.version) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await saveVersionSource(state.version)
|
||||
} catch (err) {
|
||||
notify({ type: 'negative', message: t('history.downloadFailed'), caption: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put this version's source back on the page, as the history overlay's Restore does.
|
||||
*
|
||||
* The source only: the page keeps the title, tags and settings it has now. Restoring those too would
|
||||
* quietly undo everything done since, and a reader asking for an old version back is asking for the
|
||||
* text. Nothing is lost either way -- this is an ordinary edit, so it becomes a version of its own
|
||||
* with the current state recorded in it.
|
||||
*/
|
||||
function restoreVersion() {
|
||||
const version = state.version
|
||||
if (!version) {
|
||||
return
|
||||
}
|
||||
confirm({
|
||||
title: t('history.restore'),
|
||||
message: [
|
||||
t('history.restoreConfirm', { date: snapshotFrom.value }),
|
||||
t('history.restoreConfirmHint')
|
||||
],
|
||||
caption: t('history.versionId', { id: version.id }),
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('history.restore')
|
||||
}).onOk(async () => {
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/pages/${version.pageId}`, {
|
||||
json: {
|
||||
content: version.content ?? '',
|
||||
// -> For where the content is going, which is the page's path NOW and not the version's
|
||||
render: await renderFor(version, version.pagePath),
|
||||
reasonForChange: t('history.restoreReason', { date: snapshotFrom.value })
|
||||
}
|
||||
}).json()
|
||||
if (!resp?.page?.id) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({ type: 'positive', message: t('history.restoreSuccess') })
|
||||
/*
|
||||
On to the live page, which is where the restore actually happened. The overlay reloads the
|
||||
page behind itself for the same reason; here there is nothing behind, and a snapshot does not
|
||||
change -- staying put would leave the reader on a screen that looks exactly as it did and
|
||||
gives no sign anything had happened.
|
||||
*/
|
||||
router.push(livePath.value)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('history.restoreFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
} finally {
|
||||
loading.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new page from this version, leaving the live one alone.
|
||||
*
|
||||
* What to do with an old version that is worth keeping but not worth reverting to. The same path
|
||||
* picker as duplicating a page, because that is what this is -- a duplicate of a page as it was.
|
||||
*/
|
||||
function branchFrom() {
|
||||
const version = state.version
|
||||
if (!version) {
|
||||
return
|
||||
}
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
|
||||
componentProps: {
|
||||
mode: 'duplicatePage',
|
||||
folderPath: '',
|
||||
itemId: version.pageId,
|
||||
itemTitle: version.title,
|
||||
itemFileName: version.pagePath,
|
||||
locale: version.pageLocale
|
||||
}
|
||||
}).onOk(async (target) => {
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
|
||||
json: {
|
||||
path: target.path,
|
||||
title: target.title,
|
||||
locale: version.pageLocale,
|
||||
editor: version.meta?.editor || 'markdown',
|
||||
content: version.content ?? '',
|
||||
// -> Rendered for where it is going, not for where the version came from
|
||||
render: await renderFor(version, target.path),
|
||||
description: version.meta?.description ?? '',
|
||||
icon: version.meta?.icon ?? '',
|
||||
tags: version.meta?.tags ?? [],
|
||||
// -> A version that was scheduled carries dates this new page has not got, and the API
|
||||
// rightly refuses that combination
|
||||
publishState: version.meta?.publishState === 'published' ? 'published' : 'draft',
|
||||
reasonForChange: t('history.branchReason', { date: snapshotFrom.value })
|
||||
}
|
||||
}).json()
|
||||
const page = resp?.page
|
||||
if (!page?.id) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({ type: 'positive', message: t('history.branchSuccess') })
|
||||
// -> This page went up with the version's tags, so a tag no page carried any more is back; the
|
||||
// tag fields have to hear about it, the same as after a save
|
||||
siteStore.staleTags()
|
||||
router.push(`/${page.path}`)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('history.branchFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
} finally {
|
||||
loading.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onContentClick(ev) {
|
||||
const intent = resolveContentClick(ev, window.location)
|
||||
if (!intent) {
|
||||
return
|
||||
}
|
||||
// -> A heading in the snapshot: travelled to rather than jumped at, as everywhere else in the app.
|
||||
// The URL follows so the section can be linked to, and Back returns to the one before.
|
||||
if (intent.kind === 'hash') {
|
||||
if (scrollToAnchor(intent.hash, { smooth: true })) {
|
||||
ev.preventDefault()
|
||||
router.push({ path: route.path, query: route.query, hash: intent.hash })
|
||||
}
|
||||
return
|
||||
}
|
||||
/*
|
||||
Anything else goes to the LIVE wiki, and deliberately so: a link in a snapshot was written to point
|
||||
at a page, not at that page as it stood on the same day, and there is no version of the target to
|
||||
send the reader to even if it had been.
|
||||
*/
|
||||
ev.preventDefault()
|
||||
router.push(intent.target)
|
||||
}
|
||||
|
||||
/*
|
||||
Following a link out of the panel puts it away, since what the reader asked for is behind it. A
|
||||
`<button>` in here is not that, which is why the test is `closest('a')` rather than any click.
|
||||
*/
|
||||
function onSidebarClick(ev) {
|
||||
if (tocPanelIsOpen.value && ev.target?.closest?.('a')) {
|
||||
closeTocPanel()
|
||||
}
|
||||
}
|
||||
|
||||
function openTocPanel() {
|
||||
state.tocPanelOpen = true
|
||||
}
|
||||
|
||||
function closeTocPanel() {
|
||||
state.tocPanelOpen = false
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
Loading…
Reference in new issue