mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
542 lines
18 KiB
Vue
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>
|