diff --git a/apps/docs/content/docs/dev/search.mdx b/apps/docs/content/docs/dev/search.mdx
index e75da632f..3e48f9df4 100644
--- a/apps/docs/content/docs/dev/search.mdx
+++ b/apps/docs/content/docs/dev/search.mdx
@@ -29,10 +29,11 @@ config change followed by a rebuild.
## Indexing content
- A content type only needs a
- [`search` block](/docs/dev/content-engine/public-api-and-caching#2-full-text-search-indexing) - publishing, editing,
- unpublishing and deleting a record then keep its document in step
- automatically, and it joins the rebuild without any of the wiring below.
+ A content type only needs a [`search`
+ block](/docs/dev/content-engine/public-api-and-caching#2-full-text-search-indexing)
+ - publishing, editing, unpublishing and deleting a record then keep its
+ document in step automatically, and it joins the rebuild without any of the
+ wiring below.
Any API handler can (re)index or remove an item through `c.get("search")`. It is
@@ -93,8 +94,8 @@ agnostic** and match every locale, so single-language plugins need no changes.
Postgres full-text ranking picks a text-search configuration per locale
- (`polish` for `pl`, `german` for `de`, and so on), falling back to `simple` for
- a locale with no bundled dictionary - and for a document with no
+ (`polish` for `pl`, `german` for `de`, and so on), falling back to `simple`
+ for a locale with no bundled dictionary - and for a document with no
`languageCode`, which matches every locale. Matching works across languages
either way; only stemming and stop-words differ.
@@ -146,8 +147,8 @@ documents (one per language) or none at all (a row that cannot be projected).
Returning `documents.length` as `itemsRead`, or ending the loop on an empty
`documents` array, silently truncates the index: a page whose rows all fail to
- project would stop the rebuild before the valid rows behind it. Report the rows
- you read.
+ project would stop the rebuild before the valid rows behind it. Report the
+ rows you read.
### The older array result
@@ -230,18 +231,18 @@ A collection is **unmanaged by the rebuild system** when documents for its
`itemType` are in the index but no `SearchIndexer` is registered for it.
- It does **not** prove the plugin is uninstalled or inactive. A live-only plugin
- looks exactly the same from the index's point of view, and may be keeping the
- collection completely up to date. All VitNode can tell is that it has no way to
- rebuild it.
+ It does **not** prove the plugin is uninstalled or inactive. A live-only
+ plugin looks exactly the same from the index's point of view, and may be
+ keeping the collection completely up to date. All VitNode can tell is that it
+ has no way to rebuild it.
-**AdminCP → Advanced → Search** labels those rows *Unmanaged*, shows the plugin
+**AdminCP → Advanced → Search** labels those rows _Unmanaged_, shows the plugin
stored on their documents, and says no rebuild indexer is registered. Coverage is
left blank rather than calculated: with no indexer there is no source count, and
`11 / 11` would claim a collection nothing can rebuild is fully covered.
-*Reindex* is replaced by **Remove documents**, behind a confirmation. It deletes
+_Reindex_ is replaced by **Remove documents**, behind a confirmation. It deletes
what is currently indexed and rebuilds nothing - but it does not stop anything
either, so a live-writing plugin may recreate those documents on its next write.
It is a way to clear a stale indexed state, not a way to uninstall a collection.
@@ -255,6 +256,44 @@ Result cards look up an icon and label by `itemType`. Add an entry to the render
registry (`@vitnode/core/views/search/registry`); unknown types fall back to a
generic renderer, so nothing breaks if an entry is missing.
+## Rendering search in your own app
+
+The search UI ships in three framework-neutral pieces, so a Next.js page and a
+TanStack Start route render the same components:
+
+| Module | What it is |
+| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| `@vitnode/core/views/search/search-params` | Pure functions: normalise a term from a URL, pick the default sort, build the feed's parameters. |
+| `@vitnode/core/views/search/search-feed-query` | The feed as one query definition - request, page size, cursor rule, response check, cache key. |
+| `@vitnode/core/views/search/search-controls-content` | The search box, type filters, sort and results, ready to mount. |
+
+Two things are injected, because they are the only two a shared component cannot
+answer for itself: how a page is fetched, and how an internal link becomes a
+navigation.
+
+```tsx title="A search page, in any framework"
+import { SearchControlsContent } from "@vitnode/core/views/search/search-controls-content";
+import { searchFeedQueryOptions } from "@vitnode/core/views/search/search-feed-query";
+import { searchFeedParamsFor } from "@vitnode/core/views/search/search-params";
+
+ searchFeedQueryOptions({ locale, params })}
+ LinkComponent={MyLink}
+ variant="timeline"
+/>;
+```
+
+
+ Warm the *same* `searchFeedQueryOptions` in your loader
+ (`ensureInfiniteQueryData`) and the first page is already in the cache when
+ the component mounts - no `initialData`, no second copy of the same bytes.
+
+
+Only the term belongs in the URL. The sort and the type filters are controls the
+visitor drives after the page loads, so they stay component state - and a
+malformed `?search=` normalises to the browse feed rather than breaking the page.
+
## Choosing the engine
The engine is set in `vitnode.api.config.ts`, exactly like the storage and email
diff --git a/apps/docs/content/docs/ui/data-table.mdx b/apps/docs/content/docs/ui/data-table.mdx
index effd19ed9..8f4231a48 100644
--- a/apps/docs/content/docs/ui/data-table.mdx
+++ b/apps/docs/content/docs/ui/data-table.mdx
@@ -500,6 +500,76 @@ That pruning is also what makes a partly-successful action readable: revalidate
so a run that partly succeeded can say so.
+## URL State
+
+Every control on the table is really a URL editor. Sorting writes `?orderBy=` and `?order=`, paging writes `?first=`/`?last=` and `?cursor=`, the search box writes `?search=`, and each filter writes its own parameter. Nothing is kept in React state, which is why a table link can be bookmarked, shared, or opened in a new tab and show the same rows.
+
+The rules those controls follow live in one framework-free module, so you can reuse them - or test them - without a router:
+
+```ts
+import {
+ readTableOrder,
+ readTablePageSize,
+ readTableSearch,
+ toggleTableOrder,
+ withTableFilter,
+ withTableOrder,
+ withTablePage,
+ withTablePageSize,
+ withTableSearch,
+} from "@vitnode/core/components/table/url-state";
+
+withTableSearch("page=3&tab=media", "vitnode");
+// → "page=3&tab=media&search=vitnode"
+```
+
+Each `with*` helper takes the current query string (or a `URLSearchParams`) and returns a new one. They never mutate what you hand them, they always keep parameters they don't own - your own `?tab=` survives a sort click - and they remove a parameter rather than leaving it empty. Filtering and changing the page size drop the pagination cursor, because the rows underneath it changed; sorting and searching leave it alone.
+
+### Using the table outside Next.js
+
+`DataTable` is the Next.js binding: it supplies the current search parameters and a locale-aware, scroll-free push, and every page in this documentation uses it. Under it sits the same table with that one decision taken as an argument, which is all another router needs to render it.
+
+Give `DataTableNavigationProvider` where you are and how to move, and render `ContentDataTable` inside it:
+
+```tsx
+import { ContentDataTable } from "@vitnode/core/components/table/content";
+import { DataTableNavigationProvider } from "@vitnode/core/components/table/navigation";
+
+ router.navigate({ search: nextSearch }),
+ searchParams: new URLSearchParams(location.searchStr),
+ }}
+>
+
+;
+```
+
+ Promise | void",
+ },
+ searchParams: {
+ description:
+ "The query string the table is currently rendering. Never mutated.",
+ required: true,
+ type: "URLSearchParams",
+ },
+ }}
+/>
+
+The types, and the `DataTableSkeleton` you render as a loading fallback, come from `@vitnode/core/components/table/data-table-content` - importing them from `data-table` would pull Next.js in behind them.
+
## Complete Example
Here's a complete example showing how to use the `DataTable` component in a page:
diff --git a/apps/web/src/lib/files/my-files-route.ts b/apps/web/src/lib/files/my-files-route.ts
new file mode 100644
index 000000000..95c497b0a
--- /dev/null
+++ b/apps/web/src/lib/files/my-files-route.ts
@@ -0,0 +1,229 @@
+import type {
+ MyFilesOrder,
+ MyFilesOrderBy,
+ MyFilesParams,
+ RawMyFilesParams,
+} from '@vitnode/core/views/files/my-files-query'
+
+import { DEFAULT_TABLE_PAGE_SIZE } from '@vitnode/core/components/table/url-state'
+import { normalizeMyFilesParams } from '@vitnode/core/views/files/my-files-query'
+
+/**
+ * What `/files` reads out of its URL, and the three things it turns that into.
+ *
+ * Four pure functions, no transport and no React, so the route's contract can be
+ * stated and tested without a router - `src/tests/my-files-route.test.ts` is the
+ * whole of it. The same split `/search` already uses
+ * (`lib/search/search-request.ts`), applied to a table instead of a feed.
+ *
+ * Every one of them delegates the *meaning* of a parameter to
+ * `@vitnode/core/views/files/my-files-query`, which is the module the Next.js
+ * `MyFilesTableView` reads its `searchParams` through. So `/files?orderBy=name`
+ * is the same request in both applications rather than two hand-written
+ * approximations of it, and nothing here re-states which columns are sortable or
+ * how large a page may be.
+ *
+ * ## Three shapes, and why they are not one
+ *
+ * the URL ?orderBy=name&first=20 what a visitor sees and shares
+ * the search { orderBy: 'name', first: 20 } the route's validated state
+ * the request { first: '20', orderBy: 'name' } what the API is asked for
+ *
+ * The middle one is the URL, validated. The last one is core's `MyFilesParams`,
+ * which additionally *always* names a page size, because a request must - and a
+ * URL need not. Keeping them apart is what stops `?first=10` being written into
+ * every link to a page whose canonical address is `/files`.
+ *
+ * ## All four are total and idempotent
+ *
+ * None of them can throw and none of them reject: a URL typed by hand renders
+ * the table it would have rendered anyway. That is not politeness, it is a
+ * requirement of where they run - `validateSearch` throwing turns a hand-edited
+ * query string into a router error screen, and this page's query string is edited
+ * by hand every time somebody shares a sorted link.
+ *
+ * Idempotent because they are applied twice on every navigation: once when a
+ * table control's new query string is turned back into route search, and once
+ * more by the router when it validates the location that produces. A rule that
+ * moved the value on the second pass would make the table drift a step per click.
+ */
+
+/**
+ * The page size the URL does not need to mention.
+ *
+ * `DEFAULT_TABLE_PAGE_SIZE` is what every `DataTable` falls back to when the URL
+ * asks for no size, so `?first=10` and no `first` at all are the same request
+ * spelled two ways - and the shorter spelling is the one this route settles on.
+ */
+const DEFAULT_PAGE_SIZE = String(DEFAULT_TABLE_PAGE_SIZE)
+
+/**
+ * The route's validated search - the URL contract, and nothing else.
+ *
+ * Exactly the six parameters `DataTable`'s controls write: the sort header emits
+ * `orderBy`/`order`, the search box `search`, and the pager `first`/`last` with
+ * a `cursor`. There is no seventh, because this table declares no filters.
+ *
+ * `first` and `last` are numbers rather than strings, and that is about the
+ * address bar rather than about types. TanStack Router's default search
+ * serializer JSON-encodes a *string* that would parse as JSON, so the string
+ * `'20'` is written to the URL as `first=%2220%22`; the number `20` is written
+ * as `first=20`, which is what the Next.js page produces and what the API reads.
+ */
+export interface MyFilesRouteSearch {
+ cursor?: string
+ first?: number
+ last?: number
+ order?: MyFilesOrder
+ orderBy?: MyFilesOrderBy
+ search?: string
+}
+
+/**
+ * A search as it arrives, before anything has checked it.
+ *
+ * Two shapes, because there are two callers and they are genuinely different.
+ * The router hands over its *parsed* search - an arbitrary bag of whatever was
+ * in the query string - and this route hands its own validated search straight
+ * back in, on every navigation and in the idempotence assertions. An `interface`
+ * has no implicit index signature, so the second is not assignable to the first
+ * and the union has to say so.
+ */
+export type UncheckedMyFilesSearch =
+ MyFilesRouteSearch | Record
+
+/**
+ * One search parameter as the string it was in the query string.
+ *
+ * The router hands `validateSearch` its *parsed* search, and the default parser
+ * JSON-parses every value - so `?first=20` arrives as the number `20`, `?x=true`
+ * as a boolean, and a repeated key as an array. Core's normaliser is written
+ * against a query string, where everything is a string, and one of its rules
+ * (`search.trim()`) throws on anything else.
+ *
+ * So this is the seam between the two, and it is deliberately narrow: scalars
+ * become their string spelling, the first entry of an array wins because only one
+ * value can reach the API, and everything else - an object, a nested array, a
+ * `null` - is *absent* rather than coerced. `String({})` is `"[object Object]"`,
+ * which is a value no rule below would recognise but every rule would have to
+ * consider.
+ */
+const readParam = (value: unknown): string | undefined => {
+ const one = Array.isArray(value) ? (value[0] as unknown) : value
+
+ if (typeof one === 'string') return one
+ if (typeof one === 'number')
+ return Number.isFinite(one) ? String(one) : undefined
+ if (typeof one === 'boolean') return String(one)
+
+ return undefined
+}
+
+/**
+ * The six parameters this route has, in the shape core's normaliser reads.
+ *
+ * Named one by one rather than passed through, which is the whole of rule 3:
+ * nothing a visitor puts in the query string reaches the request builder unless
+ * this route asked for it. A stray `?tab=2` is not carried, not validated, and
+ * not sent.
+ */
+const rawParamsOf = (input: UncheckedMyFilesSearch): RawMyFilesParams => ({
+ cursor: readParam(input.cursor),
+ first: readParam(input.first),
+ last: readParam(input.last),
+ order: readParam(input.order),
+ orderBy: readParam(input.orderBy),
+ search: readParam(input.search),
+})
+
+/**
+ * The request this URL is asking for - core's `MyFilesParams`, and therefore also
+ * the object the query key is built from.
+ *
+ * Every defaulting and clamping rule is `normalizeMyFilesParams`': an unusable
+ * page size falls back rather than 400ing, `first` beats `last`, a sort column
+ * the list cannot sort by is dropped so the API applies its own `createdAt desc`,
+ * a blank search is no search, and a cursor that cannot be one is not sent.
+ *
+ * Takes the loose object rather than {@link MyFilesRouteSearch} on purpose. The
+ * router merges a route's validated search over the *raw* parsed one, so
+ * `Route.useSearch()` still carries whatever else was in the query string; going
+ * back through the same normalisation is what makes this answer depend only on
+ * the six parameters above, whoever is calling it.
+ */
+export const myFilesRouteParams = (
+ input: UncheckedMyFilesSearch,
+): MyFilesParams => normalizeMyFilesParams(rawParamsOf(input))
+
+/**
+ * The route's search schema - written as a function rather than a schema object
+ * because its job is to *normalise*, not to reject.
+ *
+ * `/files` is a page whose query string is edited by hand and pasted between
+ * people: `?orderBy=password`, `?first=5000`, `?first=abc`, `?cursor=💥`. Every
+ * one of them should render the visitor's files sorted the way the table
+ * defaults to, not a router error - so an unusable value becomes an absent one,
+ * and the API's own `createdAt desc` is what an unrecognised `orderBy` falls back
+ * to.
+ *
+ * The one thing it does *not* keep is a page size equal to the default. `/files`
+ * and `/files?first=10` are the same page, and a schema that answered `first: 10`
+ * for the first of them would write `?first=10` into every link the router builds
+ * to this route - including the one `MigrationLink` renders and the one a guest's
+ * `?returnTo=` comes back through.
+ */
+export const normalizeMyFilesRouteSearch = (
+ input: UncheckedMyFilesSearch,
+): MyFilesRouteSearch => {
+ const { cursor, first, last, order, orderBy, search } =
+ myFilesRouteParams(input)
+
+ return {
+ ...(cursor === undefined ? {} : { cursor }),
+ // See above: the default page size is the URL saying nothing.
+ ...(first === undefined || first === DEFAULT_PAGE_SIZE
+ ? {}
+ : { first: Number(first) }),
+ // `last` is never dropped: paging *backwards* at the default size is a
+ // different request from not paging at all, and the parameter is what says so.
+ ...(last === undefined ? {} : { last: Number(last) }),
+ ...(order === undefined ? {} : { order }),
+ ...(orderBy === undefined ? {} : { orderBy }),
+ ...(search === undefined ? {} : { search }),
+ }
+}
+
+/**
+ * The query string the table's controls read themselves out of.
+ *
+ * `DataTable`'s sort headers, pager and search box are handed a
+ * `URLSearchParams` and produce a new query string from it
+ * (`components/table/url-state.ts`); this is the other end of that, and it is
+ * built from the validated search rather than from the address bar so a control
+ * can only ever edit a parameter this route recognises.
+ */
+export const myFilesSearchParams = (
+ input: UncheckedMyFilesSearch,
+): URLSearchParams => {
+ const params = new URLSearchParams()
+
+ for (const [key, value] of Object.entries(
+ normalizeMyFilesRouteSearch(input),
+ )) {
+ params.set(key, String(value))
+ }
+
+ return params
+}
+
+/**
+ * A query string one of those controls produced, back as route search.
+ *
+ * The return leg, and the point at which the table's own URL arithmetic is
+ * re-validated: a control cannot write a sort column this route does not have,
+ * because what it wrote goes through the same schema the address bar does.
+ */
+export const myFilesSearchFrom = (nextSearch: string): MyFilesRouteSearch =>
+ normalizeMyFilesRouteSearch(
+ Object.fromEntries(new URLSearchParams(nextSearch)),
+ )
diff --git a/apps/web/src/lib/files/my-files.ts b/apps/web/src/lib/files/my-files.ts
new file mode 100644
index 000000000..3be846ea3
--- /dev/null
+++ b/apps/web/src/lib/files/my-files.ts
@@ -0,0 +1,172 @@
+import type { QueryClient } from '@tanstack/react-query'
+import type {
+ BulkDeleteFilesResult,
+ DeleteFileResult,
+ DeleteMyFile,
+ DeleteMyFileArgs,
+ DeleteMyFiles,
+ DeleteMyFilesArgs,
+} from '@vitnode/core/views/files/my-files-delete'
+import type {
+ MyFilesPageFetcher,
+ MyFilesParams,
+} from '@vitnode/core/views/files/my-files-query'
+
+import { useQueryClient } from '@tanstack/react-query'
+import { createIsomorphicFn } from '@tanstack/react-start'
+import {
+ deleteMyFileInBrowser,
+ deleteMyFilesInBrowser,
+ shouldRefreshAfterBulkDelete,
+} from '@vitnode/core/views/files/my-files-delete'
+import {
+ fetchMyFilesPageInBrowser,
+ MY_FILES_QUERY_ROOT,
+ myFilesQueryOptions,
+} from '@vitnode/core/views/files/my-files-query'
+import React from 'react'
+
+import { fetchMyFilesPageOnServer } from '#/server/my-files.server'
+
+/**
+ * The visitor's own files, as this app's one query definition and two deletes.
+ *
+ * Everything about *what* the list is - the request, the defaults, the cache
+ * key, what counts as a refusal - comes from
+ * `@vitnode/core/views/files/my-files-query`, which is also what the mounted
+ * `MyFilesTableContent` is rendered from. This module supplies only the two
+ * things core cannot know: how to reach the API from a server that is rendering
+ * a request, and what "refresh the table" means in a router that has a query
+ * cache instead of `revalidatePath`.
+ */
+
+/**
+ * The transport boundary, and the reason one query definition works in a loader
+ * and in a component.
+ *
+ * Both branches call the Hono API directly - the server one from inside the
+ * request being rendered, the browser one over the network to the same origin.
+ * There is deliberately no `createServerFn` in between. A server function is a
+ * `POST` back to this app that then calls Hono, so every sort, page and search
+ * of the table would cost two round trips for a read the API is already the
+ * boundary for. The session read next door *is* a server function, and the
+ * difference is real rather than stylistic: nothing here needs a `Set-Cookie`
+ * copied onto this app's own response.
+ *
+ * The cookie still travels on both branches. On the server `fetcherServer`
+ * forwards the one the page request arrived with; in the browser the call is
+ * same-origin, so the browser attaches it without being asked. That is what
+ * makes a `401` here mean "the session ended", never "we forgot to say who was
+ * asking".
+ *
+ * `createIsomorphicFn` is what makes that safe rather than merely tidy: the
+ * Start compiler keeps only the branch belonging to the bundle it is building
+ * and drops the other's import with it, so `my-files.server.ts` - and the
+ * `server-only` marker at the top of it - never reaches the browser.
+ */
+const fetchMyFilesPage: MyFilesPageFetcher = createIsomorphicFn()
+ .server(fetchMyFilesPageOnServer)
+ .client(fetchMyFilesPageInBrowser)
+
+/**
+ * The files table, as the one query definition every caller shares.
+ *
+ * loader: context.queryClient.ensureQueryData(myFilesQuery({ params }))
+ * component: useQuery(myFilesQuery({ params }))
+ * after a delete: invalidate, and the component above refetches
+ *
+ * `params` must be the *normalised* ones - `normalizeMyFilesParams` from core,
+ * over the route's validated search - because the cache key is built from them.
+ * Passing raw URL values would make `?first=10` and no `first` two entries
+ * holding identical rows, and the loader would fill one while the component read
+ * the other.
+ *
+ * No `initialData`: the loader has already put the page in the entry this key
+ * names and the SSR pass dehydrates it, so passing it again would be a second
+ * copy of the same bytes that can disagree with the first.
+ */
+export const myFilesQuery = ({ params }: { params: MyFilesParams }) =>
+ myFilesQueryOptions({ fetchPage: fetchMyFilesPage, params })
+
+/**
+ * Marks every cached page of the visitor's files stale.
+ *
+ * The whole family, by prefix - not the one page on screen. A delete changes
+ * which rows exist, so every other page, sort and search of the same list is now
+ * wrong too, and the visitor reaches those by pressing a button that reads from
+ * the cache. It is emphatically *not* `queryClient.invalidateQueries()` with no
+ * key: the session, the messages and every other list this app holds have not
+ * changed, and refetching them because a file was deleted is the blunt version
+ * of the `revalidatePath` this replaces.
+ *
+ * Invalidating rather than removing keeps the current rows on screen while the
+ * fresh ones are fetched, instead of blanking the table under the dialog that is
+ * still open.
+ */
+export const invalidateMyFiles = async (
+ queryClient: QueryClient,
+): Promise =>
+ await queryClient.invalidateQueries({ queryKey: MY_FILES_QUERY_ROOT })
+
+/**
+ * Deletes one file, then refreshes the table if it actually went.
+ *
+ * Only on success. A `409` left the file exactly where it was and the dialog is
+ * still open offering to force past the revisions holding it; refetching
+ * underneath that would replace the rows the person is being asked about.
+ */
+export const deleteMyFile = async (
+ queryClient: QueryClient,
+ args: DeleteMyFileArgs,
+): Promise => {
+ const result = await deleteMyFileInBrowser(args)
+
+ if (!result.error) await invalidateMyFiles(queryClient)
+
+ return result
+}
+
+/**
+ * Deletes a selection, then refreshes the table if anything went.
+ *
+ * `shouldRefreshAfterBulkDelete` is core's rule, and the same one the Next.js
+ * server action applies before it calls `revalidatePath`: a run that deleted
+ * nothing leaves the page as it was, and refetching would drop the selection
+ * that is showing which rows were kept - which is the only thing telling the
+ * person what to do next.
+ */
+export const deleteMyFiles = async (
+ queryClient: QueryClient,
+ args: DeleteMyFilesArgs,
+): Promise => {
+ const result = await deleteMyFilesInBrowser(args)
+
+ if (shouldRefreshAfterBulkDelete(result)) await invalidateMyFiles(queryClient)
+
+ return result
+}
+
+/**
+ * The two callbacks `MyFilesTableContent` takes, bound to this router's cache.
+ *
+ * Memoised on the client, which is the only reason this is a hook rather than
+ * two calls at the point of use: the callbacks are props on a table that
+ * re-renders on every navigation, and new function identities would remount the
+ * confirm dialogs mid-delete.
+ */
+export const useMyFilesDeleteCallbacks = (): {
+ onDeleteFile: DeleteMyFile
+ onDeleteFiles: DeleteMyFiles
+} => {
+ const queryClient = useQueryClient()
+
+ return React.useMemo(
+ () => ({
+ onDeleteFile: async (args: DeleteMyFileArgs) =>
+ await deleteMyFile(queryClient, args),
+ onDeleteFiles: async (args: DeleteMyFilesArgs) =>
+ await deleteMyFiles(queryClient, args),
+ }),
+ [queryClient],
+ )
+}
diff --git a/apps/web/src/lib/search/discover-feed.ts b/apps/web/src/lib/search/discover-feed.ts
index 32c2feb20..5e240e127 100644
--- a/apps/web/src/lib/search/discover-feed.ts
+++ b/apps/web/src/lib/search/discover-feed.ts
@@ -1,93 +1,36 @@
-import type {
- SearchFeedPageArgs,
- SearchFeedPageFetcher,
-} from '@vitnode/core/views/search/search-feed-query'
-
-import { createIsomorphicFn } from '@tanstack/react-start'
-import {
- fetchSearchFeedPageInBrowser,
- searchFeedQueryKey,
- searchFeedQueryOptions,
-} from '@vitnode/core/views/search/search-feed-query'
+import type { SearchFeedPageArgs } from '@vitnode/core/views/search/search-feed-query'
import type { Locale } from '#/lib/i18n/shared'
import { DISCOVER_FEED_PARAMS } from '#/lib/search/discover-request'
-import { fetchDiscoverFeedPageOnServer } from '#/server/discover-feed.server'
+import { feedQueryKey, feedQueryOptions } from '#/lib/search/feed'
/**
- * The Discover feed, as this app's one query definition.
- *
- * Everything about *what* a feed page is - the request, the page size, the
- * cursor rule, what counts as a failure - comes from
- * `@vitnode/core/views/search/search-feed-query`, which is also what the mounted
- * `SearchFeedContent` runs. This module supplies only the two things core cannot
- * know: which parameters Discover browses with, and how to reach the API from a
- * server that is rendering a request.
- */
-
-/**
- * The transport boundary, and the reason one query definition works in a loader
- * and in a component.
- *
- * Both branches call the Hono API directly - the server one from inside the
- * request being rendered, the browser one over the network to the same origin.
- * There is deliberately no `createServerFn` in between: a server function is a
- * `POST` back to this app that then calls Hono, so every scroll of the feed
- * would cost two round trips to fetch a public, anonymous read that the API is
- * already the boundary for.
- *
- * `createIsomorphicFn` is what makes that safe rather than merely tidy. The
- * Start compiler keeps only the branch belonging to the bundle it is building
- * and drops the other's import with it, so `discover-feed.server.ts` - and the
- * `server-only` marker at the top of it - never reaches the browser. The client
- * branch is core's own browser fetcher, so a hydrated page and a Next.js page
- * fetch through exactly the same code.
- *
- * Un-compiled (tests, plain Node) the stub falls back to the server branch,
- * which is the right default off a browser.
+ * Discover, as the shared feed with Discover's parameters.
+ *
+ * Two bindings and no logic. What a feed page *is* - the request, the page size,
+ * the cursor rule, what counts as a failure - comes from
+ * `@vitnode/core/views/search/search-feed-query`; how it travels comes from
+ * `#/lib/search/feed`, which every feed in this app shares. All that is left
+ * here is which parameters this route browses with, and those live in
+ * `discover-request.ts`.
+ *
+ * The named exports stay, because a loader, a component and a test all read
+ * "the Discover feed" and none of them should have to know it is
+ * `{ sort: 'newest' }`.
*/
-const fetchDiscoverFeedPage: SearchFeedPageFetcher = createIsomorphicFn()
- .server(fetchDiscoverFeedPageOnServer)
- .client(fetchSearchFeedPageInBrowser)
/**
- * The cache entry one language's feed lives in.
- *
- * Core's key, not one of this app's devising. `SearchFeedContent` runs the
- * mounted `useInfiniteQuery` and stores its pages here; a key invented locally
- * would be a *second* entry holding the same feed, so the loader would fill one,
- * the component would miss the other, and every visit would render a skeleton
- * and fetch page one again from the browser.
+ * The cache entry one language's Discover feed lives in.
*
- * The locale is in it, which is the whole contract: `/discover` and
- * `/pl/discover` are two feeds over two sets of documents, so they get two
- * entries. A language switch changes the key rather than the value under it.
+ * Core's key, through this app's one binding of it - see `feedQueryKey`. A key
+ * invented here would be a second entry holding the same feed.
*/
export const discoverFeedQueryKey = (locale: Locale) =>
- searchFeedQueryKey({ locale, params: DISCOVER_FEED_PARAMS })
+ feedQueryKey({ locale, params: DISCOVER_FEED_PARAMS })
-/**
- * The Discover feed, as the one query definition every caller shares.
- *
- * loader: context.queryClient.ensureInfiniteQueryData(options)
- * component:
- * load more: fetchNextPage() // the same queryFn, cursor rule and checks
- *
- * No `initialData`. The loader has already put page one in the entry this key
- * names and the SSR pass dehydrates it, so passing it again would be a second
- * copy of the same bytes that can disagree with the first.
- *
- * No `staleTime` either. Freshness is whatever the API's own caching gives, plus
- * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
- * off), so a hydrated feed is not refetched behind the reader. Deciding a cache
- * lifetime belongs to the caching stage, with the API and Redis in the same view.
- */
+/** The Discover feed, as the one query definition every caller shares. */
export const discoverFeedQueryOptions = ({ locale }: { locale: Locale }) =>
- searchFeedQueryOptions({
- fetchPage: fetchDiscoverFeedPage,
- locale,
- params: DISCOVER_FEED_PARAMS,
- })
+ feedQueryOptions({ locale, params: DISCOVER_FEED_PARAMS })
export type { SearchFeedPageArgs }
diff --git a/apps/web/src/lib/search/feed.ts b/apps/web/src/lib/search/feed.ts
new file mode 100644
index 000000000..49dbf5a8a
--- /dev/null
+++ b/apps/web/src/lib/search/feed.ts
@@ -0,0 +1,109 @@
+import type {
+ SearchFeedPageArgs,
+ SearchFeedPageFetcher,
+ SearchFeedParams,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import { createIsomorphicFn } from '@tanstack/react-start'
+import {
+ fetchSearchFeedPageInBrowser,
+ searchFeedQueryKey,
+ searchFeedQueryOptions,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import type { Locale } from '#/lib/i18n/shared'
+
+import { fetchSearchFeedPageOnServer } from '#/server/search-feed.server'
+
+/**
+ * The search feed, as this app's one query definition.
+ *
+ * Everything about *what* a feed page is - the request, the page size, the
+ * cursor rule, what counts as a failure, the cache entry it lands in - comes
+ * from `@vitnode/core/views/search/search-feed-query`, which is also what the
+ * mounted `SearchFeedContent` runs. This module supplies only the one thing core
+ * cannot know: how to reach the API from a server that is rendering a request.
+ *
+ * Every feed in the app is built from here - `/discover` browsing newest-first,
+ * `/search` with a term and filters, and whatever comes next - because they are
+ * the same query with different parameters. A route that bound its own transport
+ * would be a second definition of a feed that agreed with this one only until it
+ * didn't.
+ */
+
+/**
+ * The transport boundary, and the reason one query definition works in a loader
+ * and in a component.
+ *
+ * Both branches call the Hono API directly - the server one from inside the
+ * request being rendered, the browser one over the network to the same origin.
+ * There is deliberately no `createServerFn` in between: a server function is a
+ * `POST` back to this app that then calls Hono, so every scroll of the feed and
+ * every keystroke in the search box would cost two round trips to fetch a
+ * public, anonymous read that the API is already the boundary for.
+ *
+ * `createIsomorphicFn` is what makes that safe rather than merely tidy. The
+ * Start compiler keeps only the branch belonging to the bundle it is building
+ * and drops the other's import with it, so `search-feed.server.ts` - and the
+ * `server-only` marker at the top of it - never reaches the browser. The client
+ * branch is core's own browser fetcher, so a hydrated page and a Next.js page
+ * fetch through exactly the same code.
+ *
+ * Un-compiled (tests, plain Node) the stub falls back to the server branch,
+ * which is the right default off a browser.
+ */
+export const fetchSearchFeedPage: SearchFeedPageFetcher = createIsomorphicFn()
+ .server(fetchSearchFeedPageOnServer)
+ .client(fetchSearchFeedPageInBrowser)
+
+/**
+ * The cache entry one feed lives in.
+ *
+ * Core's key, not one of this app's devising. `SearchFeedContent` runs the
+ * mounted `useInfiniteQuery` and stores its pages here; a key invented locally
+ * would be a *second* entry holding the same feed, so the loader would fill one,
+ * the component would miss the other, and every visit would render a skeleton
+ * and fetch page one again from the browser.
+ *
+ * The locale is in it, which is the whole contract: `/discover` and
+ * `/pl/discover` are two feeds over two sets of documents, so they get two
+ * entries. A language switch changes the key rather than the value under it.
+ */
+export const feedQueryKey = ({
+ locale,
+ params,
+}: {
+ locale: Locale
+ params: SearchFeedParams
+}) => searchFeedQueryKey({ locale, params })
+
+/**
+ * One feed, as the one query definition every caller shares.
+ *
+ * loader: context.queryClient.ensureInfiniteQueryData(options)
+ * component:
+ * load more: fetchNextPage() // the same queryFn, cursor rule and checks
+ *
+ * No `initialData`. A route loader has already put page one in the entry this
+ * key names and the SSR pass dehydrates it, so passing it again would be a
+ * second copy of the same bytes that can disagree with the first.
+ *
+ * No `staleTime` either. Freshness is whatever the API's own caching gives, plus
+ * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
+ * off), so a hydrated feed is not refetched behind the reader. Deciding a cache
+ * lifetime belongs to the caching stage, with the API and Redis in the same view.
+ */
+export const feedQueryOptions = ({
+ locale,
+ params,
+}: {
+ locale: Locale
+ params: SearchFeedParams
+}) =>
+ searchFeedQueryOptions({
+ fetchPage: fetchSearchFeedPage,
+ locale,
+ params,
+ })
+
+export type { SearchFeedPageArgs, SearchFeedParams }
diff --git a/apps/web/src/lib/search/search-request.ts b/apps/web/src/lib/search/search-request.ts
new file mode 100644
index 000000000..ea5a2c23c
--- /dev/null
+++ b/apps/web/src/lib/search/search-request.ts
@@ -0,0 +1,74 @@
+import type { SearchFeedParams } from '@vitnode/core/views/search/search-feed-query'
+
+import {
+ normalizeSearchTerm,
+ searchFeedParamsFor,
+} from '@vitnode/core/views/search/search-params'
+
+/**
+ * What `/search` reads out of its URL, and what it turns that into.
+ *
+ * Two pure functions, no transport and no React, so the route's contract can be
+ * stated and tested without a router: `src/tests/search-request.test.ts` is the
+ * whole of it.
+ *
+ * Both delegate to `@vitnode/core/views/search/search-params`, which is where
+ * the meaning of a search request lives - the same module the Next.js
+ * `SearchView` reads its `searchParams` through. `/search?search=hello` is
+ * therefore the same request in both applications rather than two hand-written
+ * approximations of it.
+ */
+
+/**
+ * The one search parameter this route has.
+ *
+ * `search` is in the URL because a search has to be shareable and because a
+ * crawler landing on `/search?search=hello` should be served those results. The
+ * sort and the type filters are **not**: they are controls the visitor drives
+ * after the page has loaded, they have never been in the URL, and putting them
+ * there means deciding what a canonical search URL is and whether every
+ * keystroke is a history entry. That is a product question; Stage 7 is a move.
+ */
+export interface SearchRouteSearch {
+ search?: string
+}
+
+/**
+ * The route's search schema - written as a function rather than a schema object
+ * because its job is to *normalise*, not to reject.
+ *
+ * A search page is the one page whose query string is typed by strangers.
+ * `?search=` arrives empty, `?search=a&search=b` arrives as an array,
+ * `?search=<40KB>` arrives as a denial-of-service attempt on the full-text
+ * index - and every one of them should render the search page, not an error
+ * boundary. `normalizeSearchTerm` answers all three: anything that is not a
+ * usable term becomes no term at all, which is the browse feed.
+ *
+ * A missing term is returned as an *absent* key rather than
+ * `{ search: undefined }`, so the router has nothing to write back into the URL
+ * and `/search?search=%20` settles as `/search`.
+ */
+export const normalizeSearchRouteSearch = (
+ input: Record,
+): SearchRouteSearch => {
+ const search = normalizeSearchTerm(input.search)
+
+ return search === undefined ? {} : { search }
+}
+
+/**
+ * The route's search parameters, as the shared feed's own.
+ *
+ * The sort is not passed and is not missing: `searchFeedParamsFor` derives it
+ * from whether there is a term - relevance when there is one, newest when there
+ * is not - which is the rule the controls then start from and the rule the
+ * Next.js page has always applied.
+ *
+ * With no term this is `{ sort: 'newest' }`, which is exactly
+ * `DISCOVER_FEED_PARAMS`. That is deliberate: `/search` with an empty box and
+ * `/discover` are the same request over the same documents, so they share one
+ * cache entry rather than fetching it twice.
+ */
+export const searchRouteFeedParams = ({
+ search,
+}: SearchRouteSearch): SearchFeedParams => searchFeedParamsFor({ search })
diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json
index b42564635..f9023f682 100644
--- a/apps/web/src/locales/@vitnode/core/pl.json
+++ b/apps/web/src/locales/@vitnode/core/pl.json
@@ -8,11 +8,20 @@
"theme_switcher": "Zmień motyw"
},
"search": {
+ "title": "Szukaj",
+ "desc": "Przeszukaj wszystko w społeczności.",
"discoverTitle": "Odkrywaj",
"discoverDesc": "Zobacz najnowszą aktywność w społeczności.",
+ "placeholder": "Szukaj…",
"empty": "Nic tu jeszcze nie ma.",
"loadMore": "Wczytaj więcej",
"loading": "Wczytywanie…",
+ "sortBy": "Sortuj według",
+ "sort": {
+ "relevance": "Trafność",
+ "newest": "Najnowsze",
+ "oldest": "Najstarsze"
+ },
"types": {
"blog_post": "Wpis",
"unknown": "Treść"
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index c4ed15692..75e434876 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -13,7 +13,9 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as DiscoverRouteImport } from './routes/discover'
import { Route as LoginRouteImport } from './routes/login'
+import { Route as SearchRouteImport } from './routes/search'
import { Route as AuthenticatedAccountRouteImport } from './routes/_authenticated/account'
+import { Route as AuthenticatedFilesRouteImport } from './routes/_authenticated/files'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as LoginSsoProviderIdRouteImport } from './routes/login_.sso.$providerId'
@@ -36,11 +38,21 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
+const SearchRoute = SearchRouteImport.update({
+ id: '/search',
+ path: '/search',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AuthenticatedAccountRoute = AuthenticatedAccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => AuthenticatedRoute,
} as any)
+const AuthenticatedFilesRoute = AuthenticatedFilesRouteImport.update({
+ id: '/files',
+ path: '/files',
+ getParentRoute: () => AuthenticatedRoute,
+} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
@@ -56,7 +68,9 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/discover': typeof DiscoverRoute
'/login': typeof LoginRoute
+ '/search': typeof SearchRoute
'/account': typeof AuthenticatedAccountRoute
+ '/files': typeof AuthenticatedFilesRoute
'/api/$': typeof ApiSplatRoute
'/login/sso/$providerId': typeof LoginSsoProviderIdRoute
}
@@ -64,7 +78,9 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/discover': typeof DiscoverRoute
'/login': typeof LoginRoute
+ '/search': typeof SearchRoute
'/account': typeof AuthenticatedAccountRoute
+ '/files': typeof AuthenticatedFilesRoute
'/api/$': typeof ApiSplatRoute
'/login/sso/$providerId': typeof LoginSsoProviderIdRoute
}
@@ -74,7 +90,9 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/discover': typeof DiscoverRoute
'/login': typeof LoginRoute
+ '/search': typeof SearchRoute
'/_authenticated/account': typeof AuthenticatedAccountRoute
+ '/_authenticated/files': typeof AuthenticatedFilesRoute
'/api/$': typeof ApiSplatRoute
'/login_/sso/$providerId': typeof LoginSsoProviderIdRoute
}
@@ -84,7 +102,9 @@ export interface FileRouteTypes {
| '/'
| '/discover'
| '/login'
+ | '/search'
| '/account'
+ | '/files'
| '/api/$'
| '/login/sso/$providerId'
fileRoutesByTo: FileRoutesByTo
@@ -92,7 +112,9 @@ export interface FileRouteTypes {
| '/'
| '/discover'
| '/login'
+ | '/search'
| '/account'
+ | '/files'
| '/api/$'
| '/login/sso/$providerId'
id:
@@ -101,7 +123,9 @@ export interface FileRouteTypes {
| '/_authenticated'
| '/discover'
| '/login'
+ | '/search'
| '/_authenticated/account'
+ | '/_authenticated/files'
| '/api/$'
| '/login_/sso/$providerId'
fileRoutesById: FileRoutesById
@@ -111,6 +135,7 @@ export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
DiscoverRoute: typeof DiscoverRoute
LoginRoute: typeof LoginRoute
+ SearchRoute: typeof SearchRoute
ApiSplatRoute: typeof ApiSplatRoute
LoginSsoProviderIdRoute: typeof LoginSsoProviderIdRoute
}
@@ -145,6 +170,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
+ '/search': {
+ id: '/search'
+ path: '/search'
+ fullPath: '/search'
+ preLoaderRoute: typeof SearchRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_authenticated/account': {
id: '/_authenticated/account'
path: '/account'
@@ -152,6 +184,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAccountRouteImport
parentRoute: typeof AuthenticatedRoute
}
+ '/_authenticated/files': {
+ id: '/_authenticated/files'
+ path: '/files'
+ fullPath: '/files'
+ preLoaderRoute: typeof AuthenticatedFilesRouteImport
+ parentRoute: typeof AuthenticatedRoute
+ }
'/api/$': {
id: '/api/$'
path: '/api/$'
@@ -171,10 +210,12 @@ declare module '@tanstack/react-router' {
interface AuthenticatedRouteChildren {
AuthenticatedAccountRoute: typeof AuthenticatedAccountRoute
+ AuthenticatedFilesRoute: typeof AuthenticatedFilesRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAccountRoute: AuthenticatedAccountRoute,
+ AuthenticatedFilesRoute: AuthenticatedFilesRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
@@ -186,6 +227,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren,
DiscoverRoute: DiscoverRoute,
LoginRoute: LoginRoute,
+ SearchRoute: SearchRoute,
ApiSplatRoute: ApiSplatRoute,
LoginSsoProviderIdRoute: LoginSsoProviderIdRoute,
}
diff --git a/apps/web/src/routes/_authenticated/files.tsx b/apps/web/src/routes/_authenticated/files.tsx
new file mode 100644
index 000000000..7dff5bf0f
--- /dev/null
+++ b/apps/web/src/routes/_authenticated/files.tsx
@@ -0,0 +1,261 @@
+import type { DataTableNavigation } from '@vitnode/core/components/table/navigation'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+import { DataTableNavigationProvider } from '@vitnode/core/components/table/navigation'
+import { HeaderContent } from '@vitnode/core/components/ui/header-content'
+import { formatPageTitle } from '@vitnode/core/lib/metadata'
+import { MyFilesTableContent } from '@vitnode/core/views/files/my-files-table-content'
+import React from 'react'
+import { createTranslator } from 'use-intl'
+
+import { RouteMessages } from '#/components/route-messages'
+import { myFilesQuery, useMyFilesDeleteCallbacks } from '#/lib/files/my-files'
+import {
+ myFilesRouteParams,
+ myFilesSearchFrom,
+ myFilesSearchParams,
+ normalizeMyFilesRouteSearch,
+} from '#/lib/files/my-files-route'
+import { intlQueryOptions } from '#/lib/i18n/query'
+import { vitNodeShellConfig } from '#/vitnode.shell.config'
+
+/**
+ * The visitor's own files, rendered outside Next.js.
+ *
+ * One route file serving two public URLs. `/files` and `/pl/files` match *this*
+ * route: the locale is stripped before matching and written back into every link
+ * the router builds (`rewrite` in `src/router.tsx`), so nothing here mentions a
+ * language and there is no `/pl/files.tsx` to keep in step. The Next.js route at
+ * `packages/vitnode/src/routes/main/files/page.tsx` is still live and unchanged -
+ * this is a parallel slice until the cutover.
+ *
+ * ## Where it sits, and what that buys
+ *
+ * Under `_authenticated`, which is a pathless layout: the file's *location* is
+ * the guard, and this route contributes no URL segment of its own. There is
+ * deliberately no session check in this file. The Next.js page opens with
+ * `getSessionApi()` and `notFound()` because it has nowhere else to put the rule;
+ * here that rule is `routes/_authenticated.tsx`, it runs in `beforeLoad` before
+ * any of this renders, and it answers an anonymous visitor with
+ * `/login?returnTo=/files` - carrying whatever sort and page they were heading
+ * for, and no locale, because the rewrite writes that back on the way home.
+ *
+ * A second check here would not be defence in depth, it would be a second rule to
+ * keep in step with the first. The actual boundary is neither: `GET
+ * /api/@vitnode/core/users/files` derives the owner from the session cookie on
+ * every request, which is why a session that ends while this page is open shows
+ * up below as a failed query rather than as somebody else's files.
+ *
+ * ## One query contract, one cache entry
+ *
+ * The table is `myFilesQuery` and nothing else, in the loader and in the
+ * component:
+ *
+ * loader: ensureQueryData(myFilesQuery({ params }))
+ * component: useSuspenseQuery(myFilesQuery({ params }))
+ * after a delete: invalidate the family, and the component refetches
+ *
+ * Same key, same request, same refusal handling - so the page the server rendered
+ * is the page the browser reads, and there is no `initialData` anywhere: the
+ * loader has already put it in the entry the component reads and the SSR pass
+ * dehydrates it, so a second copy of those bytes could only disagree with the
+ * first.
+ *
+ * `params` is the *normalised* request from `loaderDeps`, handed to the component
+ * through the loader rather than derived a second time, so the two cannot drift
+ * apart through a difference in how each computed it.
+ */
+
+/**
+ * What this page renders strings from.
+ *
+ * `core.files` is the heading, the columns, the empty state and every word of
+ * both delete dialogs. `core.global` is the rest of the table - the pager's
+ * labels, the search placeholder, the confirm dialog's buttons and the error
+ * toasts - and it is listed even though the root already provides it, because
+ * `RouteMessages` mounts its own provider over the root's rather than adding to
+ * it.
+ *
+ * One list, read by both the loader that fetches them and the provider that
+ * mounts them, because they have to be the same set or the provider suspends on
+ * a key nobody warmed.
+ */
+const FILES_NAMESPACES = ['core.files', 'core.global'] as const
+
+export const Route = createFileRoute('/_authenticated/files')({
+ component: MyFilesRoute,
+ /**
+ * The request, as the only thing the loader re-runs for.
+ *
+ * The *normalised* parameters rather than the raw search, and that is what
+ * makes this exact. The router hands `loaderDeps` the validated search merged
+ * over everything else that was in the query string, so keying on it directly
+ * would re-run the loader for a stray `?utm_source=` - and, worse, would treat
+ * `?first=10` and no `first` as two different pages of the same rows.
+ * Normalised, the dependency is precisely "which rows are being asked for",
+ * which is also what the query key is built from.
+ */
+ loaderDeps: ({ search }) => ({ params: myFilesRouteParams(search) }),
+ /**
+ * Both reads this page needs, in parallel, before it renders.
+ *
+ * `context.locale` comes from the root route's `beforeLoad`, which resolved it
+ * from the public URL - so `/pl/files` fetches Polish messages, and the first
+ * byte of HTML is already in that language.
+ *
+ * Neither call is repeated by the component: the messages are read back by
+ * `RouteMessages` through the identical `intlQueryOptions`, and the page by
+ * `useSuspenseQuery` through the identical `myFilesQuery`.
+ *
+ * The session is *not* fetched here. `_authenticated`'s `beforeLoad` has
+ * already put it in the one cache entry every guard reads.
+ *
+ * A refusal from the files API is deliberately left to propagate. `401`, `403`
+ * and `429` reject as `MyFilesRequestError`, which fails this loader and shows
+ * the router's error path - the honest answer. The alternative, catching it and
+ * rendering an empty table, is indistinguishable from an account with nothing
+ * uploaded, which is the one thing this must never look like.
+ */
+ loader: async ({ context, deps }) => {
+ const [intl] = await Promise.all([
+ context.queryClient.ensureQueryData(
+ intlQueryOptions({
+ locale: context.locale,
+ namespaces: FILES_NAMESPACES,
+ }),
+ ),
+ context.queryClient.ensureQueryData(
+ myFilesQuery({ params: deps.params }),
+ ),
+ ])
+
+ /**
+ * The heading and the tab title, translated once so they cannot disagree.
+ *
+ * The cast is what makes `createTranslator` usable here, and it is the same
+ * one `login.tsx` explains at length: its key type is derived from the
+ * *inferred* type of `messages`, and `AbstractIntlMessages` is a bare index
+ * signature - so `MessageKeys` cannot tell a leaf from a branch and collapses
+ * to `never`, making every key a type error. Naming the two keys this route
+ * reads is both the smallest fix and a true statement: rename either in
+ * `core/locales/en.json` and this stops compiling rather than rendering a raw
+ * message key into a ``.
+ */
+ const t = createTranslator({
+ locale: context.locale,
+ messages: intl.messages as {
+ core: { files: { desc: string; title: string } }
+ },
+ namespace: 'core.files',
+ })
+
+ return { description: t('desc'), params: deps.params, title: t('title') }
+ },
+ /**
+ * The page's metadata, in the language the request resolved to.
+ *
+ * **`head` must be written after `loader`.** `loaderData`'s type is inferred
+ * from `loader` in the same object literal, and TypeScript reads a literal's
+ * members in order - put `head` first and `loaderData` is `never`, while
+ * `Route.useLoaderData()` collapses to `undefined`. Neither error names the
+ * cause.
+ *
+ * The loader translates once, so the tab title and the `
` are the same
+ * string by construction - which is what the Next.js route gets from calling
+ * `getTranslations` once per request. `formatPageTitle` applies the same
+ * `" - "` rule Next.js applies through `title.template`.
+ */
+ head: ({ loaderData }) => ({
+ meta: [
+ // The Next.js page sets `robots: { index: false, follow: false }`, and this
+ // is that: a listing of one person's uploads, behind a login, with nothing
+ // on it a crawler may see or follow. Stated rather than assumed - TanStack
+ // Start emits no robots directive of its own.
+ { content: 'noindex, nofollow', name: 'robots' },
+ ...(loaderData
+ ? [
+ {
+ title: formatPageTitle(
+ vitNodeShellConfig.metadata,
+ loaderData.title,
+ ),
+ },
+ { content: loaderData.description, name: 'description' },
+ ]
+ : []),
+ ],
+ }),
+ validateSearch: normalizeMyFilesRouteSearch,
+})
+
+function MyFilesRoute() {
+ const { description, params, title } = Route.useLoaderData()
+ const search = Route.useSearch()
+ const navigate = Route.useNavigate()
+ const { data } = useSuspenseQuery(myFilesQuery({ params }))
+ const { onDeleteFile, onDeleteFiles } = useMyFilesDeleteCallbacks()
+
+ /**
+ * The one thing the shared table cannot decide for itself: how to change a URL.
+ *
+ * `DataTable` mounts this for Next.js (`NextDataTableNavigation`, a
+ * locale-aware `push`); a TanStack route mounts it with the router's own
+ * navigate. Everything either side of it - which parameter a sort header
+ * rewrites, which ones a filter resets, what a page button does with a cursor -
+ * is `components/table/url-state.ts` and is shared.
+ *
+ * `to` is deliberately absent: with no destination the router stays on this
+ * route and changes only its search, which is the whole of what a table
+ * control does. `resetScroll: false` is Next's `scroll: false` - somebody
+ * sorting the last column of a long table is looking at the header they
+ * clicked.
+ *
+ * The promise is returned rather than dropped so the seam's `useTransition`
+ * stays pending for the whole navigation, which is what keeps the current rows
+ * on screen with a spinner instead of blanking the table.
+ */
+ const navigation = React.useMemo(
+ () => ({
+ navigate: async (nextSearch) => {
+ await navigate({
+ resetScroll: false,
+ search: myFilesSearchFrom(nextSearch),
+ })
+ },
+ searchParams: myFilesSearchParams(search),
+ }),
+ [navigate, search],
+ )
+
+ return (
+
+
+
+
+
+ {/*
+ The same component the Next.js page renders, handed the three things a
+ shared table cannot resolve for itself: the page, and the two deletes.
+ The columns, the preview, the metadata popover and the empty state are
+ core's and are not restated here - see `my-files-table-content.tsx`.
+
+ Both callbacks end in a query invalidation of the whole `files/me`
+ family rather than in `revalidatePath`, and only when something
+ actually went: a `409` leaves the file where it was and the dialog
+ open, and a bulk run that deleted nothing must not drop the selection
+ that is showing which rows were kept. That rule is core's
+ (`shouldRefreshAfterBulkDelete`) and is applied by
+ `#/lib/files/my-files`, so both frameworks refresh on the same
+ condition.
+ */}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx
new file mode 100644
index 000000000..9e7164183
--- /dev/null
+++ b/apps/web/src/routes/search.tsx
@@ -0,0 +1,213 @@
+import type { SearchFeedLinkProps } from '@vitnode/core/views/search/search-feed-content'
+
+import { createFileRoute } from '@tanstack/react-router'
+import { HeaderContent } from '@vitnode/core/components/ui/header-content'
+import { formatPageTitle } from '@vitnode/core/lib/metadata'
+import { SearchControlsContent } from '@vitnode/core/views/search/search-controls-content'
+import { createTranslator } from 'use-intl'
+
+import { MigrationLink } from '#/components/migration-link'
+import { RouteMessages } from '#/components/route-messages'
+import { useLocale } from '#/lib/i18n/client'
+import { intlQueryOptions } from '#/lib/i18n/query'
+import { feedQueryOptions } from '#/lib/search/feed'
+import {
+ normalizeSearchRouteSearch,
+ searchRouteFeedParams,
+} from '#/lib/search/search-request'
+import { vitNodeShellConfig } from '#/vitnode.shell.config'
+
+/**
+ * Search, rendered outside Next.js.
+ *
+ * One route file serving two public URLs. `/search` and `/pl/search` match
+ * *this* route: the locale is stripped before matching and written back into
+ * every link the router builds (`rewrite` in `src/router.tsx`), so nothing here
+ * mentions a language and there is no `/pl/search.tsx` to keep in step. The
+ * Next.js route at `packages/vitnode/src/routes/main/search/page.tsx` is still
+ * live and unchanged - this is a parallel slice until the cutover.
+ *
+ * Everything visible is shared: `HeaderContent` and `SearchControlsContent` -
+ * which is the search box, the type filters, the sort and the feed - are the same
+ * modules the Next.js page renders, with the three things a shared component
+ * cannot resolve for itself passed in: the locale, a `Link`, and how a feed page
+ * is fetched.
+ *
+ * ## One query contract, one cache entry
+ *
+ * The feed is `feedQueryOptions` and nothing else, in the loader and in the
+ * component:
+ *
+ * loader: ensureInfiniteQueryData(feedQueryOptions({ locale, params }))
+ * component: feedQueryOptions({ locale, params })} />
+ *
+ * Same key, same page function, same cursor rule - so the loader's page is the
+ * page the component renders, and `fetchNextPage` continues from it. There is no
+ * `initialData` anywhere on this route: the loader has already put page one in
+ * the entry the component reads and the SSR pass dehydrates it, so a second copy
+ * of those bytes could only disagree with the first.
+ */
+
+/**
+ * What this page renders strings from.
+ *
+ * `core.global` is the shell's, `core.search` is everything else here - the
+ * heading, the placeholder, the sort labels, the type labels, the feed's empty
+ * state and its "load more". One list, read by both the loader that fetches them
+ * and the provider that mounts them, because they have to be the same set or the
+ * provider suspends on a key nobody warmed.
+ */
+const SEARCH_NAMESPACES = ['core.global', 'core.search'] as const
+
+/**
+ * The feed's link.
+ *
+ * `MigrationLink` rather than the router's `Link` directly, because a search
+ * result points wherever the indexed content lives and most of VitNode has not
+ * moved yet. It asks the route tree whether this app can render the destination:
+ * `/discover` is a client-side navigation, `/blog/post-30` is a document load
+ * into the Next.js app that still serves it. There is no hand-written list of
+ * migrated routes anywhere in that decision - the route tree is the list - so the
+ * day `/blog` moves, this file does not change.
+ *
+ * Declared at module scope rather than inline, so it is the same component type
+ * on every render and React reconciles the feed rather than remounting every
+ * result on every keystroke. External and unsafe URLs never reach it:
+ * `SearchFeedContent` classifies those and renders them itself, which is what
+ * keeps a plugin-authored `javascript:` url out of the router.
+ */
+const SearchFeedLink = ({ children, className, href }: SearchFeedLinkProps) => (
+
+ {children}
+
+)
+
+export const Route = createFileRoute('/search')({
+ component: SearchRoute,
+ /**
+ * The loader re-runs when the term in the URL changes, and only then.
+ *
+ * Without this the loader would warm the feed for whatever term the page was
+ * first opened with and never again, so following a link from
+ * `/search?search=hono` to `/search?search=drizzle` would render the first
+ * result set and fetch the second from the browser.
+ */
+ loaderDeps: ({ search }) => ({ search: search.search }),
+ /**
+ * Everything this page needs, fetched in parallel before it renders.
+ *
+ * `context.locale` comes from the root route's `beforeLoad`, which resolved it
+ * from the public URL - so `/pl/search` fetches Polish messages and a Polish
+ * feed, and the first byte of HTML is already in that language.
+ *
+ * Neither call is repeated by the component. The messages are read back by
+ * `RouteMessages` through the identical `intlQueryOptions`, and the feed by
+ * `SearchFeedContent` through the key `feedQueryOptions` warms. A mismatch on
+ * either would show up as a render that starts empty and fills in a round trip
+ * later, which is the thing SSR is for.
+ *
+ * `params` is returned rather than rebuilt in the component for exactly that
+ * reason: the object handed to the controls as their starting point is
+ * *literally* the one whose cache entry was warmed, so the two cannot drift
+ * apart through a difference in how each derived it.
+ *
+ * The strings the metadata needs are returned too rather than looked up again:
+ * `createTranslator` is `use-intl`'s framework-free translator, over the
+ * messages just fetched.
+ */
+ loader: async ({ context, deps }) => {
+ const params = searchRouteFeedParams({ search: deps.search })
+
+ const [intl] = await Promise.all([
+ context.queryClient.ensureQueryData(
+ intlQueryOptions({
+ locale: context.locale,
+ namespaces: SEARCH_NAMESPACES,
+ }),
+ ),
+ context.queryClient.ensureInfiniteQueryData(
+ feedQueryOptions({ locale: context.locale, params }),
+ ),
+ ])
+
+ const t = createTranslator({
+ locale: context.locale,
+ messages: intl.messages,
+ namespace: 'core.search',
+ })
+
+ return { description: t('desc'), params, title: t('title') }
+ },
+ /**
+ * The page's metadata, in the language the request resolved to.
+ *
+ * **`head` must be written after `loader`.** `loaderData`'s type is inferred
+ * from `loader` in the same object literal, and TypeScript reads a literal's
+ * members in order - put `head` first and `loaderData` is `never`, while
+ * `Route.useLoaderData()` collapses to `undefined`. Neither error names the
+ * cause.
+ *
+ * The loader translates once, so the tab title and the `
` are the same
+ * string by construction - which is what the Next.js route gets from calling
+ * `getTranslations` once per request. `formatPageTitle` applies the same
+ * `" - "` rule Next.js applies through `title.template`.
+ */
+ head: ({ loaderData }) => ({
+ meta: [
+ // Indexable, and stated rather than assumed: TanStack Start emits no
+ // robots directive of its own, and the Next.js route this replaces sets
+ // `robots: { index: true, follow: true }` explicitly. Whether a search
+ // page with an arbitrary term *should* be indexed is a question for the
+ // SEO pass, not for a migration.
+ { content: 'index, follow', name: 'robots' },
+ ...(loaderData
+ ? [
+ {
+ title: formatPageTitle(
+ vitNodeShellConfig.metadata,
+ loaderData.title,
+ ),
+ },
+ { content: loaderData.description, name: 'description' },
+ ]
+ : []),
+ ],
+ }),
+ validateSearch: normalizeSearchRouteSearch,
+})
+
+function SearchRoute() {
+ const locale = useLocale()
+ const { description, params, title } = Route.useLoaderData()
+
+ return (
+
+
+
+
+ {/*
+ The term in the URL is the controls' starting point, so a *change* to
+ it has to become a new starting point - and the controls hold their
+ term in state, which React preserves across a re-render. Keyed on the
+ term, the loader re-running for `?search=drizzle` remounts them, and
+ they read the entry that loader just warmed instead of showing the
+ previous search over freshly-fetched-and-ignored results.
+
+ `feedQuery` rather than a finished options object because the visitor
+ changes the request: every keystroke, filter and sort is a different
+ query, built here from the same factory the loader used, so all of them
+ share one contract and one cache.
+ */}
+
+ feedQueryOptions({ locale, params: feedParams })
+ }
+ key={params.search ?? ''}
+ LinkComponent={SearchFeedLink}
+ variant="timeline"
+ />
+
+
+ )
+}
diff --git a/apps/web/src/server/auth.server.ts b/apps/web/src/server/auth.server.ts
index 9d77e9385..5f71cd134 100644
--- a/apps/web/src/server/auth.server.ts
+++ b/apps/web/src/server/auth.server.ts
@@ -32,7 +32,7 @@ import { fetcherServer, saveApiCookies } from '#/server/fetcher.server'
* browser <- saveApiCookies <- Set-Cookie
*
* Split out of `#/lib/auth/mutations` for the same reason
- * `discover-feed.server.ts` is split out of `lib/search/discover-feed.ts`: that
+ * `search-feed.server.ts` is split out of `lib/search/feed.ts`: that
* module is imported by the browser bundle, and this one imports the request
* scope (`getRequestHeaders`, `setCookie`) and the `server-only` marker above.
* Reached only from inside a `createServerFn` handler, which is what keeps it -
diff --git a/apps/web/src/server/my-files.server.ts b/apps/web/src/server/my-files.server.ts
new file mode 100644
index 000000000..1cf8c6736
--- /dev/null
+++ b/apps/web/src/server/my-files.server.ts
@@ -0,0 +1,46 @@
+import '@tanstack/react-start/server-only'
+import type {
+ MyFilesPageFetcher,
+ MyFilesParams,
+} from '@vitnode/core/views/files/my-files-query'
+
+import {
+ myFilesRequest,
+ MyFilesRequestError,
+ userFilesModuleRef,
+} from '@vitnode/core/views/files/my-files-query'
+
+import { fetcherServer } from '#/server/fetcher.server'
+
+/**
+ * One page of the visitor's own files, fetched during SSR.
+ *
+ * The request and the refusal check are core's - the same two the browser
+ * fetcher uses - so a page rendered on the server and a page fetched after
+ * hydration are the same request with the same failure semantics. Only the
+ * *transport* is this module's, and it is the only part that genuinely cannot be
+ * shared.
+ *
+ * `fetcherServer` rather than a bare `fetch`, and here that is not a nicety: the
+ * list is per-visitor and the API decides whose it is from the `Cookie` header.
+ * A render that forwarded nothing would be answered as an anonymous visitor -
+ * `401` - so this is the difference between a signed-in page and an error. It
+ * also resolves the API origin from the request being rendered, so a preview
+ * deployment calls its own hostname rather than a configured one.
+ *
+ * Only ever reached through the isomorphic transport in `#/lib/files/my-files`,
+ * which is what keeps this module - and the `server-only` marker above it - out
+ * of the browser bundle.
+ */
+export const fetchMyFilesPageOnServer: MyFilesPageFetcher = async (
+ params: MyFilesParams,
+) => {
+ const response = await fetcherServer(
+ userFilesModuleRef,
+ myFilesRequest(params),
+ )
+
+ if (!response.ok) throw new MyFilesRequestError(response.status, params)
+
+ return await response.json()
+}
diff --git a/apps/web/src/server/discover-feed.server.ts b/apps/web/src/server/search-feed.server.ts
similarity index 68%
rename from apps/web/src/server/discover-feed.server.ts
rename to apps/web/src/server/search-feed.server.ts
index b41b11caf..aac242263 100644
--- a/apps/web/src/server/discover-feed.server.ts
+++ b/apps/web/src/server/search-feed.server.ts
@@ -13,7 +13,12 @@ import {
import { fetcherServer } from '#/server/fetcher.server'
/**
- * One page of the Discover feed, fetched during SSR.
+ * One page of a search feed, fetched during SSR.
+ *
+ * Every feed this app renders on the server comes through here - `/discover`
+ * browsing newest-first, `/search` with a term - because they are one request
+ * with different parameters. There is deliberately no per-route copy: the whole
+ * point of `searchFeedRequest` is that the request is decided once.
*
* The request and the response check are core's - the same two the browser
* fetcher uses, so a page fetched here and a page fetched by `fetchNextPage()`
@@ -28,11 +33,11 @@ import { fetcherServer } from '#/server/fetcher.server'
* reads those for the rate-limit bucket and the audit IP, and a render that
* sends none of them puts every visitor in one bucket.
*
- * Only ever reached through the isomorphic transport in
- * `#/lib/search/discover-feed`, which is what keeps this module - and the
- * `server-only` import above - out of the browser bundle.
+ * Only ever reached through the isomorphic transport in `#/lib/search/feed`,
+ * which is what keeps this module - and the `server-only` import above - out of
+ * the browser bundle.
*/
-export const fetchDiscoverFeedPageOnServer: SearchFeedPageFetcher = async (
+export const fetchSearchFeedPageOnServer: SearchFeedPageFetcher = async (
args: SearchFeedPageArgs,
) => {
const response = await fetcherServer(searchModuleRef, searchFeedRequest(args))
diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts
index 2e1888f4e..bea90ee6c 100644
--- a/apps/web/src/tests/isolation.test.ts
+++ b/apps/web/src/tests/isolation.test.ts
@@ -365,16 +365,28 @@ describe('the whole graph this app imports stays Next-free', () => {
'apps/web/src/routes/_authenticated/account.tsx',
'apps/web/src/routes/login.tsx',
'apps/web/src/routes/login_.sso.$providerId.tsx',
+ // Stage 7. `/files` renders the whole data table - eight columns, the
+ // bulk-action bar and both confirm dialogs - which is the deepest this app
+ // reaches into the design system after the auth screens. That graph was
+ // Next-only until `next/dynamic` inside `ConfirmActionAlertDialog` became
+ // `React.lazy`, so it is exactly the graph worth walking here.
+ 'apps/web/src/lib/files/my-files-route.ts',
+ 'apps/web/src/lib/files/my-files.ts',
+ 'apps/web/src/routes/_authenticated/files.tsx',
+ 'apps/web/src/server/my-files.server.ts',
'apps/web/src/lib/i18n/client.ts',
'apps/web/src/lib/i18n/query.ts',
'apps/web/src/lib/i18n/shared.ts',
'apps/web/src/lib/search/discover-feed.ts',
'apps/web/src/lib/search/discover-request.ts',
+ 'apps/web/src/lib/search/feed.ts',
+ 'apps/web/src/lib/search/search-request.ts',
'apps/web/src/router.tsx',
'apps/web/src/routes/__root.tsx',
'apps/web/src/routes/discover.tsx',
'apps/web/src/routes/index.tsx',
- 'apps/web/src/server/discover-feed.server.ts',
+ 'apps/web/src/routes/search.tsx',
+ 'apps/web/src/server/search-feed.server.ts',
'apps/web/src/server/locale.server.ts',
'apps/web/src/server/messages.server.ts',
'apps/web/src/start.ts',
@@ -480,6 +492,171 @@ describe('the whole graph this app imports stays Next-free', () => {
expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
})
})
+
+ /**
+ * `/search`, on its own.
+ *
+ * Stated separately from `/discover` because it renders strictly more of the
+ * shared stack: the same feed, plus the controls above it - an input group, a
+ * native select, a row of buttons and a debounced callback. That is the design
+ * system, and the design system is where a stray `next/dynamic` or
+ * `next-intl/navigation` hides. `SearchControls` was Next-only for exactly
+ * that reason until the controls became `SearchControlsContent`.
+ */
+ describe('the /search runtime graph reaches no Next.js', () => {
+ const SEARCH = ['apps/web/src/routes/search.tsx']
+
+ it('walks into the shared controls the route renders', () => {
+ // Without this the assertions below would pass on a graph that stopped at
+ // the route file - which is exactly the graph that cannot break.
+ const reached = [...reachableExternals(SEARCH).visited]
+
+ expect(
+ reached.some((path) => path.includes('search-controls-content')),
+ ).toBe(true)
+ expect(reached.some((path) => path.includes('search-feed-content'))).toBe(
+ true,
+ )
+ expect(reached.some((path) => path.includes('input-group'))).toBe(true)
+ })
+
+ it('never reaches the Next wrapper the shared controls were split from', () => {
+ // `search-controls.tsx` resolves the locale through `next-intl` and takes
+ // its link from `@/lib/navigation`. Reaching it from here would mean the
+ // route imported the wrapper rather than the shared component.
+ const reached = [...reachableExternals(SEARCH).visited]
+
+ expect(
+ reached.filter((path) => /search-(controls|feed)\.js$/.test(path)),
+ ).toEqual([])
+ })
+
+ it.each([
+ 'next',
+ 'next/cache',
+ 'next/dynamic',
+ 'next/server',
+ 'next-intl/navigation',
+ 'next-intl/server',
+ 'server-only',
+ ])('never reaches %s', (forbidden) => {
+ expect(offenders(SEARCH, [forbidden])).toEqual([])
+ })
+
+ it('takes its translations from use-intl', () => {
+ const reached = [...reachableExternals(SEARCH).externals.keys()]
+
+ expect(reached).toContain('use-intl')
+ })
+
+ it("only ever reaches next-intl's framework-free root entry", () => {
+ const reached = [...reachableExternals(SEARCH).externals.keys()]
+
+ expect(reached.filter((one) => one.startsWith('next-intl/'))).toEqual([])
+ })
+
+ it('never reaches a locale-aware navigation module', () => {
+ const reached = [...reachableExternals(SEARCH).externals.keys()]
+
+ expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
+ })
+ })
+
+ /**
+ * `/files`, on its own.
+ *
+ * The deepest graph this app has after the auth screens, and the one with the
+ * most ways to go wrong: the data table, its four URL controls, the bulk
+ * action bar, the row menu and both confirm dialogs. Three separate imports
+ * kept it Next-only until Stage 7 - `next/dynamic` inside
+ * `ConfirmActionAlertDialog`, `@/lib/navigation` inside four table controls,
+ * and a `"use server"` module behind the delete button - and none of the three
+ * was visible from the route file.
+ */
+ describe('the /files runtime graph reaches no Next.js', () => {
+ const FILES = ['apps/web/src/routes/_authenticated/files.tsx']
+
+ it('walks into the table and the dialogs the route renders', () => {
+ // Without this the assertions below would pass on a graph that stopped at
+ // the route file - which is exactly the graph that cannot break.
+ const reached = [...reachableExternals(FILES).visited]
+
+ expect(
+ reached.some((path) => path.includes('my-files-table-content')),
+ ).toBe(true)
+ expect(reached.some((path) => path.includes('table/content'))).toBe(true)
+ expect(
+ reached.some((path) => path.includes('confirm-action-alert-dialog')),
+ ).toBe(true)
+ })
+
+ it('never reaches the Next wrappers the shared halves were split from', () => {
+ // `my-files-table-view` fetches through `next/headers` and imports the
+ // server actions; `data-table` mounts `NextDataTableNavigation`. Reaching
+ // either would mean the route imported a wrapper rather than the shared
+ // component.
+ const reached = [...reachableExternals(FILES).visited]
+
+ expect(
+ reached.filter((path) =>
+ /(my-files-table-view|table\/data-table|navigation-next)\.js$/.test(
+ path,
+ ),
+ ),
+ ).toEqual([])
+ })
+
+ it("never reaches the core package's delete server action", () => {
+ // Importing a `"use server"` module pulls the fetcher, `next/headers` and
+ // the whole API module graph in behind it. Both deletes are props.
+ //
+ // Note this is not a blanket ban on `*.server`: the route legitimately
+ // reaches `apps/web/src/server/my-files.server.ts`, which is this app's
+ // own SSR transport behind `createIsomorphicFn`. The two conventions share
+ // a suffix and nothing else.
+ const reached = [...reachableExternals(FILES).visited]
+
+ expect(
+ reached.filter((path) => path.includes('delete-action.server')),
+ ).toEqual([])
+ })
+
+ it.each([
+ 'next',
+ 'next/cache',
+ 'next/dynamic',
+ 'next/headers',
+ 'next/navigation',
+ 'next/server',
+ 'next-intl/navigation',
+ 'next-intl/server',
+ 'server-only',
+ ])('never reaches %s', (forbidden) => {
+ expect(offenders(FILES, [forbidden])).toEqual([])
+ })
+
+ it('never reaches the API the table is authorized by', () => {
+ // `my-files-query.ts` imports the files module as a *type* only, so the
+ // route literals still infer while Hono, Drizzle and `@/database` stay out
+ // of the bundle. A value import here is a server framework in the browser.
+ const reached = [...reachableExternals(FILES).externals.keys()]
+
+ expect(reached).not.toContain('drizzle-orm')
+ expect(reached.filter((one) => one.startsWith('hono'))).toEqual([])
+ })
+
+ it("only ever reaches next-intl's framework-free root entry", () => {
+ const reached = [...reachableExternals(FILES).externals.keys()]
+
+ expect(reached.filter((one) => one.startsWith('next-intl/'))).toEqual([])
+ })
+
+ it('never reaches a locale-aware navigation module', () => {
+ const reached = [...reachableExternals(FILES).externals.keys()]
+
+ expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
+ })
+ })
})
/**
diff --git a/apps/web/src/tests/my-files-route.test.ts b/apps/web/src/tests/my-files-route.test.ts
new file mode 100644
index 000000000..694bccdfd
--- /dev/null
+++ b/apps/web/src/tests/my-files-route.test.ts
@@ -0,0 +1,443 @@
+import { hashKey, QueryClient } from '@tanstack/react-query'
+import { defaultParseSearch } from '@tanstack/react-router'
+import {
+ DEFAULT_TABLE_PAGE_SIZE,
+ toggleTableOrder,
+ withTablePage,
+ withTablePageSize,
+ withTableSearch,
+} from '@vitnode/core/components/table/url-state'
+import {
+ MY_FILES_MAX_PAGE_SIZE,
+ MY_FILES_QUERY_ROOT,
+} from '@vitnode/core/views/files/my-files-query'
+import { describe, expect, it } from 'vitest'
+
+import { invalidateMyFiles, myFilesQuery } from '#/lib/files/my-files'
+import {
+ myFilesRouteParams,
+ myFilesSearchFrom,
+ myFilesSearchParams,
+ normalizeMyFilesRouteSearch,
+} from '#/lib/files/my-files-route'
+import { isTanStackOwnedPath } from '#/lib/migration-navigation'
+import { getRouter } from '#/router'
+
+/**
+ * `/files`'s contract with its own URL, and with the cache underneath it.
+ *
+ * Pure functions only. `normalizeMyFilesRouteSearch` is what the route hands to
+ * `validateSearch`, so calling it directly is calling the route's schema - no
+ * router, no request, no rendering. The *meaning* of a files request is core's
+ * and is asserted in `packages/vitnode/src/views/files/my-files-query.test.ts`;
+ * what is asserted here is that this route asks for the right one, that a table
+ * control's URL survives the round trip through it, and that a delete
+ * invalidates the right family and nothing else.
+ */
+
+/**
+ * The route's schema, over a query string as a visitor would type it.
+ *
+ * Through the router's *own* parser rather than `URLSearchParams`, because what
+ * reaches `validateSearch` is not a query string and not even strings: the
+ * default parser turns `?first=20` into the number `20`, `?x=true` into a
+ * boolean, and a repeated key into an array. Half the rules below exist for
+ * exactly that, so a test that flattened it first would be testing something
+ * else.
+ */
+const searchFor = (query: string) =>
+ normalizeMyFilesRouteSearch(defaultParseSearch(query))
+
+/** The cache entry one URL lands in. */
+const keyFor = (query: string) =>
+ hashKey(
+ myFilesQuery({ params: myFilesRouteParams(searchFor(query)) }).queryKey,
+ )
+
+describe('the route schema reads a table request out of the URL', () => {
+ it('is nothing at all for the page with no query string', () => {
+ // `/files` is the canonical address of this page. A schema that answered
+ // `{ first: 10 }` here would write `?first=10` into every link the router
+ // builds to it - including the one a guest's `?returnTo=` comes back through.
+ expect(searchFor('')).toEqual({})
+ })
+
+ it('takes the six parameters the table writes', () => {
+ expect(
+ searchFor(
+ 'search=logo&orderBy=name&order=asc&first=20&cursor=eyJpZCI6MX0',
+ ),
+ ).toEqual({
+ cursor: 'eyJpZCI6MX0',
+ first: 20,
+ order: 'asc',
+ orderBy: 'name',
+ search: 'logo',
+ })
+ })
+
+ it('carries no parameter this route does not have', () => {
+ // Rule 3: nothing a visitor puts in the query string is accepted unless the
+ // route asked for it. `?tab=` is not validated, not carried, and not sent.
+ expect(searchFor('orderBy=name&tab=2&utm_source=x&__proto__=y')).toEqual({
+ orderBy: 'name',
+ })
+ })
+
+ it('spells the default page size as saying nothing', () => {
+ expect(searchFor(`first=${DEFAULT_TABLE_PAGE_SIZE}`)).toEqual({})
+ expect(searchFor('first=20')).toEqual({ first: 20 })
+ })
+
+ it('keeps a backwards page of the default size, which is not the same request', () => {
+ // `last` says *which direction*, so it survives at a size `first` would not.
+ expect(
+ searchFor(`last=${DEFAULT_TABLE_PAGE_SIZE}&cursor=eyJpZCI6MX0`),
+ ).toEqual({
+ cursor: 'eyJpZCI6MX0',
+ last: DEFAULT_TABLE_PAGE_SIZE,
+ })
+ })
+
+ it('reads the numbers the router has already parsed', () => {
+ // `?first=20` reaches `validateSearch` as a number and `?search=1` as one
+ // too, while core's normaliser is written against a query string, where
+ // everything is a string. Without the coercion in between, `search.trim()`
+ // throws inside the schema and a perfectly ordinary search becomes a router
+ // error screen.
+ expect(searchFor('first=20&search=1')).toEqual({ first: 20, search: '1' })
+ })
+
+ it('takes the first value when a key is repeated', () => {
+ expect(searchFor('orderBy=name&orderBy=size')).toEqual({ orderBy: 'name' })
+ })
+})
+
+describe('a query string typed by hand renders the table anyway', () => {
+ it.each([
+ ['an unknown sort column', 'orderBy=password'],
+ ['a sort direction that is not one', 'order=sideways'],
+ ['a page size that is not a number', 'first=abc'],
+ ['an empty page size', 'first='],
+ ['a page size of zero', 'first=0'],
+ ['a negative page size', 'first=-5'],
+ ['a fractional page size', 'first=10.5'],
+ ['a cursor that cannot be one', 'cursor=%F0%9F%92%A5'],
+ ['a search of nothing but blanks', 'search=%20%20'],
+ ])('renders the default table for %s', (_case, query) => {
+ // Not an error screen, and not a 400 from the API: an unusable value becomes
+ // an absent one, so an unrecognised `orderBy` falls back to the list's own
+ // `createdAt desc` rather than being sent.
+ expect(searchFor(query)).toEqual({})
+ expect(keyFor(query)).toBe(keyFor(''))
+ })
+
+ it('clamps a page size past what the API will serve rather than 400ing', () => {
+ expect(searchFor('first=5000')).toEqual({ first: MY_FILES_MAX_PAGE_SIZE })
+ })
+
+ it('never asks for both directions at once, which the API refuses', () => {
+ const search = searchFor('first=20&last=20')
+
+ expect(search.first).toBe(20)
+ expect(search.last).toBeUndefined()
+ })
+
+ it('settles rather than drifting when applied to its own output', () => {
+ // The schema runs twice on every navigation - once on the query string a
+ // control produced, once more when the router validates the location that
+ // makes. A rule that moved the value on the second pass would drift a step
+ // per click.
+ for (const query of [
+ '',
+ 'first=10',
+ 'first=5000',
+ 'orderBy=name&order=desc',
+ 'search=%20logo%20',
+ ]) {
+ const once = searchFor(query)
+
+ expect(normalizeMyFilesRouteSearch(once)).toEqual(once)
+ }
+ })
+})
+
+describe('the request the URL is asking for', () => {
+ it('always names a page size, because a request must', () => {
+ // The URL need not, and does not - see above. The default is applied here,
+ // where the query key can see it, rather than inside the URL builder.
+ expect(myFilesRouteParams(searchFor(''))).toEqual({
+ first: String(DEFAULT_TABLE_PAGE_SIZE),
+ })
+ })
+
+ it('sends the sort the URL asked for', () => {
+ expect(myFilesRouteParams(searchFor('orderBy=size&order=asc'))).toEqual({
+ first: String(DEFAULT_TABLE_PAGE_SIZE),
+ order: 'asc',
+ orderBy: 'size',
+ })
+ })
+
+ it('ignores whatever else the router merged into the search', () => {
+ // The router merges a route's validated search over the *raw* parsed one, so
+ // `Route.useSearch()` still carries the rest of the query string. Going back
+ // through the same normalisation is what makes the request depend on the six.
+ expect(myFilesRouteParams({ orderBy: 'name', tab: '2' })).toEqual(
+ myFilesRouteParams({ orderBy: 'name' }),
+ )
+ })
+})
+
+describe('one URL, one cache entry', () => {
+ it('is the same entry for two spellings of the same request', () => {
+ expect(keyFor(`first=${DEFAULT_TABLE_PAGE_SIZE}`)).toBe(keyFor(''))
+ expect(keyFor('search=logo')).toBe(keyFor('search=%20logo%20'))
+ expect(keyFor('orderBy=name&tab=2')).toBe(keyFor('orderBy=name'))
+ })
+
+ it('is a different entry for everything that changes the rows', () => {
+ const keys = [
+ keyFor(''),
+ keyFor('first=20'),
+ keyFor('orderBy=name'),
+ keyFor('orderBy=name&order=asc'),
+ keyFor('search=logo'),
+ keyFor('cursor=eyJpZCI6MX0'),
+ ]
+
+ expect(new Set(keys).size).toBe(keys.length)
+ })
+
+ it('hangs off the root a delete invalidates', () => {
+ expect(
+ myFilesQuery({ params: myFilesRouteParams({}) }).queryKey.slice(
+ 0,
+ MY_FILES_QUERY_ROOT.length,
+ ),
+ ).toEqual([...MY_FILES_QUERY_ROOT])
+ })
+})
+
+describe('the table changes the URL through the route, not around it', () => {
+ /** One control's click: read the URL, rewrite it, hand it back to the route. */
+ const afterControl = (
+ query: string,
+ control: (search: URLSearchParams) => string,
+ ) => myFilesSearchFrom(control(myFilesSearchParams(searchFor(query))))
+
+ const defaultOrder = { column: 'createdAt', order: 'desc' } as const
+
+ it('hands the controls the validated search and nothing else', () => {
+ expect(
+ myFilesSearchParams(searchFor('orderBy=name&tab=2')).toString(),
+ ).toBe('orderBy=name')
+ expect(myFilesSearchParams(searchFor('')).toString()).toBe('')
+ })
+
+ it('sorts a column the table offers', () => {
+ expect(
+ afterControl('', (search) =>
+ toggleTableOrder(search, { column: 'name', defaultOrder }),
+ ),
+ ).toEqual({ order: 'asc', orderBy: 'name' })
+ })
+
+ it('flips a column that is already ascending', () => {
+ expect(
+ afterControl('orderBy=name&order=asc', (search) =>
+ toggleTableOrder(search, { column: 'name', defaultOrder }),
+ ),
+ ).toEqual({ order: 'desc', orderBy: 'name' })
+ })
+
+ it('cannot write a sort column this route does not have', () => {
+ // The return leg re-validates, so a control - or a plugin handing one a
+ // column list of its own - cannot put a column in the URL that the API would
+ // 400 on. The *direction* survives, and deliberately: `order` alone is what
+ // the list route reads as its own default column in that direction
+ // (`orderBy: query.orderBy ? ... : core_files.createdAt`), and the Next.js
+ // page produces exactly the same URL from the same click. One contract, two
+ // frameworks - not two normalisations that agree until they don't.
+ expect(
+ afterControl('', (search) =>
+ toggleTableOrder(search, { column: 'password', defaultOrder }),
+ ),
+ ).toEqual({ order: 'asc' })
+ })
+
+ it('pages forwards from the cursor the API handed back', () => {
+ expect(
+ afterControl('orderBy=name&order=asc', (search) =>
+ withTablePage(search, {
+ cursor: 'eyJpZCI6MX0',
+ direction: 'next',
+ pageSize: DEFAULT_TABLE_PAGE_SIZE,
+ }),
+ ),
+ ).toEqual({
+ cursor: 'eyJpZCI6MX0',
+ order: 'asc',
+ orderBy: 'name',
+ })
+ })
+
+ it('pages backwards, and says so', () => {
+ expect(
+ afterControl('cursor=eyJpZCI6OX0', (search) =>
+ withTablePage(search, {
+ cursor: 'eyJpZCI6MX0',
+ direction: 'previous',
+ pageSize: DEFAULT_TABLE_PAGE_SIZE,
+ }),
+ ),
+ ).toEqual({ cursor: 'eyJpZCI6MX0', last: DEFAULT_TABLE_PAGE_SIZE })
+ })
+
+ it('changes the page size as a number, so the URL says `first=20`', () => {
+ // A *string* `'20'` is written to the address bar as `first=%2220%22` by
+ // TanStack Router's default serializer, which is neither what the Next.js
+ // page produces nor what a person pastes into a browser.
+ expect(afterControl('', (search) => withTablePageSize(search, 20))).toEqual(
+ {
+ first: 20,
+ },
+ )
+ })
+
+ it('returns the default page size to saying nothing', () => {
+ expect(
+ afterControl('first=20', (search) =>
+ withTablePageSize(search, DEFAULT_TABLE_PAGE_SIZE),
+ ),
+ ).toEqual({})
+ })
+
+ it('drops the cursor when the page size changes, and keeps the sort', () => {
+ expect(
+ afterControl('orderBy=name&order=asc&cursor=eyJpZCI6MX0', (search) =>
+ withTablePageSize(search, 20),
+ ),
+ ).toEqual({ first: 20, order: 'asc', orderBy: 'name' })
+ })
+
+ it('searches, and stops searching, without losing the sort', () => {
+ expect(
+ afterControl('orderBy=name&order=asc', (search) =>
+ withTableSearch(search, 'logo'),
+ ),
+ ).toEqual({ order: 'asc', orderBy: 'name', search: 'logo' })
+
+ expect(
+ afterControl('orderBy=name&order=asc&search=logo', (search) =>
+ withTableSearch(search, ''),
+ ),
+ ).toEqual({ order: 'asc', orderBy: 'name' })
+ })
+
+ it('survives a full round trip unchanged when nothing was clicked', () => {
+ for (const query of [
+ '',
+ 'first=20',
+ 'orderBy=name&order=desc',
+ 'search=logo&cursor=eyJpZCI6MX0',
+ ]) {
+ const search = searchFor(query)
+
+ expect(myFilesSearchFrom(myFilesSearchParams(search).toString())).toEqual(
+ search,
+ )
+ }
+ })
+})
+
+describe('a delete makes the visitor’s files stale, and only those', () => {
+ const seed = () => {
+ const queryClient = new QueryClient()
+ const firstPage = myFilesQuery({
+ params: myFilesRouteParams(searchFor('')),
+ })
+ const sorted = myFilesQuery({
+ params: myFilesRouteParams(searchFor('orderBy=name&order=asc')),
+ })
+ const session = ['vitnode', 'session'] as const
+
+ queryClient.setQueryData(firstPage.queryKey, { edges: [], pageInfo: {} })
+ queryClient.setQueryData(sorted.queryKey, { edges: [], pageInfo: {} })
+ queryClient.setQueryData(session, { user: { id: 1 } })
+
+ return { firstPage, queryClient, session, sorted }
+ }
+
+ const isStale = (queryClient: QueryClient, queryKey: readonly unknown[]) =>
+ queryClient.getQueryState(queryKey)?.isInvalidated === true
+
+ it('marks every page, sort and search of the list, not just the one on screen', () => {
+ // A delete changes which rows exist, so the pages the visitor reaches by
+ // pressing a button - and reads from the cache - are wrong too.
+ const { firstPage, queryClient, sorted } = seed()
+
+ void invalidateMyFiles(queryClient)
+
+ expect(isStale(queryClient, firstPage.queryKey)).toBe(true)
+ expect(isStale(queryClient, sorted.queryKey)).toBe(true)
+ })
+
+ it('leaves everything else in the cache alone', () => {
+ // Emphatically not `invalidateQueries()` with no key: the session and the
+ // messages have not changed because a file was deleted.
+ const { queryClient, session } = seed()
+
+ void invalidateMyFiles(queryClient)
+
+ expect(isStale(queryClient, session)).toBe(false)
+ })
+
+ it('keeps the rows on screen while the fresh ones are fetched', () => {
+ // Invalidating rather than removing, so the table is not blanked under a
+ // dialog that is still open.
+ const { firstPage, queryClient } = seed()
+
+ void invalidateMyFiles(queryClient)
+
+ expect(queryClient.getQueryData(firstPage.queryKey)).toBeDefined()
+ })
+})
+
+describe('`/files` is this app’s route now', () => {
+ const router = getRouter()
+
+ it('is owned, so MigrationLink navigates to it client-side', () => {
+ // There is no list of migrated routes anywhere in that decision - the route
+ // tree is the list. Adding the route file is the whole of the handover.
+ expect(isTanStackOwnedPath(router, '/files')).toBe(true)
+ })
+
+ it('is owned under the locale prefix too, because that is the same route', () => {
+ expect(isTanStackOwnedPath(router, '/pl/files')).toBe(true)
+ })
+
+ it('is owned with the table’s own parameters on it', () => {
+ expect(isTanStackOwnedPath(router, '/files?orderBy=name&order=asc')).toBe(
+ true,
+ )
+ })
+
+ it('does not drag the routes underneath it away from Next.js', () => {
+ // `matchRoutes` answers with the deepest *branch* it can resolve, so owning
+ // `/files` used to make anything below it look owned as well.
+ expect(isTanStackOwnedPath(router, '/files/12')).toBe(false)
+ })
+
+ it('sits under the pathless guard rather than at the top of the tree', () => {
+ const matched = router.matchRoutes('/files', undefined) as {
+ routeId: string
+ }[]
+
+ expect(matched.map((match) => match.routeId)).toEqual([
+ '__root__',
+ '/_authenticated',
+ '/_authenticated/files',
+ ])
+ })
+})
diff --git a/apps/web/src/tests/plugin-routes.test.ts b/apps/web/src/tests/plugin-routes.test.ts
index 980c0a704..133a65027 100644
--- a/apps/web/src/tests/plugin-routes.test.ts
+++ b/apps/web/src/tests/plugin-routes.test.ts
@@ -385,6 +385,25 @@ describe("the app's real route tree", () => {
// Behind `_authenticated`, which is pathless: the guard adds no segment, so
// the page is owned at its own path and the boundary is invisible here.
['/account', true],
+ // Stage 7. `/search` is a plain route; `/files` is a second page behind the
+ // pathless guard, so owning it must still be decided at `/files` and not at
+ // the boundary above it.
+ ['/search', true],
+ ['/pl/search', true],
+ ['/files', true],
+ ['/pl/files', true],
+ // A data table never navigates without a query string, and `matchRoutes`
+ // takes a pathname - so a table URL is the shape that would break if the
+ // query were not stripped before matching.
+ ['/files?orderBy=name&order=asc&first=20', true],
+ // Still the Next.js app's, and the case a migrated `/files` most easily
+ // annexes by accident: `/settings` is a sibling of nothing here, so a
+ // prefix-matching rule would answer for it. `/settings/security` is the
+ // nested one - see the `/login` note below for why that distinction is
+ // load-bearing rather than decorative.
+ ['/settings', false],
+ ['/settings/security', false],
+ ['/pl/settings/security', false],
])('answers %s as owned: %s', (href, owned) => {
expect(isTanStackOwnedPath(getRouter(), href)).toBe(owned)
})
diff --git a/apps/web/src/tests/router-query.test.ts b/apps/web/src/tests/router-query.test.ts
index 821f61d7d..2e8f238ec 100644
--- a/apps/web/src/tests/router-query.test.ts
+++ b/apps/web/src/tests/router-query.test.ts
@@ -109,14 +109,20 @@ describe('the Query SSR integration is installed', () => {
describe('nothing but the router creates a query client', () => {
const appFiles = [
'routes/__root.tsx',
+ 'routes/_authenticated/files.tsx',
'routes/discover.tsx',
'routes/index.tsx',
+ 'routes/search.tsx',
'components/route-messages.tsx',
+ 'lib/files/my-files-route.ts',
+ 'lib/files/my-files.ts',
'lib/i18n/client.ts',
'lib/i18n/query.ts',
'lib/i18n/shared.ts',
'lib/search/discover-feed.ts',
'lib/search/discover-request.ts',
+ 'lib/search/feed.ts',
+ 'lib/search/search-request.ts',
]
it.each(appFiles)('%s mounts no QueryClientProvider', (file) => {
diff --git a/apps/web/src/tests/search-request.test.ts b/apps/web/src/tests/search-request.test.ts
new file mode 100644
index 000000000..67fecbf99
--- /dev/null
+++ b/apps/web/src/tests/search-request.test.ts
@@ -0,0 +1,172 @@
+import { hashKey } from '@tanstack/react-query'
+import { MAX_SEARCH_TERM_LENGTH } from '@vitnode/core/views/search/search-params'
+import { describe, expect, it } from 'vitest'
+
+import { discoverFeedQueryKey } from '#/lib/search/discover-feed'
+import { DISCOVER_FEED_PARAMS } from '#/lib/search/discover-request'
+import { feedQueryKey, feedQueryOptions } from '#/lib/search/feed'
+import {
+ normalizeSearchRouteSearch,
+ searchRouteFeedParams,
+} from '#/lib/search/search-request'
+
+/**
+ * `/search`'s contract with its own URL, and with the cache underneath it.
+ *
+ * Pure functions only. `normalizeSearchRouteSearch` is what the route hands to
+ * `validateSearch`, so calling it directly is calling the route's schema - no
+ * router, no request, no rendering. The feed's *behaviour* is core's and is
+ * asserted in `packages/vitnode/src/views/search`; what is asserted here is that
+ * this route asks for the right feed.
+ */
+
+const paramsFor = (input: Record) =>
+ searchRouteFeedParams(normalizeSearchRouteSearch(input))
+
+const hashOf = (input: Record) =>
+ hashKey(feedQueryKey({ locale: 'en', params: paramsFor(input) }))
+
+describe('the route schema reads a term out of the URL', () => {
+ it('takes the term somebody searched for', () => {
+ expect(normalizeSearchRouteSearch({ search: 'hono' })).toEqual({
+ search: 'hono',
+ })
+ })
+
+ it('trims it', () => {
+ expect(normalizeSearchRouteSearch({ search: ' hono ' })).toEqual({
+ search: 'hono',
+ })
+ })
+
+ it('ignores every other parameter in the query string', () => {
+ // The sort and the type filters are controls, not URL state - see
+ // `lib/search/search-request.ts`. A stray `?sort=` must not become one by
+ // accident.
+ expect(
+ normalizeSearchRouteSearch({
+ search: 'hono',
+ sort: 'oldest',
+ types: 'blog_post',
+ }),
+ ).toEqual({ search: 'hono' })
+ })
+})
+
+describe('a malformed query string renders the page anyway', () => {
+ it.each([
+ ['nothing at all', {}],
+ ['a bare ?search=', { search: '' }],
+ ['blanks', { search: ' ' }],
+ ['a repeated ?search=', { search: ['a', 'b'] }],
+ ['a number', { search: 42 }],
+ ['null', { search: null }],
+ ['an object', { search: { toString: () => 'hono' } }],
+ ])('reads %s as no term', (_case, input) => {
+ // Not an error boundary and not a 404: a search page is the one page whose
+ // query string is typed by strangers, so anything unusable is the browse
+ // feed.
+ expect(normalizeSearchRouteSearch(input)).toEqual({})
+ })
+
+ it('returns an absent key rather than an explicit undefined', () => {
+ // So the router has nothing to write back into the URL, and
+ // `/search?search=%20` settles as `/search`.
+ expect(Object.keys(normalizeSearchRouteSearch({ search: ' ' }))).toEqual([])
+ })
+
+ it('caps a term that was never typed by hand', () => {
+ const { search } = normalizeSearchRouteSearch({
+ search: 'x'.repeat(MAX_SEARCH_TERM_LENGTH * 10),
+ })
+
+ expect(search).toHaveLength(MAX_SEARCH_TERM_LENGTH)
+ })
+
+ it('never throws, whatever it is handed', () => {
+ for (const input of [
+ {},
+ { search: [] },
+ { search: [[]] },
+ { search: Number.NaN },
+ { search: Symbol('hono') },
+ { other: 'ignored' },
+ ]) {
+ expect(() =>
+ normalizeSearchRouteSearch(input as Record),
+ ).not.toThrow()
+ }
+ })
+})
+
+describe('the feed the route asks for', () => {
+ it('searches by relevance when there is a term', () => {
+ expect(paramsFor({ search: 'hono' })).toEqual({
+ search: 'hono',
+ sort: 'relevance',
+ })
+ })
+
+ it('browses newest-first when there is not', () => {
+ expect(paramsFor({})).toEqual({ sort: 'newest' })
+ })
+
+ it('is the Discover feed when the box is empty', () => {
+ // `/search` with nothing typed and `/discover` are the same request over the
+ // same documents. Sharing the entry is the point: a visitor arriving from
+ // one has the other already in hand.
+ expect(paramsFor({})).toEqual(DISCOVER_FEED_PARAMS)
+ expect(hashOf({})).toBe(hashKey(discoverFeedQueryKey('en')))
+ })
+
+ it('is a different entry per term', () => {
+ expect(hashOf({ search: 'hono' })).not.toBe(hashOf({ search: 'drizzle' }))
+ })
+
+ it('is a different entry per language', () => {
+ // `/search?search=hono` and `/pl/search?search=hono` are two feeds over two
+ // sets of documents, so a language switch changes the key rather than the
+ // value under it.
+ expect(hashOf({ search: 'hono' })).not.toBe(
+ hashKey(
+ feedQueryKey({ locale: 'pl', params: paramsFor({ search: 'hono' }) }),
+ ),
+ )
+ })
+
+ it('is one entry however the URL spelled the term', () => {
+ expect(hashOf({ search: ' hono ' })).toBe(hashOf({ search: 'hono' }))
+ })
+})
+
+describe('the loader and the component read one query definition', () => {
+ it('names the same cache entry from the same parameters', () => {
+ // The loader ensures `feedQueryOptions({ locale, params })`; the mounted
+ // `SearchFeedContent` is handed the same factory with the same parameters.
+ // A mismatch here is an SSR page that renders, then refetches page one from
+ // the browser and flickers back to a skeleton.
+ const params = paramsFor({ search: 'hono' })
+
+ expect(hashKey(feedQueryOptions({ locale: 'en', params }).queryKey)).toBe(
+ hashKey(feedQueryKey({ locale: 'en', params })),
+ )
+ })
+
+ it('starts every feed from no cursor', () => {
+ // `null`, spelled as the absence of a cursor: the API's schema rejects
+ // `cursor=` outright, so an empty one would 400 the first page of a visit.
+ expect(
+ feedQueryOptions({ locale: 'en', params: paramsFor({}) })
+ .initialPageParam,
+ ).toBeNull()
+ })
+
+ it('passes no initialData, because the loader warmed the entry', () => {
+ // Two copies of page one - one in the cache, one in the options - can
+ // disagree. The loader's copy is the only one.
+ expect(
+ feedQueryOptions({ locale: 'en', params: paramsFor({ search: 'hono' }) })
+ .initialData,
+ ).toBeUndefined()
+ })
+})
diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts
index f80c1fbd1..eede27005 100644
--- a/packages/vitnode/src/api/models/storage.ts
+++ b/packages/vitnode/src/api/models/storage.ts
@@ -3,6 +3,8 @@ import type { Context } from "hono";
import { and, eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
+import type { StorageFileInUseBody } from "@/lib/files/in-use";
+
import { core_content_file_refs } from "@/database/content";
import { core_files } from "@/database/files";
import { isPgReferenceViolation } from "@/lib/api/pg-error";
@@ -11,6 +13,7 @@ import {
generateStorageFileName,
replaceFileExtension,
} from "@/lib/api/upload";
+import { STORAGE_FILE_IN_USE } from "@/lib/files/in-use";
import { formatBytes } from "@/lib/format-bytes";
const DEFAULT_IMAGE_QUALITY = 85;
@@ -58,26 +61,16 @@ export interface StorageFileUploadResult extends StorageUploadResult {
size: number;
}
-/** Why {@link StorageModel.deleteFile} refused. */
-export const STORAGE_FILE_IN_USE = "FILE_IN_USE";
-
/**
- * The body of that refusal, and the reason it is not just a code.
+ * Why {@link StorageModel.deleteFile} refused, and the body it refuses with.
*
- * "In use" covers two situations a person has to act on differently: content
- * that would break, and history that would merely lose a restore. `content` is
- * the one that is final; `revisions` is how many retained revisions hold the
- * file, so a client can offer to force past them and say how much it is giving
- * up.
+ * Defined in `@/lib/files/in-use` and re-exported here, so every existing
+ * importer keeps working. The definition had to move because the browser reads
+ * the same code off the same 409 - and importing it from this module dragged
+ * Hono, Drizzle and `@/database` into the client bundle behind one string.
*/
-export interface StorageFileInUseBody {
- code: typeof STORAGE_FILE_IN_USE;
- /** A live content column or gallery row still points at this file. */
- content: boolean;
- id: number;
- /** Retained revisions pinning it - releasable with `force`. */
- revisions: number;
-}
+export type { StorageFileInUseBody } from "@/lib/files/in-use";
+export { STORAGE_FILE_IN_USE } from "@/lib/files/in-use";
export interface StorageDeleteFileOptions {
/**
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index 4f9b8c2b6..40f9d75c2 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -1,7 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
-import dynamic from "next/dynamic";
import React from "react";
import {
@@ -14,7 +13,17 @@ import {
AlertDialogTrigger,
} from "../ui/alert-dialog";
-const ContentConfirmAction = dynamic(async () =>
+/**
+ * `React.lazy` rather than `next/dynamic`, which is what this used to be.
+ *
+ * The two are the same thing here - this is a client component, the import is
+ * already wrapped in the `` below, and `next/dynamic` defaults
+ * to server rendering the chunk - but only one of them resolves outside a
+ * Next.js app. Every confirm dialog in VitNode goes through this component,
+ * including the ones on the shared `/files` table, so that single import was
+ * enough to make the whole screen Next.js-only.
+ */
+const ContentConfirmAction = React.lazy(async () =>
import("./content").then(module => ({
default: module.ContentConfirmAction,
})),
diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx
index 8117e068b..75734df46 100644
--- a/packages/vitnode/src/components/table/content.tsx
+++ b/packages/vitnode/src/components/table/content.tsx
@@ -4,9 +4,9 @@ import { useTranslations } from "next-intl";
import type {
AlignDataTable,
ColumnDef,
- DataTable,
+ DataTableProps,
DataTableTMin,
-} from "./data-table";
+} from "./data-table-content";
import { cn } from "../../lib/utils";
import {
@@ -46,7 +46,7 @@ export function ContentDataTable({
searchPlaceholder,
filters,
...props
-}: React.ComponentProps>) {
+}: DataTableProps) {
const t = useTranslations("core.global");
const hasToolbar = Boolean(search) || Boolean(filters?.length);
const allColumns: ColumnDef[] = bulkActions
diff --git a/packages/vitnode/src/components/table/data-table-content.tsx b/packages/vitnode/src/components/table/data-table-content.tsx
new file mode 100644
index 000000000..fb997fcd5
--- /dev/null
+++ b/packages/vitnode/src/components/table/data-table-content.tsx
@@ -0,0 +1,169 @@
+import React from "react";
+
+import type { FilterDataTable } from "./filters";
+import type { PaginationDataTable } from "./pagination";
+import type { SearchDataTable } from "./search";
+
+import { cn } from "../../lib/utils";
+import { Skeleton } from "../ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../ui/table";
+
+export interface DataTableTMin {
+ id: number;
+}
+
+export interface SearchParamsDataTable {
+ cursor?: string;
+ first?: string;
+ last?: string;
+ order?: "asc" | "desc";
+ orderBy?: keyof T;
+}
+
+export type AlignDataTable = "center" | "left" | "right";
+
+interface ColumnDefBase {
+ align?: AlignDataTable;
+ cell?: (data: { allData: T[]; row: T }) => React.ReactNode;
+ className?: string;
+ header: React.ReactNode;
+}
+
+interface AccessorColumnDef extends ColumnDefBase {
+ accessorKey: keyof T;
+ id?: string;
+}
+
+interface DisplayColumnDef extends ColumnDefBase {
+ accessorKey?: never;
+ id: string;
+}
+
+export type ColumnDef =
+ AccessorColumnDef | DisplayColumnDef;
+
+/**
+ * Everything a data table is told, in one place both frameworks can import.
+ *
+ * Named rather than inferred from the component because the component the props
+ * belong to is now the Next.js one - `content.tsx` and the sort header would
+ * otherwise reach for `ComponentProps` and, through it, for a
+ * module a TanStack Start route cannot load.
+ */
+export type DataTableProps = Omit<
+ React.ComponentProps,
+ "columns"
+> &
+ React.ComponentProps &
+ React.ComponentProps & {
+ bulkActions?: React.ReactNode;
+ columns: ColumnDef[];
+ customNoResults?: {
+ description?: string;
+ footer?: React.ReactNode;
+ icon?: React.ReactNode;
+ title?: string;
+ };
+ edges: T[];
+ filters?: FilterDataTable[];
+ id: string;
+ order: {
+ columns?: (keyof T)[];
+ defaultOrder: {
+ column: keyof T;
+ order: "asc" | "desc";
+ };
+ };
+ search?: boolean;
+ };
+
+const SKELETON_HEAD_WIDTHS = ["w-24", "w-16", "w-20", "w-14"];
+const SKELETON_CELL_WIDTHS = ["w-full", "w-3/4", "w-1/2", "w-5/6", "w-2/3"];
+
+/**
+ * The table's shape before its rows arrive.
+ *
+ * It lives beside the types rather than with `DataTable` because a Suspense
+ * fallback is the one part of the table a route renders *outside* the table -
+ * and a TanStack Start route reaching into the Next.js module for it would drag
+ * `next-intl`'s navigation in behind it.
+ */
+export const DataTableSkeleton = ({
+ columns,
+ rows = 6,
+ toolbar = false,
+}: {
+ columns: number;
+ rows?: number;
+ toolbar?: boolean;
+}) => {
+ const headerIds = Array.from({ length: columns }, (_, i) => `s-head-${i}`);
+ const rowIds = Array.from({ length: rows }, (_, i) => `s-row-${i}`);
+
+ return (
+
- );
-};
-
-export function DataTable(
- props: Omit, "columns"> &
- React.ComponentProps &
- React.ComponentProps & {
- bulkActions?: React.ReactNode;
- columns: ColumnDef[];
- customNoResults?: {
- description?: string;
- footer?: React.ReactNode;
- icon?: React.ReactNode;
- title?: string;
- };
- edges: T[];
- filters?: FilterDataTable[];
- id: string;
- order: {
- columns?: (keyof T)[];
- defaultOrder: {
- column: keyof T;
- order: "asc" | "desc";
- };
- };
- search?: boolean;
- },
-) {
+import { NextDataTableNavigation } from "./navigation-next";
+
+export type {
+ AlignDataTable,
+ ColumnDef,
+ DataTableProps,
+ DataTableTMin,
+ SearchParamsDataTable,
+} from "./data-table-content";
+export { DataTableSkeleton } from "./data-table-content";
+
+/**
+ * {@link ContentDataTable}, wired to Next.js.
+ *
+ * The props are unchanged, so every AdminCP view and every `/files` page sees
+ * exactly the component they always did. This supplies the one thing the shared
+ * table cannot resolve for itself - how to change the URL - and the failure
+ * screen, which is `next-intl`'s locale-aware navigation wearing two buttons.
+ *
+ * The provider is a client component and the table it wraps is not: passing the
+ * table as `children` is what keeps it that way, so the `cell` functions in
+ * `columns` are called on the server and never have to cross a serialization
+ * boundary.
+ */
+export function DataTable(props: DataTableProps) {
if (!(props.edges && props.pageInfo)) {
return ;
}
- return {...props} />;
+ return (
+
+ {...props} />
+
+ );
}
diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx
index bab6afa98..ee583a92c 100644
--- a/packages/vitnode/src/components/table/filters.tsx
+++ b/packages/vitnode/src/components/table/filters.tsx
@@ -2,11 +2,9 @@
import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
-import { useSearchParams } from "next/navigation";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
-import { usePathname, useRouter } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { Badge } from "../ui/badge";
@@ -23,6 +21,8 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Separator } from "../ui/separator";
import { Spinner } from "../ui/spinner";
+import { useDataTableUrl } from "./navigation";
+import { readTableFilter, withTableFilter } from "./url-state";
export interface FilterOption {
keywords?: string[];
@@ -39,18 +39,13 @@ export interface FilterDataTable {
function FilterItem({ filter }: { filter: FilterDataTable }) {
const t = useTranslations("core.global");
- const searchParams = useSearchParams();
- const pathname = usePathname();
- const { push } = useRouter();
- const [isPending, startTransition] = React.useTransition();
+ const { isPending, navigate, searchParams } = useDataTableUrl();
const isAsync = Boolean(filter.onSearch);
const [asyncOptions, setAsyncOptions] = React.useState([]);
const [isSearching, setIsSearching] = React.useState(false);
- const selected = (searchParams.get(filter.id)?.split(",") ?? []).filter(
- Boolean,
- );
+ const selected = readTableFilter(searchParams, filter.id);
const selectedSet = new Set(selected);
const options = isAsync ? asyncOptions : (filter.options ?? []);
@@ -76,21 +71,7 @@ function FilterItem({ filter }: { filter: FilterDataTable }) {
};
const applySelection = (values: string[]) => {
- startTransition(() => {
- const params = new URLSearchParams(searchParams.toString());
-
- if (values.length) {
- params.set(filter.id, values.join(","));
- } else {
- params.delete(filter.id);
- }
-
- params.delete("cursor");
- params.delete("first");
- params.delete("last");
-
- push(`${pathname}?${params.toString()}`, { scroll: false });
- });
+ navigate(withTableFilter(searchParams, { id: filter.id, values }));
};
const toggle = (value: string) => {
diff --git a/packages/vitnode/src/components/table/navigation-next.tsx b/packages/vitnode/src/components/table/navigation-next.tsx
new file mode 100644
index 000000000..69ba9199e
--- /dev/null
+++ b/packages/vitnode/src/components/table/navigation-next.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import React from "react";
+
+import { usePathname, useRouter } from "@/lib/navigation";
+
+import type { DataTableNavigation } from "./navigation";
+
+import { DataTableNavigationProvider } from "./navigation";
+
+/**
+ * {@link DataTableNavigationProvider}, wired to Next.js.
+ *
+ * The whole of the framework coupling the data table used to spread across four
+ * control components, in one place: the current search parameters, the pathname
+ * `next-intl` has already stripped the locale prefix from, and a push that does
+ * not scroll. `DataTable` mounts this, so every existing page keeps the
+ * behaviour it had without knowing anything changed.
+ *
+ * `usePathname` is the locale-aware one on purpose. `next/navigation`'s returns
+ * `/pl/files`, and pushing that through a router that prefixes the locale again
+ * gives `/pl/pl/files`.
+ */
+export const NextDataTableNavigation = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const { push } = useRouter();
+
+ const value = React.useMemo(
+ () => ({
+ navigate: nextSearch => {
+ push(`${pathname}?${nextSearch}`, { scroll: false });
+ },
+ searchParams,
+ }),
+ [pathname, push, searchParams],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/vitnode/src/components/table/navigation.tsx b/packages/vitnode/src/components/table/navigation.tsx
new file mode 100644
index 000000000..f8cd3e877
--- /dev/null
+++ b/packages/vitnode/src/components/table/navigation.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import React from "react";
+
+/**
+ * The one thing a `DataTable` cannot decide for itself.
+ *
+ * Every control in the table turns the current query string into a new one - a
+ * pure function, in `url-state.ts` - and then has to get the page there. That
+ * last step is the single question whose answer differs between the two
+ * frameworks: Next.js wants `next-intl`'s locale-aware router pointed at a
+ * pathname it has to look up, TanStack Start wants `router.navigate` and no
+ * pathname at all. Both can be expressed as "here is the search string, and
+ * here is a function that goes to it", so the table takes those two and stops
+ * caring.
+ *
+ * Deliberately two members and no more. A table needs to read its own search
+ * parameters and to replace them; it never needs a pathname, a locale, params,
+ * prefetching or history state, so widening this later is a decision somebody
+ * has to make on purpose rather than one that leaks in. It is not a router.
+ */
+export interface DataTableNavigation {
+ /**
+ * Goes to `nextSearch` - a query string with no leading `?`, exactly as
+ * `URLSearchParams.toString()` produces it.
+ *
+ * Implementations must not scroll: a person sorting the last column of a long
+ * table is looking at the header they clicked, and yanking them to the top of
+ * the page loses their place.
+ *
+ * Returning a promise is optional and only affects the pending indicator.
+ * Next's `push` resolves through the transition it was called in, so it
+ * returns nothing; a router whose `navigate` is awaitable should return it, so
+ * the spinner lasts as long as the navigation does instead of flashing.
+ */
+ navigate: (nextSearch: string) => Promise | void;
+ /** The query string the table is currently rendering. Never mutated. */
+ searchParams: URLSearchParams;
+}
+
+const DataTableNavigationContext =
+ React.createContext(null);
+
+/**
+ * Context rather than props, and not by preference.
+ *
+ * In Next.js the table is assembled by a Server Component: `DataTable` renders
+ * on the server, and a `navigate` function cannot cross that boundary as a prop.
+ * The controls that need it are client components several levels down, so the
+ * value has to be created on the client and read from there - the same shape
+ * `SelectionProviderDataTable` already uses, for the same reason.
+ */
+export const DataTableNavigationProvider = ({
+ children,
+ value,
+}: {
+ children: React.ReactNode;
+ value: DataTableNavigation;
+}) => (
+
+ {children}
+
+);
+
+/**
+ * The seam, as a control sees it: where the table is, and how to move it.
+ *
+ * The transition is here rather than in each control because every one of them
+ * wants the same thing from it - a pending flag to swap a spinner in for while
+ * the next page is fetched - and because it is what keeps the old rows on
+ * screen instead of blanking the table mid-navigation.
+ *
+ * `navigate` is awaited inside the transition so that a router returning a
+ * promise keeps the control pending for the whole navigation. Awaiting a
+ * `void` return costs one microtask and changes nothing: the navigation itself
+ * was already started synchronously, inside the transition.
+ *
+ * The one it hands back returns nothing, deliberately. A control has no use for
+ * the navigation's promise - that is what `isPending` is for - and typing it as
+ * awaitable would make every call site a floating promise.
+ */
+export const useDataTableUrl = (): {
+ isPending: boolean;
+ navigate: (nextSearch: string) => void;
+ searchParams: URLSearchParams;
+} => {
+ const navigation = React.use(DataTableNavigationContext);
+
+ if (!navigation) {
+ throw new Error(
+ "A DataTable control must be rendered inside a DataTableNavigationProvider.",
+ );
+ }
+
+ const { navigate, searchParams } = navigation;
+ const [isPending, startTransition] = React.useTransition();
+
+ const navigateInTransition = React.useCallback(
+ (nextSearch: string) => {
+ startTransition(async () => {
+ await navigate(nextSearch);
+ });
+ },
+ [navigate],
+ );
+
+ return { isPending, navigate: navigateInTransition, searchParams };
+};
diff --git a/packages/vitnode/src/components/table/order-table-head.tsx b/packages/vitnode/src/components/table/order-table-head.tsx
index 2805ee6ec..d49f81abc 100644
--- a/packages/vitnode/src/components/table/order-table-head.tsx
+++ b/packages/vitnode/src/components/table/order-table-head.tsx
@@ -1,41 +1,37 @@
"use client";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
-import { useSearchParams } from "next/navigation";
import React from "react";
-import { usePathname, useRouter } from "@/lib/navigation";
-
-import type { DataTable, DataTableTMin } from "./data-table";
+import type { DataTableProps, DataTableTMin } from "./data-table-content";
import { Button } from "../ui/button";
import { Loader } from "../ui/loader";
+import { useDataTableUrl } from "./navigation";
+import { readTableOrder, toggleTableOrder } from "./url-state";
export function OrderTableHeadDataTable({
id,
children,
order: { defaultOrder },
-}: Pick>, "order"> & {
+}: Pick, "order"> & {
children: React.ReactNode;
id: keyof T;
}) {
- const [isPending, startTransition] = React.useTransition();
- const searchParams = useSearchParams();
- const pathname = usePathname();
- const { push } = useRouter();
-
- const currentOrderBy =
- searchParams.get("orderBy") ?? defaultOrder.column.toString();
- const currentOrder = searchParams.get("order") ?? defaultOrder.order;
-
- const isActive = currentOrderBy === id.toString();
- const nextOrder = isActive && currentOrder === "asc" ? "desc" : "asc";
+ const { isPending, navigate, searchParams } = useDataTableUrl();
+ const column = id.toString();
+ const fallback = {
+ column: defaultOrder.column.toString(),
+ order: defaultOrder.order,
+ };
+ const current = readTableOrder(searchParams, fallback);
+ const isActive = current.column === column;
let icon: React.ReactNode;
if (isPending) {
icon = ;
} else if (isActive) {
- icon = currentOrder === "asc" ? : ;
+ icon = current.order === "asc" ? : ;
} else {
icon = ;
}
@@ -44,14 +40,9 @@ export function OrderTableHeadDataTable({