+
+
+ {count === 0
+ ? t("results_not_found")
+ : t("showing_range", { from, to, total: totalCount })}
+
+
+
-
- {isPending ? (
-
- ) : (
-
- )}
+ {totalPages > 1 && (
+
+
+
+
+
+
+
+
+ {t("page_of", { page, total: totalPages })}
+
+
+
+ {tablePageWindow({ current: page, total: totalPages }).map(
+ (slot, index) => (
+
+ {slot === "ellipsis" ? (
+
+ ) : (
+
+ {slot}
+
+ )}
+
+ ),
+ )}
- {isPending ? (
-
- ) : (
-
- )}
-
+
+
+
+
+
+ )}
);
diff --git a/packages/vitnode/src/components/table/url-state.test.ts b/packages/vitnode/src/components/table/url-state.test.ts
index ea9542653..faee08ad9 100644
--- a/packages/vitnode/src/components/table/url-state.test.ts
+++ b/packages/vitnode/src/components/table/url-state.test.ts
@@ -4,12 +4,14 @@ import {
DEFAULT_TABLE_PAGE_SIZE,
readTableFilter,
readTableOrder,
+ readTablePage,
readTablePageSize,
readTableSearch,
toggleTableOrder,
withTableFilter,
withTableOrder,
withTablePage,
+ withTablePageNumber,
withTablePageSize,
withTableSearch,
} from "./url-state";
@@ -161,7 +163,7 @@ describe("changing the page size", () => {
it("keeps the sort and the search", () => {
expect(withTablePageSize(FULL, 40)).toBe(
- "search=foo&page=3&tab=media&orderBy=name&order=asc&first=40",
+ "search=foo&tab=media&orderBy=name&order=asc&first=40",
);
});
});
@@ -232,6 +234,16 @@ describe("paging", () => {
"search=foo&page=3&tab=media&orderBy=name&order=asc&first=20&cursor=end-1",
);
});
+
+ it("returns to the first page when the page size changes", () => {
+ expect(readTablePage(withTablePageSize(FULL, 40))).toBe(1);
+ });
+
+ it("returns to the first page when a filter changes", () => {
+ expect(
+ readTablePage(withTableFilter(FULL, { id: "roles", values: ["1"] })),
+ ).toBe(1);
+ });
});
describe("searching", () => {
@@ -289,7 +301,7 @@ describe("filtering", () => {
it("keeps the sort and the search", () => {
expect(withTableFilter(FULL, { id: "roles", values: ["1"] })).toBe(
- "search=foo&page=3&tab=media&orderBy=name&order=asc&roles=1",
+ "search=foo&tab=media&orderBy=name&order=asc&roles=1",
);
});
@@ -309,6 +321,7 @@ describe("the helpers are pure", () => {
withTableOrder(params, { column: "size", order: "desc" });
withTablePageSize(params, 40);
withTablePage(params, { cursor: "x", direction: "next", pageSize: 40 });
+ withTablePageNumber(params, 5);
withTableSearch(params, "bar");
withTableFilter(params, { id: "roles", values: ["1"] });
diff --git a/packages/vitnode/src/components/table/url-state.ts b/packages/vitnode/src/components/table/url-state.ts
index efdd5befc..735f2874a 100644
--- a/packages/vitnode/src/components/table/url-state.ts
+++ b/packages/vitnode/src/components/table/url-state.ts
@@ -4,6 +4,7 @@ const FIRST = "first";
const LAST = "last";
/** The row the next page starts from. Meaningless without `first` or `last`. */
const CURSOR = "cursor";
+const PAGE = "page";
export const DEFAULT_TABLE_PAGE_SIZE = 10;
@@ -27,6 +28,32 @@ const resetPagination = (params: URLSearchParams): void => {
params.delete(CURSOR);
params.delete(FIRST);
params.delete(LAST);
+ params.delete(PAGE);
+};
+
+export const readTablePage = (search: TableSearch): number => {
+ const page = Number(copy(search).get(PAGE));
+
+ return Number.isInteger(page) && page > 0 ? page : 1;
+};
+
+export const withTablePageNumber = (
+ search: TableSearch,
+ page: number | string,
+): string => {
+ const params = copy(search);
+ const next = Math.trunc(Number(page));
+
+ if (Number.isFinite(next) && next > 1) {
+ params.set(PAGE, `${next}`);
+ } else {
+ params.delete(PAGE);
+ }
+
+ params.delete(CURSOR);
+ params.delete(LAST);
+
+ return params.toString();
};
/** The column and direction the table is sorted by right now. */
@@ -84,6 +111,7 @@ export const withTablePageSize = (
params.set(FIRST, `${pageSize}`);
params.delete(LAST);
params.delete(CURSOR);
+ params.delete(PAGE);
return params.toString();
};
diff --git a/packages/vitnode/src/components/ui/badge.tsx b/packages/vitnode/src/components/ui/badge.tsx
index ccc23bd4c..9c4f314a9 100644
--- a/packages/vitnode/src/components/ui/badge.tsx
+++ b/packages/vitnode/src/components/ui/badge.tsx
@@ -13,6 +13,10 @@ const badgeVariants = cva(
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ success:
+ "bg-success/10 text-success focus-visible:ring-success/20 dark:bg-success/20 dark:focus-visible:ring-success/40 [a]:hover:bg-success/20",
+ warning:
+ "bg-warn/10 text-warn focus-visible:ring-warn/20 dark:bg-warn/20 dark:focus-visible:ring-warn/40 [a]:hover:bg-warn/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
diff --git a/packages/vitnode/src/components/ui/button.tsx b/packages/vitnode/src/components/ui/button.tsx
index 0bacf139e..31c400ea9 100644
--- a/packages/vitnode/src/components/ui/button.tsx
+++ b/packages/vitnode/src/components/ui/button.tsx
@@ -18,6 +18,10 @@ const buttonVariants = cva(
"hover:bg-muted/70 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ success:
+ "bg-success/10 text-success hover:bg-success/20 focus-visible:border-success/40 focus-visible:ring-success/20 dark:bg-success/20 dark:hover:bg-success/30 dark:focus-visible:ring-success/40",
+ warning:
+ "bg-warn/10 text-warn hover:bg-warn/20 focus-visible:border-warn/40 focus-visible:ring-warn/20 dark:bg-warn/20 dark:hover:bg-warn/30 dark:focus-visible:ring-warn/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
diff --git a/packages/vitnode/src/components/ui/select.tsx b/packages/vitnode/src/components/ui/select.tsx
index cf6686766..14ba7d800 100644
--- a/packages/vitnode/src/components/ui/select.tsx
+++ b/packages/vitnode/src/components/ui/select.tsx
@@ -90,7 +90,7 @@ function SelectContent({
[]> => {
@@ -336,8 +341,8 @@ export const createContentLocalizedPublicService = <
.where(where);
return order
- ? await scoped.orderBy(order).limit(limit)
- : await scoped.limit(limit);
+ ? await scoped.orderBy(order).limit(limit).offset(offset)
+ : await scoped.limit(limit).offset(offset);
}
const scoped = query
@@ -346,8 +351,8 @@ export const createContentLocalizedPublicService = <
.where(where);
return order
- ? await scoped.orderBy(order).limit(limit)
- : await scoped.limit(limit);
+ ? await scoped.orderBy(order).limit(limit).offset(offset)
+ : await scoped.limit(limit).offset(offset);
};
const readOne = async (
@@ -494,6 +499,7 @@ export const createContentLocalizedPublicService = <
query: async ({
cursorSelection,
limit,
+ offset,
orderBy: order,
where: paged,
}) =>
@@ -503,6 +509,7 @@ export const createContentLocalizedPublicService = <
typeof limit === "number"
? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1)
: CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
+ offset,
order,
}),
});
diff --git a/packages/vitnode/src/content/server/pagination-routes.test.ts b/packages/vitnode/src/content/server/pagination-routes.test.ts
index 0e62550ab..d3788b43b 100644
--- a/packages/vitnode/src/content/server/pagination-routes.test.ts
+++ b/packages/vitnode/src/content/server/pagination-routes.test.ts
@@ -121,6 +121,28 @@ describe("pagination input a list route refuses", () => {
);
});
+ it("carries a numbered page through to the service", async () => {
+ const { app, findMany } = harness();
+
+ const res = await app.request("/?page=3&first=10");
+
+ expect(res.status).toBe(200);
+ expect(findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: expect.objectContaining({ first: "10", page: "3" }),
+ }),
+ );
+ });
+
+ it("refuses a page beside a cursor, which mean different things", async () => {
+ const { app, findMany } = harness();
+
+ const res = await app.request("/?page=2&cursor=eyJpZCI6MX0");
+
+ expect(res.status).toBe(400);
+ expect(findMany).not.toHaveBeenCalled();
+ });
+
it("refuses a legacy numeric cursor on an ordering that is not the identifier", async () => {
// The exact shape of the old bug, refused where the ordering is known: a
// bare number says nothing about where `title` was, so honouring it would
diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts
index 7dedabbab..5569d7d03 100644
--- a/packages/vitnode/src/content/server/public-routes.ts
+++ b/packages/vitnode/src/content/server/public-routes.ts
@@ -331,7 +331,7 @@ export const buildContentPublicRoutes = <
// parameter is ignored rather than turned into a 400. `orderBy` is the
// exception: a *present* but unknown column fails validation.
const raw = c.req.query();
- const { cursor, first, last, order, orderBy, search } =
+ const { cursor, first, last, order, orderBy, page, search } =
paginationQuery.parse(raw);
const filters = schemas.publicFilters.parse(
raw,
@@ -354,7 +354,7 @@ export const buildContentPublicRoutes = <
column: orderBy as ContentPublicOrderableFieldName,
order,
},
- query: { cursor, first, last, search },
+ query: { cursor, first, last, page, search },
});
return c.json(data, 200, localeHeaders(resolved.locale, resolved.source));
diff --git a/packages/vitnode/src/content/server/public-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts
index 0df953f0e..9bf55e687 100644
--- a/packages/vitnode/src/content/server/public-service.test.ts
+++ b/packages/vitnode/src/content/server/public-service.test.ts
@@ -41,6 +41,7 @@ const createDbMock = (results: unknown[][]) => {
from: (value: unknown) => record("from", value),
leftJoin: (value: unknown) => record("leftJoin", value),
limit: (value: unknown) => record("limit", value),
+ offset: (value: unknown) => record("offset", value),
orderBy: (value: unknown) => record("orderBy", value),
then: async (resolve: (rows: unknown[]) => TResult) =>
Promise.resolve(rows).then(resolve),
diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts
index 310b74d5a..690fac607 100644
--- a/packages/vitnode/src/content/server/public-service.ts
+++ b/packages/vitnode/src/content/server/public-service.ts
@@ -52,8 +52,14 @@ export interface ContentPublicFindManyArgs<
column?: ContentPublicOrderableFieldName;
order?: "asc" | "desc";
};
- /** Raw pagination query (`cursor`, `first`, `last`, `search`). */
- query?: { cursor?: string; first?: string; last?: string; search?: string };
+ /** Raw pagination query (`cursor`, `first`, `last`, `page`, `search`). */
+ query?: {
+ cursor?: string;
+ first?: string;
+ last?: string;
+ page?: string;
+ search?: string;
+ };
}
export interface ContentPublicService {
@@ -338,7 +344,13 @@ export const createContentPublicService = <
},
table,
where: conditions.length > 1 ? and(...conditions) : conditions[0],
- query: async ({ cursorSelection, limit, orderBy: order, where }) =>
+ query: async ({
+ cursorSelection,
+ limit,
+ offset,
+ orderBy: order,
+ where,
+ }) =>
await c
.get("db")
// The cursor value is projected by this statement and stripped from
@@ -352,7 +364,8 @@ export const createContentPublicService = <
typeof limit === "number"
? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1)
: CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
- ),
+ )
+ .offset(offset),
});
return {
diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts
index f98b96f3d..8ed01b0de 100644
--- a/packages/vitnode/src/content/server/routes.ts
+++ b/packages/vitnode/src/content/server/routes.ts
@@ -353,7 +353,7 @@ export const buildContentRoutes = <
// The whole query string goes through both schemas, each of which reads
// only the keys it owns:
//
- // paginationQuery cursor, first, last, order, orderBy, search
+ // paginationQuery cursor, first, last, order, orderBy, page, search
// schemas.filters one entry per declared filterable field
//
// Neither is strict, so anything else - a stale bookmark, a tracking
@@ -361,7 +361,7 @@ export const buildContentRoutes = <
// exception: it is a literal enum, so a *present* but unknown column is a
// 400 at validation time.
const raw = c.req.query();
- const { cursor, first, last, order, orderBy, search } =
+ const { cursor, first, last, order, orderBy, page, search } =
paginationQuery.parse(raw);
// Every value is coerced here (query strings carry numbers and booleans as
// text), and an unsupported field cannot survive the parse - so this path
@@ -381,7 +381,7 @@ export const buildContentRoutes = <
column: orderBy as ContentOrderableFieldName,
order,
},
- query: { cursor, first, last, search },
+ query: { cursor, first, last, page, search },
});
const withTranslations = await withRowTranslations(c, data, raw.locale);
diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts
index fadf370a8..7f2fdc66f 100644
--- a/packages/vitnode/src/content/server/service.test.ts
+++ b/packages/vitnode/src/content/server/service.test.ts
@@ -67,6 +67,7 @@ const createDbMock = (
from: (value: unknown) => record("from", value),
leftJoin: (value: unknown) => record("leftJoin", value),
limit: (value: unknown) => record("limit", value),
+ offset: (value: unknown) => record("offset", value),
orderBy: (value: unknown) => record("orderBy", value),
returning: (value: unknown) => record("returning", value),
set: (value: unknown) => record("set", value),
diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts
index 7511ad8a1..74c24c1bf 100644
--- a/packages/vitnode/src/content/server/service.ts
+++ b/packages/vitnode/src/content/server/service.ts
@@ -76,12 +76,15 @@ export type ContentListRow = ContentSelect & {
export interface ContentPageInfo {
count: number;
+ currentPage: null | number;
endCursor: null | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
+ pageSize: number;
startCursor: null | string;
totalCount: number;
+ totalPages: number;
}
export interface ContentFindManyArgs {
@@ -91,8 +94,14 @@ export interface ContentFindManyArgs {
column?: ContentOrderableFieldName;
order?: "asc" | "desc";
};
- /** Raw pagination query (`cursor`, `first`, `last`, `search`). */
- query?: { cursor?: string; first?: string; last?: string; search?: string };
+ /** Raw pagination query (`cursor`, `first`, `last`, `page`, `search`). */
+ query?: {
+ cursor?: string;
+ first?: string;
+ last?: string;
+ page?: string;
+ search?: string;
+ };
where?: SQL;
}
@@ -785,6 +794,7 @@ export const createContentService = <
query: async ({
cursorSelection,
limit,
+ offset,
orderBy: order,
where: rowWhere,
}) => {
@@ -825,7 +835,8 @@ export const createContentService = <
.orderBy(order)
.limit(
typeof limit === "number" ? limit : CONTENT_DEFAULT_PAGE_SIZE,
- );
+ )
+ .offset(offset);
},
});
diff --git a/packages/vitnode/src/database/admins.ts b/packages/vitnode/src/database/admins.ts
index 15e57fb24..bfca2c2d4 100644
--- a/packages/vitnode/src/database/admins.ts
+++ b/packages/vitnode/src/database/admins.ts
@@ -33,6 +33,7 @@ export const core_admin_permissions = camelCase.table.withRLS(
t => [
index("core_admin_permissions_role_id_idx").on(t.roleId),
index("core_admin_permissions_user_id_idx").on(t.userId),
+ index("core_admin_permissions_updated_at_id_idx").on(t.updatedAt, t.id),
],
);
diff --git a/packages/vitnode/src/database/cron.ts b/packages/vitnode/src/database/cron.ts
index 6ca64465d..9480d8fab 100644
--- a/packages/vitnode/src/database/cron.ts
+++ b/packages/vitnode/src/database/cron.ts
@@ -1,13 +1,17 @@
-import { camelCase } from "drizzle-orm/pg-core";
+import { camelCase, index } from "drizzle-orm/pg-core";
-export const core_cron = camelCase.table.withRLS("core_cron", t => ({
- id: t.serial().primaryKey(),
- name: t.varchar({ length: 255 }).notNull(),
- description: t.varchar({ length: 255 }),
- lastRun: t.timestamp(),
- createdAt: t.timestamp().notNull().defaultNow(),
- pluginId: t.varchar({ length: 100 }).notNull(),
- module: t.varchar({ length: 100 }).notNull(),
- nextRun: t.timestamp(),
- schedule: t.varchar({ length: 100 }).notNull(),
-}));
+export const core_cron = camelCase.table.withRLS(
+ "core_cron",
+ t => ({
+ id: t.serial().primaryKey(),
+ name: t.varchar({ length: 255 }).notNull(),
+ description: t.varchar({ length: 255 }),
+ lastRun: t.timestamp(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ pluginId: t.varchar({ length: 100 }).notNull(),
+ module: t.varchar({ length: 100 }).notNull(),
+ nextRun: t.timestamp(),
+ schedule: t.varchar({ length: 100 }).notNull(),
+ }),
+ t => [index("core_cron_last_run_id_idx").on(t.lastRun, t.id)],
+);
diff --git a/packages/vitnode/src/database/files.ts b/packages/vitnode/src/database/files.ts
index 906d78b19..b95580d88 100644
--- a/packages/vitnode/src/database/files.ts
+++ b/packages/vitnode/src/database/files.ts
@@ -18,5 +18,8 @@ export const core_files = camelCase.table.withRLS(
metadata: t.jsonb().$type>().notNull().default({}),
createdAt: t.timestamp().notNull().defaultNow(),
}),
- t => [index("core_files_user_id_idx").on(t.userId)],
+ t => [
+ index("core_files_user_id_idx").on(t.userId),
+ index("core_files_created_at_id_idx").on(t.createdAt, t.id),
+ ],
);
diff --git a/packages/vitnode/src/database/logs.ts b/packages/vitnode/src/database/logs.ts
index 070a75ce1..ed49ef521 100644
--- a/packages/vitnode/src/database/logs.ts
+++ b/packages/vitnode/src/database/logs.ts
@@ -1,21 +1,25 @@
-import { camelCase } from "drizzle-orm/pg-core";
+import { camelCase, index } from "drizzle-orm/pg-core";
import { core_users } from "./users";
-export const core_logs = camelCase.table.withRLS("core_logs", t => ({
- id: t.serial().primaryKey(),
- pluginId: t.varchar({ length: 255 }).notNull(),
- type: t.varchar({ enum: ["warn", "error", "debug"], length: 10 }).notNull(),
- content: t.text().notNull(),
- createdAt: t.timestamp().notNull().defaultNow(),
- ipAddress: t.varchar({ length: 45 }).notNull(),
- method: t.varchar({ length: 10 }).notNull().default("GET"),
- path: t.text().notNull().default("localhost"),
- userAgent: t.text(),
- statusCode: t.integer().notNull().default(500),
- userId: t.bigint({ mode: "number" }).references(() => core_users.id, {
- onDelete: "set null",
- onUpdate: "cascade",
+export const core_logs = camelCase.table.withRLS(
+ "core_logs",
+ t => ({
+ id: t.serial().primaryKey(),
+ pluginId: t.varchar({ length: 255 }).notNull(),
+ type: t.varchar({ enum: ["warn", "error", "debug"], length: 10 }).notNull(),
+ content: t.text().notNull(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ ipAddress: t.varchar({ length: 45 }).notNull(),
+ method: t.varchar({ length: 10 }).notNull().default("GET"),
+ path: t.text().notNull().default("localhost"),
+ userAgent: t.text(),
+ statusCode: t.integer().notNull().default(500),
+ userId: t.bigint({ mode: "number" }).references(() => core_users.id, {
+ onDelete: "set null",
+ onUpdate: "cascade",
+ }),
+ test123: t.boolean().notNull().default(false),
}),
- test123: t.boolean().notNull().default(false),
-}));
+ t => [index("core_logs_created_at_id_idx").on(t.createdAt, t.id)],
+);
diff --git a/packages/vitnode/src/database/moderators.ts b/packages/vitnode/src/database/moderators.ts
index 37acb899c..6e13094c6 100644
--- a/packages/vitnode/src/database/moderators.ts
+++ b/packages/vitnode/src/database/moderators.ts
@@ -32,5 +32,9 @@ export const core_moderators_permissions = camelCase.table.withRLS(
t => [
index("core_moderators_permissions_role_id_idx").on(t.roleId),
index("core_moderators_permissions_user_id_idx").on(t.userId),
+ index("core_moderators_permissions_updated_at_id_idx").on(
+ t.updatedAt,
+ t.id,
+ ),
],
);
diff --git a/packages/vitnode/src/database/queue.ts b/packages/vitnode/src/database/queue.ts
index 6e3dc14b3..a35d1c1f7 100644
--- a/packages/vitnode/src/database/queue.ts
+++ b/packages/vitnode/src/database/queue.ts
@@ -31,5 +31,6 @@ export const core_queue = camelCase.table.withRLS(
}),
t => [
index("core_queue_status_available_at_idx").on(t.status, t.availableAt),
+ index("core_queue_created_at_id_idx").on(t.createdAt, t.id),
],
);
diff --git a/packages/vitnode/src/database/roles.ts b/packages/vitnode/src/database/roles.ts
index b9af5a6ba..1d5fbf016 100644
--- a/packages/vitnode/src/database/roles.ts
+++ b/packages/vitnode/src/database/roles.ts
@@ -1,24 +1,28 @@
-import { camelCase } from "drizzle-orm/pg-core";
+import { camelCase, index } from "drizzle-orm/pg-core";
-export const core_roles = camelCase.table.withRLS("core_roles", t => ({
- id: t.serial().primaryKey(),
- createdAt: t.timestamp().notNull().defaultNow(),
- updatedAt: t
- .timestamp()
- .notNull()
- .$onUpdate(() => new Date()),
- protected: t.boolean().notNull().default(false),
- default: t.boolean().notNull().default(false),
- root: t.boolean().notNull().default(false),
- guest: t.boolean().notNull().default(false),
- color: t.varchar({ length: 50 }),
- prefix: t.varchar({ length: 64 }),
- allowUploadFiles: t.boolean().notNull().default(false),
- totalMaxStorage: t.integer(),
- maxStorageForSubmit: t.integer(),
- allowUploadAvatar: t.boolean().notNull().default(true),
- maxAvatarSize: t.integer().notNull().default(2048),
- allowUploadCover: t.boolean().notNull().default(true),
- maxCoverSize: t.integer().notNull().default(5120),
- allowEditPersonalInfo: t.boolean().notNull().default(true),
-}));
+export const core_roles = camelCase.table.withRLS(
+ "core_roles",
+ t => ({
+ id: t.serial().primaryKey(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ protected: t.boolean().notNull().default(false),
+ default: t.boolean().notNull().default(false),
+ root: t.boolean().notNull().default(false),
+ guest: t.boolean().notNull().default(false),
+ color: t.varchar({ length: 50 }),
+ prefix: t.varchar({ length: 64 }),
+ allowUploadFiles: t.boolean().notNull().default(false),
+ totalMaxStorage: t.integer(),
+ maxStorageForSubmit: t.integer(),
+ allowUploadAvatar: t.boolean().notNull().default(true),
+ maxAvatarSize: t.integer().notNull().default(2048),
+ allowUploadCover: t.boolean().notNull().default(true),
+ maxCoverSize: t.integer().notNull().default(5120),
+ allowEditPersonalInfo: t.boolean().notNull().default(true),
+ }),
+ t => [index("core_roles_updated_at_id_idx").on(t.updatedAt, t.id)],
+);
diff --git a/packages/vitnode/src/database/users.ts b/packages/vitnode/src/database/users.ts
index cb22726aa..ec02cc791 100644
--- a/packages/vitnode/src/database/users.ts
+++ b/packages/vitnode/src/database/users.ts
@@ -59,6 +59,7 @@ export const core_users = camelCase.table.withRLS(
index("core_users_email_idx").on(t.email),
index("core_users_avatar_id_idx").on(t.avatarId),
index("core_users_cover_id_idx").on(t.coverId),
+ index("core_users_created_at_id_idx").on(t.createdAt, t.id),
],
);
diff --git a/packages/vitnode/src/framework/vite/tailwind-sources.test.ts b/packages/vitnode/src/framework/vite/tailwind-sources.test.ts
index 00d75890c..337c35bff 100644
--- a/packages/vitnode/src/framework/vite/tailwind-sources.test.ts
+++ b/packages/vitnode/src/framework/vite/tailwind-sources.test.ts
@@ -17,14 +17,17 @@ const installed = (appRoot: string, packageName: string): null | string =>
const prepared = async ({
readBuildOutput = installed,
readPluginIds = vi.fn(async () => Promise.resolve(["@acme/blog"])),
+ stylesheets = {},
}: {
readBuildOutput?: (appRoot: string, packageName: string) => null | string;
readPluginIds?: () => Promise;
+ stylesheets?: Record;
} = {}): Promise<{ plugin: Plugin; transform: TransformHook }> => {
const plugin = vitNodeTailwindSources({
appRoot: "/app",
readBuildOutput,
readPluginIds,
+ readStylesheet: path => stylesheets[path] ?? null,
});
await (plugin.configResolved as () => Promise)();
@@ -71,6 +74,55 @@ describe("the Tailwind sources a VitNode app scans", () => {
).toBeNull();
});
+ it("scans the same sources for a route stylesheet built on the app one", async () => {
+ const { transform } = await prepared({
+ stylesheets: { "/app/src/styles.css": APP_CSS },
+ });
+
+ expect(
+ sourcesIn(
+ transform(
+ '@import "../styles.css";\n@import "fumadocs-ui/css/preset.css";\n',
+ "/app/src/docs/docs.css",
+ )?.code ?? "",
+ ),
+ ).toEqual([
+ "/app/node_modules/@vitnode/core/dist/src/**/*.js",
+ "/app/node_modules/@acme/blog/dist/src/**/*.js",
+ ]);
+ });
+
+ it("follows a chain of relative imports to the Tailwind one", async () => {
+ const { transform } = await prepared({
+ stylesheets: {
+ "/app/src/app.css": '@import "./base/tailwind.css";',
+ "/app/src/base/tailwind.css": APP_CSS,
+ },
+ });
+
+ expect(
+ transform('@import "./app.css";', "/app/src/route.css"),
+ ).not.toBeNull();
+ });
+
+ it("gives up on a stylesheet that imports itself", async () => {
+ const { transform } = await prepared({
+ stylesheets: { "/app/src/loop.css": '@import "./loop.css";' },
+ });
+
+ expect(transform('@import "./loop.css";', "/app/src/loop.css")).toBeNull();
+ });
+
+ it("leaves a package stylesheet's imports to that package", async () => {
+ const { transform } = await prepared({
+ stylesheets: { "/app/src/styles.css": APP_CSS },
+ });
+
+ expect(
+ transform('@import "fumadocs-ui/css/preset.css";', "/app/src/docs.css"),
+ ).toBeNull();
+ });
+
it("leaves modules that are not stylesheets alone", async () => {
const { transform } = await prepared();
diff --git a/packages/vitnode/src/framework/vite/tailwind-sources.ts b/packages/vitnode/src/framework/vite/tailwind-sources.ts
index 69bed67b8..dab15f74b 100644
--- a/packages/vitnode/src/framework/vite/tailwind-sources.ts
+++ b/packages/vitnode/src/framework/vite/tailwind-sources.ts
@@ -1,7 +1,7 @@
import type { Plugin } from "vite";
-import { existsSync } from "node:fs";
-import { dirname, join } from "node:path";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
import { configuredPluginIds } from "./plugin-routes";
@@ -13,12 +13,16 @@ const SCANNED_FILES = "**/*.js";
const TAILWIND_IMPORT = /@import\s+["']tailwindcss["']/;
+const RELATIVE_IMPORT = /@import\s+(?:url\()?["'](\.[^"']*)["']/g;
+
export interface VitNodeTailwindSourcesOptions {
appRoot: string;
readBuildOutput?: (appRoot: string, packageName: string) => null | string;
readPluginIds?: (appRoot: string) => Promise;
+
+ readStylesheet?: (path: string) => null | string;
}
const buildOutputOf = (appRoot: string, packageName: string): null | string => {
@@ -35,6 +39,40 @@ const buildOutputOf = (appRoot: string, packageName: string): null | string => {
}
};
+const stylesheetOf = (path: string): null | string => {
+ for (const candidate of [path, `${path}.css`]) {
+ try {
+ return readFileSync(candidate, "utf8");
+ } catch {
+ continue;
+ }
+ }
+
+ return null;
+};
+
+const buildsTailwind = (
+ code: string,
+ id: string,
+ readStylesheet: (path: string) => null | string,
+ seen: Set = new Set(),
+): boolean => {
+ if (TAILWIND_IMPORT.test(code)) return true;
+
+ return [...code.matchAll(RELATIVE_IMPORT)].some(([, specifier]) => {
+ const path = resolve(dirname(id), specifier);
+
+ if (seen.has(path)) return false;
+ seen.add(path);
+
+ const imported = readStylesheet(path);
+
+ return (
+ imported !== null && buildsTailwind(imported, path, readStylesheet, seen)
+ );
+ });
+};
+
const sourceDirective = (directory: string): string =>
`@source "${directory.replaceAll("\\", "/")}/${SCANNED_FILES}";`;
@@ -42,6 +80,7 @@ export const vitNodeTailwindSources = ({
appRoot,
readBuildOutput = buildOutputOf,
readPluginIds = configuredPluginIds,
+ readStylesheet = stylesheetOf,
}: VitNodeTailwindSourcesOptions): Plugin => {
let directives = "";
@@ -59,8 +98,11 @@ export const vitNodeTailwindSources = ({
name: "vitnode:tailwind-sources",
transform: (code, id) => {
if (directives === "") return null;
- if (!id.split("?")[0].endsWith(".css")) return null;
- if (!TAILWIND_IMPORT.test(code)) return null;
+
+ const path = id.split("?")[0];
+
+ if (!path.endsWith(".css")) return null;
+ if (!buildsTailwind(code, path, readStylesheet)) return null;
return { code: `${code}\n${directives}\n`, map: null };
},
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index fb2f85bd3..5beece8a7 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -394,7 +394,10 @@
"unreadable": "This image could not be read. Try a different file.",
"too_large_after_crop": "The cropped image is {size}, which is over the {max} limit. Zoom in or pick a smaller image."
}
- }
+ },
+ "showing_range": "Showing {from}–{to} of {total}",
+ "rows_per_page": "Rows per page",
+ "page_of": "Page {page} of {total}"
},
"navigation": {
"discover": {
diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts
index fe27c7b2a..66816251c 100644
--- a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts
+++ b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import type { AnyContentTypeDefinition } from "@/content/types";
-import { DEFAULT_TABLE_PAGE_SIZE } from "@/components/table/url-state";
import { CONTENT_DEFAULT_PAGE_SIZE } from "@/content/const";
import { defineContentType } from "@/content/define";
import { field } from "@/content/fields";
@@ -185,11 +184,10 @@ describe("normalizeContentListSearch", () => {
).toBeUndefined();
});
- it("asks the API for the Content Engine's page size, not the table's", () => {
+ it("asks the API for the Content Engine's page size", () => {
expect(contentListRouteParams({}, articles).first).toBe(
String(CONTENT_DEFAULT_PAGE_SIZE),
);
- expect(CONTENT_DEFAULT_PAGE_SIZE).not.toBe(DEFAULT_TABLE_PAGE_SIZE);
});
it("keeps page sizes as numbers", () => {
diff --git a/packages/vitnode/src/tanstack/admin/table-search.test.ts b/packages/vitnode/src/tanstack/admin/table-search.test.ts
index cb198d221..cc03b66d0 100644
--- a/packages/vitnode/src/tanstack/admin/table-search.test.ts
+++ b/packages/vitnode/src/tanstack/admin/table-search.test.ts
@@ -5,6 +5,7 @@ import type { AdminTableContract } from "@/views/admin/table/params";
import { cronRouteParams, normalizeCronRouteSearch } from "./cron/route-search";
import { normalizeSearchIndexRouteSearch } from "./search-index/route-search";
import {
+ adminTableRouteParams,
adminTableSearchFrom,
adminTableSearchParams,
normalizeAdminTableSearch,
@@ -128,3 +129,54 @@ describe("the search index screen's single parameter", () => {
expect(normalizeSearchIndexRouteSearch({ search: 42 })).toEqual({});
});
});
+
+describe("a numbered page in the URL", () => {
+ it("survives validation", () => {
+ expect(normalizeAdminTableSearch({ page: 4 }, contract)).toEqual({
+ page: 4,
+ });
+ });
+
+ it("is the URL saying nothing on page one", () => {
+ expect(normalizeAdminTableSearch({ page: 1 }, contract)).toEqual({});
+ });
+
+ it("is dropped when it is not a page number", () => {
+ for (const page of [0, -3, "abc", 1.5, {}, null]) {
+ expect(normalizeAdminTableSearch({ page }, contract)).toEqual({});
+ }
+ });
+
+ it("wins over a cursor, which the API refuses beside it", () => {
+ expect(
+ normalizeAdminTableSearch({ cursor: "eyJpZCI6MX0", page: 3 }, contract),
+ ).toEqual({ page: 3 });
+ });
+
+ it("wins over a backwards walk, which the API refuses beside it", () => {
+ const search = normalizeAdminTableSearch({ last: 20, page: 3 }, contract);
+
+ expect(search.last).toBeUndefined();
+ expect(search.page).toBe(3);
+ });
+
+ it("stays out of the URL beyond the page itself", () => {
+ expect(
+ Object.fromEntries(adminTableSearchParams({ page: 3 }, contract)),
+ ).toEqual({ page: "3" });
+ });
+
+ it("asks the API for the page and the size it is counting in", () => {
+ expect(adminTableRouteParams({ page: 3 }, contract)).toEqual({
+ first: "10",
+ page: "3",
+ });
+ });
+
+ it("is carried back out of a control's query string", () => {
+ expect(adminTableSearchFrom("page=5&first=20", contract)).toEqual({
+ first: 20,
+ page: 5,
+ });
+ });
+});
diff --git a/packages/vitnode/src/tanstack/admin/table-search.ts b/packages/vitnode/src/tanstack/admin/table-search.ts
index 323588682..33352ef17 100644
--- a/packages/vitnode/src/tanstack/admin/table-search.ts
+++ b/packages/vitnode/src/tanstack/admin/table-search.ts
@@ -18,6 +18,7 @@ export interface AdminTableRouteSearch {
last?: number;
order?: AdminTableOrder;
orderBy?: TOrderBy;
+ page?: number;
search?: string;
status?: string;
}
@@ -31,6 +32,7 @@ const rawParamsOf = (
cursor: asSearchValue(input.cursor),
first: asSearchValue(input.first),
last: asSearchValue(input.last),
+ page: asSearchValue(input.page),
order: asSearchValue(input.order),
orderBy: asSearchValue(input.orderBy),
search: asSearchValue(input.search),
@@ -47,11 +49,12 @@ export const normalizeAdminTableSearch = (
input: UncheckedAdminTableSearch,
contract: AdminTableContract,
): AdminTableRouteSearch => {
- const { cursor, first, last, order, orderBy, search, status } =
+ const { cursor, first, last, order, orderBy, page, search, status } =
adminTableRouteParams(input, contract);
return {
...(cursor === undefined ? {} : { cursor }),
+ ...(page === undefined ? {} : { page: Number(page) }),
// See above: the default page size is the URL saying nothing.
...(first === undefined || first === defaultPageSizeOf(contract)
? {}
diff --git a/packages/vitnode/src/tanstack/files/route-search.test.ts b/packages/vitnode/src/tanstack/files/route-search.test.ts
index 4bc8afc95..57a1628d8 100644
--- a/packages/vitnode/src/tanstack/files/route-search.test.ts
+++ b/packages/vitnode/src/tanstack/files/route-search.test.ts
@@ -366,6 +366,9 @@ const emptyPage = (): MyFilesPage => ({
edges: [],
pageInfo: {
count: 0,
+ currentPage: null,
+ pageSize: 10,
+ totalPages: 0,
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
diff --git a/packages/vitnode/src/tanstack/files/route-search.ts b/packages/vitnode/src/tanstack/files/route-search.ts
index 1dffd3e83..4c24b1340 100644
--- a/packages/vitnode/src/tanstack/files/route-search.ts
+++ b/packages/vitnode/src/tanstack/files/route-search.ts
@@ -17,6 +17,7 @@ export interface MyFilesRouteSearch {
last?: number;
order?: MyFilesOrder;
orderBy?: MyFilesOrderBy;
+ page?: number;
search?: string;
}
@@ -28,6 +29,7 @@ const rawParamsOf = (input: UncheckedMyFilesSearch): RawMyFilesParams => ({
first: asSearchValue(input.first),
last: asSearchValue(input.last),
order: asSearchValue(input.order),
+ page: asSearchValue(input.page),
orderBy: asSearchValue(input.orderBy),
search: asSearchValue(input.search),
});
@@ -39,11 +41,12 @@ export const myFilesRouteParams = (
export const normalizeMyFilesRouteSearch = (
input: UncheckedMyFilesSearch,
): MyFilesRouteSearch => {
- const { cursor, first, last, order, orderBy, search } =
+ const { cursor, first, last, order, orderBy, page, search } =
myFilesRouteParams(input);
return {
...(cursor === undefined ? {} : { cursor }),
+ ...(page === undefined ? {} : { page: Number(page) }),
// See above: the default page size is the URL saying nothing.
...(first === undefined || first === DEFAULT_PAGE_SIZE
? {}
diff --git a/packages/vitnode/src/views/admin/table/params.ts b/packages/vitnode/src/views/admin/table/params.ts
index 74161f2eb..48d325e76 100644
--- a/packages/vitnode/src/views/admin/table/params.ts
+++ b/packages/vitnode/src/views/admin/table/params.ts
@@ -13,6 +13,7 @@ export interface AdminTableParams {
last?: string;
order?: AdminTableOrder;
orderBy?: TOrderBy;
+ page?: string;
search?: string;
/** Comma-separated, as the queue route's `status` filter reads it. */
status?: string;
@@ -50,7 +51,12 @@ export const normalizeAdminTableParams = (
): AdminTableParams => {
const params: AdminTableParams = {};
- const cursor = readFirstValue(raw.cursor);
+ const page = readFirstValue(raw.page);
+ if (/^[1-9]\d{0,8}$/.test(page) && page !== "1") params.page = page;
+
+ // A page number and a cursor answer different questions, and the API refuses
+ // both together, so the number wins and the walk is dropped.
+ const cursor = params.page ? "" : readFirstValue(raw.cursor);
if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.cursor = cursor;
const first = readPageSize(
@@ -64,6 +70,8 @@ export const normalizeAdminTableParams = (
if (first !== undefined) {
params.first = first;
+ } else if (params.page !== undefined) {
+ params.first = String(contract.defaultPageSize ?? DEFAULT_TABLE_PAGE_SIZE);
} else if (last === undefined) {
params.first = String(contract.defaultPageSize ?? DEFAULT_TABLE_PAGE_SIZE);
} else {
@@ -91,11 +99,14 @@ export const normalizeAdminTableParams = (
export interface AdminTablePageInfo {
count: number;
+ currentPage: null | number;
endCursor: null | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
+ pageSize: number;
startCursor: null | string;
totalCount: number;
+ totalPages: number;
}
/** One page of an admin list: the rows, and where the pager is. */
diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx
index 34e5d17bf..f12d975f6 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx
+++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx
@@ -398,14 +398,12 @@ const ContentFormFields = ({
// MUST NOT be async: `AutoForm` calls this to get an element, and an
// async function hands it a fresh Promise every render - React 19
// suspends on promise children, so the dialog spins forever.
- // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above
component: props => {
- const override = fieldOverrides[fieldSpec.name];
- if (override) {
- return override({
- ...props,
- multiLang: fieldSpec.localized === true,
- });
+ const Override = fieldOverrides[fieldSpec.name];
+ if (Override) {
+ return (
+
+ );
}
return (
diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts
index 148e359cb..295483f1a 100644
--- a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts
+++ b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts
@@ -153,6 +153,9 @@ describe("one page, as the table model", () => {
],
pageInfo: {
count: 1,
+ currentPage: 1,
+ pageSize: 25,
+ totalPages: 1,
endCursor: "b",
hasNextPage: false,
hasPreviousPage: false,
diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.ts
index 923c4eda8..5340a6d56 100644
--- a/packages/vitnode/src/views/admin/views/content/table/list-query.ts
+++ b/packages/vitnode/src/views/admin/views/content/table/list-query.ts
@@ -19,9 +19,12 @@ export type ContentListPage = AdminTablePage;
const zodPageInfo = z.object({
count: z.number(),
+ currentPage: z.number().nullable(),
+ totalPages: z.number(),
endCursor: z.string().nullable(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
+ pageSize: z.number(),
startCursor: z.string().nullable(),
totalCount: z.number(),
});
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx
index 7e0e66ef0..50e139eeb 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx
@@ -243,6 +243,9 @@ export const CollectionsTableContent = ({
order={{ defaultOrder: { column: "itemType", order: "asc" } }}
pageInfo={{
count: edges.length,
+ currentPage: 1,
+ pageSize: edges.length || 1,
+ totalPages: 1,
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts
index a3fd2a02b..ea65e0705 100644
--- a/packages/vitnode/src/views/files/my-files-query.ts
+++ b/packages/vitnode/src/views/files/my-files-query.ts
@@ -21,6 +21,7 @@ export interface MyFilesParams {
last?: string;
order?: MyFilesOrder;
orderBy?: MyFilesOrderBy;
+ page?: string;
search?: string;
}
@@ -33,7 +34,10 @@ export const normalizeMyFilesParams = (
): MyFilesParams => {
const params: MyFilesParams = {};
- const cursor = readFirstValue(raw.cursor);
+ const page = readFirstValue(raw.page);
+ if (/^[1-9]\d{0,8}$/.test(page) && page !== "1") params.page = page;
+
+ const cursor = params.page ? "" : readFirstValue(raw.cursor);
if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.cursor = cursor;
const first = readPageSize(readFirstValue(raw.first), MY_FILES_MAX_PAGE_SIZE);
@@ -41,6 +45,8 @@ export const normalizeMyFilesParams = (
if (first !== undefined) {
params.first = first;
+ } else if (params.page !== undefined) {
+ params.first = String(DEFAULT_TABLE_PAGE_SIZE);
} else if (last === undefined) {
params.first = String(DEFAULT_TABLE_PAGE_SIZE);
} else {
@@ -78,11 +84,14 @@ export interface MyFilesPage {
edges: MyFile[];
pageInfo: {
count: number;
+ currentPage: null | number;
endCursor: null | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
+ pageSize: number;
startCursor: null | string;
totalCount: number;
+ totalPages: number;
};
}