mirror of https://github.com/requarks/wiki
parent
81fc8db4f7
commit
8e6a35de98
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE "siteAssets" (
|
||||||
|
"siteId" uuid,
|
||||||
|
"kind" varchar(255),
|
||||||
|
"data" bytea NOT NULL,
|
||||||
|
CONSTRAINT "siteAssets_pkey" PRIMARY KEY("siteId","kind")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "siteAssets" ADD CONSTRAINT "siteAssets_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editor-redirect">
|
||||||
|
<w-scroll-area style="height: 100%">
|
||||||
|
<div class="editor-redirect-form">
|
||||||
|
<w-card class="pb-2">
|
||||||
|
<w-card-header>{{ t('editor.redirect.title') }}</w-card-header>
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- Title -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="new-document" />
|
||||||
|
<w-item-section>
|
||||||
|
<w-item-label>{{ t('editor.redirect.pageTitle') }}</w-item-label>
|
||||||
|
<w-item-label caption>{{ t('editor.redirect.pageTitleHint') }}</w-item-label>
|
||||||
|
</w-item-section>
|
||||||
|
<w-item-section>
|
||||||
|
<!--
|
||||||
|
The same title the header edits in place, so the two are one field with two places to
|
||||||
|
type it: both write to the store, and the header's watcher follows what is typed here.
|
||||||
|
-->
|
||||||
|
<w-input
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
hide-bottom-space
|
||||||
|
:model-value="pageStore.title"
|
||||||
|
:aria-label="t(`editor.redirect.pageTitle`)"
|
||||||
|
@update:model-value="setTitle" />
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
<w-separator class="my-2" inset />
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- Target -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="advance" />
|
||||||
|
<w-item-section>
|
||||||
|
<w-item-label>{{ t('editor.redirect.target') }}</w-item-label>
|
||||||
|
<w-item-label caption>{{ t('editor.redirect.targetHint') }}</w-item-label>
|
||||||
|
</w-item-section>
|
||||||
|
<w-item-section side>
|
||||||
|
<w-btn
|
||||||
|
class="acrylic-btn"
|
||||||
|
flat
|
||||||
|
icon="la:folder-open"
|
||||||
|
color="primary"
|
||||||
|
padding="xs md"
|
||||||
|
no-caps
|
||||||
|
:label="t(`editor.redirect.choose`)"
|
||||||
|
@click="chooseTarget" />
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
<!--
|
||||||
|
What was chosen, indented to the row above rather than made a row of its own: it is that
|
||||||
|
row's answer, and a `w-item` of its own would need an empty avatar section to line up
|
||||||
|
with. The icon says which kind it is, which is the only place that distinction shows now
|
||||||
|
that one dialog answers both halves of it.
|
||||||
|
-->
|
||||||
|
<div class="editor-redirect-field">
|
||||||
|
<div class="text-body2 font-robotomono editor-redirect-target" v-if="state.target">
|
||||||
|
<w-icon
|
||||||
|
class="mr-2"
|
||||||
|
:name="state.kind === `url` ? `la:globe` : `la:file-alt`"
|
||||||
|
size="sm" />
|
||||||
|
{{ state.target }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption opacity-60" v-else>
|
||||||
|
{{ t('editor.redirect.noTargetSelected') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<w-separator class="my-2" inset />
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- Interstitial -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="timer" />
|
||||||
|
<w-item-section>
|
||||||
|
<w-item-label>{{ t('editor.redirect.showInterstitial') }}</w-item-label>
|
||||||
|
<w-item-label caption>{{ t('editor.redirect.showInterstitialHint') }}</w-item-label>
|
||||||
|
</w-item-section>
|
||||||
|
<w-item-section side>
|
||||||
|
<w-toggle
|
||||||
|
:model-value="state.showInterstitial"
|
||||||
|
:aria-label="t(`editor.redirect.showInterstitial`)"
|
||||||
|
@update:model-value="setShowInterstitial" />
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
</w-card>
|
||||||
|
<!--
|
||||||
|
What the page will do, spelled out, because everything above is settings and none of it says
|
||||||
|
what a reader arriving here actually gets. Also where a half-filled form is reported: the
|
||||||
|
save is refused by the server either way, and this says so before it is attempted.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="editor-redirect-summary"
|
||||||
|
:class="isFollowable(state) ? `is-ready` : `is-incomplete`">
|
||||||
|
<w-icon :name="isFollowable(state) ? `la:info-circle` : `la:exclamation-triangle`" />
|
||||||
|
<div class="pl-3">
|
||||||
|
<template v-if="!isFollowable(state)">
|
||||||
|
{{ t('editor.redirect.summaryIncomplete') }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="state.showInterstitial">
|
||||||
|
{{ t('editor.redirect.summaryInterstitial', { target: state.target }) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ t('editor.redirect.summaryDirect', { target: state.target }) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</w-scroll-area>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineAsyncComponent, reactive, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { dialog } from '@/composables/dialog'
|
||||||
|
|
||||||
|
import { isFollowable, parseRedirect, serializeRedirect } from '@/helpers/pageRedirect'
|
||||||
|
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
import { usePageStore } from '@/stores/page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `redirect` editor: a page that sends its reader somewhere else.
|
||||||
|
*
|
||||||
|
* There is no content to write, so this is a form rather than an editor — a title, where the page
|
||||||
|
* points, and whether the reader is told about it on the way. All three are the page's own fields:
|
||||||
|
* the title is the page's, and the other two are its content, as JSON. See `helpers/pageRedirect.js`.
|
||||||
|
*
|
||||||
|
* What the page then DOES with that is `PageRedirect.vue`, which is what the page view draws in place
|
||||||
|
* of an article.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// STORES
|
||||||
|
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
const pageStore = usePageStore()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redirection being edited, which is also exactly what gets saved — the form has no field that is
|
||||||
|
* not one of these three. Seeded from the stored content and written back by the watcher below.
|
||||||
|
*/
|
||||||
|
const state = reactive(parseRedirect(pageStore.content))
|
||||||
|
|
||||||
|
// WATCHERS
|
||||||
|
|
||||||
|
/*
|
||||||
|
The form IS the content, so the store follows it on every keystroke — there is nothing here that a
|
||||||
|
save would collect afterwards.
|
||||||
|
|
||||||
|
Immediate, and deliberately not a change: this also writes the canonical spelling of what was
|
||||||
|
already stored, and seeds a page being created with an empty redirection. Neither is an edit, so
|
||||||
|
neither may set the unsaved-changes flag — `touch` is called by the handlers instead, where a
|
||||||
|
person actually did something.
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
state,
|
||||||
|
(value) => {
|
||||||
|
pageStore.content = serializeRedirect(value)
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
/** Say that the page has unsaved changes, which is what turns the header's Save button on. */
|
||||||
|
function touch() {
|
||||||
|
editorStore.lastChangeTimestamp = Temporal.Now.instant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(title) {
|
||||||
|
pageStore.title = title
|
||||||
|
touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShowInterstitial(showInterstitial) {
|
||||||
|
state.showInterstitial = showInterstitial
|
||||||
|
touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks where this page sends its reader: a page of this wiki, or any URL.
|
||||||
|
*
|
||||||
|
* One dialog for both, because they are one question — the link picker asks it the way the rest of the
|
||||||
|
* app already asks it, and answers with which of its two tabs the answer came from. That is what
|
||||||
|
* `kind` is: the choice somebody made, rather than a guess made afterwards from the look of the
|
||||||
|
* string. It opens on the current target, so coming back starts from what is already set.
|
||||||
|
*
|
||||||
|
* Its "open in a new tab" offer is turned off. A redirection is not a link somebody clicks — the
|
||||||
|
* reader is taken there — so there is no tab to choose, and nowhere here to store the answer.
|
||||||
|
*/
|
||||||
|
function chooseTarget() {
|
||||||
|
dialog({
|
||||||
|
component: defineAsyncComponent(() => import('./LinkPickerDialog.vue')),
|
||||||
|
componentProps: {
|
||||||
|
title: t('editor.redirect.pickerTitle'),
|
||||||
|
okLabel: t('common.actions.select'),
|
||||||
|
initialHref: state.target,
|
||||||
|
newTabOption: false
|
||||||
|
}
|
||||||
|
}).onOk(({ href, kind }) => {
|
||||||
|
state.kind = kind === 'url' ? 'url' : 'page'
|
||||||
|
state.target = href
|
||||||
|
touch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.editor-redirect {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
@at-root .body--light & {
|
||||||
|
background-color: $grey-3;
|
||||||
|
}
|
||||||
|
@at-root .body--dark & {
|
||||||
|
background-color: $dark-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -> A form, not a document: it stops widening well before the column does */
|
||||||
|
&-form {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Lined up with the main section of the row above it: `w-item` pads 16px and its avatar section is
|
||||||
|
56px wide, so the field starts where that row's label does.
|
||||||
|
*/
|
||||||
|
&-field {
|
||||||
|
padding: 0 16px 8px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The one line that says what a reader arriving at this page gets. Blue while the form is answerable
|
||||||
|
and amber while it is not -- the second is a warning about a save that will be refused, not an
|
||||||
|
error that has happened yet.
|
||||||
|
*/
|
||||||
|
&-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&.is-ready {
|
||||||
|
background-color: rgba(25, 118, 210, 0.1);
|
||||||
|
color: $blue-9;
|
||||||
|
|
||||||
|
@at-root .body--dark &.is-ready {
|
||||||
|
color: $blue-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-incomplete {
|
||||||
|
background-color: rgba(255, 152, 0, 0.12);
|
||||||
|
color: $orange-9;
|
||||||
|
|
||||||
|
@at-root .body--dark &.is-incomplete {
|
||||||
|
color: $orange-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-placeholder">
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- Nowhere to go -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<template v-if="problem">
|
||||||
|
<w-icon class="page-placeholder-icon" name="la:exclamation-triangle" />
|
||||||
|
<div class="text-h6">{{ t(`common.redirect.${problem}`) }}</div>
|
||||||
|
<div class="text-body2 mt-1 opacity-60" v-if="canEditPage">
|
||||||
|
{{ t('common.redirect.brokenHint') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption font-robotomono mt-3 opacity-50" v-if="redirect.target">
|
||||||
|
{{ redirect.target }}
|
||||||
|
</div>
|
||||||
|
<w-btn
|
||||||
|
class="mt-6"
|
||||||
|
v-if="canEditPage"
|
||||||
|
unelevated
|
||||||
|
icon="la:edit"
|
||||||
|
color="primary"
|
||||||
|
padding="xs lg"
|
||||||
|
:label="t(`common.actions.edit`)"
|
||||||
|
@click="editPage" />
|
||||||
|
</template>
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- Held, so the page can be worked on -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<template v-else-if="!following">
|
||||||
|
<w-icon class="page-placeholder-icon" name="la:directions" />
|
||||||
|
<div class="text-h6">{{ t('common.redirect.held') }}</div>
|
||||||
|
<div class="text-caption font-robotomono mt-3 opacity-50">{{ redirect.target }}</div>
|
||||||
|
<w-btn
|
||||||
|
class="mt-6"
|
||||||
|
unelevated
|
||||||
|
icon="la:arrow-right"
|
||||||
|
color="primary"
|
||||||
|
padding="xs lg"
|
||||||
|
:label="t(`common.redirect.follow`)"
|
||||||
|
@click="follow" />
|
||||||
|
</template>
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<!-- On the way -->
|
||||||
|
<!-- ----------------------- -->
|
||||||
|
<template v-else>
|
||||||
|
<w-icon class="page-placeholder-icon" name="la:directions" />
|
||||||
|
<!--
|
||||||
|
`aria-live`, because nothing here is clicked: a reader on a screen reader is told where they
|
||||||
|
are being taken at the moment the page announces it, not when they get around to reading it.
|
||||||
|
-->
|
||||||
|
<div class="text-h6" role="status" aria-live="polite">
|
||||||
|
{{ t('common.redirect.redirectingTo', { target: redirect.target }) }}
|
||||||
|
</div>
|
||||||
|
<!-- -> The way out of a wait, and the way past it: a reader who does not want to sit through
|
||||||
|
the notice can go now, and one who lands here with scripting half-loaded has a link -->
|
||||||
|
<w-btn
|
||||||
|
class="mt-6"
|
||||||
|
unelevated
|
||||||
|
icon="la:arrow-right"
|
||||||
|
color="primary"
|
||||||
|
padding="xs lg"
|
||||||
|
:label="t(`common.redirect.goNow`)"
|
||||||
|
@click="go" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { loading } from '@/composables/loading'
|
||||||
|
|
||||||
|
import { isFollowable, parseRedirect, REDIRECT_INTERSTITIAL_MS } from '@/helpers/pageRedirect'
|
||||||
|
|
||||||
|
import { usePageStore } from '@/stores/page'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page view draws in place of an article for a page authored with the `redirect` editor.
|
||||||
|
*
|
||||||
|
* A redirection has no body: it sends its reader somewhere else, either straight away or after a
|
||||||
|
* short notice saying where. See `helpers/pageRedirect.js` for what is stored, and
|
||||||
|
* `EditorRedirect.vue` for where it is filled in.
|
||||||
|
*
|
||||||
|
* **`?redirect=no` holds it**, which is what makes a redirection maintainable: without it, a page
|
||||||
|
* whose whole purpose is to bounce the reader elsewhere cannot be opened by the person who has to
|
||||||
|
* fix it. The link a redirection's own screens hand out carries it, so following a chain of them
|
||||||
|
* backwards keeps working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many redirections may be followed one after another before the chain is treated as a loop.
|
||||||
|
*
|
||||||
|
* `A → B → A` is a browser that never stops navigating, and no single page can see it: each one is
|
||||||
|
* pointing somewhere perfectly reasonable. Only the count across them says otherwise. Generous enough
|
||||||
|
* that a chain nobody meant to build still works, and small enough to stop before anything hangs.
|
||||||
|
*/
|
||||||
|
const MAX_HOPS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirections followed in a row. Deliberately outside the component, because that is the whole point:
|
||||||
|
* the page view keeps this one component mounted from a redirection to the next, and the count is
|
||||||
|
* about the chain rather than about any page in it. Reset the moment the chain ends — a page that is
|
||||||
|
* read rather than followed unmounts this, and one held by `?redirect=no` is not being followed.
|
||||||
|
*/
|
||||||
|
let hops = 0
|
||||||
|
|
||||||
|
// STORES
|
||||||
|
|
||||||
|
const pageStore = usePageStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// ROUTER
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
/** The timer behind the interstitial. Cleared on the way out, so a page left early does not fire. */
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
/** Whether this page ends a chain that has gone on too long; see `MAX_HOPS`. */
|
||||||
|
const chainStopped = ref(false)
|
||||||
|
|
||||||
|
// COMPUTED
|
||||||
|
|
||||||
|
const redirect = computed(() => parseRedirect(pageStore.content))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why this redirection cannot be followed, if it cannot: nothing was filled in, it points at the page
|
||||||
|
* it is on, or it is the last hop of a chain that has come back around. All three would leave a reader
|
||||||
|
* bouncing rather than arriving.
|
||||||
|
*
|
||||||
|
* Doubles as the name of the string that says so.
|
||||||
|
*/
|
||||||
|
const problem = computed(() => {
|
||||||
|
if (!isFollowable(redirect.value)) {
|
||||||
|
return 'broken'
|
||||||
|
}
|
||||||
|
if (chainStopped.value) {
|
||||||
|
return 'chain'
|
||||||
|
}
|
||||||
|
return redirect.value.kind === 'page' && isSelf(redirect.value.target) ? 'loop' : null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the reader is on their way, rather than being held.
|
||||||
|
*
|
||||||
|
* Two things hold a redirection. `?redirect=no` is the one a person asks for. The other is an editor
|
||||||
|
* route: `/_edit/<path>` and `/_create/<editor>` load the page BEFORE they open the editor on it, and
|
||||||
|
* in the gap between the two the page view is drawn for a page that is already known to be a
|
||||||
|
* redirection — which would take the author to the target instead of showing them the form for it.
|
||||||
|
* Nobody on those routes is reading the page, so nothing there is ever followed.
|
||||||
|
*/
|
||||||
|
const following = computed(
|
||||||
|
() =>
|
||||||
|
route.query.redirect !== 'no' &&
|
||||||
|
!route.path.startsWith('/_edit') &&
|
||||||
|
!route.path.startsWith('/_create')
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whoever can save the page is who the broken-redirection screen offers a way to fix it to. */
|
||||||
|
const canEditPage = computed(() =>
|
||||||
|
['write:pages', 'manage:pages'].some((permission) =>
|
||||||
|
userStore.pagePermissions.includes(permission)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WATCHERS
|
||||||
|
|
||||||
|
/*
|
||||||
|
Keyed on the page rather than run on mount: this component stays mounted from one redirection to the
|
||||||
|
next -- the page view swaps the store's contents under it -- so a mount hook would fire for the
|
||||||
|
first one only.
|
||||||
|
|
||||||
|
`immediate`, because arriving at a redirection directly is the ordinary case.
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => [pageStore.id, redirect.value.target, following.value],
|
||||||
|
() => {
|
||||||
|
clear()
|
||||||
|
if (!following.value) {
|
||||||
|
// -> Held, so nothing is being followed and whatever came before it was not a chain
|
||||||
|
hops = 0
|
||||||
|
chainStopped.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (problem.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Counted here rather than in `go`, so that the interstitial on the page that breaks the chain is
|
||||||
|
never shown: it would say the reader is on their way somewhere they are about to be told they
|
||||||
|
cannot go. A URL target leaves the app entirely, which ends any chain by itself.
|
||||||
|
*/
|
||||||
|
if (redirect.value.kind === 'page' && ++hops > MAX_HOPS) {
|
||||||
|
chainStopped.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!redirect.value.showInterstitial) {
|
||||||
|
go()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setTimeout(go, REDIRECT_INTERSTITIAL_MS)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clear()
|
||||||
|
// -> Whatever comes next is read rather than followed, so the chain ends here
|
||||||
|
hops = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a page target is the page holding it, however the two are spelled. */
|
||||||
|
function isSelf(target) {
|
||||||
|
const stored = `/${pageStore.path}`.replace(/\/+$/, '')
|
||||||
|
const to = target.replace(/\/+$/, '').toLowerCase()
|
||||||
|
return to === stored.toLowerCase() || (stored === '/home' && to === '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the reader on.
|
||||||
|
*
|
||||||
|
* `replace` rather than a push, both ways: a redirection is not somewhere anyone meant to be, and
|
||||||
|
* leaving it in the history means the back button lands on it and bounces straight forward again.
|
||||||
|
*/
|
||||||
|
function go() {
|
||||||
|
clear()
|
||||||
|
if (problem.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (redirect.value.kind === 'url') {
|
||||||
|
// -> Leaving the app entirely, so the loading bar is what stands in for the wait
|
||||||
|
loading.show()
|
||||||
|
window.location.replace(redirect.value.target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(redirect.value.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follow it deliberately, from the screen `?redirect=no` holds the reader on. */
|
||||||
|
function follow() {
|
||||||
|
router.replace({ path: route.path, query: { ...route.query, redirect: undefined } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPage() {
|
||||||
|
router.push(`/_edit/${pageStore.path}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* The one spelling a page path has.
|
||||||
|
*
|
||||||
|
* Mirrors `normalizePagePath` in the backend's `helpers/common.ts`, so that a path typed into a
|
||||||
|
* dialog is corrected in front of the person typing it rather than silently changed by the server
|
||||||
|
* after they hit save. Whether what comes out is *allowed* is still each field's own rule — this only
|
||||||
|
* settles casing and spaces.
|
||||||
|
*/
|
||||||
|
export function normalizePagePath(input) {
|
||||||
|
return (input ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.replaceAll(/\s+/g, '-')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a site's page extension from the end of a URL path.
|
||||||
|
*
|
||||||
|
* The server redirects these too, but a link inside page content is followed by the router without
|
||||||
|
* ever asking it — so `/foo/bar.md` written into a page has to resolve to `/foo/bar` here as well.
|
||||||
|
* Mirrors `stripPageExtension` in the backend's `helpers/common.ts`.
|
||||||
|
*
|
||||||
|
* @param extensions Lowercase and without the dot, as `siteStore.pageExtensions` holds them
|
||||||
|
* @returns The path without the extension, or null if it does not end in one of them
|
||||||
|
*/
|
||||||
|
export function stripPageExtension(urlPath, extensions) {
|
||||||
|
if (!extensions?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const dot = urlPath.lastIndexOf('.')
|
||||||
|
if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return urlPath.slice(0, dot)
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* What a redirection page holds instead of a body.
|
||||||
|
*
|
||||||
|
* A redirection is an ordinary page authored with the `redirect` editor: it has a path, a title and a
|
||||||
|
* place in the tree, and nothing to read. Where it points is its content, as JSON — see
|
||||||
|
* `normalizeRedirectContent` in the backend's `models/pages.ts`, which is the authority on the shape
|
||||||
|
* and refuses a save that does not match it. This file is the same reading, in front of the author:
|
||||||
|
* the editor round-trips through it, and the page view follows what it returns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the interstitial is shown before the reader is taken on, in milliseconds.
|
||||||
|
*
|
||||||
|
* Long enough to read one line and see where they are going, short enough that nobody waits on it.
|
||||||
|
*/
|
||||||
|
export const REDIRECT_INTERSTITIAL_MS = 2500
|
||||||
|
|
||||||
|
/** An empty redirection, which is what a page being created starts as. */
|
||||||
|
export function emptyRedirect() {
|
||||||
|
return { kind: 'page', target: '', showInterstitial: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a stored redirection. Never throws: content that is missing or unparseable comes back as an
|
||||||
|
* empty redirection, which the editor opens on and the page view reports as having nowhere to go.
|
||||||
|
*/
|
||||||
|
export function parseRedirect(content) {
|
||||||
|
let parsed = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content ?? '')
|
||||||
|
} catch {
|
||||||
|
// -> An empty redirection is the answer; see above
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: parsed?.kind === 'url' ? 'url' : 'page',
|
||||||
|
target: typeof parsed?.target === 'string' ? parsed.target.trim() : '',
|
||||||
|
showInterstitial: parsed?.showInterstitial === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical spelling of a redirection, which is what gets saved. */
|
||||||
|
export function serializeRedirect({ kind, target, showInterstitial } = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: kind === 'url' ? 'url' : 'page',
|
||||||
|
target: (target ?? '').trim(),
|
||||||
|
showInterstitial: showInterstitial === true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a redirection can actually be followed.
|
||||||
|
*
|
||||||
|
* The same two rules the server enforces: a page target is a rooted path within this wiki, and a URL
|
||||||
|
* target is a complete `http(s)` address — anything else is either not a destination or, for
|
||||||
|
* `javascript:`, a link nobody chose to follow.
|
||||||
|
*/
|
||||||
|
export function isFollowable({ kind, target } = {}) {
|
||||||
|
const value = (target ?? '').trim()
|
||||||
|
if (value.length < 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return kind === 'url'
|
||||||
|
? /^https?:\/\/\S/i.test(value)
|
||||||
|
: value.startsWith('/') && !value.startsWith('//')
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* The images a site has of its own — its logo, its favicon and the backdrop of its login page.
|
||||||
|
*
|
||||||
|
* Uploading one is the same exchange whichever it is, and the accepted formats have to agree with
|
||||||
|
* what the endpoint checks, so both live here rather than in each admin view that offers an upload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What the endpoint accepts, mirroring the formats it recognizes from the bytes themselves. */
|
||||||
|
export const SITE_IMAGE_TYPES = [
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for an image file.
|
||||||
|
*
|
||||||
|
* @returns The chosen file, or null if the picker was dismissed
|
||||||
|
*/
|
||||||
|
export function pickSiteImage() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = SITE_IMAGE_TYPES.join(',')
|
||||||
|
input.onchange = (ev) => resolve(ev.target.files?.[0] ?? null)
|
||||||
|
// -> Dismissing the picker fires no `change` event, so the promise would otherwise never settle
|
||||||
|
input.oncancel = () => resolve(null)
|
||||||
|
input.click()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a chosen file is one the endpoint will take. The picker's filter is a suggestion the user
|
||||||
|
* can override, and the server checks the bytes anyway; asking here beats a 415 with nothing to
|
||||||
|
* explain it.
|
||||||
|
*/
|
||||||
|
export function isAcceptedSiteImage(file) {
|
||||||
|
return SITE_IMAGE_TYPES.includes(file.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace one of a site's images.
|
||||||
|
*
|
||||||
|
* @param kind One of `logo`, `favicon` or `loginBg`
|
||||||
|
*/
|
||||||
|
export async function uploadSiteImage(siteId, kind, file) {
|
||||||
|
// -> The image is the request body itself: the endpoint takes the raw file, not a form
|
||||||
|
const resp = await API_CLIENT.put(`sites/${siteId}/images/${kind}`, {
|
||||||
|
body: file,
|
||||||
|
headers: {
|
||||||
|
'content-type': file.type
|
||||||
|
}
|
||||||
|
}).json()
|
||||||
|
if (!resp?.ok) {
|
||||||
|
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one of a site's images, leaving the built-in default in its place.
|
||||||
|
*/
|
||||||
|
export async function clearSiteImage(siteId, kind) {
|
||||||
|
const resp = await API_CLIENT.delete(`sites/${siteId}/images/${kind}`).json()
|
||||||
|
if (!resp?.ok) {
|
||||||
|
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue