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 'base';
|
||||||
@use 'animation';
|
@use 'animation';
|
||||||
|
@use 'page-chrome';
|
||||||
@use 'page-contents';
|
@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