feat: tags page + various fixes

scarlett 3.0.0-beta.561
NGPixel 17 hours ago
parent 48835a2701
commit b6bcee8a3d
No known key found for this signature in database

@ -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,

@ -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.",

@ -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}`

@ -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":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -64,7 +64,6 @@ export const BUNDLED_ICONS = {
"la:file-image": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM21.094 14c-.551 0-1 .45-1 1s.449 1 1 1s1-.45 1-1s-.45-1-1-1M14 15.594l-.719.687l-4 4l1.438 1.438L14 18.437l2.281 2.282l.719.687l.719-.687L19 19.437l2.281 2.282l1.438-1.438l-3-3l-.719-.687l-.719.687L17 18.563l-2.281-2.282z\"/>","width":32,"height":32},
"la:file-import": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-9h-2v7H8V6h16v7h2V4zm11.5 7l-4.313 4.281L12.5 16l.688.719L17.5 21l1.406-1.406L16.313 17H28v-2H16.312l2.594-2.594z\"/>","width":32,"height":32},
"la:file-invoice": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM10 13v2h12v-2zm0 5v2h7v-2zm9 0v2h3v-2zm-9 4v2h7v-2zm9 0v2h3v-2z\"/>","width":32,"height":32},
"la:file-pdf": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V3zm2 2h16v22H8zm7.406 5.344a1.44 1.44 0 0 0-.906.312c-.254.215-.367.48-.438.75c-.136.54-.097 1.098.032 1.719c.152.727.586 1.602.937 2.438c-.18.761-.226 1.437-.5 2.218c-.234.672-.535 1.059-.812 1.657c-.63.238-1.38.378-1.875.687c-.535.332-1.004.7-1.281 1.219c-.278.52-.247 1.254.124 1.781a1.6 1.6 0 0 0 .75.625c.325.129.676.133.97.031c.59-.203 1.007-.656 1.405-1.187c.372-.492.633-1.328.97-2c.503-.168.866-.38 1.405-.5c.563-.125.942-.067 1.47-.125c.226.258.417.672.655.875c.477.414 1 .742 1.625.781s1.25-.352 1.594-.938h.032v-.03c.152-.266.257-.555.25-.876a1.4 1.4 0 0 0-.375-.875c-.41-.437-.934-.55-1.5-.625c-.438-.058-1.047.098-1.563.125c-.453-.597-.902-1.047-1.313-1.812c-.222-.414-.28-.766-.468-1.188c.144-.68.43-1.437.468-2.031c.047-.719.02-1.34-.187-1.906a1.8 1.8 0 0 0-.531-.781a1.5 1.5 0 0 0-.907-.344h-.03zm.656 7.406c.18.316.403.516.594.813c-.281.05-.496 0-.781.062c-.047.012-.078.05-.125.063c.059-.157.133-.25.188-.407c.062-.183.066-.347.124-.531m3.688 2.031c.336.043.457.106.5.125c-.008.016.012.012 0 .032c-.125.207-.137.19-.219.187c-.066-.004-.32-.14-.562-.313c.07.004.218-.039.281-.03zm-7 1.563c-.055.082-.102.273-.156.343c-.305.407-.586.594-.656.625c-.012-.015.019 0 0-.03h-.032c-.101-.145-.074-.087 0-.22c.074-.132.309-.402.719-.656c.031-.02.094-.043.125-.062\"/>","width":32,"height":32},
"la:fill": {"body":"<path fill=\"currentColor\" d=\"M11.313 3.281L9.905 4.72l1.782 1.78l-6.906 6.906a3.063 3.063 0 0 0 0 4.313l.063.062l6.343 6.313a3.063 3.063 0 0 0 4.313 0l7.594-7.594l.718-.688l-9.718-9.718l-.781-.813l-.22-.187zm1.812 4.656L21 15.813l-6.906 6.876a1.054 1.054 0 0 1-1.5 0L6.219 16.28a1.017 1.017 0 0 1 0-1.468zM25 19.25l-.813 1.188s-.539.753-1.062 1.656c-.262.453-.508.926-.719 1.406S22 24.422 22 25c0 1.645 1.355 3 3 3s3-1.355 3-3c0-.578-.195-1.02-.406-1.5s-.457-.953-.719-1.406c-.523-.903-1.063-1.657-1.063-1.657zm0 3.625c.066.11.059.102.125.219c.238.41.492.847.656 1.218c.164.372.219.715.219.688c0 .555-.445 1-1 1s-1-.445-1-1c0 .027.055-.316.219-.688c.164-.37.418-.808.656-1.218c.066-.117.059-.11.125-.219\"/>","width":32,"height":32},
"la:fingerprint": {"body":"<path fill=\"currentColor\" d=\"M16 4c-.262 0-.496.016-.75.031a13 13 0 0 0-4.063.875l.75 1.875a10.8 10.8 0 0 1 3.407-.75C15.55 6.02 15.774 6 16 6c1.883 0 3.664.477 5.219 1.313l.937-1.75A13 13 0 0 0 16 4M9.5 5.719a13 13 0 0 0-3.188 2.593c-.414.461-.777.981-1.125 1.5c-.382.57-.714 1.168-1 1.782L6 12.406a11.2 11.2 0 0 1 1.813-2.75A11 11 0 0 1 10.5 7.47zm14.469 1L22.75 8.312a10.93 10.93 0 0 1 4.219 8.094c.004.063.047.61 0 1.532l2 .125c.05-1.004.008-1.665 0-1.782a12.94 12.94 0 0 0-5-9.562M16 7v2c4.25 0 7.77 3.313 8 7.563c.008.113.129 3.066-1 6.625l1.906.593c1.239-3.902 1.11-7.031 1.094-7.312C25.715 11.176 21.293 7 16 7m-1.844.156a9.9 9.9 0 0 0-5.594 3.157c-.32.355-.636.753-.906 1.156h.032v.031C6.52 13.262 5.902 15.3 6 17.406v.563l2 .062v-.656c-.09-1.715.383-3.375 1.344-4.813c.21-.32.433-.624.687-.906a7.96 7.96 0 0 1 4.5-2.531zM15.594 10a6.9 6.9 0 0 0-4.25 1.781l1.312 1.5A5 5 0 0 1 15.72 12c.105-.008.183 0 .281 0c.582 0 1.14.098 1.656.281l.688-1.875A7.1 7.1 0 0 0 16 10c-.145 0-.27-.008-.406 0m4.281 1.156l-1.094 1.688A4.95 4.95 0 0 1 21 16.719l2-.094a7.05 7.05 0 0 0-3.125-5.469M15.781 13a4 4 0 0 0-2.75 1.344A3.98 3.98 0 0 0 12 17.219c0-.004.05 1.125-.406 2.437c-.457 1.313-1.371 2.793-3.344 3.75l-.625.282c-.332.148-.75.32-.844.343l.438 1.938c.445-.102.875-.301 1.25-.469s.656-.313.656-.313c2.5-1.21 3.762-3.207 4.344-4.875c.582-1.667.539-2.996.531-3.187v-.031a1.93 1.93 0 0 1 .5-1.438A1.95 1.95 0 0 1 15.875 15c.05-.004.09 0 .125 0v-2c-.082 0-.148-.004-.219 0m-5.625.125A6.96 6.96 0 0 0 9 17.344v.031c.004.082.09 2.266-2.063 3.313C6.891 20.706 6.146 21 5 21v2c1.566 0 2.75-.469 2.75-.469h.031l.032-.031c3.222-1.563 3.19-5.04 3.187-5.219v-.031c-.059-1.09.25-2.11.844-3zm7.75.344l-.968 1.781c.593.32 1.023.902 1.062 1.625c.008.164.285 6.387-4.625 10.344l1.25 1.562c5.719-4.605 5.402-11.531 5.375-12a4 4 0 0 0-2.094-3.312M16 16c-.55 0-1 .45-1 1v.063s.117 2.058-.906 4.375l1.812.812C17.09 19.574 17.008 17.172 17 17v-.063A1.004 1.004 0 0 0 16 16m4.969 1.938c-.125 2.03-.766 6.195-3.719 9.687l1.5 1.281c3.363-3.972 4.078-8.558 4.219-10.843zM13.562 22.5c-.8 1.348-2.039 2.645-4 3.594l.876 1.812c2.32-1.125 3.87-2.77 4.843-4.406z\"/>","width":32,"height":32},
"la:folder-open": {"body":"<path fill=\"currentColor\" d=\"M5 3v24.813l.781.156l12 2.5l1.219.25V28h6V15.437l1.719-1.718l.281-.313V3zm9.125 2H25v7.563l-1.719 1.718l-.281.313V26h-4v-8.906l-.281-.313L17 15.063V5.719zM7 5.281l8 2v8.625l.281.313L17 17.937v10.344L7 26.188z\"/>","width":32,"height":32},
@ -172,9 +171,12 @@ export const BUNDLED_ICONS = {
"mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24},
"mdi:checkbox-blank-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5z\"/>","width":24,"height":24},
"mdi:checkbox-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5zm-9 12l-4-4l1.41-1.42L10 14.17l6.59-6.59L18 9\"/>","width":24,"height":24},
"mdi:chevron-double-down": {"body":"<path fill=\"currentColor\" d=\"M16.59 5.59L18 7l-6 6l-6-6l1.41-1.41L12 10.17zm0 6L18 13l-6 6l-6-6l1.41-1.41L12 16.17z\"/>","width":24,"height":24},
"mdi:chevron-double-up": {"body":"<path fill=\"currentColor\" d=\"M7.41 18.41L6 17l6-6l6 6l-1.41 1.41L12 13.83zm0-6L6 11l6-6l6 6l-1.41 1.41L12 7.83z\"/>","width":24,"height":24},
"mdi:chevron-down": {"body":"<path fill=\"currentColor\" d=\"M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z\"/>","width":24,"height":24},
"mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:chevron-right": {"body":"<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>","width":24,"height":24},
"mdi:clear-circle-multiple-outline": {"body":"<path fill=\"currentColor\" d=\"m18.54 9.88l-1.42-1.41L15 10.59l-2.12-2.12l-1.41 1.41L13.59 12l-2.12 2.12l1.41 1.42L15 13.41l2.12 2.13l1.42-1.42L16.41 12M2 12c0-2.79 1.64-5.2 4-6.32V3.5C2.5 4.76 0 8.09 0 12s2.5 7.24 6 8.5v-2.18C3.64 17.2 2 14.79 2 12m13-9c-4.96 0-9 4.04-9 9s4.04 9 9 9s9-4.04 9-9s-4.04-9-9-9m0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7s7 3.14 7 7s-3.14 7-7 7\"/>","width":24,"height":24},
"mdi:clipboard-text-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.25 1.44 12.53.64 11 1.2c-.86.3-1.5.96-1.82 1.8H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm10 4H7V9h10zm-2 4H7v-2h8z\"/>","width":24,"height":24},
"mdi:clock-outline": {"body":"<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z\"/>","width":24,"height":24},
"mdi:close": {"body":"<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>","width":24,"height":24},
@ -261,6 +263,8 @@ export const BUNDLED_ICONS = {
"mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
"mdi:sort-alphabetical-descending-variant": {"body":"<path fill=\"currentColor\" d=\"m15.75 19l-3.25 3.25L9.25 19zm-6.86-4.7H6L5.28 17H2.91L6 7h3l3.13 10H9.67zm-2.56-1.62h2.23l-.63-2.12l-.26-.97l-.25-.96h-.03l-.22.97l-.24.98zM13.05 17v-1.26l4.75-6.77v-.06h-4.3V7h7.23v1.34L16.09 15v.08h4.71V17z\"/>","width":24,"height":24},
"mdi:sort-numeric-descending-variant": {"body":"<path fill=\"currentColor\" d=\"M7.78 7c1.3.04 2.22.53 2.79 1.46c.56.94.84 2.1.82 3.49c.01 1.55-.3 2.78-.89 3.67c-.62.88-1.55 1.35-2.79 1.38c-1.26-.04-2.17-.5-2.75-1.44c-.58-.93-.87-2.11-.87-3.56s.3-2.64.91-3.56C5.59 7.5 6.5 7.04 7.78 7m-.03 1.63c-.44 0-.79.27-1.05.83c-.26.54-.38 1.41-.38 2.54c-.01 1.15.12 2 .37 2.54c.26.56.62.83 1.08.83c.92 0 1.39-1.13 1.4-3.37c0-2.23-.47-3.35-1.42-3.37M13.33 17v-1.78l.43.02l.54-.02l1.04-.19c.34-.11.66-.25.92-.45c.33-.23.6-.5.81-.82c.22-.31.37-.64.46-.98l-.03-.01c-.45.42-1.12.63-2.03.64c-.85-.01-1.56-.26-2.13-.76s-.84-1.22-.88-2.15c.01-1 .35-1.81 1.01-2.47c.67-.66 1.53-1 2.65-1.03c1.25.04 2.17.45 2.76 1.24c.59.76.88 1.76.88 2.95c-.01.96-.15 1.81-.44 2.57c-.29.74-.68 1.37-1.2 1.88c-.46.42-1.01.74-1.65.97q-.96.33-2.13.39zm2.73-8.37c-.41.01-.74.17-1 .48c-.25.31-.38.73-.38 1.25c0 .44.12.8.35 1.1c.24.31.6.46 1.08.47c.32 0 .59-.07.81-.19c.22-.13.38-.28.49-.46c.09-.11.12-.31.12-.57c.01-.55-.1-1.02-.33-1.43s-.61-.63-1.14-.65M15.75 19l-3.25 3.25L9.25 19z\"/>","width":24,"height":24},
"mdi:star": {"body":"<path fill=\"currentColor\" d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z\"/>","width":24,"height":24},
"mdi:tab-plus": {"body":"<path fill=\"currentColor\" d=\"M3 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h10v4h8v10H3zm7 5v3H7v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":24,"height":24},
"mdi:table": {"body":"<path fill=\"currentColor\" d=\"M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 4v4h6V8zm8 0v4h6V8zm-8 6v4h6v-4zm8 0v4h6v-4z\"/>","width":24,"height":24},

@ -85,7 +85,17 @@
<div class="searchpanel-header">
<span>Popular Tags</span>
<w-space />
<w-btn class="acrylic-btn" flat label="View All" size="xs" />
<!--
`mousedown.prevent` for the same reason the clear button has it: the press would
otherwise blur the input, which closes this panel before the click lands.
-->
<w-btn
class="acrylic-btn"
flat
label="View All"
size="xs"
@mousedown.prevent
@click="viewAllTags" />
</div>
<div class="mb-4 flex flex-wrap gap-1">
<w-chip
@ -245,6 +255,12 @@ function clearSearch() {
searchField.value.focus()
}
/** The tag browser, which is what this panel's handful of popular ones is a shortcut into. */
function viewAllTags() {
state.searchIsFocused = false
router.push('/_tags')
}
function addTag(tag) {
if (!siteStore.search.includes(`#${tag}`)) {
siteStore.search = siteStore.search ? `${siteStore.search} #${tag}` : `#${tag}`

@ -1,40 +1,19 @@
<template>
<w-menu class="translucent-menu" auto-close anchor="bottom right" self="top right">
<w-list padding>
<!--
The editors this site writes pages with, in the order the store lists them -- which is where
the rule lives, so that what can be created here and what a search can be filtered by cannot
drift apart. Each still carries its own icon and wording: `redirect` makes a redirection
rather than a page, and `wysiwyg` is just "New Page".
-->
<w-item
v-for="editor of siteStore.activeEditors"
:key="editor"
clickable
@click="create(`wysiwyg`)"
v-if="siteStore.editors.wysiwyg && flagsStore.experimental">
<blueprint-icon icon="google-presentation" />
<w-item-section class="pr-2">New Page</w-item-section>
</w-item>
<w-item clickable @click="create(`markdown`)" v-if="siteStore.editors.markdown">
<blueprint-icon icon="markdown" />
<w-item-section class="pr-2">New Markdown Page</w-item-section>
</w-item>
<template v-if="flagsStore.experimental">
<w-item clickable @click="create(`asciidoc`)" v-if="siteStore.editors.asciidoc">
<blueprint-icon icon="asciidoc" />
<w-item-section class="pr-2">New AsciiDoc Page</w-item-section>
</w-item>
<w-item clickable @click="create(`channel`)">
<blueprint-icon icon="chat" />
<w-item-section class="pr-2">New Discussion Space</w-item-section>
</w-item>
<w-item clickable @click="create(`blog`)">
<blueprint-icon icon="typewriter-with-paper" />
<w-item-section class="pr-2">New Blog Page</w-item-section>
</w-item>
<w-item clickable @click="create(`api`)">
<blueprint-icon icon="api" />
<w-item-section class="pr-2">New API Documentation</w-item-section>
</w-item>
</template>
<!-- -> Not an editor the site can turn off, because it authors nothing: a redirection is a page
with a target instead of a body -->
<w-item clickable @click="create(`redirect`)">
<blueprint-icon icon="advance" />
<w-item-section class="pr-2">New Redirection</w-item-section>
@click="create(editor)">
<blueprint-icon :icon="NEW_PAGE_ITEMS[editor].icon" />
<w-item-section class="pr-2">{{ NEW_PAGE_ITEMS[editor].label }}</w-item-section>
</w-item>
<template v-if="props.hideAssetBtn === false">
<w-separator class="my-2" inset />
@ -62,7 +41,24 @@ import { loading } from '@/composables/loading'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useFlagsStore } from '@/stores/flags'
/**
* How each editor is offered: its icon, and the wording of the item that creates one with it.
*
* WHICH of them are offered is `siteStore.activeEditors`, and only that list decides -- these are the
* words for the ones that are.
*/
const NEW_PAGE_ITEMS = {
wysiwyg: { icon: 'google-presentation', label: 'New Page' },
markdown: { icon: 'markdown', label: 'New Markdown Page' },
asciidoc: { icon: 'asciidoc', label: 'New AsciiDoc Page' },
channel: { icon: 'chat', label: 'New Discussion Space' },
blog: { icon: 'typewriter-with-paper', label: 'New Blog Page' },
api: { icon: 'api', label: 'New API Documentation' },
// -> Not an editor the site can turn off, because it authors nothing: a redirection is a page with
// a target instead of a body
redirect: { icon: 'advance', label: 'New Redirection' }
}
// PROPS
@ -97,7 +93,6 @@ const emit = defineEmits(['newFolder', 'newPage'])
// STORES
const editorStore = useEditorStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()

@ -12,7 +12,7 @@
dense
:clickable="!props.edit"
:removable="props.edit"
@click="searchTag(tag)"
@click="browseTag(tag)"
@remove="removeTag(tag)"
v-for="tag of pageStore.tags"
:key="`tag-` + tag">
@ -55,6 +55,7 @@ import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
import { tagBrowserRoute } from '@/helpers/tagBrowser'
// PROPS
@ -177,8 +178,17 @@ function createTag(val) {
* Only reachable in view mode -- WChip emits `click` only while `clickable`, which the editing chips
* are not, their control being the remove button instead.
*/
function searchTag(tag) {
router.push({ path: '/_search', query: { q: `#${tag}` } })
/**
* Open the tag browser on this tag.
*
* It used to push `/_search?q=#tag`, which answered the question by running a search for nothing but
* a filter -- the results screen then showed an empty query box above a list it could not explain,
* and offered no way to narrow the set except by typing more tags into that box. The browser is the
* screen for this: the tag arrives ticked, every other tag on the wiki is beside it to add or swap,
* and the address says what is being looked at.
*/
function browseTag(tag) {
router.push(tagBrowserRoute([tag]))
}
function removeTag(tag) {

@ -17,6 +17,7 @@
type="button"
role="radio"
:aria-checked="String(opt.value === modelValue)"
:aria-label="opt.ariaLabel || undefined"
class="w-btn-toggle__segment w-unstyled relative cursor-pointer px-3 py-1.5 text-sm font-medium transition-[background-color,color,transform]"
:class="[
noCaps ? 'normal-case' : 'uppercase',
@ -34,8 +35,22 @@
]"
:style="segmentStyle(opt)"
@click="$emit('update:modelValue', opt.value)">
<w-icon v-if="opt.icon" :name="opt.icon" class="mr-1 align-middle" />
<!--
`mr-1` is the gutter between the icon and the label, so it belongs to segments that HAVE a
label. Applied unconditionally it also padded an icon-only segment on one side, which against
the segment's symmetric `px-3` left the icon sitting 4px left of centre.
-->
<w-icon
v-if="opt.icon"
:name="opt.icon"
class="align-middle"
:class="opt.label !== undefined ? 'mr-1' : ''" />
<span v-if="opt.label !== undefined">{{ opt.label }}</span>
<!--
Inside the segment, so WTooltip's climb to `closest('button, ...)` lands on THIS segment and
not on the group -- a tooltip per option is the only kind worth having on a segmented control.
-->
<w-tooltip v-if="opt.tooltip">{{ opt.tooltip }}</w-tooltip>
</button>
</div>
</template>
@ -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: () => []

@ -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 } : {}
}
}

@ -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)
}
}
</script>

@ -106,7 +106,9 @@
<!-- ) -->
<!-- template(v-slot:prepend) -->
<!-- q-icon(name='la:user-edit', size='xs') -->
<!-- -> Nothing to choose between on a site with one locale, where every result is in it -->
<w-select
v-if="hasMultipleLocales"
class="mt-2"
outlined
v-model="state.params.filterLocale"
@ -119,18 +121,7 @@
option-label="name"
options-dense
multiple
:display-value="
t(
`search.filterLocaleDisplay`,
{
n:
state.params.filterLocale.length > 0
? state.params.filterLocale[0].toUpperCase()
: state.params.filterLocale.length
},
state.params.filterLocale.length
)
">
:display-value="localeFilterLabel">
<template #prepend><w-icon name="la:language" size="xs" /></template>
</w-select>
<w-select
@ -299,7 +290,11 @@ const state = reactive({
filtersOpen: false,
params: {
filterPath: '',
filterLocale: [],
/*
The reader's own locale to start with, rather than every one -- see `defaultLocaleFilter`. Empty
is still what "every locale" is, both here and on the wire, so nothing downstream changes.
*/
filterLocale: siteStore.defaultLocaleFilter ? [siteStore.defaultLocaleFilter] : [],
filterEditor: '',
filterPublishState: '',
orderBy: 'relevancy',
@ -330,15 +325,36 @@ const orderByOptions = computed(() => {
]
})
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)
}
}

File diff suppressed because it is too large Load Diff

@ -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)
}
}

@ -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/<id>/avatar` -- both `backend/index.ts` and the dev proxy in `frontend/vite.config.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: {

Loading…
Cancel
Save