}}
- slots={{ actions: DocsActions, themeSwitch: ThemeSwitcher }}
+ searchToggle={{ enabled: false }}
+ slots={{
+ navTitle: DocsNavTitle,
+ sidebar: {
+ drawer: SidebarDrawer,
+ main: Sidebar,
+ provider: DocsSidebarProvider,
+ },
+ }}
tabs={{
transform(option) {
const tab = docsSectionOf(option.url)
@@ -95,6 +87,7 @@ export const DocsShellContent = ({
}
},
}}
+ themeSwitch={{ enabled: false }}
tree={pageTree}
>
{children}
diff --git a/apps/web/src/docs/sidebar-toggle.tsx b/apps/web/src/docs/sidebar-toggle.tsx
new file mode 100644
index 0000000000..5d9a133151
--- /dev/null
+++ b/apps/web/src/docs/sidebar-toggle.tsx
@@ -0,0 +1,21 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import { useSidebar } from 'fumadocs-ui/components/sidebar/base'
+import { PanelLeftCloseIcon, PanelLeftOpenIcon } from 'lucide-react'
+
+export const DocsSidebarToggle = () => {
+ const { collapsed, setCollapsed } = useSidebar()
+
+ return (
+
setCollapsed((value) => !value)}
+ size="icon"
+ variant="ghost"
+ >
+ {collapsed ? : }
+
+ )
+}
diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json
index 916e9ce008..b03b3518a0 100644
--- a/apps/web/src/locales/@vitnode/core/pl.json
+++ b/apps/web/src/locales/@vitnode/core/pl.json
@@ -2505,6 +2505,11 @@
"results_not_found": "Brak wyników",
"save": "Zapisz",
"search_placeholder": "Szukaj...",
+ "search_dialog": {
+ "desc": "Znajduj artykuły, tematy i komentarze już podczas pisania.",
+ "open": "Otwórz wyszukiwanie",
+ "title": "Szukaj"
+ },
"select_language": "Wybierz język",
"select_option": "Wybierz opcję",
"select_options": "Wybierz opcje",
@@ -2759,6 +2764,25 @@
"search": "Szukaj"
},
"placeholder": "Szukaj…",
+ "dialog": {
+ "count": "{count, plural, =0 {Brak wyników} one {# wynik} few {# wyniki} many {# wyników} other {# wyniku}}",
+ "error": "Wyszukiwarka ucięła sobie drzemkę. Spróbuj ponownie za chwilę.",
+ "in_context": "w {context}",
+ "label": "Przeszukaj społeczność",
+ "no_results": "Brak szybkich dopasowań. Spróbuj innego słowa albo zobacz wszystkie wyniki.",
+ "popular": "Popularne teraz",
+ "popular_empty": "Nic jeszcze nie jest popularne. Zacznij pisać, aby wyszukać.",
+ "see_all": "Zobacz wszystkie wyniki dla „{term}”",
+ "suggestions": "Podpowiedzi",
+ "recent": "Ostatnie wyszukiwania",
+ "complete": "aby uzupełnić",
+ "close": "aby zamknąć",
+ "navigate": "aby się poruszać",
+ "open": "aby otworzyć",
+ "key_up": "Strzałka w górę",
+ "key_down": "Strzałka w dół",
+ "key_enter": "Enter"
+ },
"sort": {
"newest": "Najnowsze",
"oldest": "Najstarsze",
@@ -2812,7 +2836,8 @@
},
"timeline": {
"items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}",
- "in_context": "w
{context} "
+ "posted_in": "
{author} publikuje w
{context} ",
+ "posted_in_self": "Opublikowano w
{context} "
}
},
"notifications": {
diff --git a/apps/web/src/locales/site/en.json b/apps/web/src/locales/site/en.json
index 77bcc27301..c787132c1c 100644
--- a/apps/web/src/locales/site/en.json
+++ b/apps/web/src/locales/site/en.json
@@ -228,6 +228,9 @@
}
}
}
+ },
+ "search": {
+ "docs": "Documentation"
}
}
}
diff --git a/apps/web/src/locales/site/pl.json b/apps/web/src/locales/site/pl.json
index 3c0883bba3..b7d3ffadb7 100644
--- a/apps/web/src/locales/site/pl.json
+++ b/apps/web/src/locales/site/pl.json
@@ -228,6 +228,9 @@
}
}
}
+ },
+ "search": {
+ "docs": "Dokumentacja"
}
}
}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index b7f0dd098c..d7dd181c1c 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -16,12 +16,14 @@ import {
pluginRouteSpecs,
withVitNodeRoutes,
} from '@vitnode/core/tanstack/plugin-routes'
+import { configureSearchSources } from '@vitnode/core/tanstack/search'
import { navigationViewTransition } from '@vitnode/core/tanstack/view-transitions'
// Imported for its side effect: this module calls `configureIntl`, which is
// what registers the locale rules every route's redirects are rewritten by.
import './lib/i18n'
import { dehydrateDocsPage, hydrateDocsPage } from './docs/hydration'
+import { docsSearchSource } from './docs/search-source'
import { pluginRouteSources } from './plugin-routes.gen'
import { Route as adminShellRoute } from './routes/_admin'
import { Route as mainShellRoute } from './routes/_main'
@@ -32,6 +34,8 @@ configureContentRegistry(
async () => (await import('@/content-registry.gen')).contentRegistry,
)
+configureSearchSources([docsSearchSource])
+
const routeTree = withVitNodeRoutes(
fileRouteTree,
pluginRouteSpecs(pluginRouteSources),
diff --git a/apps/web/src/routes/_docs.tsx b/apps/web/src/routes/_docs.tsx
index 851e7555f1..bd9fa02b8f 100644
--- a/apps/web/src/routes/_docs.tsx
+++ b/apps/web/src/routes/_docs.tsx
@@ -1,19 +1,28 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
+import { loadMainShell } from '@vitnode/core/tanstack/layout'
import { useFumadocsLoader } from 'fumadocs-core/source/client'
import docsCss from '@/docs/docs.css?url'
import { DocsShellPendingSkeleton } from '@/docs/pending'
-import { DOCS_STALE_TIME } from '@/docs/shared'
+import { DOCS_PENDING_MS, DOCS_STALE_TIME } from '@/docs/shared'
import { DocsShellContent } from '@/docs/shell-content'
import { getDocsPageTree } from '@/docs/transport'
import { withoutHmrTimestamp } from '@/lib/without-hmr-timestamp'
export const Route = createFileRoute('/_docs')({
- loader: async () => ({ pageTree: await getDocsPageTree() }),
+ loader: async ({ context }) => {
+ const [pageTree] = await Promise.all([
+ getDocsPageTree(),
+ loadMainShell(context),
+ ])
+
+ return { pageTree }
+ },
head: () => ({
links: [{ href: withoutHmrTimestamp(docsCss), rel: 'stylesheet' }],
}),
staleTime: DOCS_STALE_TIME,
+ pendingMs: DOCS_PENDING_MS,
component: DocsShell,
pendingComponent: DocsShellPendingSkeleton,
})
diff --git a/apps/web/src/routes/_docs/docs.$.tsx b/apps/web/src/routes/_docs/docs.$.tsx
index 78327e75df..193a587df1 100644
--- a/apps/web/src/routes/_docs/docs.$.tsx
+++ b/apps/web/src/routes/_docs/docs.$.tsx
@@ -6,7 +6,7 @@ import { DocsError, DocsNotFound } from '@/docs/error-views'
import { DocsPageContent } from '@/docs/page-content'
import { DocsPagePendingSkeleton } from '@/docs/pending'
import { movedDocsPath } from '@/docs/redirects'
-import { DOCS_STALE_TIME } from '@/docs/shared'
+import { DOCS_PENDING_MS, DOCS_STALE_TIME } from '@/docs/shared'
import { getDocsPage } from '@/docs/transport'
export const Route = createFileRoute('/_docs/docs/$')({
@@ -42,6 +42,7 @@ export const Route = createFileRoute('/_docs/docs/$')({
title: loaderData?.metaTitle,
}),
staleTime: DOCS_STALE_TIME,
+ pendingMs: DOCS_PENDING_MS,
component: DocsRoute,
errorComponent: DocsError,
notFoundComponent: DocsNotFound,
diff --git a/apps/web/src/routes/docs.search.ts b/apps/web/src/routes/docs.search.ts
index 7f44c236c8..ad2b925e00 100644
--- a/apps/web/src/routes/docs.search.ts
+++ b/apps/web/src/routes/docs.search.ts
@@ -1,14 +1,32 @@
import { createFileRoute } from '@tanstack/react-router'
+import { componentNameToIconName } from '@vitnode/core/lib/emoji-icon'
import { createFromSource } from 'fumadocs-core/search/server'
import { source } from '@/docs/source.server'
const docsSearch = createFromSource(source, { language: 'english' })
+const pageIconOf = (url: string): string | undefined => {
+ const icon = source.getPageByHref(url)?.page.data.icon
+
+ return icon ? componentNameToIconName(icon) : undefined
+}
+
export const Route = createFileRoute('/docs/search')({
server: {
handlers: {
- GET: async ({ request }) => await docsSearch.GET(request),
+ GET: async ({ request }) => {
+ const query = new URL(request.url).searchParams.get('query') ?? ''
+ const results = await docsSearch.search(query)
+
+ return Response.json(
+ results.map((result) =>
+ result.type === 'page'
+ ? { ...result, icon: pageIconOf(result.url) }
+ : result,
+ ),
+ )
+ },
},
},
})
diff --git a/packages/vitnode/scripts/cli/db/database.ts b/packages/vitnode/scripts/cli/db/database.ts
index 2bb78456d5..d93f80fa50 100644
--- a/packages/vitnode/scripts/cli/db/database.ts
+++ b/packages/vitnode/scripts/cli/db/database.ts
@@ -128,6 +128,8 @@ export const createDatabaseServices = (root: string): DatabaseServices => ({
await bootstrap.runMigrations({
config: apiConfig,
drizzle: config,
+ log,
+ root,
});
await bootstrap.initialDataForDatabase(apiConfig);
},
diff --git a/packages/vitnode/scripts/cli/db/missing-indexes.ts b/packages/vitnode/scripts/cli/db/missing-indexes.ts
new file mode 100644
index 0000000000..c3652c0e3a
--- /dev/null
+++ b/packages/vitnode/scripts/cli/db/missing-indexes.ts
@@ -0,0 +1,229 @@
+import type { Index } from "drizzle-orm/pg-core";
+
+import { is, SQL, sql } from "drizzle-orm";
+import {
+ getTableConfig,
+ IndexedColumn,
+ PgDialect,
+ PgTable,
+} from "drizzle-orm/pg-core";
+import { createJiti } from "jiti";
+import {
+ existsSync,
+ globSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+} from "node:fs";
+import { join, resolve } from "node:path";
+
+import type { QueryRows } from "./migration-state";
+
+import { findDrizzleConfig } from "../project/project";
+
+export interface IndexIdentity {
+ name: string;
+ schema: string;
+}
+
+export interface DeclaredIndex extends IndexIdentity {
+ index: Index;
+}
+
+const SCHEMA_FILE = /\.[cm]?[jt]sx?$/;
+
+const dialect = new PgDialect();
+
+export const indexKey = ({ name, schema }: IndexIdentity) =>
+ `${schema}.${name}`;
+
+const isRecord = (value: unknown): value is Record
=>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isDefined = (value: T | undefined): value is T => value !== undefined;
+
+export const declaredIndexes = (tables: readonly PgTable[]): DeclaredIndex[] =>
+ tables.flatMap(table => {
+ const { indexes, schema = "public" } = getTableConfig(table);
+
+ return indexes.flatMap(index =>
+ index.config.name === undefined
+ ? []
+ : [{ index, name: index.config.name, schema }],
+ );
+ });
+
+export const recordedIndexKeys = (snapshot: unknown): Set => {
+ const keys = new Set();
+ const ddl =
+ isRecord(snapshot) && Array.isArray(snapshot.ddl) ? snapshot.ddl : [];
+
+ for (const entity of ddl) {
+ if (
+ isRecord(entity) &&
+ entity.entityType === "indexes" &&
+ typeof entity.name === "string"
+ ) {
+ keys.add(
+ indexKey({
+ name: entity.name,
+ schema: typeof entity.schema === "string" ? entity.schema : "public",
+ }),
+ );
+ }
+ }
+
+ return keys;
+};
+
+export const findMissingIndexes = ({
+ declared,
+ existing,
+ recorded,
+}: {
+ declared: readonly T[];
+ existing: ReadonlySet;
+ recorded: ReadonlySet;
+}): T[] => [
+ ...new Map(
+ declared
+ .filter(
+ index =>
+ recorded.has(indexKey(index)) && !existing.has(indexKey(index)),
+ )
+ .map(index => [indexKey(index), index] as const),
+ ).values(),
+];
+
+const renderColumn = (column: unknown): SQL => {
+ if (is(column, SQL)) return sql`(${column})`;
+ if (!is(column, IndexedColumn)) {
+ throw new Error("An index column is neither a column nor an expression.");
+ }
+
+ const { nulls, opClass, order } = column.indexConfig;
+ const descending = order === "desc";
+
+ return sql.join(
+ [
+ sql.identifier(column.name),
+ opClass === undefined ? undefined : sql.raw(opClass),
+ descending ? sql.raw("DESC") : undefined,
+ nulls === undefined || (!descending && nulls === "last")
+ ? undefined
+ : sql.raw(`NULLS ${nulls.toUpperCase()}`),
+ ].filter(isDefined),
+ sql.raw(" "),
+ );
+};
+
+export const renderCreateIndex = ({ index, name }: DeclaredIndex): string => {
+ const {
+ columns,
+ method = "btree",
+ table,
+ unique,
+ where,
+ with: storage = {},
+ } = index.config;
+ const parameters = Object.entries(storage as Record);
+
+ const statement = sql.join(
+ [
+ sql.raw(unique ? "CREATE UNIQUE INDEX" : "CREATE INDEX"),
+ sql`IF NOT EXISTS ${sql.identifier(name)} ON ${table}`,
+ sql`USING ${sql.raw(method)} (${sql.join(columns.map(renderColumn), sql.raw(", "))})`,
+ parameters.length === 0
+ ? undefined
+ : sql`WITH (${sql.join(
+ parameters.map(([key, value]) => sql`${sql.raw(key)} = ${value}`),
+ sql.raw(", "),
+ )})`,
+ where === undefined ? undefined : sql`WHERE ${where}`,
+ ].filter(isDefined),
+ sql.raw(" "),
+ );
+
+ return dialect.sqlToQuery(statement.inlineParams(), "indexes").sql;
+};
+
+const latestSnapshot = (migrationsFolder: string): unknown => {
+ if (!existsSync(migrationsFolder)) return null;
+
+ const latest = readdirSync(migrationsFolder)
+ .map(entry => join(migrationsFolder, entry, "snapshot.json"))
+ .filter(file => existsSync(file))
+ .sort()
+ .at(-1);
+
+ return latest === undefined
+ ? null
+ : (JSON.parse(readFileSync(latest, "utf8")) as unknown);
+};
+
+const schemaFiles = (patterns: readonly string[], root: string): string[] => [
+ ...new Set(
+ globSync(patterns, { cwd: root })
+ .map(match => resolve(root, match))
+ .flatMap(path =>
+ statSync(path).isDirectory()
+ ? readdirSync(path).map(file => join(path, file))
+ : [path],
+ )
+ .filter(file => SCHEMA_FILE.test(file) && statSync(file).isFile()),
+ ),
+];
+
+const loadSchemaTables = async (root: string): Promise => {
+ const configPath = findDrizzleConfig(root);
+ if (configPath === null) return [];
+
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
+ const loaded = await jiti.import<
+ Record & { default?: Record }
+ >(configPath);
+ const { schema } = loaded.default ?? loaded;
+ const patterns = (Array.isArray(schema) ? schema : [schema]).filter(
+ (pattern): pattern is string => typeof pattern === "string",
+ );
+ const modules = await Promise.all(
+ schemaFiles(patterns, root).map(async file =>
+ jiti.import>(file),
+ ),
+ );
+
+ return [
+ ...new Set(
+ modules
+ .flatMap(module => Object.values(module))
+ .filter((value): value is PgTable => is(value, PgTable)),
+ ),
+ ];
+};
+
+export const restoreMissingIndexes = async ({
+ log,
+ migrationsFolder,
+ query,
+ root,
+}: {
+ log: (message: string) => void;
+ migrationsFolder: string;
+ query: QueryRows;
+ root: string;
+}): Promise => {
+ const recorded = recordedIndexKeys(latestSnapshot(migrationsFolder));
+ if (recorded.size === 0) return;
+
+ const declared = declaredIndexes(await loadSchemaTables(root));
+ const existing = new Set(
+ (await query("SELECT schemaname, indexname FROM pg_indexes")).map(row =>
+ indexKey({ name: String(row.indexname), schema: String(row.schemaname) }),
+ ),
+ );
+
+ for (const missing of findMissingIndexes({ declared, existing, recorded })) {
+ await query(renderCreateIndex(missing));
+ log(`Restored index ${missing.name}`);
+ }
+};
diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts
index 6d539d7011..7dfdabd478 100644
--- a/packages/vitnode/scripts/prepare-database.ts
+++ b/packages/vitnode/scripts/prepare-database.ts
@@ -14,6 +14,7 @@ import { SEARCH_TEXT_CONFIGS } from "@/database/search.js";
import type { VitNodeApiI18nConfig } from "../src/lib/i18n/types.js";
import type { VitNodeApiConfig } from "../src/vitnode.config.js";
+import { restoreMissingIndexes } from "./cli/db/missing-indexes.js";
import { RuntimeError } from "./cli/errors.js";
import { resolveBin } from "./cli/project/packages.js";
import { runProcess } from "./cli/project/processes.js";
@@ -223,9 +224,15 @@ export const describePostgresError = (err: unknown): string[] => {
export const runMigrations = async ({
config: given,
drizzle,
+ log = message => {
+ console.log(`[VitNode] ${message}`);
+ },
+ root = process.cwd(),
}: {
config?: VitNodeApiConfig;
drizzle?: DrizzleProjectConfig;
+ log?: (message: string) => void;
+ root?: string;
} = {}) => {
const config = given ?? (await getConfig({ type: "api.config" }));
@@ -234,7 +241,7 @@ export const runMigrations = async ({
await ensureSearchTextConfigs(config.dbProvider);
const { migrationsFolder, migrationsSchema, migrationsTable } =
- drizzle ?? (await readDrizzleConfig());
+ drizzle ?? (await readDrizzleConfig(root));
try {
// Run migrations in-process instead of shelling out to `drizzle-kit migrate`:
@@ -256,6 +263,20 @@ export const runMigrations = async ({
details: describePostgresError(err),
});
}
+
+ try {
+ await restoreMissingIndexes({
+ log,
+ migrationsFolder,
+ query: async text => await config.dbProvider.execute(sql.raw(text)),
+ root,
+ });
+ } catch (err) {
+ throw new RuntimeError("Restoring missing indexes failed.", {
+ cause: err,
+ details: describePostgresError(err),
+ });
+ }
};
/**
diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts
index d5b9be94a5..333e50829d 100644
--- a/packages/vitnode/src/api/adapters/search/postgres.ts
+++ b/packages/vitnode/src/api/adapters/search/postgres.ts
@@ -1,24 +1,7 @@
import type { SQL } from "drizzle-orm";
-import type { PgColumn } from "drizzle-orm/pg-core";
import type { Context } from "hono";
-import {
- and,
- arrayContains,
- arrayOverlaps,
- asc,
- count,
- desc,
- eq,
- gt,
- gte,
- inArray,
- lt,
- lte,
- notExists,
- or,
- sql,
-} from "drizzle-orm";
+import { and, asc, count, desc, eq, gt, lt, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type {
@@ -28,6 +11,7 @@ import type {
SearchResult,
} from "@/api/models/search";
+import { searchIndexFilters } from "@/api/lib/search-filters";
import { parseSearchOffset } from "@/api/lib/search-offset";
import { storageUrlOf } from "@/api/lib/storage-url";
import { searchItemIdFromKey } from "@/api/models/search";
@@ -39,117 +23,9 @@ const authorAvatarFile = alias(core_files, "search_author_avatar_file");
const leadAuthorId = sql`${core_search_index.authorIds}[1]`;
-const requestedTranslation = alias(
- core_search_index,
- "search_requested_translation",
-);
-
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
-const visibleCondition = (
- table: Record<"authorIds" | "isHidden" | "isPublic" | "itemType", PgColumn>,
- params: SearchQueryParams,
-): SQL | undefined => {
- if (params.includePrivate) return undefined;
-
- const access = params.hiddenAccess;
- const viewer = or(
- access?.authorId === undefined
- ? undefined
- : arrayContains(table.authorIds, [access.authorId]),
- access?.itemTypes.length
- ? inArray(table.itemType, [...access.itemTypes])
- : undefined,
- );
-
- return viewer
- ? or(eq(table.isPublic, true), and(eq(table.isHidden, true), viewer))
- : eq(table.isPublic, true);
-};
-
-const fallbackLanguageConditions = (
- c: Context,
- params: SearchQueryParams,
- languageCode: string,
-): SQL[] =>
- (params.languageFallbacks ?? []).flatMap(fallback => {
- const condition = and(
- eq(core_search_index.itemType, fallback.itemType),
- eq(core_search_index.languageCode, fallback.languageCode),
- notExists(
- c
- .get("db")
- .select({ id: requestedTranslation.id })
- .from(requestedTranslation)
- .where(
- and(
- eq(requestedTranslation.itemType, core_search_index.itemType),
- eq(requestedTranslation.itemId, core_search_index.itemId),
- eq(requestedTranslation.languageCode, languageCode),
- visibleCondition(requestedTranslation, params),
- ),
- ),
- ),
- );
-
- return condition ? [condition] : [];
- });
-
-const buildFilters = (
- c: Context,
- params: SearchQueryParams,
-): SQL | undefined => {
- const conditions: SQL[] = [];
-
- const term = params.term?.trim();
- if (term) {
- // Match the config the row's vector was built with (scoped by locale, else
- // `simple`) so `websearch_to_tsquery` stems the term the same way.
- const config = resolveSearchTextConfig(params.languageCode);
- conditions.push(
- sql`"core_search_index"."search_vector" @@ websearch_to_tsquery(${config}::regconfig, ${term})`,
- );
- }
- if (params.languageCode) {
- // Language-agnostic rows (empty `languageCode`) match every locale.
- const languageCondition = or(
- eq(core_search_index.languageCode, params.languageCode),
- eq(core_search_index.languageCode, ""),
- ...fallbackLanguageConditions(c, params, params.languageCode),
- );
- if (languageCondition) {
- conditions.push(languageCondition);
- }
- }
- if (params.itemTypes?.length) {
- conditions.push(inArray(core_search_index.itemType, params.itemTypes));
- }
- if (params.authorId !== undefined) {
- conditions.push(
- arrayContains(core_search_index.authorIds, [params.authorId]),
- );
- }
- if (params.authorIds?.length) {
- conditions.push(
- arrayOverlaps(core_search_index.authorIds, [...params.authorIds]),
- );
- }
- if (params.containerId !== undefined) {
- conditions.push(eq(core_search_index.containerId, params.containerId));
- }
- if (params.dateFrom) {
- conditions.push(gte(core_search_index.createdAt, params.dateFrom));
- }
- if (params.dateTo) {
- conditions.push(lte(core_search_index.createdAt, params.dateTo));
- }
- const visible = visibleCondition(core_search_index, params);
- if (visible) conditions.push(visible);
-
- return conditions.length ? and(...conditions) : undefined;
-};
-
export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
name: "postgres",
// Two capabilities that are both true for the same reason: this provider's
@@ -188,7 +64,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
.get("db")
.select({ itemType: core_search_index.itemType, total: count() })
.from(core_search_index)
- .where(buildFilters(c, params))
+ .where(searchIndexFilters(c, params))
.groupBy(core_search_index.itemType);
return Object.fromEntries(rows.map(row => [row.itemType, row.total]));
@@ -202,9 +78,9 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
const term = params.term?.trim();
const useRelevance = params.sort === "relevance" && !!term;
const size = Math.min(params.first ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
- const filters = buildFilters(c, params);
+ const filters = searchIndexFilters(c, params);
- const [{ total }] = await db
+ const totalQuery = db
.select({ total: count() })
.from(core_search_index)
.where(filters);
@@ -244,7 +120,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
}
}
- const rows = await db
+ const rowsQuery = db
.select({
id: core_search_index.id,
pluginId: core_search_index.pluginId,
@@ -280,6 +156,8 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
.limit(size + 1)
.offset(useRelevance ? offset : 0);
+ const [[{ total }], rows] = await Promise.all([totalQuery, rowsQuery]);
+
const hasNextPage = rows.length > size;
const sliced = rows.slice(0, size);
diff --git a/packages/vitnode/src/api/lib/search-filters.ts b/packages/vitnode/src/api/lib/search-filters.ts
new file mode 100644
index 0000000000..eb4e22a884
--- /dev/null
+++ b/packages/vitnode/src/api/lib/search-filters.ts
@@ -0,0 +1,130 @@
+import type { SQL } from "drizzle-orm";
+import type { PgColumn } from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import {
+ and,
+ arrayContains,
+ arrayOverlaps,
+ eq,
+ gte,
+ inArray,
+ lte,
+ notExists,
+ or,
+ sql,
+} from "drizzle-orm";
+import { alias } from "drizzle-orm/pg-core";
+
+import type { SearchQueryParams } from "@/api/models/search";
+
+import { core_search_index, resolveSearchTextConfig } from "@/database/search";
+
+const requestedTranslation = alias(
+ core_search_index,
+ "search_requested_translation",
+);
+
+const visibleCondition = (
+ table: Record<"authorIds" | "isHidden" | "isPublic" | "itemType", PgColumn>,
+ params: SearchQueryParams,
+): SQL | undefined => {
+ if (params.includePrivate) return undefined;
+
+ const access = params.hiddenAccess;
+ const viewer = or(
+ access?.authorId === undefined
+ ? undefined
+ : arrayContains(table.authorIds, [access.authorId]),
+ access?.itemTypes.length
+ ? inArray(table.itemType, [...access.itemTypes])
+ : undefined,
+ );
+
+ return viewer
+ ? or(eq(table.isPublic, true), and(eq(table.isHidden, true), viewer))
+ : eq(table.isPublic, true);
+};
+
+const fallbackLanguageConditions = (
+ c: Context,
+ params: SearchQueryParams,
+ languageCode: string,
+): SQL[] =>
+ (params.languageFallbacks ?? []).flatMap(fallback => {
+ const condition = and(
+ eq(core_search_index.itemType, fallback.itemType),
+ eq(core_search_index.languageCode, fallback.languageCode),
+ notExists(
+ c
+ .get("db")
+ .select({ id: requestedTranslation.id })
+ .from(requestedTranslation)
+ .where(
+ and(
+ eq(requestedTranslation.itemType, core_search_index.itemType),
+ eq(requestedTranslation.itemId, core_search_index.itemId),
+ eq(requestedTranslation.languageCode, languageCode),
+ visibleCondition(requestedTranslation, params),
+ ),
+ )
+ .offset(0),
+ ),
+ );
+
+ return condition ? [condition] : [];
+ });
+
+export const searchIndexFilters = (
+ c: Context,
+ params: SearchQueryParams,
+): SQL | undefined => {
+ const conditions: SQL[] = [];
+
+ const term = params.term?.trim();
+ if (term) {
+ // Match the config the row's vector was built with (scoped by locale, else
+ // `simple`) so `websearch_to_tsquery` stems the term the same way.
+ const config = resolveSearchTextConfig(params.languageCode);
+ conditions.push(
+ sql`"core_search_index"."search_vector" @@ websearch_to_tsquery(${config}::regconfig, ${term})`,
+ );
+ }
+ if (params.languageCode) {
+ // Language-agnostic rows (empty `languageCode`) match every locale.
+ const languageCondition = or(
+ eq(core_search_index.languageCode, params.languageCode),
+ eq(core_search_index.languageCode, ""),
+ ...fallbackLanguageConditions(c, params, params.languageCode),
+ );
+ if (languageCondition) {
+ conditions.push(languageCondition);
+ }
+ }
+ if (params.itemTypes?.length) {
+ conditions.push(inArray(core_search_index.itemType, params.itemTypes));
+ }
+ if (params.authorId !== undefined) {
+ conditions.push(
+ arrayContains(core_search_index.authorIds, [params.authorId]),
+ );
+ }
+ if (params.authorIds?.length) {
+ conditions.push(
+ arrayOverlaps(core_search_index.authorIds, [...params.authorIds]),
+ );
+ }
+ if (params.containerId !== undefined) {
+ conditions.push(eq(core_search_index.containerId, params.containerId));
+ }
+ if (params.dateFrom) {
+ conditions.push(gte(core_search_index.createdAt, params.dateFrom));
+ }
+ if (params.dateTo) {
+ conditions.push(lte(core_search_index.createdAt, params.dateTo));
+ }
+ const visible = visibleCondition(core_search_index, params);
+ if (visible) conditions.push(visible);
+
+ return conditions.length ? and(...conditions) : undefined;
+};
diff --git a/packages/vitnode/src/api/lib/search-popularity.ts b/packages/vitnode/src/api/lib/search-popularity.ts
new file mode 100644
index 0000000000..b8652ab12a
--- /dev/null
+++ b/packages/vitnode/src/api/lib/search-popularity.ts
@@ -0,0 +1,79 @@
+import type { Context } from "hono";
+
+import { and, eq, sql } from "drizzle-orm";
+
+import { core_search_popularity } from "@/database/search";
+
+const SEARCH_POPULARITY_WEIGHTS = {
+ click: 3,
+ comment: 5,
+ view: 1,
+} as const;
+
+export type SearchPopularitySignal = keyof typeof SEARCH_POPULARITY_WEIGHTS;
+
+const SEARCH_POPULARITY_HALF_LIFE_DAYS = 7;
+
+const HALF_LIFE_MS = SEARCH_POPULARITY_HALF_LIFE_DAYS * 24 * 60 * 60 * 1000;
+
+const NEGLIGIBLE_EXPONENT = -50;
+
+const searchPopularityLogScore = (
+ signal: SearchPopularitySignal,
+ at: Date,
+): number =>
+ Math.log(SEARCH_POPULARITY_WEIGHTS[signal]) +
+ (at.getTime() / HALF_LIFE_MS) * Math.LN2;
+
+export const recordSearchPopularity = async (
+ c: Context,
+ {
+ at = new Date(),
+ itemId,
+ itemType,
+ signal,
+ }: {
+ at?: Date;
+ itemId: string;
+ itemType: string;
+ signal: SearchPopularitySignal;
+ },
+): Promise => {
+ const incoming = searchPopularityLogScore(signal, at);
+ const added = {
+ clicks: signal === "click" ? 1 : 0,
+ comments: signal === "comment" ? 1 : 0,
+ views: signal === "view" ? 1 : 0,
+ };
+ const current = core_search_popularity.logScore;
+
+ await c
+ .get("db")
+ .insert(core_search_popularity)
+ .values({ itemType, itemId, logScore: incoming, ...added, updatedAt: at })
+ .onConflictDoUpdate({
+ target: [core_search_popularity.itemType, core_search_popularity.itemId],
+ set: {
+ logScore: sql`greatest(${current}, ${incoming}::double precision) + ln(1 + exp(greatest(${NEGLIGIBLE_EXPONENT}::double precision, -abs(${current} - ${incoming}::double precision))))`,
+ clicks: sql`${core_search_popularity.clicks} + ${added.clicks}`,
+ comments: sql`${core_search_popularity.comments} + ${added.comments}`,
+ views: sql`${core_search_popularity.views} + ${added.views}`,
+ updatedAt: at,
+ },
+ });
+};
+
+export const deleteSearchPopularity = async (
+ c: Context,
+ { itemId, itemType }: { itemId: string; itemType: string },
+): Promise => {
+ await c
+ .get("db")
+ .delete(core_search_popularity)
+ .where(
+ and(
+ eq(core_search_popularity.itemType, itemType),
+ eq(core_search_popularity.itemId, itemId),
+ ),
+ );
+};
diff --git a/packages/vitnode/src/api/lib/search-suggestions.ts b/packages/vitnode/src/api/lib/search-suggestions.ts
new file mode 100644
index 0000000000..cddb534935
--- /dev/null
+++ b/packages/vitnode/src/api/lib/search-suggestions.ts
@@ -0,0 +1,192 @@
+import type { Context } from "hono";
+
+import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
+import { union } from "drizzle-orm/pg-core";
+
+import type { SearchQueryParams } from "@/api/models/search";
+
+import {
+ core_search_index,
+ core_search_popularity,
+ searchTitleVector,
+} from "@/database/search";
+
+import { searchIndexFilters } from "./search-filters";
+
+export interface SearchSuggestion {
+ context: null | string;
+ createdAt: Date;
+ excerpt: string;
+ id: number;
+ imageId: null | number;
+ itemId: string;
+ itemType: string;
+ title: string;
+ url: string;
+}
+
+export const MAX_SEARCH_SUGGESTIONS = 10;
+
+const SEARCH_SUGGESTION_EXCERPT_LENGTH = 500;
+
+const MAX_PREFIX_WORDS = 8;
+const MAX_PREFIX_WORD_LENGTH = 64;
+const POPULAR_CANDIDATES_PER_SLOT = 3;
+const SUGGESTION_CANDIDATES_PER_SOURCE = 100;
+
+const searchPrefixQuery = (term: string): null | string => {
+ const words = (
+ term
+ .normalize("NFKC")
+ .toLowerCase()
+ .match(/[\p{L}\p{N}]+/gu) ?? []
+ )
+ .slice(0, MAX_PREFIX_WORDS)
+ .map(word => `${word.slice(0, MAX_PREFIX_WORD_LENGTH)}:*`);
+
+ return words.length > 0 ? words.join(" & ") : null;
+};
+
+const contextTitleOf = (metadata: Record): null | string => {
+ const context = metadata.context;
+ if (typeof context !== "object" || context === null) return null;
+
+ const title: unknown = Reflect.get(context, "title");
+
+ return typeof title === "string" && title !== "" ? title : null;
+};
+
+const suggestionColumns = {
+ createdAt: core_search_index.createdAt,
+ excerpt: sql`left(${core_search_index.content}, ${SEARCH_SUGGESTION_EXCERPT_LENGTH})`,
+ id: core_search_index.id,
+ imageId: core_search_index.imageId,
+ itemId: core_search_index.itemId,
+ itemType: core_search_index.itemType,
+ metadata: core_search_index.metadata,
+ title: core_search_index.title,
+ url: core_search_index.url,
+};
+
+const toSuggestions = (
+ rows: {
+ createdAt: Date;
+ excerpt: string;
+ id: number;
+ imageId: null | number;
+ itemId: string;
+ itemType: string;
+ metadata: Record;
+ title: string;
+ url: null | string;
+ }[],
+): SearchSuggestion[] =>
+ rows.flatMap(({ metadata, url, ...row }) =>
+ url === null ? [] : [{ ...row, context: contextTitleOf(metadata), url }],
+ );
+
+const popularityOfIndexRow = and(
+ eq(core_search_popularity.itemType, core_search_index.itemType),
+ eq(core_search_popularity.itemId, core_search_index.itemId),
+);
+
+export const canonicalSearchSuggestions = async (
+ c: Context,
+ {
+ limit,
+ params,
+ term,
+ }: { limit: number; params: SearchQueryParams; term: string },
+): Promise => {
+ const prefixQuery = searchPrefixQuery(term);
+ if (prefixQuery === null) return [];
+
+ const db = c.get("db");
+ const tsQuery = sql`to_tsquery('simple'::regconfig, ${prefixQuery})`;
+ const titleVector = searchTitleVector(core_search_index.title);
+ const matching = and(
+ searchIndexFilters(c, { ...params, term: undefined }),
+ isNotNull(core_search_index.url),
+ sql`${titleVector} @@ ${tsQuery}`,
+ );
+
+ const recent = db
+ .$with("search_suggest_recent")
+ .as(
+ db
+ .select({ id: core_search_index.id })
+ .from(core_search_index)
+ .where(matching)
+ .orderBy(desc(core_search_index.createdAt))
+ .limit(SUGGESTION_CANDIDATES_PER_SOURCE),
+ );
+ const popular = db
+ .$with("search_suggest_popular")
+ .as(
+ db
+ .select({ id: core_search_index.id })
+ .from(core_search_popularity)
+ .innerJoin(core_search_index, popularityOfIndexRow)
+ .where(matching)
+ .orderBy(desc(core_search_popularity.logScore))
+ .limit(SUGGESTION_CANDIDATES_PER_SOURCE),
+ );
+
+ const rows = await db
+ .with(recent, popular)
+ .select(suggestionColumns)
+ .from(core_search_index)
+ .leftJoin(core_search_popularity, popularityOfIndexRow)
+ .where(
+ inArray(
+ core_search_index.id,
+ union(
+ db.select({ id: recent.id }).from(recent),
+ db.select({ id: popular.id }).from(popular),
+ ),
+ ),
+ )
+ .orderBy(
+ sql`ts_rank(${titleVector}, ${tsQuery}) DESC`,
+ sql`${core_search_popularity.logScore} DESC NULLS LAST`,
+ desc(core_search_index.createdAt),
+ )
+ .limit(limit);
+
+ return toSuggestions(rows);
+};
+
+export const canonicalPopularSearchItems = async (
+ c: Context,
+ { limit, params }: { limit: number; params: SearchQueryParams },
+): Promise => {
+ const requestedLanguage = params.languageCode ?? "";
+
+ const rows = await c
+ .get("db")
+ .select(suggestionColumns)
+ .from(core_search_popularity)
+ .innerJoin(core_search_index, popularityOfIndexRow)
+ .where(
+ and(
+ searchIndexFilters(c, { ...params, term: undefined }),
+ isNotNull(core_search_index.url),
+ ),
+ )
+ .orderBy(
+ desc(core_search_popularity.logScore),
+ sql`${core_search_index.languageCode} = ${requestedLanguage} DESC`,
+ )
+ .limit(limit * POPULAR_CANDIDATES_PER_SLOT);
+
+ const seen = new Set();
+ const unique = rows.filter(row => {
+ const key = `${row.itemType}\u0000${row.itemId}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+
+ return true;
+ });
+
+ return toSuggestions(unique.slice(0, limit));
+};
diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts
index f5b9c3114c..6b5c6a5b08 100644
--- a/packages/vitnode/src/api/models/search.ts
+++ b/packages/vitnode/src/api/models/search.ts
@@ -13,9 +13,19 @@ import {
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
+import type { SearchPopularitySignal } from "@/api/lib/search-popularity";
+import type { SearchSuggestion } from "@/api/lib/search-suggestions";
import type { ContentId } from "@/content/ids";
import type { RegisteredContentType } from "@/content/registry";
+import {
+ deleteSearchPopularity,
+ recordSearchPopularity,
+} from "@/api/lib/search-popularity";
+import {
+ canonicalPopularSearchItems,
+ canonicalSearchSuggestions,
+} from "@/api/lib/search-suggestions";
import { storageUrlOf } from "@/api/lib/storage-url";
import { contentIdFromKey, contentIdKey } from "@/content/ids";
import { contentLocalesMatch } from "@/content/locale";
@@ -28,6 +38,8 @@ import { stripHtml } from "@/lib/strip-html";
const authorAvatarFile = alias(core_files, "search_author_avatar_file");
+const SEARCH_TOP_AUTHORS_TTL_SECONDS = 5 * 60;
+
export interface SearchDocument {
authorIds?: readonly number[];
containerId?: number;
@@ -126,6 +138,10 @@ export interface SearchHitImage {
width?: number;
}
+export type SearchSuggestionResult = Omit & {
+ image: null | SearchHitImage;
+};
+
export interface SearchHit {
author: null | SearchHitAuthor;
authorId: null | number;
@@ -401,7 +417,9 @@ export class SearchModel {
});
}
- private async hydrateImages(edges: SearchHit[]): Promise {
+ private async hydrateImages(
+ edges: T[],
+ ): Promise<(T & { image: null | SearchHitImage })[]> {
const wanted = [
...new Set(
edges.flatMap(edge => (edge.imageId === null ? [] : [edge.imageId])),
@@ -450,6 +468,27 @@ export class SearchModel {
}));
}
+ private async newCommentParent(
+ doc: SearchDocument,
+ ): Promise {
+ if (doc.isPublic === false) return null;
+ if (doc.containerType === undefined || doc.containerId === undefined) {
+ return null;
+ }
+
+ const parentType = doc.containerType;
+ const isIndexedContent = (this.c.get("core")?.contentTypes ?? []).some(
+ ({ definition }) =>
+ definition.id === parentType && definition.search.enabled,
+ );
+ if (!isIndexedContent) return null;
+ if (await this.isListed(doc.itemType, contentIdKey(doc.itemId))) {
+ return null;
+ }
+
+ return { itemId: contentIdKey(doc.containerId), itemType: parentType };
+ }
+
private provider(): SearchProviderApiPlugin {
return this.c.get("core").search.adapter;
}
@@ -519,6 +558,14 @@ export class SearchModel {
: params;
}
+ private async withSuggestionImages(
+ suggestions: SearchSuggestion[],
+ ): Promise {
+ const hydrated = await this.hydrateImages(suggestions);
+
+ return hydrated.map(({ imageId: _imageId, ...suggestion }) => suggestion);
+ }
+
async authors({
limit,
term,
@@ -578,37 +625,42 @@ export class SearchModel {
return rows.map(toAuthor);
}
- const activity = db
- .select({
- authorId: sql`unnest(${core_search_index.authorIds})`.as(
- "author_id",
- ),
- })
- .from(core_search_index)
- .where(listed)
- .as("activity");
- const top = await db
- .select({ authorId: activity.authorId, total: count() })
- .from(activity)
- .groupBy(activity.authorId)
- .orderBy(desc(count()), asc(activity.authorId))
- .limit(limit);
- if (top.length === 0) return [];
+ const topAuthorIds = await this.c
+ .get("cache")
+ .remember(
+ `search:top-authors:${limit}`,
+ SEARCH_TOP_AUTHORS_TTL_SECONDS,
+ async () => {
+ const activity = db
+ .select({
+ authorId: sql`unnest(${core_search_index.authorIds})`.as(
+ "author_id",
+ ),
+ })
+ .from(core_search_index)
+ .where(listed)
+ .as("activity");
+ const top = await db
+ .select({ authorId: activity.authorId, total: count() })
+ .from(activity)
+ .groupBy(activity.authorId)
+ .orderBy(desc(count()), asc(activity.authorId))
+ .limit(limit);
+
+ return top.map(row => row.authorId);
+ },
+ );
+ if (topAuthorIds.length === 0) return [];
const rows = await db
.select(columns)
.from(core_users)
.leftJoin(authorAvatarFile, eq(authorAvatarFile.id, core_users.avatarId))
- .where(
- inArray(
- core_users.id,
- top.map(row => row.authorId),
- ),
- );
+ .where(inArray(core_users.id, topAuthorIds));
const byId = new Map(rows.map(row => [row.id, toAuthor(row)]));
- return top.flatMap(row => {
- const author = byId.get(row.authorId);
+ return topAuthorIds.flatMap(authorId => {
+ const author = byId.get(authorId);
return author ? [author] : [];
});
@@ -687,6 +739,13 @@ export class SearchModel {
),
);
+ if (languageCode === undefined) {
+ await deleteSearchPopularity(this.c, {
+ itemId: contentIdKey(itemId),
+ itemType,
+ });
+ }
+
await this.provider().delete(this.c, itemType, itemId, languageCode);
}
@@ -727,8 +786,17 @@ export class SearchModel {
content: stripHtml(doc.content),
});
+ const commentParent = await this.newCommentParent(clean);
+
await this.upsertRow(clean);
await this.provider().index(this.c, clean);
+
+ if (commentParent) {
+ await recordSearchPopularity(this.c, {
+ ...commentParent,
+ signal: "comment",
+ });
+ }
}
/**
@@ -741,6 +809,23 @@ export class SearchModel {
return this.provider().capabilities?.canonicalStorage === true;
}
+ async isListed(itemType: string, itemId: string): Promise {
+ const [row] = await this.c
+ .get("db")
+ .select({ id: core_search_index.id })
+ .from(core_search_index)
+ .where(
+ and(
+ eq(core_search_index.itemType, itemType),
+ eq(core_search_index.itemId, itemId),
+ eq(core_search_index.isPublic, true),
+ ),
+ )
+ .limit(1);
+
+ return row !== undefined;
+ }
+
name(): string {
return this.provider().name;
}
@@ -751,15 +836,67 @@ export class SearchModel {
return provider.ping ? provider.ping(this.c) : true;
}
+ async popular({
+ languageCode,
+ limit,
+ }: {
+ languageCode?: string;
+ limit: number;
+ }): Promise {
+ return await this.withSuggestionImages(
+ await canonicalPopularSearchItems(this.c, {
+ limit,
+ params: this.withFallbacks({ includePrivate: false, languageCode }),
+ }),
+ );
+ }
+
+ async recordPopularity({
+ itemId,
+ itemType,
+ signal,
+ }: {
+ itemId: string;
+ itemType: string;
+ signal: SearchPopularitySignal;
+ }): Promise {
+ await recordSearchPopularity(this.c, { itemId, itemType, signal });
+ }
+
async search(params: SearchQueryParams): Promise {
const result = await this.provider().search(
this.c,
this.withFallbacks(params),
);
+ const [withAuthors, withImages] = await Promise.all([
+ this.hydrateAuthors(result.edges),
+ this.hydrateImages(result.edges),
+ ]);
+
return {
...result,
- edges: await this.hydrateImages(await this.hydrateAuthors(result.edges)),
+ edges: withAuthors.map((edge, index) => ({
+ ...edge,
+ image: withImages[index]?.image ?? null,
+ })),
};
}
+
+ async suggest({
+ limit,
+ term,
+ ...params
+ }: Omit & {
+ limit: number;
+ term: string;
+ }): Promise {
+ return await this.withSuggestionImages(
+ await canonicalSearchSuggestions(this.c, {
+ limit,
+ params: this.withFallbacks(params),
+ term,
+ }),
+ );
+ }
}
diff --git a/packages/vitnode/src/api/modules/search/routes/popular.route.ts b/packages/vitnode/src/api/modules/search/routes/popular.route.ts
new file mode 100644
index 0000000000..ffd749c9d3
--- /dev/null
+++ b/packages/vitnode/src/api/modules/search/routes/popular.route.ts
@@ -0,0 +1,41 @@
+import { z } from "@hono/zod-openapi";
+
+import { CONFIG_PLUGIN } from "@/config";
+
+import { buildRoute } from "../../../lib/route";
+import { searchSuggestionsLimit, zodSearchSuggestion } from "./suggest.route";
+
+export const popularRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "Public content people are looking at right now, ranked by recent views, search clicks and new comments.",
+ path: "/popular",
+ request: {
+ query: z.object({
+ first: z.string().optional(),
+ lang: z.string().max(32).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ items: z.array(zodSearchSuggestion) }),
+ },
+ },
+ description: "Popular content",
+ },
+ },
+ },
+ handler: async c => {
+ const { first, lang } = c.req.valid("query");
+
+ const items = await c
+ .get("search")
+ .popular({ languageCode: lang, limit: searchSuggestionsLimit(first) });
+
+ return c.json({ items });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/search/routes/popularity.route.ts b/packages/vitnode/src/api/modules/search/routes/popularity.route.ts
new file mode 100644
index 0000000000..605fe0a4f3
--- /dev/null
+++ b/packages/vitnode/src/api/modules/search/routes/popularity.route.ts
@@ -0,0 +1,73 @@
+import type { Context } from "hono";
+
+import { z } from "@hono/zod-openapi";
+import { createHash } from "node:crypto";
+
+import { resolveClientIp } from "@/api/lib/client-ip";
+import { CONFIG_PLUGIN } from "@/config";
+import { CONTENT_ID_KEY_LENGTH } from "@/content/const";
+
+import { buildRoute } from "../../../lib/route";
+
+const SEARCH_POPULARITY_REPEAT_WINDOW_SECONDS = 30 * 60;
+
+const viewerKey = (c: Context): string => {
+ const user = c.get("user");
+ if (user) return `user:${user.id}`;
+
+ return `ip:${createHash("sha256").update(resolveClientIp(c)).digest("hex").slice(0, 32)}`;
+};
+
+export const popularityRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Counts a page view or a search click towards an item's popularity. Repeats from the same viewer within 30 minutes are ignored when Redis is configured.",
+ path: "/popularity",
+ request: {
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: z.object({
+ itemId: z.string().trim().min(1).max(CONTENT_ID_KEY_LENGTH),
+ itemType: z.string().trim().min(1).max(100),
+ signal: z.enum(["click", "view"]),
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ recorded: z.boolean() }),
+ },
+ },
+ description: "Whether the signal counted",
+ },
+ },
+ },
+ handler: async c => {
+ const { itemId, itemType, signal } = c.req.valid("json");
+
+ const search = c.get("search");
+ if (!(await search.isListed(itemType, itemId))) {
+ return c.json({ recorded: false });
+ }
+
+ const isFirstInWindow = await c
+ .get("cache")
+ .acquireLock(
+ `search-popularity:${signal}:${itemType}:${itemId}:${viewerKey(c)}`,
+ SEARCH_POPULARITY_REPEAT_WINDOW_SECONDS,
+ );
+ if (!isFirstInWindow) return c.json({ recorded: false });
+
+ await search.recordPopularity({ itemId, itemType, signal });
+
+ return c.json({ recorded: true });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/search/routes/suggest.route.ts b/packages/vitnode/src/api/modules/search/routes/suggest.route.ts
new file mode 100644
index 0000000000..c6aadba899
--- /dev/null
+++ b/packages/vitnode/src/api/modules/search/routes/suggest.route.ts
@@ -0,0 +1,74 @@
+import { z } from "@hono/zod-openapi";
+
+import { MAX_SEARCH_SUGGESTIONS } from "@/api/lib/search-suggestions";
+import { CONFIG_PLUGIN } from "@/config";
+import { contentSearchHiddenAccess } from "@/content/server/hidden-access";
+
+import { buildRoute } from "../../../lib/route";
+import { positiveIntOrUndefined } from "./search.route";
+
+const DEFAULT_SEARCH_SUGGESTIONS = 6;
+
+export const zodSearchSuggestion = z.object({
+ context: z.string().nullable(),
+ createdAt: z.date(),
+ excerpt: z.string(),
+ id: z.number(),
+ image: z
+ .object({
+ height: z.number().optional(),
+ url: z.string(),
+ width: z.number().optional(),
+ })
+ .nullable(),
+ itemId: z.string(),
+ itemType: z.string(),
+ title: z.string(),
+ url: z.string(),
+});
+
+export const searchSuggestionsLimit = (first: string | undefined): number =>
+ Math.min(
+ positiveIntOrUndefined(first) ?? DEFAULT_SEARCH_SUGGESTIONS,
+ MAX_SEARCH_SUGGESTIONS,
+ );
+
+export const suggestRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "As-you-type suggestions: content whose title words start with what was typed, best match first and the more popular item on a tie.",
+ path: "/suggest",
+ request: {
+ query: z.object({
+ search: z.string().trim().min(1).max(100),
+ first: z.string().optional(),
+ lang: z.string().max(32).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ items: z.array(zodSearchSuggestion) }),
+ },
+ },
+ description: "Suggestions",
+ },
+ },
+ },
+ handler: async c => {
+ const { first, lang, search } = c.req.valid("query");
+
+ const items = await c.get("search").suggest({
+ hiddenAccess: await contentSearchHiddenAccess(c),
+ includePrivate: false,
+ languageCode: lang,
+ limit: searchSuggestionsLimit(first),
+ term: search,
+ });
+
+ return c.json({ items });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/search/search.module.ts b/packages/vitnode/src/api/modules/search/search.module.ts
index bb47fa87cf..63b8c4dd44 100644
--- a/packages/vitnode/src/api/modules/search/search.module.ts
+++ b/packages/vitnode/src/api/modules/search/search.module.ts
@@ -3,13 +3,24 @@ import { CONFIG_PLUGIN } from "@/config";
import { authorsRoute } from "./routes/authors.route";
import { facetsRoute } from "./routes/facets.route";
+import { popularRoute } from "./routes/popular.route";
+import { popularityRoute } from "./routes/popularity.route";
import { searchRoute } from "./routes/search.route";
+import { suggestRoute } from "./routes/suggest.route";
import { timelineRoute } from "./routes/timeline.route";
import { rebuildSearchIndexTask } from "./tasks/rebuild-index.task";
export const searchModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "search",
- routes: [searchRoute, timelineRoute, authorsRoute, facetsRoute],
+ routes: [
+ searchRoute,
+ timelineRoute,
+ authorsRoute,
+ facetsRoute,
+ suggestRoute,
+ popularRoute,
+ popularityRoute,
+ ],
queueTasks: [rebuildSearchIndexTask],
});
diff --git a/packages/vitnode/src/components/image-crop/use-image-crop.ts b/packages/vitnode/src/components/image-crop/use-image-crop.ts
index 5e1f65bfc7..138c7e23b8 100644
--- a/packages/vitnode/src/components/image-crop/use-image-crop.ts
+++ b/packages/vitnode/src/components/image-crop/use-image-crop.ts
@@ -48,6 +48,11 @@ export interface ImageCrop {
zoom: number;
}
+const INITIAL_VIEW: { offset: Offset; zoom: number } = {
+ offset: { x: 0, y: 0 },
+ zoom: MIN_ZOOM,
+};
+
export const useImageCrop = ({
constraints,
fileName,
@@ -60,8 +65,8 @@ export const useImageCrop = ({
const sourceRef = React.useRef(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState(null);
- const [zoom, setZoom] = React.useState(MIN_ZOOM);
- const [offset, setOffset] = React.useState({ x: 0, y: 0 });
+ const [view, setView] = React.useState(INITIAL_VIEW);
+ const { offset, zoom } = view;
const frameRef = React.useRef({ height: 0, width: 0 });
const formats = fileFormatLabels(constraints);
@@ -77,8 +82,7 @@ export const useImageCrop = ({
if (sourceRef.current) URL.revokeObjectURL(sourceRef.current.url);
sourceRef.current = next;
setSource(next);
- setZoom(MIN_ZOOM);
- setOffset({ x: 0, y: 0 });
+ setView(INITIAL_VIEW);
onSourceChange?.(next !== null);
};
@@ -122,27 +126,28 @@ export const useImageCrop = ({
if (frame) frameRef.current = frame;
if (!source) return;
- setOffset(current =>
- clampOffset(
- update(current, frameRef.current),
+ setView(current => ({
+ offset: clampOffset(
+ update(current.offset, frameRef.current),
source.size,
frameRef.current,
- zoom,
+ current.zoom,
),
- );
+ zoom: current.zoom,
+ }));
};
const updateZoom = React.useCallback(
(update: (current: number) => number) => {
- setZoom(current => {
- const next = clampZoom(update(current));
- if (source) {
- setOffset(offsetNow =>
- clampOffset(offsetNow, source.size, frameRef.current, next),
- );
- }
-
- return next;
+ setView(current => {
+ const next = clampZoom(update(current.zoom));
+
+ return {
+ offset: source
+ ? clampOffset(current.offset, source.size, frameRef.current, next)
+ : current.offset,
+ zoom: next,
+ };
});
},
[source],
diff --git a/packages/vitnode/src/components/theme-provider.tsx b/packages/vitnode/src/components/theme-provider.tsx
index 127e30d03f..1e67ac7577 100644
--- a/packages/vitnode/src/components/theme-provider.tsx
+++ b/packages/vitnode/src/components/theme-provider.tsx
@@ -166,22 +166,19 @@ export const ThemeProvider = ({
const setTheme = React.useCallback(
(next: React.SetStateAction) => {
- setThemeState(prev => {
- const resolved = typeof next === "function" ? next(prev) : next;
- try {
- if (resolved === undefined) {
- window.localStorage.removeItem(storageKey);
- } else {
- window.localStorage.setItem(storageKey, resolved);
- }
- } catch {
- // localStorage unavailable
+ const resolved = typeof next === "function" ? next(theme) : next;
+ setThemeState(resolved);
+ try {
+ if (resolved === undefined) {
+ window.localStorage.removeItem(storageKey);
+ } else {
+ window.localStorage.setItem(storageKey, resolved);
}
-
- return resolved;
- });
+ } catch {
+ // localStorage unavailable
+ }
},
- [storageKey],
+ [storageKey, theme],
);
React.useEffect(() => {
diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts
index 8a7bdcde7d..a5048950d1 100644
--- a/packages/vitnode/src/database/search.ts
+++ b/packages/vitnode/src/database/search.ts
@@ -1,7 +1,14 @@
import type { SQL } from "drizzle-orm";
+import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
-import { camelCase, customType, index, unique } from "drizzle-orm/pg-core";
+import {
+ camelCase,
+ customType,
+ index,
+ primaryKey,
+ unique,
+} from "drizzle-orm/pg-core";
import { CONTENT_ID_KEY_LENGTH } from "../content/const";
import { core_files } from "./files";
@@ -62,6 +69,9 @@ const SEARCH_CONFIG_CASE = `CASE lower(split_part("core_search_index"."languageC
.map(([code, config]) => `WHEN '${code}' THEN '${config}'::regconfig`)
.join(" ")} ELSE '${DEFAULT_SEARCH_TEXT_CONFIG}'::regconfig END`;
+export const searchTitleVector = (title: AnyPgColumn): SQL =>
+ sql`to_tsvector('simple'::regconfig, ${title})`;
+
// One row per (itemType, itemId, languageCode): multi-language content is
// indexed once per language so search and discovery can be scoped to the
// viewer's locale.
@@ -109,6 +119,10 @@ export const core_search_index = camelCase.table.withRLS(
t.languageCode,
),
index("core_search_index_search_vector_idx").using("gin", t.searchVector),
+ index("core_search_index_title_vector_idx").using(
+ "gin",
+ searchTitleVector(t.title),
+ ),
index("core_search_index_created_at_idx").on(t.createdAt),
index("core_search_index_author_ids_idx").using("gin", t.authorIds),
index("core_search_index_item_type_idx").on(t.itemType),
@@ -118,3 +132,20 @@ export const core_search_index = camelCase.table.withRLS(
index("core_search_index_container_idx").on(t.containerType, t.containerId),
],
);
+
+export const core_search_popularity = camelCase.table.withRLS(
+ "core_search_popularity",
+ t => ({
+ itemType: t.varchar({ length: 100 }).notNull(),
+ itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(),
+ logScore: t.doublePrecision().notNull(),
+ views: t.integer().notNull().default(0),
+ clicks: t.integer().notNull().default(0),
+ comments: t.integer().notNull().default(0),
+ updatedAt: t.timestamp().notNull().defaultNow(),
+ }),
+ t => [
+ primaryKey({ columns: [t.itemType, t.itemId] }),
+ index("core_search_popularity_log_score_idx").on(t.logScore),
+ ],
+);
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index ac7b2f3ade..03f5b93c74 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -76,6 +76,25 @@
"discover": "Discover"
},
"placeholder": "Search…",
+ "dialog": {
+ "label": "Search the community",
+ "popular": "Popular right now",
+ "popular_empty": "Nothing is trending yet. Start typing to search.",
+ "suggestions": "Suggestions",
+ "no_results": "No quick matches. Try another word or see all results.",
+ "error": "Search is taking a nap. Try again in a moment.",
+ "see_all": "See all results for “{term}”",
+ "in_context": "in {context}",
+ "count": "{count, plural, =0 {No results} one {# result} other {# results}}",
+ "recent": "Recent searches",
+ "complete": "to complete",
+ "close": "to close",
+ "navigate": "to navigate",
+ "open": "to open",
+ "key_up": "Up arrow",
+ "key_down": "Down arrow",
+ "key_enter": "Enter"
+ },
"empty": "Nothing found yet.",
"loadMore": "Load more",
"loading": "Loading…",
@@ -194,7 +213,8 @@
},
"timeline": {
"items": "{count, plural, one {# item} other {# items}}",
- "in_context": "in {context} "
+ "posted_in": "{author} posted in {context} ",
+ "posted_in_self": "Posted in {context} "
}
},
"global": {
@@ -398,6 +418,11 @@
"confirm": "Yes, confirm"
},
"search_placeholder": "Search...",
+ "search_dialog": {
+ "open": "Open search",
+ "title": "Search",
+ "desc": "Find articles, topics and comments as you type."
+ },
"results_not_found": "No results found",
"selected_count": "{count} selected",
"clear_filters": "Clear filters",
diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx
index f223f0d14d..d48fc9b9ca 100644
--- a/packages/vitnode/src/routes.tsx
+++ b/packages/vitnode/src/routes.tsx
@@ -54,9 +54,11 @@ import { normalizeNotificationsRouteSearch } from "./tanstack/notifications/rout
import {
AuthPendingSkeleton,
CardsPendingSkeleton,
+ DiscoverPendingSkeleton,
FeedPendingSkeleton,
FormPendingSkeleton,
ProfilePendingSkeleton,
+ SearchPendingSkeleton,
TablePendingSkeleton,
} from "./tanstack/pending";
import { PROFILE_NAMESPACES } from "./tanstack/profile/route";
@@ -89,13 +91,13 @@ export const routes = defineRoutes([
page("/discover", {
component: lazy(() => import("./pages/discover")),
messages: DISCOVER_NAMESPACES,
- pendingComponent: FeedPendingSkeleton,
+ pendingComponent: DiscoverPendingSkeleton,
}),
page("/search", {
component: lazy(() => import("./pages/search")),
messages: SEARCH_NAMESPACES,
- pendingComponent: FeedPendingSkeleton,
+ pendingComponent: SearchPendingSkeleton,
search: normalizeSearchRouteSearch,
}),
diff --git a/packages/vitnode/src/tanstack/layout/header.tsx b/packages/vitnode/src/tanstack/layout/header.tsx
index 0bc2d59da0..03d748f80e 100644
--- a/packages/vitnode/src/tanstack/layout/header.tsx
+++ b/packages/vitnode/src/tanstack/layout/header.tsx
@@ -9,6 +9,7 @@ import type {
PublicNavigationItem,
PublicNavigationNode,
} from "@/lib/navigation";
+import type { HeaderVariant } from "@/views/layouts/theme/header/header-content";
import type { HeaderNavTranslate } from "@/views/layouts/theme/header/header-nav";
import { useLogo } from "@/components/logo-context";
@@ -30,6 +31,7 @@ import { useLocale } from "../i18n/locale";
import { GLOBAL_NAMESPACE, intlQueryOptions } from "../i18n/query";
import { NotificationsBell } from "../notifications/bell";
import { isSharedRenderRequest } from "../prerender";
+import { HeaderSearch } from "../search/header-search";
import { MobileUserMenu } from "./mobile-user-menu";
const NO_MESSAGES: AbstractIntlMessages = {};
@@ -54,7 +56,17 @@ const headerNavTranslator = (
};
};
-export const Header = ({ user }: { user?: React.ReactNode }) => {
+export const Header = ({
+ firstSearchSourceId,
+ leading,
+ user,
+ variant,
+}: {
+ firstSearchSourceId?: string;
+ leading?: React.ReactNode;
+ user?: React.ReactNode;
+ variant?: HeaderVariant;
+}) => {
const logo = useLogo();
const locale = useLocale();
const t = useTranslations("core.global");
@@ -94,6 +106,7 @@ export const Header = ({ user }: { user?: React.ReactNode }) => {
<>
{
navigation={navigation}
navigationLoadingLabel={t("loading")}
notifications={ }
+ search={ }
user={user}
+ variant={variant}
/>
>
diff --git a/packages/vitnode/src/tanstack/layout/main-header.tsx b/packages/vitnode/src/tanstack/layout/main-header.tsx
index 867058b564..8b16f77013 100644
--- a/packages/vitnode/src/tanstack/layout/main-header.tsx
+++ b/packages/vitnode/src/tanstack/layout/main-header.tsx
@@ -1,4 +1,6 @@
import { Header } from "./header";
import { UserHeader } from "./user-header";
-export const MainHeader = () => } />;
+export const MainHeader = (
+ props: Omit, "user">,
+) => } />;
diff --git a/packages/vitnode/src/tanstack/pending/index.ts b/packages/vitnode/src/tanstack/pending/index.ts
index 04fd2d0904..23d4dcb96d 100644
--- a/packages/vitnode/src/tanstack/pending/index.ts
+++ b/packages/vitnode/src/tanstack/pending/index.ts
@@ -8,8 +8,10 @@ export {
AuthPendingSkeleton,
BreadcrumbPendingSkeleton,
CardsPendingSkeleton,
+ DiscoverPendingSkeleton,
FeedPendingSkeleton,
FormPendingSkeleton,
ProfilePendingSkeleton,
+ SearchPendingSkeleton,
TablePendingSkeleton,
} from "./shapes";
diff --git a/packages/vitnode/src/tanstack/pending/shapes.tsx b/packages/vitnode/src/tanstack/pending/shapes.tsx
index 161311393d..633998a45d 100644
--- a/packages/vitnode/src/tanstack/pending/shapes.tsx
+++ b/packages/vitnode/src/tanstack/pending/shapes.tsx
@@ -1,6 +1,7 @@
import { cn } from "cn";
import { Skeleton } from "@/components/ui/skeleton";
+import { SearchTimelineSkeleton } from "@/views/search/search-timeline-skeleton";
import type { RoutePendingSkeletonProps } from "./route-pending-skeleton";
@@ -45,6 +46,66 @@ export const FeedPendingSkeleton = ({
);
+export const DiscoverPendingSkeleton = ({
+ className,
+ label,
+}: Pick) => (
+
+
+
+
+);
+
+const SEARCH_FILTER_GROUPS = [
+ { id: "content", rows: 3 },
+ { id: "date", rows: 6 },
+] as const;
+
+export const SearchPendingSkeleton = ({
+ className,
+ label,
+}: Pick) => (
+
+
+
+
+
+
+ {SEARCH_FILTER_GROUPS.map(group => (
+
+
+ {pendingRowKeys(group.rows).map(key => (
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
+
+);
+
export const TablePendingSkeleton = ({
className,
label,
@@ -241,6 +302,11 @@ export const ProfilePendingSkeleton = ({
))}