Files
wiki/frontend/src/components/LinkPickerDialog.vue
T

542 lines
18 KiB
Vue

<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="link-picker" style="width: 860px; max-width: 90vw">
<w-card-section class="card-header">
<w-icon name="la:link" size="sm" class="mr-2" />
<span>{{ props.title ?? t('linkPicker.title') }}</span>
<w-space />
<!-- -> Only where there is a choice to make: one active locale is most wikis, and a button
that can only say `en` is noise on all of them -- and never when the caller has asked
for one locale, where the picker is filling a slot that belongs to it -->
<w-btn
v-if="siteStore.locales.active.length > 1 && !props.lockLocale"
class="acrylic-btn -my-2"
flat
dense
padding="xs md"
color="white"
:label="siteStore.localeAlias(state.locale)"
:aria-label="siteStore.localeAlias(state.locale)">
<w-tooltip>{{ t(`linkPicker.localeHint`) }}</w-tooltip>
<locale-selector-menu
:selected="state.locale"
:navigate="false"
anchor="bottom right"
self="top right"
@select="switchLocale" />
</w-btn>
</w-card-section>
<!-- -> Inset from the card's edges, as in the icon picker: the strip is a segmented control with
a track of its own, so it sits ON the card rather than spanning it edge to edge -->
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
<w-tab name="page" icon="la:file-alt" :label="t(`linkPicker.page`)" />
<w-tab v-if="!props.pagesOnly" name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
</w-tabs>
<w-separator />
<w-tab-panels v-model="state.currentTab">
<!-- ----------------------- -->
<!-- A page of this wiki -->
<!-- ----------------------- -->
<w-tab-panel class="p-0" name="page">
<div class="link-picker-browser flex flex-nowrap">
<div class="link-picker-tree w-1/3">
<w-scroll-area style="height: 300px">
<!-- -> No side padding: the rows carry their own and span the column, as in the File
Manager. Padding here would inset the highlight band as well. -->
<div>
<tree
ref="treeComp"
v-model:selected="state.currentFolderId"
:nodes="state.treeNodes"
:roots="state.treeRoots"
:use-lazy-load="true"
:context-action-list="[]"
@lazy-load="treeLazyLoad" />
</div>
</w-scroll-area>
</div>
<div class="w-2/3">
<w-scroll-area style="height: 300px">
<w-list class="link-picker-list" dense>
<w-item
v-for="item of state.items"
:key="item.id"
clickable
active-class="active"
:active="item.type === `page` && item.path === state.path"
@click="selectItem(item)">
<w-item-section side>
<w-icon :name="item.icon" size="sm" :style="item.iconStyle" />
</w-item-section>
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
<w-item-label caption class="font-robotomono">/{{ item.path }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
<div
v-if="state.items.length < 1 && !state.isFetching"
class="text-caption text-center p-6 text-black/60 dark:text-white/70">
{{ t('linkPicker.emptyFolder') }}
</div>
</w-scroll-area>
</div>
</div>
</w-tab-panel>
<!-- ----------------------- -->
<!-- Anywhere else -->
<!-- ----------------------- -->
<w-tab-panel class="p-4" name="url">
<w-input
ref="iptUrl"
v-model="state.url"
outlined
dense
hide-bottom-space
:label="t(`linkPicker.linkUrl`)"
:aria-label="t(`linkPicker.linkUrl`)"
placeholder="https://example.com/page" />
<w-checkbox
v-if="props.newTabOption"
class="mt-4"
v-model="state.openInNewTab"
:label="t(`linkPicker.openInNewTab`)" />
</w-tab-panel>
</w-tab-panels>
<w-separator />
<!-- -> The same footer the icon picker has: what is about to be committed, spelled out, since
both tabs can be half-filled and only one of them is the answer -->
<w-card-section class="flex flex-nowrap items-center py-2">
<w-icon
:name="state.currentTab === `page` ? `la:file-alt` : `la:globe`"
size="sm"
color="primary" />
<div class="min-w-0 flex-1 pl-3">
<div class="text-caption text-grey">{{ t('linkPicker.selection') }}</div>
<div class="text-body2 font-robotomono link-picker-href">{{ href || '—' }}</div>
</div>
</w-card-section>
<w-separator />
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
icon="la:times"
:label="t(`common.actions.cancel`)"
color="grey-7"
padding="xs md"
@click="onDialogCancel" />
<w-btn
icon="la:check"
:label="props.okLabel ?? t(`common.actions.insert`)"
unelevated
color="primary"
padding="xs md"
:disabled="!canSubmit"
@click="submit" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import { splitLocalePath } from '@/helpers/pagePaths'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
import Tree from '@/components/TreeNav.vue'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
/**
* Picks a link target: a page of this wiki, or any URL.
*
* Written to be opened from anywhere that needs one — the markdown editor's Insert Link, the target of
* a page relation — so it decides nothing about what the link is FOR. It answers with
* `{ href, openInNewTab, title }` and leaves the caller to render that as markdown, store it on a
* relation, or whatever else.
*
* dialog({ component: LinkPickerDialog }).onOk(({ href }) => ...)
*/
// PROPS
const props = defineProps({
/** Card heading. The insert-a-link wording by default. */
title: {
type: String,
default: null
},
/** Label on the confirm button, for a caller that is selecting rather than inserting. */
okLabel: {
type: String,
default: null
},
/** An href to open on, so re-opening the picker starts where the last choice left it. */
initialHref: {
type: String,
default: ''
},
/**
* Whether the URL tab offers "open in a new tab". Off for a caller with nowhere to put the answer —
* a control whose effect is discarded is worse than no control.
*/
newTabOption: {
type: Boolean,
default: true
},
/**
* The locale to browse, and the one a chosen page's link is prefixed for. The page the picker was
* opened from by default, which is what every caller is editing — its content, its relations, its
* redirect target, its sidebar.
*/
locale: {
type: String,
default: null
},
/**
* Whether the locale above is fixed. For a caller picking the page that fills one locale's slot —
* the translation set in the page properties panel — where a page from another locale is not a
* different answer but a wrong one, so the switcher is not offered at all.
*/
lockLocale: {
type: Boolean,
default: false
},
/**
* Offer the Page tab alone, for a caller whose slot holds a page of this wiki and nothing else -- a
* glossary term's documentation page. An `initialHref` that is a URL then opens on the page tree
* rather than on a tab that is not there.
*/
pagesOnly: {
type: Boolean,
default: false
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// REFS
const treeComp = ref(null)
const iptUrl = ref(null)
// DATA
const state = reactive({
currentTab: 'page',
/*
Which locale's pages are listed, and what a chosen one's link is prefixed for. The tree holds
every translation side by side, so unfiltered this listed all of them at once and linked to
whichever page of that name the PRIMARY locale had.
*/
locale: props.locale || pageStore.locale,
/** Folder whose contents the right-hand pane lists. Null is the site root. */
currentFolderId: null,
treeNodes: {},
treeRoots: [],
items: [],
/** The chosen page, as a slash path with no leading slash. Only a row in the list sets it. */
path: '',
/** Title of the page the path came from, which a caller can use as the link's text. */
pageTitle: '',
isFetching: false,
url: 'https://',
openInNewTab: false
})
// COMPUTED
const href = computed(() => {
if (state.currentTab !== 'page') {
return state.url.trim()
}
// -> Prefixed for the locale being browsed: an unprefixed path addresses the PRIMARY locale's page
// of that name, which for a link picked out of the French tree is a different page or none
return state.path ? `${siteStore.localeUrlPrefix(state.locale)}/${state.path}` : ''
})
const canSubmit = computed(() => {
if (state.currentTab === 'page') {
return state.path.length > 0
}
// -> The scheme alone is what the field is prefilled with, so it does not count as an answer
return href.value.length > 0 && !/^[a-z][a-z0-9+.-]*:\/*$/i.test(href.value)
})
// WATCHERS
watch(
() => state.currentFolderId,
(folderId) => loadTree({ parentId: folderId })
)
// METHODS
/**
* Browse another locale.
*
* Back to its root, and the chosen page is dropped with it: the tree being left and the one being
* entered share no ids, and a path picked out of one names a different page — or none — in the other.
*/
async function switchLocale(locale) {
if (locale === state.locale) {
return
}
state.locale = locale
state.treeNodes = {}
state.treeRoots = []
state.currentFolderId = null
state.items = []
state.path = ''
state.pageTitle = ''
treeComp.value?.resetLoaded()
await loadTree({ initLoad: true })
}
/**
* Loads one folder into the tree, and — when that folder is the selected one — into the list beside it.
*
* `initLoad` also asks for the folders above the one being listed, so that opening on a page buried a
* few levels down draws its whole branch from a single request. Those extra entries come back flagged
* `isAncestor` and belong in the tree only, never in the list.
*/
async function loadTree({ parentId = null, parentPath = null, initLoad = false }) {
if (state.isFetching) {
return
}
state.isFetching = true
const isCurrentFolder = (parentId ?? null) === state.currentFolderId
if (isCurrentFolder) {
state.items = []
}
try {
const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: {
locale: state.locale,
...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}),
types: 'folder,page',
includeAncestors: initLoad,
includeRootFolders: initLoad
}
}).json()
for (const entry of entries ?? []) {
const path = entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName
if (entry.type === 'folder') {
state.treeNodes[entry.id] = {
folderPath: entry.folderPath,
fileName: entry.fileName,
title: entry.title,
hue: entry.hue,
children: state.treeNodes[entry.id]?.children ?? []
}
if (entry.folderPath) {
const parentOfEntry = parentId ?? findFolderIdByPath(entry.folderPath)
if (
entry.id !== parentOfEntry &&
!state.treeNodes[parentOfEntry]?.children?.includes(entry.id)
) {
state.treeNodes[parentOfEntry]?.children?.push(entry.id)
}
} else if (!state.treeRoots.includes(entry.id)) {
state.treeRoots.push(entry.id)
}
}
// -> An ancestor is drawn in the tree to give the branch its shape; it is not IN this folder
if (isCurrentFolder && !entry.isAncestor) {
state.items.push({
id: entry.id,
type: entry.type,
title: entry.title,
path,
icon: entry.type === 'folder' ? fileTypes.folder.icon : fileTypes.page.icon,
iconStyle: entry.type === 'folder' ? folderIconStyle(entry.hue) : undefined
})
}
}
// -> Folders first, as the File Manager lists them, then by what they are called
state.items.sort((a, b) =>
a.type === b.type ? a.title.localeCompare(b.title) : a.type === 'folder' ? -1 : 1
)
} catch (err) {
notify({
type: 'negative',
message: t('linkPicker.loadFailed'),
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId) {
treeComp.value?.setLoaded(parentId)
}
state.isFetching = false
}
function treeLazyLoad(nodeId, isCurrent, { done }) {
loadTree({ parentId: nodeId }).then(done)
}
/** The id of an already-loaded folder, addressed the way a path addresses it. */
function findFolderIdByPath(path) {
if (!path) {
return null
}
const entry = Object.entries(state.treeNodes).find(
([, node]) => (node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) === path
)
return entry?.[0] ?? null
}
/** A folder is somewhere to look; a page is the answer. */
function selectItem(item) {
if (item.type === 'folder') {
state.currentFolderId = item.id
treeComp.value?.setOpened(item.id)
return
}
state.path = item.path
state.pageTitle = item.title
}
function submit() {
onDialogOK({
href: href.value,
/*
The page as the wiki addresses it, beside the href built from it: a caller storing a relation to
a page needs the path and the locale as two values, and taking them back apart from an href means
knowing which prefixes are locales. Empty for the URL tab, which names no page of this wiki.
*/
path: state.currentTab === 'page' ? state.path : '',
locale: state.currentTab === 'page' ? state.locale : '',
/*
Which tab answered, so a caller that stores the two kinds differently does not have to work it
out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki
and a perfectly good relative URL elsewhere. This is the choice somebody made.
*/
kind: state.currentTab,
// -> Only ever true for a URL: a page of this wiki opens in the tab the reader is already in
openInNewTab: state.currentTab === 'url' && props.newTabOption && state.openInNewTab,
title: state.currentTab === 'page' ? state.pageTitle : ''
})
}
// MOUNTED
onMounted(async () => {
/*
An href that is already set decides which tab opens and what it starts on, so re-opening the picker
on a link that exists starts from that link rather than from nothing. A page shows up as the
highlighted row once its folder is listed, and in the footer either way.
*/
if (props.initialHref) {
const isUrl =
/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')
if (isUrl && !props.pagesOnly) {
state.currentTab = 'url'
state.url = props.initialHref
} else if (!isUrl) {
/*
Re-opening on a link that already carries a prefix: the locale comes off it, so the picker
starts in the tree the link points into rather than in the page's own.
*/
const split = splitLocalePath(props.initialHref, siteStore.localePrefixes)
if (split) {
state.locale = split.locale
}
state.path = (split?.path ?? props.initialHref).replace(/^\/+/, '')
}
}
// -> Opens on the folder holding the page being edited, which is where a link is most often going
const startFolder = pageStore.folderPath
await loadTree({ parentPath: startFolder, initLoad: true })
const startFolderId = findFolderIdByPath(startFolder)
if (startFolderId) {
const parts = startFolder.split('/')
for (let i = 1; i <= parts.length; i++) {
const ancestorId = findFolderIdByPath(parts.slice(0, i).join('/'))
if (ancestorId) {
treeComp.value?.setOpened(ancestorId)
}
}
state.currentFolderId = startFolderId
} else {
// -> Already at the root, which the watcher above will not fire for
await loadTree({})
}
if (state.currentTab === 'url') {
await nextTick()
iptUrl.value?.focus()
}
})
</script>
<style lang="scss">
.link-picker {
&-browser {
height: 300px;
max-height: 90vh;
}
/* -> The tree column carries the recessed surface, as it does in the File Manager */
&-tree {
height: 300px;
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
&-list {
padding: 8px 12px;
> .w-item {
padding: 4px 6px;
border-radius: 4px;
&.active {
background-color: var(--color-primary);
color: #fff;
.w-item-label--caption {
color: rgba(255, 255, 255, 0.7);
}
}
}
}
&-href {
overflow-wrap: anywhere;
}
}
</style>