From b6bcee8a3dea8558a015f5f32bd7952920850db1 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 12 Sep 2026 02:03:11 -0400 Subject: [PATCH] feat: tags page + various fixes --- backend/api/pages.ts | 19 +- backend/locales/en.json | 17 + backend/models/search.ts | 37 +- frontend/src/assets/icons.generated.js | 8 +- frontend/src/components/HeaderSearch.vue | 18 +- frontend/src/components/PageNewMenu.vue | 63 +- frontend/src/components/PageTags.vue | 16 +- frontend/src/components/shared/WBtnToggle.vue | 27 +- frontend/src/helpers/tagBrowser.js | 28 + frontend/src/pages/Index.vue | 3 +- frontend/src/pages/Search.vue | 80 +- frontend/src/pages/Tags.vue | 1593 +++++++++++++++++ frontend/src/pages/UserProfile.vue | 13 +- frontend/src/router/routes.js | 8 + frontend/src/stores/site.js | 69 + 15 files changed, 1924 insertions(+), 75 deletions(-) create mode 100644 frontend/src/helpers/tagBrowser.js create mode 100644 frontend/src/pages/Tags.vue 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 @@
Popular Tags - + +
+ - - New Page - - - - New Markdown Page - - - - - - New Redirection + @click="create(editor)"> + + {{ NEW_PAGE_ITEMS[editor].label }} @@ -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: {