diff --git a/backend/api/pages.ts b/backend/api/pages.ts
index 9ee5e2a09..ee47bea3f 100644
--- a/backend/api/pages.ts
+++ b/backend/api/pages.ts
@@ -1,7 +1,12 @@
import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
-import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
+import {
+ SEARCH_ORDER_BY,
+ SEARCH_TAGS_MATCH,
+ type SearchOrderBy,
+ type SearchTagsMatch
+} from '../models/search.ts'
import { audit } from '../helpers/audit.ts'
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
@@ -292,6 +297,7 @@ async function routes(app: FastifyInstance) {
path?: string
locales?: string
tags?: string
+ tagsMatch?: SearchTagsMatch
editor?: string
publishState?: string
creatorId?: string
@@ -331,7 +337,15 @@ async function routes(app: FastifyInstance) {
tags: {
type: 'string',
maxLength: 2048,
- description: 'Comma-separated tags a page must carry all of.'
+ description:
+ "Comma-separated tags to match against a page's own, as `tagsMatch` says."
+ },
+ tagsMatch: {
+ type: 'string',
+ enum: SEARCH_TAGS_MATCH,
+ default: 'all',
+ description:
+ 'Whether a page must carry `all` of the tags given (the default) or `any` one of them.'
},
editor: {
type: 'string',
@@ -419,6 +433,7 @@ async function routes(app: FastifyInstance) {
path: req.query.path,
locales: splitList(req.query.locales),
tags: splitList(req.query.tags),
+ tagsMatch: req.query.tagsMatch,
editor: req.query.editor,
publishState: req.query.publishState,
creatorId: req.query.creatorId,
diff --git a/backend/locales/en.json b/backend/locales/en.json
index a110a9d82..428a4b087 100644
--- a/backend/locales/en.json
+++ b/backend/locales/en.json
@@ -2587,23 +2587,40 @@
"search.sortByRelevance": "Relevance",
"search.sortByTitle": "Title",
"search.totalResults": "No result | {0} result | {0} results",
+ "tags.allLocales": "All locales",
+ "tags.allTags": "All Tags",
"tags.clearSelection": "Clear Selection",
"tags.currentSelection": "Current Selection",
+ "tags.loadFailed": "Failed to load the list of tags.",
+ "tags.loadMore": "Load More",
"tags.locale": "Locale",
"tags.localeAny": "Any",
+ "tags.matchAll": "AND",
+ "tags.matchAllHint": "Pages carrying every selected tag",
+ "tags.matchAny": "OR",
+ "tags.matchAnyHint": "Pages carrying any selected tag",
+ "tags.matchMode": "Match tags",
"tags.noResults": "Couldn't find any page with the selected tags.",
"tags.noResultsWithFilter": "Couldn't find any page matching the current filtering options.",
+ "tags.noTags": "No tags have been used on this site yet.",
"tags.orderBy": "Order By",
"tags.orderByField.ID": "ID",
"tags.orderByField.creationDate": "Creation Date",
"tags.orderByField.lastModified": "Last Modified",
"tags.orderByField.path": "Path",
"tags.orderByField.title": "Title",
+ "tags.orderDirectionAscending": "Ascending",
+ "tags.orderDirectionDescending": "Descending",
"tags.pageLastUpdated": "Last Updated {date}",
"tags.retrievingResultsLoading": "Retrieving page results...",
+ "tags.searchFailed": "Failed to load the pages for the selected tags.",
"tags.searchWithinResultsPlaceholder": "Search within results...",
"tags.selectOneMoreTags": "Select one or more tags",
"tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
+ "tags.sortAlphabetical": "List tags alphabetically",
+ "tags.sortPopularity": "List tags by popularity",
+ "tags.sortTags": "Sort tags",
+ "tags.title": "Tags",
"userProfile.lastLogin": "Last Login",
"userProfile.loadMore": "Load More",
"userProfile.loadingFailed": "Failed to load user profile.",
diff --git a/backend/models/search.ts b/backend/models/search.ts
index dcc3b07e6..1bdf97374 100644
--- a/backend/models/search.ts
+++ b/backend/models/search.ts
@@ -54,7 +54,25 @@ export interface RebuildResult {
locales: { locale: string; dictionary: string; pages: number }[]
}
-export const SEARCH_ORDER_BY = ['relevancy', 'title', 'createdAt', 'updatedAt'] as const
+/**
+ * How a list of tags is matched against a page's own.
+ *
+ * `all` is the narrowing sense a filter usually has — each tag added takes pages away — and is what
+ * a tag filter alongside a text query means. `any` is the widening one, which is what browsing by
+ * tag wants: two tags picked off a list are two things the reader is interested in, not a demand
+ * that one page be both.
+ */
+export const SEARCH_TAGS_MATCH = ['all', 'any'] as const
+export type SearchTagsMatch = (typeof SEARCH_TAGS_MATCH)[number]
+
+export const SEARCH_ORDER_BY = [
+ 'relevancy',
+ 'id',
+ 'path',
+ 'title',
+ 'createdAt',
+ 'updatedAt'
+] as const
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
export interface SearchResult {
@@ -82,6 +100,8 @@ export interface SearchPagesParams {
path?: string
locales?: string[]
tags?: string[]
+ /** Whether a page must carry every tag in `tags` (the default) or merely one of them. */
+ tagsMatch?: SearchTagsMatch
editor?: string
publishState?: string
/**
@@ -229,6 +249,7 @@ class Search {
path = '',
locales = [],
tags = [],
+ tagsMatch = 'all',
editor = '',
publishState = '',
creatorId = '',
@@ -300,7 +321,12 @@ class Search {
conditions.push(sql`p.locale = ANY(${sql.param(locales)}::text[])`)
}
if (tags.length > 0) {
- conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
+ // -> `@>` is contains-all, `&&` is overlaps; both are indexable the same way
+ conditions.push(
+ tagsMatch === 'any'
+ ? sql`p.tags && ${sql.param(tags)}::text[]`
+ : sql`p.tags @> ${sql.param(tags)}::text[]`
+ )
}
if (editor) {
conditions.push(sql`p.editor = ${editor}`)
@@ -317,6 +343,13 @@ class Search {
const effectiveOrderBy = orderBy === 'relevancy' && !hasQuery ? 'updatedAt' : orderBy
const ordering = {
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
+ id: sql`p.id ${direction}`,
+ /*
+ A path is only unique within a locale, so two translations of the same page would otherwise
+ come back in whatever order the planner chose -- and swap places between two requests for the
+ same list. The locale settles it.
+ */
+ path: sql`p.path ${direction}, p.locale ASC`,
title: sql`p.title ${direction}`,
createdAt: sql`p."createdAt" ${direction}`,
updatedAt: sql`p."updatedAt" ${direction}`
diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js
index ce12ff626..45df58055 100644
--- a/frontend/src/assets/icons.generated.js
+++ b/frontend/src/assets/icons.generated.js
@@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
- 278 icons.
+ 282 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"","width":32,"height":32},
@@ -64,7 +64,6 @@ export const BUNDLED_ICONS = {
"la:file-image": {"body":"","width":32,"height":32},
"la:file-import": {"body":"","width":32,"height":32},
"la:file-invoice": {"body":"","width":32,"height":32},
- "la:file-pdf": {"body":"","width":32,"height":32},
"la:fill": {"body":"","width":32,"height":32},
"la:fingerprint": {"body":"","width":32,"height":32},
"la:folder-open": {"body":"","width":32,"height":32},
@@ -172,9 +171,12 @@ export const BUNDLED_ICONS = {
"mdi:check-circle": {"body":"","width":24,"height":24},
"mdi:checkbox-blank-outline": {"body":"","width":24,"height":24},
"mdi:checkbox-outline": {"body":"","width":24,"height":24},
+ "mdi:chevron-double-down": {"body":"","width":24,"height":24},
+ "mdi:chevron-double-up": {"body":"","width":24,"height":24},
"mdi:chevron-down": {"body":"","width":24,"height":24},
"mdi:chevron-left": {"body":"","width":24,"height":24},
"mdi:chevron-right": {"body":"","width":24,"height":24},
+ "mdi:clear-circle-multiple-outline": {"body":"","width":24,"height":24},
"mdi:clipboard-text-outline": {"body":"","width":24,"height":24},
"mdi:clock-outline": {"body":"","width":24,"height":24},
"mdi:close": {"body":"","width":24,"height":24},
@@ -261,6 +263,8 @@ export const BUNDLED_ICONS = {
"mdi:power": {"body":"","width":24,"height":24},
"mdi:redo-variant": {"body":"","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"","width":24,"height":24},
+ "mdi:sort-alphabetical-descending-variant": {"body":"","width":24,"height":24},
+ "mdi:sort-numeric-descending-variant": {"body":"","width":24,"height":24},
"mdi:star": {"body":"","width":24,"height":24},
"mdi:tab-plus": {"body":"","width":24,"height":24},
"mdi:table": {"body":"","width":24,"height":24},
diff --git a/frontend/src/components/HeaderSearch.vue b/frontend/src/components/HeaderSearch.vue
index 37929c137..13cf1fb3a 100644
--- a/frontend/src/components/HeaderSearch.vue
+++ b/frontend/src/components/HeaderSearch.vue
@@ -85,7 +85,17 @@
@@ -56,7 +71,15 @@ const props = defineProps({
type: null,
default: null
},
- /** `[{ label, value, icon? }]` */
+ /**
+ * `[{ label, value, icon?, ariaLabel?, tooltip? }]`
+ *
+ * `ariaLabel` names a segment that has only an icon to go on -- an ascending/descending pair, say.
+ * Omit it wherever the label is the name, or it would be read out in place of it.
+ *
+ * `tooltip` is the same idea for the pointer: a line explaining what the segment does, for a
+ * control whose label is an icon or a word too short to say it. It appears on hover and on focus.
+ */
options: {
type: Array,
default: () => []
diff --git a/frontend/src/helpers/tagBrowser.js b/frontend/src/helpers/tagBrowser.js
new file mode 100644
index 000000000..29a5a5d94
--- /dev/null
+++ b/frontend/src/helpers/tagBrowser.js
@@ -0,0 +1,28 @@
+/**
+ * Where the tag browser lives, and how a selection is written into its URL.
+ *
+ * The selection is in the query rather than in the path, so a set of tags is a link somebody can be
+ * handed -- which is the whole point of a screen that exists to be arrived at from a tag. `t` rather
+ * than `tags`, because the path already says which of the two this is: `/_tags?t=api` reads where
+ * `/_tags?tags=api` stutters.
+ *
+ * Here rather than in `pages/Tags.vue` because the name is a contract between that screen and
+ * everything that links INTO it -- the tag chips on a page, today -- and a parameter known in two
+ * places is a parameter that can be renamed in one.
+ */
+export const TAG_BROWSER_PATH = '/_tags'
+export const TAG_BROWSER_PARAM = 't'
+
+/**
+ * A router target for the tag browser showing `tags`.
+ *
+ * @param {string[]} tags Tags to preselect. Empty for the browser with nothing chosen, which is the
+ * screen's own starting state rather than an error.
+ */
+export function tagBrowserRoute(tags = []) {
+ const selection = tags.filter((tag) => tag).join(',')
+ return {
+ path: TAG_BROWSER_PATH,
+ query: selection ? { [TAG_BROWSER_PARAM]: selection } : {}
+ }
+}
diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue
index 1bc122ec1..84608b3c6 100644
--- a/frontend/src/pages/Index.vue
+++ b/frontend/src/pages/Index.vue
@@ -941,7 +941,8 @@ function goBack() {
if (window.history.state?.back) {
router.back()
} else {
- router.push('/')
+ // -> This locale's home, not the primary locale's, which is all a bare `/` ever addresses
+ router.push(siteStore.readerHomePath)
}
}
diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue
index 6c1fe3e45..17a9e763b 100644
--- a/frontend/src/pages/Search.vue
+++ b/frontend/src/pages/Search.vue
@@ -106,7 +106,9 @@
+
+ :display-value="localeFilterLabel">
{
]
})
-const editors = computed(() => {
- return [
- { label: t('search.editorAny'), value: '' },
- { label: 'AsciiDoc', value: 'asciidoc' },
- { label: 'Markdown', value: 'markdown' },
- { label: 'Visual Editor', value: 'wysiwyg' }
- ]
+const hasMultipleLocales = computed(() => siteStore.locales.active.length > 1)
+
+/**
+ * What the locale filter's field reads when it is closed: `Any locale`, `FR locale only`, or a count.
+ *
+ * The ALIAS, uppercased, not the stored code -- `fr-FR` is what the wiki files a locale under, `fr`
+ * is what the administrator named it and what every URL, badge and folder on the site already says.
+ * The field was reading the raw code, so French searched as `FR-FR locale only`.
+ */
+const localeFilterLabel = computed(() => {
+ const picked = state.params.filterLocale
+ return t(
+ 'search.filterLocaleDisplay',
+ { n: picked.length > 0 ? siteStore.localeAlias(picked[0]).toUpperCase() : picked.length },
+ picked.length
+ )
})
+/**
+ * What the editor filter offers: every editor this site writes pages with, named as the admin area
+ * names it.
+ *
+ * The list was hardcoded, so it offered editors the site had turned off or that do not exist here at
+ * all -- and left out `redirect`, which every site has. `activeEditors` is the one place that knows.
+ */
+const editors = computed(() => [
+ { label: t('search.editorAny'), value: '' },
+ ...siteStore.activeEditors.map((id) => ({ label: t(`admin.editors.${id}Name`), value: id }))
+])
+
const publishStates = computed(() => {
return [
{ label: t('search.publishStateAny'), value: '' },
@@ -442,15 +458,14 @@ async function performSearch() {
const filters = {
...(state.params.filterPath ? { path: state.params.filterPath } : {}),
...(queryTags.length > 0 ? { tags: queryTags.join(',') } : {}),
- ...(state.params.filterLocale.length > 0
- ? { locales: state.params.filterLocale.join(',') }
- : {}),
...(state.params.filterEditor ? { editor: state.params.filterEditor } : {}),
...(state.params.filterPublishState ? { publishState: state.params.filterPublishState } : {})
}
// -> Nothing to go on: the empty state says as much, and asking the server would answer with the
- // most recently updated pages, which is not what an empty search box means
+ // most recently updated pages, which is not what an empty search box means. The locale is
+ // deliberately not counted -- it narrows an answer rather than asking for one, and it now
+ // arrives already set, so counting it would make an empty box search for every page in it
if (!q && Object.keys(filters).length < 1) {
state.results = []
state.total = 0
@@ -459,6 +474,10 @@ async function performSearch() {
return
}
+ if (state.params.filterLocale.length > 0) {
+ filters.locales = state.params.filterLocale.join(',')
+ }
+
state.loading++
siteStore.searchIsLoading = true
try {
@@ -489,11 +508,20 @@ async function performSearch() {
}
}
+/**
+ * Back to wherever the reader came from, and to this locale's home when there is nowhere to go back
+ * to -- a tab opened straight at this screen, a link followed from somewhere else.
+ *
+ * `history.state.back` is the entry vue-router itself records, and is the only thing that answers
+ * whether there is one: `history.length` counts the whole tab and is never 0, so the fallback below
+ * used to be unreachable and `router.back()` walked the reader out of the wiki instead. The fallback
+ * is prefixed because a bare `/` is the PRIMARY locale's home whoever asks -- see `readerHomePath`.
+ */
function goBack() {
- if (history.length > 0) {
+ if (window.history.state?.back) {
router.back()
} else {
- router.push('/')
+ router.push(siteStore.readerHomePath)
}
}
diff --git a/frontend/src/pages/Tags.vue b/frontend/src/pages/Tags.vue
new file mode 100644
index 000000000..0e892061a
--- /dev/null
+++ b/frontend/src/pages/Tags.vue
@@ -0,0 +1,1593 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/UserProfile.vue b/frontend/src/pages/UserProfile.vue
index 12afef58b..4c7be8767 100644
--- a/frontend/src/pages/UserProfile.vue
+++ b/frontend/src/pages/UserProfile.vue
@@ -342,11 +342,20 @@ function pageUrl(item) {
return `${siteStore.localeUrlPrefix(item.locale)}/${item.path}`
}
+/**
+ * Back to wherever the reader came from, and to this locale's home when there is nowhere to go back
+ * to -- a tab opened straight at this screen, a link followed from somewhere else.
+ *
+ * `history.state.back` is the entry vue-router itself records, and is the only thing that answers
+ * whether there is one: `history.length` counts the whole tab and is never 0, so the fallback below
+ * used to be unreachable and `router.back()` walked the reader out of the wiki instead. The fallback
+ * is prefixed because a bare `/` is the PRIMARY locale's home whoever asks -- see `readerHomePath`.
+ */
function goBack() {
- if (history.length > 0) {
+ if (window.history.state?.back) {
router.back()
} else {
- router.push('/')
+ router.push(siteStore.readerHomePath)
}
}
diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js
index 25a2b0028..233bbb5c8 100644
--- a/frontend/src/router/routes.js
+++ b/frontend/src/router/routes.js
@@ -49,6 +49,14 @@ const routes = [
path: '/_search',
component: () => import('@/pages/Search.vue')
},
+ /*
+ Browse the site by tag. The selection is in the query (`?t=a,b`) rather than in the path, so a
+ set of tags is a link that can be handed to somebody -- see `pages/Tags.vue`.
+ */
+ {
+ path: '/_tags',
+ component: () => import('@/pages/Tags.vue')
+ },
/*
The public profile of one user. `/_user` is shared with the server, which serves avatars at
`/_user//avatar` -- both `backend/index.ts` and the dev proxy in `frontend/vite.config.js`
diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js
index 92e88dcf3..2cde24bb7 100644
--- a/frontend/src/stores/site.js
+++ b/frontend/src/stores/site.js
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { sortBy } from 'es-toolkit/array'
+import { useCommonStore } from './common'
+import { useFlagsStore } from './flags'
import { useUserStore } from './user'
/**
@@ -231,6 +233,73 @@ export const useSiteStore = defineStore('site', {
this.locales.forcePrefix || code !== this.locales.primary
? `/${this.localeAlias(code)}`
: ''
+ },
+ /**
+ * The editors a page on this site may be written with, as the ids `pages.editor` stores — the
+ * order they are offered in, which is the order a reader meets them.
+ *
+ * Three questions at once, and all three have to be asked or the list is fiction: whether the
+ * site has the editor turned on (`editors`, the admin area's Editors screen), whether it is
+ * implemented at all — `channel`, `blog` and `api` are names with no editor behind them yet, and
+ * `wysiwyg` and `asciidoc` are half-built, so all five are behind the experimental flag — and
+ * `redirect`, which no site can turn off because it authors nothing: a redirection is a page with
+ * a target instead of a body. On a wiki with the flag off that leaves Markdown and Redirection,
+ * which is what most of them run.
+ *
+ * `PageNewMenu` draws its items from this, so what a page can be created with and what a search
+ * can be filtered by cannot drift apart.
+ */
+ activeEditors() {
+ const flagsStore = useFlagsStore()
+ const experimental = flagsStore.experimental
+ return [
+ ...(experimental && this.editors.wysiwyg ? ['wysiwyg'] : []),
+ ...(this.editors.markdown ? ['markdown'] : []),
+ ...(experimental && this.editors.asciidoc ? ['asciidoc'] : []),
+ ...(experimental ? ['channel', 'blog', 'api'] : []),
+ 'redirect'
+ ]
+ },
+ /** Whether `code` is one of the locales this site has enabled. */
+ isActiveLocale: (state) => (code) => state.locales.active.some((lc) => lc.code === code),
+ /**
+ * The locale the reader is reading in, as a locale this site actually has.
+ *
+ * The interface locale, which is the one the locale selector ticks and the only thing that
+ * answers the question on a screen that is not a page — `/_tags` and `/_search` carry no locale
+ * in their URLs, so a reader's own choice is all there is to go on. The site's primary where
+ * that choice is not a locale of this site, since every wiki has a primary and a reader has to
+ * be somewhere.
+ */
+ readerLocale() {
+ const commonStore = useCommonStore()
+ return this.isActiveLocale(commonStore.locale) ? commonStore.locale : this.locales.primary
+ },
+ /**
+ * The locale a listing screen opens narrowed to — the tag browser, the search filters — or null
+ * for every locale, which is where a site with one locale lands.
+ *
+ * The reader's own locale, because a list of every translation of everything is mostly pages
+ * they cannot read. Null on a single-locale site, where the control is not drawn at all and a
+ * filter nothing on screen can undo is a trap — it would also quietly hide pages left in a
+ * locale the site has since dropped. Null too where the reader's locale is not one of this
+ * site's, since opening on an empty list says a wiki is empty when it is only foreign.
+ */
+ defaultLocaleFilter() {
+ const commonStore = useCommonStore()
+ return this.locales.active.length > 1 && this.isActiveLocale(commonStore.locale)
+ ? commonStore.locale
+ : null
+ },
+ /**
+ * Where home is for the reader — `/` on a single-locale wiki, `/fr/` for somebody reading the
+ * French half of a wiki whose primary locale is English.
+ *
+ * The one place that answers it, because a bare `/` does not: that is the PRIMARY locale's home
+ * whoever asks, so every screen that fell back to it sent a French reader to the English wiki.
+ */
+ readerHomePath() {
+ return `${this.localeUrlPrefix(this.readerLocale)}/`
}
},
actions: {