Skip to content

Commit b6bcee8

Browse files
committed
feat: tags page + various fixes
1 parent 48835a2 commit b6bcee8

15 files changed

Lines changed: 1924 additions & 75 deletions

File tree

‎backend/api/pages.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { validate as uuidValidate } from 'uuid'
22
import type { FastifyInstance, FastifyRequest } from 'fastify'
33
import type { PageActor, PageInput } from '../models/pages.ts'
4-
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
4+
import {
5+
SEARCH_ORDER_BY,
6+
SEARCH_TAGS_MATCH,
7+
type SearchOrderBy,
8+
type SearchTagsMatch
9+
} from '../models/search.ts'
510
import { audit } from '../helpers/audit.ts'
611
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
712
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
@@ -292,6 +297,7 @@ async function routes(app: FastifyInstance) {
292297
path?: string
293298
locales?: string
294299
tags?: string
300+
tagsMatch?: SearchTagsMatch
295301
editor?: string
296302
publishState?: string
297303
creatorId?: string
@@ -331,7 +337,15 @@ async function routes(app: FastifyInstance) {
331337
tags: {
332338
type: 'string',
333339
maxLength: 2048,
334-
description: 'Comma-separated tags a page must carry all of.'
340+
description:
341+
"Comma-separated tags to match against a page's own, as `tagsMatch` says."
342+
},
343+
tagsMatch: {
344+
type: 'string',
345+
enum: SEARCH_TAGS_MATCH,
346+
default: 'all',
347+
description:
348+
'Whether a page must carry `all` of the tags given (the default) or `any` one of them.'
335349
},
336350
editor: {
337351
type: 'string',
@@ -419,6 +433,7 @@ async function routes(app: FastifyInstance) {
419433
path: req.query.path,
420434
locales: splitList(req.query.locales),
421435
tags: splitList(req.query.tags),
436+
tagsMatch: req.query.tagsMatch,
422437
editor: req.query.editor,
423438
publishState: req.query.publishState,
424439
creatorId: req.query.creatorId,

‎backend/locales/en.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2587,23 +2587,40 @@
25872587
"search.sortByRelevance": "Relevance",
25882588
"search.sortByTitle": "Title",
25892589
"search.totalResults": "No result | {0} result | {0} results",
2590+
"tags.allLocales": "All locales",
2591+
"tags.allTags": "All Tags",
25902592
"tags.clearSelection": "Clear Selection",
25912593
"tags.currentSelection": "Current Selection",
2594+
"tags.loadFailed": "Failed to load the list of tags.",
2595+
"tags.loadMore": "Load More",
25922596
"tags.locale": "Locale",
25932597
"tags.localeAny": "Any",
2598+
"tags.matchAll": "AND",
2599+
"tags.matchAllHint": "Pages carrying every selected tag",
2600+
"tags.matchAny": "OR",
2601+
"tags.matchAnyHint": "Pages carrying any selected tag",
2602+
"tags.matchMode": "Match tags",
25942603
"tags.noResults": "Couldn't find any page with the selected tags.",
25952604
"tags.noResultsWithFilter": "Couldn't find any page matching the current filtering options.",
2605+
"tags.noTags": "No tags have been used on this site yet.",
25962606
"tags.orderBy": "Order By",
25972607
"tags.orderByField.ID": "ID",
25982608
"tags.orderByField.creationDate": "Creation Date",
25992609
"tags.orderByField.lastModified": "Last Modified",
26002610
"tags.orderByField.path": "Path",
26012611
"tags.orderByField.title": "Title",
2612+
"tags.orderDirectionAscending": "Ascending",
2613+
"tags.orderDirectionDescending": "Descending",
26022614
"tags.pageLastUpdated": "Last Updated {date}",
26032615
"tags.retrievingResultsLoading": "Retrieving page results...",
2616+
"tags.searchFailed": "Failed to load the pages for the selected tags.",
26042617
"tags.searchWithinResultsPlaceholder": "Search within results...",
26052618
"tags.selectOneMoreTags": "Select one or more tags",
26062619
"tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
2620+
"tags.sortAlphabetical": "List tags alphabetically",
2621+
"tags.sortPopularity": "List tags by popularity",
2622+
"tags.sortTags": "Sort tags",
2623+
"tags.title": "Tags",
26072624
"userProfile.lastLogin": "Last Login",
26082625
"userProfile.loadMore": "Load More",
26092626
"userProfile.loadingFailed": "Failed to load user profile.",

‎backend/models/search.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,25 @@ export interface RebuildResult {
5454
locales: { locale: string; dictionary: string; pages: number }[]
5555
}
5656

57-
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'createdAt', 'updatedAt'] as const
57+
/**
58+
* How a list of tags is matched against a page's own.
59+
*
60+
* `all` is the narrowing sense a filter usually has — each tag added takes pages away — and is what
61+
* a tag filter alongside a text query means. `any` is the widening one, which is what browsing by
62+
* tag wants: two tags picked off a list are two things the reader is interested in, not a demand
63+
* that one page be both.
64+
*/
65+
export const SEARCH_TAGS_MATCH = ['all', 'any'] as const
66+
export type SearchTagsMatch = (typeof SEARCH_TAGS_MATCH)[number]
67+
68+
export const SEARCH_ORDER_BY = [
69+
'relevancy',
70+
'id',
71+
'path',
72+
'title',
73+
'createdAt',
74+
'updatedAt'
75+
] as const
5876
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
5977

6078
export interface SearchResult {
@@ -82,6 +100,8 @@ export interface SearchPagesParams {
82100
path?: string
83101
locales?: string[]
84102
tags?: string[]
103+
/** Whether a page must carry every tag in `tags` (the default) or merely one of them. */
104+
tagsMatch?: SearchTagsMatch
85105
editor?: string
86106
publishState?: string
87107
/**
@@ -229,6 +249,7 @@ class Search {
229249
path = '',
230250
locales = [],
231251
tags = [],
252+
tagsMatch = 'all',
232253
editor = '',
233254
publishState = '',
234255
creatorId = '',
@@ -300,7 +321,12 @@ class Search {
300321
conditions.push(sql`p.locale = ANY(${sql.param(locales)}::text[])`)
301322
}
302323
if (tags.length > 0) {
303-
conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
324+
// -> `@>` is contains-all, `&&` is overlaps; both are indexable the same way
325+
conditions.push(
326+
tagsMatch === 'any'
327+
? sql`p.tags && ${sql.param(tags)}::text[]`
328+
: sql`p.tags @> ${sql.param(tags)}::text[]`
329+
)
304330
}
305331
if (editor) {
306332
conditions.push(sql`p.editor = ${editor}`)
@@ -317,6 +343,13 @@ class Search {
317343
const effectiveOrderBy = orderBy === 'relevancy' && !hasQuery ? 'updatedAt' : orderBy
318344
const ordering = {
319345
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
346+
id: sql`p.id ${direction}`,
347+
/*
348+
A path is only unique within a locale, so two translations of the same page would otherwise
349+
come back in whatever order the planner chose -- and swap places between two requests for the
350+
same list. The locale settles it.
351+
*/
352+
path: sql`p.path ${direction}, p.locale ASC`,
320353
title: sql`p.title ${direction}`,
321354
createdAt: sql`p."createdAt" ${direction}`,
322355
updatedAt: sql`p."updatedAt" ${direction}`

‎frontend/src/assets/icons.generated.js‎

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎frontend/src/components/HeaderSearch.vue‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,17 @@
8585
<div class="searchpanel-header">
8686
<span>Popular Tags</span>
8787
<w-space />
88-
<w-btn class="acrylic-btn" flat label="View All" size="xs" />
88+
<!--
89+
`mousedown.prevent` for the same reason the clear button has it: the press would
90+
otherwise blur the input, which closes this panel before the click lands.
91+
-->
92+
<w-btn
93+
class="acrylic-btn"
94+
flat
95+
label="View All"
96+
size="xs"
97+
@mousedown.prevent
98+
@click="viewAllTags" />
8999
</div>
90100
<div class="mb-4 flex flex-wrap gap-1">
91101
<w-chip
@@ -245,6 +255,12 @@ function clearSearch() {
245255
searchField.value.focus()
246256
}
247257
258+
/** The tag browser, which is what this panel's handful of popular ones is a shortcut into. */
259+
function viewAllTags() {
260+
state.searchIsFocused = false
261+
router.push('/_tags')
262+
}
263+
248264
function addTag(tag) {
249265
if (!siteStore.search.includes(`#${tag}`)) {
250266
siteStore.search = siteStore.search ? `${siteStore.search} #${tag}` : `#${tag}`

0 commit comments

Comments
 (0)