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