-
+
{search && }
{filters && filters.length > 0 &&
}
diff --git a/packages/vitnode/src/components/user-format.tsx b/packages/vitnode/src/components/user-format.tsx
index b9c28f9f51..7961f5a7ca 100644
--- a/packages/vitnode/src/components/user-format.tsx
+++ b/packages/vitnode/src/components/user-format.tsx
@@ -1,19 +1,26 @@
+import { Link } from "@tanstack/react-router";
import { cn } from "cn";
import { parseEmojiIcon } from "@/lib/emoji-icon";
import { EmojiIcon } from "./ui/emoji-icon";
+export const userProfileHref = (nameCode: string): string =>
+ `/users/${encodeURIComponent(nameCode)}`;
+
export const UserFormat = ({
user,
format,
+ link,
className,
style,
...props
}: React.ComponentProps<"span"> & {
format?: boolean;
+ link?: boolean;
user: {
name: string;
+ nameCode?: string;
role: {
color: null | string;
prefix?: null | string;
@@ -21,21 +28,40 @@ export const UserFormat = ({
};
}) => {
const prefix = format ? parseEmojiIcon(user.role.prefix) : null;
-
- return (
-
+ const classes = cn(
+ "inline-flex min-w-0 items-center gap-1.5 font-medium",
+ className,
+ );
+ const colorStyle = {
+ ...(format && user.role.color ? { color: user.role.color } : {}),
+ ...style,
+ };
+ const content = (
+ <>
{!!prefix && }
{user.name}
+ >
+ );
+
+ if (link && user.nameCode) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
);
};
diff --git a/packages/vitnode/src/content/define-reports.test.ts b/packages/vitnode/src/content/define-reports.test.ts
new file mode 100644
index 0000000000..f481b823f0
--- /dev/null
+++ b/packages/vitnode/src/content/define-reports.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vitest";
+
+import type { ContentReportsInput } from "./types";
+
+import { defineContentType } from "./define";
+import { field } from "./fields";
+
+const fields = {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ body: field.richText({ nullable: true }),
+ summary: field.textarea({ nullable: true }),
+ internalNote: field.text({ nullable: true }),
+ views: field.number({ integer: true, defaultValue: 0 }),
+ author: field.user({ nullable: true, onDelete: "set null" }),
+};
+
+const define = (
+ reports: ContentReportsInput | undefined,
+ publicApi = true,
+) =>
+ defineContentType({
+ id: "test.reportable",
+ tableName: "test_reportables",
+ fields,
+ publication: true,
+ admin: { titleField: "title" },
+ ...(publicApi
+ ? {
+ publicApi: {
+ path: "reportables",
+ fields: ["title", "slug", "body", "summary", "views"],
+ },
+ }
+ : {}),
+ reports,
+ });
+
+describe("reports", () => {
+ it.each([undefined, false] as const)("is off when %s", reports => {
+ expect(define(reports).reports).toEqual({
+ enabled: false,
+ snapshotFields: [],
+ titleField: null,
+ });
+ });
+
+ it("snapshots the public reader-facing fields, minus the title", () => {
+ expect(define(true).reports).toEqual({
+ enabled: true,
+ snapshotFields: ["body", "summary"],
+ titleField: "title",
+ });
+ });
+
+ it("takes explicit fields, private ones included", () => {
+ expect(
+ define({ snapshotFields: ["internalNote", "views"], titleField: "slug" })
+ .reports,
+ ).toEqual({
+ enabled: true,
+ snapshotFields: ["internalNote", "views"],
+ titleField: "slug",
+ });
+ });
+
+ it("needs a public API", () => {
+ expect(() => define(true, false)).toThrow(/reports needs `publicApi`/);
+ });
+
+ it.each([
+ [
+ "an unknown snapshot field",
+ { snapshotFields: ["missing"] },
+ /unknown field "missing"/,
+ ],
+ [
+ "a snapshot field of a kind it cannot hold",
+ { snapshotFields: ["author"] },
+ /names the user field "author"/,
+ ],
+ [
+ "an unknown title field",
+ { titleField: "missing" },
+ /titleField "missing"/,
+ ],
+ [
+ "a title field that is not text",
+ { titleField: "views" },
+ /titleField "views"/,
+ ],
+ [
+ "the removed `enabled` key",
+ { enabled: true },
+ /reports.enabled is not an option/,
+ ],
+ ])("rejects %s", (_label, reports, message) => {
+ expect(() => define(reports as never)).toThrow(message);
+ });
+});
diff --git a/packages/vitnode/src/content/define-reports.ts b/packages/vitnode/src/content/define-reports.ts
new file mode 100644
index 0000000000..41c0cba3e8
--- /dev/null
+++ b/packages/vitnode/src/content/define-reports.ts
@@ -0,0 +1,143 @@
+import type {
+ ContentFieldDescriptor,
+ ContentFieldMap,
+ ContentReportsInput,
+ ResolvedContentAdminConfig,
+ ResolvedContentPublicApiConfig,
+ ResolvedContentReportsConfig,
+} from "./types";
+
+import { ContentEngineError } from "./errors";
+
+export const CONTENT_REPORT_SNAPSHOT_KINDS = new Set<
+ ContentFieldDescriptor["kind"]
+>([
+ "boolean",
+ "dateTime",
+ "enum",
+ "file",
+ "number",
+ "richText",
+ "slug",
+ "text",
+ "textarea",
+]);
+
+const DEFAULT_SNAPSHOT_KINDS = new Set([
+ "enum",
+ "file",
+ "richText",
+ "text",
+ "textarea",
+]);
+
+const TITLE_KINDS = new Set([
+ "slug",
+ "text",
+ "textarea",
+]);
+
+const DISABLED: ResolvedContentReportsConfig = {
+ enabled: false,
+ snapshotFields: [],
+ titleField: null,
+};
+
+const REPORT_OPTION_KEYS = new Set(["snapshotFields", "titleField"]);
+
+export const resolveReports = (
+ id: string,
+ reports: ContentReportsInput | undefined,
+ {
+ admin,
+ fields,
+ publicApi,
+ }: {
+ admin: ResolvedContentAdminConfig;
+ fields: ContentFieldMap;
+ publicApi: ResolvedContentPublicApiConfig;
+ },
+): ResolvedContentReportsConfig => {
+ if (reports === undefined || reports === false) return DISABLED;
+
+ if (reports !== true && (typeof reports !== "object" || reports === null)) {
+ throw new ContentEngineError(
+ "reports is `true` or an options object with `titleField` and `snapshotFields`.",
+ { contentTypeId: id },
+ );
+ }
+
+ const options = reports === true ? {} : reports;
+ const unknownKey = Object.keys(options).find(
+ key => !REPORT_OPTION_KEYS.has(key),
+ );
+ if (unknownKey) {
+ throw new ContentEngineError(
+ `reports.${unknownKey} is not an option. Use \`titleField\` and \`snapshotFields\`.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if (!publicApi.enabled) {
+ throw new ContentEngineError(
+ "reports needs `publicApi`. Users report what they can read, and the public API is what decides that.",
+ { contentTypeId: id },
+ );
+ }
+
+ const publicFields = new Set(publicApi.fields);
+
+ let titleField: null | string;
+ if (options.titleField !== undefined) {
+ const descriptor = fields[options.titleField];
+ if (!descriptor || !TITLE_KINDS.has(descriptor.kind)) {
+ throw new ContentEngineError(
+ `reports.titleField "${options.titleField}" must be a text, textarea or slug field of this content type.`,
+ { contentTypeId: id },
+ );
+ }
+ titleField = options.titleField;
+ } else {
+ titleField =
+ admin.titleField !== null && publicFields.has(admin.titleField)
+ ? admin.titleField
+ : null;
+ }
+
+ let snapshotFields: string[];
+ if (options.snapshotFields !== undefined) {
+ if (!Array.isArray(options.snapshotFields)) {
+ throw new ContentEngineError(
+ "reports.snapshotFields is a list of field names.",
+ { contentTypeId: id },
+ );
+ }
+ for (const name of options.snapshotFields) {
+ const descriptor = fields[name];
+ if (!descriptor) {
+ throw new ContentEngineError(
+ `reports.snapshotFields references unknown field "${name}".`,
+ { contentTypeId: id },
+ );
+ }
+ if (!CONTENT_REPORT_SNAPSHOT_KINDS.has(descriptor.kind)) {
+ throw new ContentEngineError(
+ `reports.snapshotFields names the ${descriptor.kind} field "${name}". A snapshot keeps self-contained values only: text, textarea, rich text, slug, number, boolean, enum, date and file fields.`,
+ { contentTypeId: id },
+ );
+ }
+ }
+ snapshotFields = [...new Set(options.snapshotFields)];
+ } else {
+ snapshotFields = Object.entries(fields)
+ .filter(
+ ([name, descriptor]) =>
+ name !== titleField &&
+ publicFields.has(name) &&
+ DEFAULT_SNAPSHOT_KINDS.has(descriptor.kind),
+ )
+ .map(([name]) => name);
+ }
+
+ return { enabled: true, snapshotFields, titleField };
+};
diff --git a/packages/vitnode/src/content/define-shared.ts b/packages/vitnode/src/content/define-shared.ts
index 18b11a7240..e20f54411c 100644
--- a/packages/vitnode/src/content/define-shared.ts
+++ b/packages/vitnode/src/content/define-shared.ts
@@ -96,6 +96,7 @@ const CONTENT_OPTION_PATHS: readonly (readonly string[])[] = [
["localization"],
["publicApi"],
["publication"],
+ ["reports"],
["search"],
["visibility"],
];
diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts
index b85bf05769..86bd7a7de3 100644
--- a/packages/vitnode/src/content/define.ts
+++ b/packages/vitnode/src/content/define.ts
@@ -20,6 +20,7 @@ import type {
ContentPreviewEnabled,
ContentPublicApiConfig,
ContentPublicExposableField,
+ ContentReportsInput,
ContentSchedulingEnabled,
ContentSearchAuthorField,
ContentSearchConfig,
@@ -63,6 +64,7 @@ import {
resolveVisibility,
} from "./define-lifecycle";
import { resolvePublicApi } from "./define-public-api";
+import { resolveReports } from "./define-reports";
import { resolveSearch } from "./define-search";
import {
assertContentOptionShapes,
@@ -220,6 +222,7 @@ export const defineContentType = <
localization,
publicApi,
publication,
+ reports,
search,
tableName,
visibility,
@@ -255,6 +258,7 @@ export const defineContentType = <
publicApi?: ContentPublicApiConfig | false;
/** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */
publication?: TPublication;
+ reports?: ContentReportsInput;
search?: TSearch;
tableName: string;
@@ -327,6 +331,7 @@ export const defineContentType = <
localization,
publicApi,
publication,
+ reports,
search,
visibility,
});
@@ -508,6 +513,12 @@ export const defineContentType = <
publication: publicationEnabled,
});
+ const resolvedReports = resolveReports(id, reports, {
+ admin: resolvedAdmin,
+ fields: fieldMap,
+ publicApi: resolvedPublicApi,
+ });
+
const resolvedDuplication = resolveDuplication(
id,
duplication,
@@ -558,6 +569,7 @@ export const defineContentType = <
publication: {
enabled: publicationEnabled as TPublication,
},
+ reports: resolvedReports,
publicApi: resolvedPublicApi as ResolvedContentPublicApiConfig<
TPublicField,
TPublicEnabled,
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index a101b634ae..8bc5656242 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -471,6 +471,8 @@ export type {
ContentRelationFilter,
ContentRelationIdOf,
ContentRelationTarget,
+ ContentReportsConfig,
+ ContentReportsInput,
ContentRichTextField,
ContentSchedulingEnabled,
ContentSearchAuthorField,
@@ -516,6 +518,7 @@ export type {
ResolvedContentLocalizationConfig,
ResolvedContentPublicApiConfig,
ResolvedContentPublicationConfig,
+ ResolvedContentReportsConfig,
ResolvedContentSearchConfig,
ResolvedContentVisibilityConfig,
SchedulableContentTypeDefinition,
diff --git a/packages/vitnode/src/content/server/hidden-access.test.ts b/packages/vitnode/src/content/server/hidden-access.test.ts
new file mode 100644
index 0000000000..6d0c24b944
--- /dev/null
+++ b/packages/vitnode/src/content/server/hidden-access.test.ts
@@ -0,0 +1,103 @@
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import { createTestCache } from "@/tests/cache";
+import { createMemoryDb } from "@/tests/memory-db";
+import { grantStaffPermissions } from "@/tests/staff-permissions";
+
+import { defineContentType } from "../define";
+import { field } from "../fields";
+import { findViewableHiddenBySlug } from "./hidden-access";
+import { createContentModel } from "./model";
+
+const PLUGIN = "@vitnode/test";
+const FAMILY = "test.section";
+
+const topics = defineContentType({
+ id: "test.moderated-topic",
+ tableName: "test_moderated_topics",
+ publication: true,
+ visibility: true,
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ sectionId: field.number({ integer: true, defaultValue: 0 }),
+ },
+ publicApi: { path: "moderated-topics", fields: ["id", "title", "slug"] },
+ reports: true,
+});
+const model = createContentModel(topics, {
+ moderationScope: { column: "sectionId", family: FAMILY },
+});
+
+const MODERATOR = { id: 9, roleId: 3 };
+
+const find = async (moderator: PermissionsStaffArgs[]) => {
+ const cache = createTestCache();
+ await grantStaffPermissions(cache, { permissions: [], userId: MODERATOR.id });
+ await grantStaffPermissions(cache, {
+ permissions: moderator,
+ type: "moderator",
+ userId: MODERATOR.id,
+ });
+ const { db } = createMemoryDb([
+ [
+ model.table,
+ [
+ {
+ hiddenAt: new Date(),
+ id: 1,
+ publishedAt: new Date("2026-01-01T00:00:00.000Z"),
+ sectionId: 10,
+ slug: "hidden-topic",
+ status: "published",
+ title: "Hidden topic",
+ },
+ ],
+ ],
+ ]);
+ const values: Record = { cache, db, user: MODERATOR };
+ const c = { get: (key: string) => values[key] } as unknown as Context;
+
+ return await findViewableHiddenBySlug(c, {
+ locale: undefined,
+ model,
+ pluginId: PLUGIN,
+ slug: "hidden-topic",
+ });
+};
+
+const viewHidden = (scope?: PermissionsStaffArgs["scope"]) => ({
+ module: topics.permissionModule,
+ permission: "can_view_hidden",
+ plugin: PLUGIN,
+ ...(scope ? { scope } : {}),
+});
+
+describe("a moderator opening hidden content", () => {
+ it.each<[string, PermissionsStaffArgs[], boolean]>([
+ [
+ "opens it with can_view_hidden in the record's section",
+ [viewHidden({ family: FAMILY, ids: ["10"], mode: "selected" })],
+ true,
+ ],
+ [
+ "opens it with can_view_hidden in every section",
+ [viewHidden({ family: FAMILY, mode: "all" })],
+ true,
+ ],
+ [
+ "keeps it closed with can_view_hidden in another section",
+ [viewHidden({ family: FAMILY, ids: ["20"], mode: "selected" })],
+ false,
+ ],
+ ["keeps it closed without can_view_hidden", [], false],
+ ])("%s", async (_label, moderator, visible) => {
+ const row = await find(moderator);
+
+ expect(row?.slug ?? null).toBe(visible ? "hidden-topic" : null);
+ });
+});
diff --git a/packages/vitnode/src/content/server/hidden-access.ts b/packages/vitnode/src/content/server/hidden-access.ts
index b2a3e0ad49..92cf106471 100644
--- a/packages/vitnode/src/content/server/hidden-access.ts
+++ b/packages/vitnode/src/content/server/hidden-access.ts
@@ -10,6 +10,7 @@ import type { AnyContentTypeDefinition } from "../types";
import type { ContentModel } from "./model";
import { checkStaffPermissionOfUser } from "../../api/lib/check-staff-permission";
+import { MODERATION_PERMISSIONS } from "../../lib/moderation/const";
import { CONTENT_PERMISSIONS } from "../const";
import { parseContentId } from "../ids";
import { toAuthorIds } from "./search-document";
@@ -32,7 +33,9 @@ const canEditContentType = async (
});
};
-const contentAuthorIds = async (
+export const contentAuthorIds = async <
+ TDefinition extends AnyContentTypeDefinition,
+>(
c: Context,
model: ContentModel,
itemId: ContentId,
@@ -61,6 +64,50 @@ const contentAuthorIds = async (
return toAuthorIds(row?.value);
};
+const canModeratorViewHidden = async <
+ TDefinition extends AnyContentTypeDefinition,
+>(
+ c: Context,
+ {
+ itemId,
+ model,
+ pluginId,
+ }: { itemId: ContentId; model: ContentModel; pluginId: string },
+): Promise => {
+ const user = c.get("user");
+ if (!user || !model.definition.reports.enabled) return false;
+
+ let resource: undefined | { family: string; id: string };
+ if (model.moderationScope) {
+ const columns = model.columns as Record;
+ const table: PgTable = model.table;
+ const column = columns[model.moderationScope.column];
+ if (!column) return false;
+ const [row] = await c
+ .get("db")
+ .select({ value: column })
+ .from(table)
+ .where(eq(columns.id, itemId))
+ .limit(1);
+ const value: unknown = row?.value;
+ resource = {
+ family: model.moderationScope.family,
+ id:
+ typeof value === "number" || typeof value === "string"
+ ? String(value)
+ : "",
+ };
+ }
+
+ return await checkStaffPermissionOfUser(c, user, {
+ module: model.definition.permissionModule,
+ permission: MODERATION_PERMISSIONS.viewHidden,
+ plugin: pluginId,
+ resource,
+ type: "moderator",
+ });
+};
+
const canViewHiddenContent = async <
TDefinition extends AnyContentTypeDefinition,
>(
@@ -77,10 +124,16 @@ const canViewHiddenContent = async <
const authors = await contentAuthorIds(c, model, itemId);
if (authors.includes(user.id)) return true;
- return await canEditContentType(c, {
- definition: model.definition,
- pluginId,
- });
+ if (
+ await canEditContentType(c, {
+ definition: model.definition,
+ pluginId,
+ })
+ ) {
+ return true;
+ }
+
+ return await canModeratorViewHidden(c, { itemId, model, pluginId });
};
export const findViewableHiddenBySlug = async <
diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts
index f75a4772df..8dc4b55ceb 100644
--- a/packages/vitnode/src/content/server/model.ts
+++ b/packages/vitnode/src/content/server/model.ts
@@ -41,6 +41,11 @@ import {
createContentTranslationTable,
} from "./translation-table";
+export interface ContentModerationScope {
+ column: TColumn;
+ family: string;
+}
+
export interface ContentModel {
advanced: ContentAdvancedStore;
@@ -69,6 +74,7 @@ export interface ContentModel {
) => ContentLocalizedService)
| undefined;
+ moderationScope: ContentModerationScope | null;
publicService:
| ((c: Context) => ContentPublicService)
| undefined;
@@ -145,7 +151,10 @@ export const createContentModel = <
TDefinition extends AnyContentTypeDefinition,
>(
definition: TDefinition,
- options: { references?: ContentReferences } = {},
+ options: {
+ moderationScope?: ContentModerationScope>;
+ references?: ContentReferences;
+ } = {},
): ContentModel => {
const table = createContentTable(definition, options);
const columns = contentTableColumns(definition, table);
@@ -306,6 +315,7 @@ export const createContentModel = <
})
: undefined,
localization: definition.localization,
+ moderationScope: options.moderationScope ?? null,
localizedService: localized
? (c: Context, options?: { pluginId?: string }) => {
const translations = buildTranslations(c);
diff --git a/packages/vitnode/src/content/server/report-target.ts b/packages/vitnode/src/content/server/report-target.ts
new file mode 100644
index 0000000000..9bd45161c7
--- /dev/null
+++ b/packages/vitnode/src/content/server/report-target.ts
@@ -0,0 +1,491 @@
+import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { and, eq, inArray } from "drizzle-orm";
+
+import type {
+ ModerationCaptureResult,
+ ModerationReportTargetDefinition,
+ ModerationTargetActionDefinition,
+ ModerationTargetState,
+} from "../../api/lib/moderation/targets";
+import type {
+ ModerationSnapshotField,
+ ModerationSnapshotFile,
+} from "../../lib/moderation/snapshot";
+import type { ContentId } from "../ids";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentVisibilityAction } from "../visibility";
+import type { ContentEditorialOutcome } from "./editorial-service";
+import type { AnyContentModel } from "./model";
+import type { ContentDatabase } from "./service";
+
+import { core_files } from "../../database/files";
+import { core_users } from "../../database/users";
+import { MODERATION_PERMISSIONS } from "../../lib/moderation/const";
+import { contentI18nKeys } from "../admin/labels";
+import { contentDeliveryPublicUrl } from "../delivery";
+import { contentIdKey, parseContentId } from "../ids";
+import { contentPublicHref } from "../public-url";
+import { isRichTextDocument } from "../rich-text/document";
+import { contentSearchUrl } from "../search";
+import { isContentHidden } from "../visibility";
+import { resolveContentActor } from "./actor";
+import { contentAuthorIds } from "./hidden-access";
+import { findContentLanguage } from "./language-resolver";
+import { contentLocaleRouting } from "./locale-routing";
+import { contentDefinitionOf } from "./model";
+import { editorialVisibilityMethods, visibilityMethods } from "./visibility";
+import { contentVisibilityEffects } from "./visibility-effects";
+
+const withDatabase = (c: Context, db: ContentDatabase): Context =>
+ new Proxy(c, {
+ get(target, property) {
+ if (property === "get") {
+ return (key: string) =>
+ key === "db" ? db : (target.get as (k: string) => unknown)(key);
+ }
+ const value: unknown = Reflect.get(target, property, target);
+
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+
+const toIso = (value: unknown): null | string => {
+ if (value instanceof Date) return value.toISOString();
+ if (typeof value === "string" && value !== "") {
+ const parsed = new Date(value);
+
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+ }
+
+ return null;
+};
+
+const latestIso = (...values: unknown[]): null | string =>
+ values
+ .map(toIso)
+ .filter((value): value is string => value !== null)
+ .sort()
+ .at(-1) ?? null;
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const resolveFiles = async (
+ db: ContentDatabase,
+ value: unknown,
+): Promise => {
+ const values = (Array.isArray(value) ? value : [value]).filter(
+ item => item !== null && item !== undefined,
+ );
+ const ids = values.filter(
+ (item): item is number => typeof item === "number" && item > 0,
+ );
+ const rows = ids.length
+ ? await db
+ .select({
+ id: core_files.id,
+ mimeType: core_files.mimeType,
+ name: core_files.name,
+ size: core_files.size,
+ })
+ .from(core_files)
+ .where(inArray(core_files.id, ids))
+ : [];
+
+ return values.flatMap((item): ModerationSnapshotFile[] => {
+ if (typeof item === "number") {
+ const row = rows.find(file => file.id === item);
+
+ return [
+ {
+ id: item,
+ mimeType: row?.mimeType ?? null,
+ name: row?.name ?? null,
+ size: row?.size ?? null,
+ url: null,
+ },
+ ];
+ }
+ if (isRecord(item)) {
+ return [
+ {
+ id:
+ typeof item.id === "number" || typeof item.id === "string"
+ ? item.id
+ : null,
+ mimeType: typeof item.mimeType === "string" ? item.mimeType : null,
+ name: typeof item.name === "string" ? item.name : null,
+ size: typeof item.size === "number" ? item.size : null,
+ url: typeof item.url === "string" ? item.url : null,
+ },
+ ];
+ }
+
+ return [];
+ });
+};
+
+const snapshotValue = async (
+ db: ContentDatabase,
+ kind: string,
+ value: unknown,
+): Promise> => {
+ switch (kind) {
+ case "dateTime":
+ return { kind: "value", value: toIso(value) };
+ case "file":
+ return { kind: "file", value: await resolveFiles(db, value) };
+ case "richText":
+ return {
+ kind: "richText",
+ value: isRichTextDocument(value) ? value : null,
+ };
+ case "slug":
+ case "text":
+ case "textarea":
+ return { kind: "text", value: typeof value === "string" ? value : null };
+ default:
+ return {
+ kind: "value",
+ value:
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ? value
+ : null,
+ };
+ }
+};
+
+const contentUrlOf = (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ { locale, slug }: { locale: null | string; slug: unknown },
+): null | string => {
+ if (typeof slug !== "string" || slug === "") return null;
+ const routing = contentLocaleRouting(c);
+ const language = locale ?? definition.localization.defaultLocale;
+
+ if (definition.delivery.enabled) {
+ const url = contentDeliveryPublicUrl({
+ definition,
+ locale: language,
+ routing,
+ slug,
+ });
+
+ return url ? contentPublicHref(url) : null;
+ }
+
+ return contentSearchUrl({
+ definition,
+ locale: definition.localization.enabled ? language : undefined,
+ routing,
+ slug,
+ });
+};
+
+const readBaseRow = async (
+ db: ContentDatabase,
+ model: AnyContentModel,
+ itemId: ContentId,
+): Promise> => {
+ const columns = model.columns as Record;
+ const table: PgTable = model.table;
+ const [row] = await db
+ .select()
+ .from(table)
+ .where(eq(columns.id, itemId))
+ .limit(1);
+
+ return row ?? null;
+};
+
+const readTranslationRow = async (
+ c: Context,
+ db: ContentDatabase,
+ model: AnyContentModel,
+ { itemId, locale }: { itemId: ContentId; locale: null | string },
+): Promise> => {
+ const translationTable = model.translationTable as null | PgTable;
+ const translationColumns = model.translationColumns as null | Record<
+ string,
+ PgColumn
+ >;
+ if (!translationTable || !translationColumns || !locale) return null;
+
+ const language = await findContentLanguage(c, locale, db);
+ if (!language) return null;
+
+ const [row] = await db
+ .select()
+ .from(translationTable)
+ .where(
+ and(
+ eq(translationColumns.itemId, itemId),
+ eq(translationColumns.languageId, language.id),
+ ),
+ )
+ .limit(1);
+
+ return row ?? null;
+};
+
+const authorsOf = async (
+ c: Context,
+ db: ContentDatabase,
+ model: AnyContentModel,
+ itemId: ContentId,
+): Promise<{ id: number; name: string }[]> => {
+ const ids = await contentAuthorIds(withDatabase(c, db), model, itemId);
+ if (ids.length === 0) return [];
+
+ const users = await db
+ .select({ id: core_users.id, name: core_users.name })
+ .from(core_users)
+ .where(inArray(core_users.id, ids));
+
+ return ids.flatMap(id => users.filter(user => user.id === id));
+};
+
+const captureContent = async (
+ c: Context,
+ model: AnyContentModel,
+ pluginId: string,
+ { locale, targetId }: { locale: null | string; targetId: string },
+): Promise => {
+ const definition = contentDefinitionOf(model);
+ const itemId = parseContentId(definition.idStrategy, targetId);
+ if (itemId === null || !model.publicService) return null;
+
+ const labels = contentI18nKeys(definition, pluginId);
+ const localized = definition.localization.enabled;
+
+ return await c.get("db").transaction(
+ async (tx: ContentDatabase) => {
+ const scoped = withDatabase(c, tx);
+ const visible = (await model
+ .publicService?.(scoped)
+ .findById(itemId, localized && locale ? { locale } : {})) as
+ | null
+ | Record
+ | undefined;
+ if (!visible) return null;
+
+ const servedLocale =
+ localized && typeof visible.locale === "string" ? visible.locale : null;
+ const base = await readBaseRow(tx, model, itemId);
+ if (!base) return null;
+ const translation = await readTranslationRow(c, tx, model, {
+ itemId,
+ locale: servedLocale,
+ });
+
+ const valueOf = (name: string): unknown => {
+ const descriptor = definition.fields[name];
+ if (descriptor?.kind === "file" && name in visible) {
+ return visible[name];
+ }
+
+ return "localized" in (descriptor ?? {}) &&
+ (descriptor as { localized?: boolean }).localized
+ ? translation?.[name]
+ : base[name];
+ };
+
+ const advanced = model.advanced.fields.filter((name: string) =>
+ definition.reports.snapshotFields.includes(name),
+ );
+ const advancedValues = advanced.length
+ ? await model.advanced.load(itemId, tx, advanced)
+ : {};
+
+ const fields: ModerationSnapshotField[] = [];
+ for (const name of definition.reports.snapshotFields) {
+ const descriptor = definition.fields[name];
+ if (!descriptor) continue;
+ const value =
+ name in advancedValues && !(name in visible)
+ ? advancedValues[name]
+ : valueOf(name);
+ fields.push({
+ label: labels.field(name),
+ name,
+ ...(await snapshotValue(tx, descriptor.kind, value)),
+ } as ModerationSnapshotField);
+ }
+
+ const titleField = definition.reports.titleField;
+ const title = titleField ? valueOf(titleField) : null;
+ const slug = definition.publicApi.enabled
+ ? (visible[definition.publicApi.slugField] ?? null)
+ : null;
+ const scopeColumn = model.moderationScope?.column;
+ const rawScope = scopeColumn ? base[scopeColumn] : null;
+ const scopeValue =
+ typeof rawScope === "number" || typeof rawScope === "string"
+ ? String(rawScope)
+ : null;
+
+ return {
+ authors: await authorsOf(c, tx, model, itemId),
+ fields,
+ locale: {
+ fallback:
+ localized &&
+ locale !== null &&
+ servedLocale !== null &&
+ servedLocale.toLowerCase() !== locale.toLowerCase(),
+ requested: localized ? locale : null,
+ served: servedLocale,
+ },
+ scope:
+ model.moderationScope && scopeValue !== null
+ ? { family: model.moderationScope.family, id: scopeValue }
+ : null,
+ source: {
+ publishedAt: toIso(translation?.publishedAt ?? base.publishedAt),
+ updatedAt: latestIso(base.updatedAt, translation?.updatedAt),
+ version: typeof base.version === "number" ? base.version : null,
+ },
+ title: typeof title === "string" ? title : null,
+ url: contentUrlOf(c, definition, { locale: servedLocale, slug }),
+ };
+ },
+ { accessMode: "read only", isolationLevel: "repeatable read" },
+ );
+};
+
+const currentContent = async (
+ c: Context,
+ model: AnyContentModel,
+ { locale, targetId }: { locale: null | string; targetId: string },
+): Promise => {
+ const definition = contentDefinitionOf(model);
+ const itemId = parseContentId(definition.idStrategy, targetId);
+ if (itemId === null) return null;
+
+ const db = c.get("db");
+ const base = await readBaseRow(db, model, itemId);
+ if (!base) return null;
+
+ const slugField = definition.publicApi.enabled
+ ? definition.publicApi.slugField
+ : null;
+ let slug: unknown = slugField ? base[slugField] : null;
+ let servedLocale = locale;
+ if (slugField && definition.localization.enabled) {
+ const translation =
+ (await readTranslationRow(c, db, model, { itemId, locale })) ??
+ (await readTranslationRow(c, db, model, {
+ itemId,
+ locale: definition.localization.defaultLocale,
+ }));
+ slug = translation?.[slugField] ?? null;
+ if (translation && !locale) {
+ servedLocale = definition.localization.defaultLocale;
+ }
+ }
+
+ return {
+ hidden: definition.visibility.enabled && isContentHidden(base),
+ url: contentUrlOf(c, definition, { locale: servedLocale, slug }),
+ };
+};
+
+const visibilityAction = (
+ model: AnyContentModel,
+ pluginId: string,
+ action: ContentVisibilityAction,
+): ModerationTargetActionDefinition => ({
+ available: state => (action === "hide" ? !state.hidden : state.hidden),
+ permission: MODERATION_PERMISSIONS.hide,
+ run: async (c, { actorUserId, targetId, tx }) => {
+ const definition = contentDefinitionOf(model);
+ const itemId = parseContentId(definition.idStrategy, targetId);
+ if (itemId === null) return null;
+
+ let outcome: ContentEditorialOutcome;
+ if (definition.editorial.enabled && model.editorialService) {
+ const actor = resolveContentActor(c);
+ const result = await editorialVisibilityMethods(
+ definition,
+ model.editorialService(c, { pluginId }),
+ )[action](itemId, {
+ actor: { ...actor, userId: actor.userId ?? actorUserId },
+ tx,
+ });
+ if (!result) return null;
+ outcome = result;
+ } else {
+ const result = await visibilityMethods(definition, model.service(c))[
+ action
+ ](itemId, { actorUserId, tx });
+ if (!result) return null;
+ outcome = {
+ changed: result.changed,
+ changedFields: [],
+ operation: action,
+ previousSlug: null,
+ restoredFromRevisionId: null,
+ revisionId: null,
+ row: result.row as ContentEditorialOutcome["row"],
+ version: 0,
+ visibility: result.visibility,
+ };
+ }
+
+ return {
+ changed: outcome.changed,
+ effects: async () => {
+ await contentVisibilityEffects(c, model, outcome, { pluginId });
+ },
+ };
+ },
+});
+
+export const contentReportTarget = (
+ model: AnyContentModel,
+ pluginId: string,
+): ModerationReportTargetDefinition => {
+ const definition = contentDefinitionOf(model);
+ const scope = model.moderationScope;
+ const scopeColumn = scope?.column;
+
+ return {
+ actions: definition.visibility.enabled
+ ? {
+ hide: visibilityAction(model, pluginId, "hide"),
+ unhide: visibilityAction(model, pluginId, "unhide"),
+ }
+ : undefined,
+ capture: async (c, args) => await captureContent(c, model, pluginId, args),
+ current: async (c, args) => await currentContent(c, model, args),
+ label: contentI18nKeys(definition, pluginId).label,
+ parseId: value => {
+ const id = parseContentId(definition.idStrategy, value);
+
+ return id === null ? null : contentIdKey(id);
+ },
+ permissionModule: definition.permissionModule,
+ ...(scope && scopeColumn
+ ? {
+ resolveScope: async (c: Context, targetId: string) => {
+ const id = parseContentId(definition.idStrategy, targetId);
+ if (id === null) return undefined;
+ const row = await readBaseRow(c.get("db"), model, id);
+ if (!row) return undefined;
+ const value = row[scopeColumn];
+
+ return typeof value === "number" || typeof value === "string"
+ ? String(value)
+ : "";
+ },
+ scope: scope.family,
+ }
+ : {}),
+ type: definition.id,
+ };
+};
diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts
index d9b0ec8f42..cf9bcbb00e 100644
--- a/packages/vitnode/src/content/types.ts
+++ b/packages/vitnode/src/content/types.ts
@@ -973,6 +973,23 @@ type ContentVisibilityColumns = TDefinition extends {
? { hiddenAt: Date | null; hiddenBy: null | number }
: Record;
+export interface ContentReportsConfig {
+ snapshotFields?: TField[];
+ titleField?: TField;
+}
+
+export type ContentReportsInput =
+ | boolean
+ | ContentReportsConfig;
+
+export interface ResolvedContentReportsConfig<
+ TEnabled extends boolean = boolean,
+> {
+ enabled: TEnabled;
+ snapshotFields: string[];
+ titleField: null | string;
+}
+
export interface ContentDuplicationConfig {
titleSuffix?: boolean;
}
@@ -1757,6 +1774,7 @@ export interface ContentTypeDefinition<
TPublicPath
>;
publication: ResolvedContentPublicationConfig;
+ reports: ResolvedContentReportsConfig;
/** Zod schemas generated from `fields`. */
schemas: ContentSchemas<
ContentTypeDefinition<
diff --git a/packages/vitnode/src/database/moderation.ts b/packages/vitnode/src/database/moderation.ts
new file mode 100644
index 0000000000..da6161adc9
--- /dev/null
+++ b/packages/vitnode/src/database/moderation.ts
@@ -0,0 +1,96 @@
+import { camelCase, index } from "drizzle-orm/pg-core";
+
+import type {
+ ModerationReportEventType,
+ ModerationReportReason,
+ ModerationReportResolution,
+ ModerationReportStatus,
+} from "@/lib/moderation/const";
+import type { ModerationReportSnapshot } from "@/lib/moderation/snapshot";
+
+import {
+ MODERATION_REPORT_EVENT_TYPES,
+ MODERATION_REPORT_REASONS,
+ MODERATION_REPORT_RESOLUTIONS,
+ MODERATION_REPORT_STATUSES,
+} from "@/lib/moderation/const";
+
+import { core_users } from "./users";
+
+export const core_moderation_reports = camelCase.table.withRLS(
+ "core_moderation_reports",
+ t => ({
+ id: t.serial().primaryKey(),
+ pluginId: t.varchar({ length: 100 }).notNull(),
+ targetType: t.varchar({ length: 100 }).notNull(),
+ targetId: t.varchar({ length: 64 }).notNull(),
+ reporterId: t.integer().references(() => core_users.id, {
+ onDelete: "set null",
+ }),
+ reason: t
+ .varchar({ enum: MODERATION_REPORT_REASONS, length: 32 })
+ .$type()
+ .notNull(),
+ description: t.text(),
+ snapshot: t.jsonb().$type().notNull(),
+ targetTitle: t.varchar({ length: 500 }),
+ status: t
+ .varchar({ enum: MODERATION_REPORT_STATUSES, length: 20 })
+ .$type()
+ .notNull()
+ .default("new"),
+ assigneeId: t.integer().references(() => core_users.id, {
+ onDelete: "set null",
+ }),
+ resolution: t
+ .varchar({ enum: MODERATION_REPORT_RESOLUTIONS, length: 32 })
+ .$type(),
+ resolutionNote: t.text(),
+ resolvedAt: t.timestamp(),
+ resolvedById: t.integer().references(() => core_users.id, {
+ onDelete: "set null",
+ }),
+ scopeFamily: t.varchar({ length: 100 }),
+ scopeId: t.varchar({ length: 64 }),
+ openKey: t.varchar({ length: 255 }).unique(),
+ version: t.integer().notNull().default(1),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ index("core_moderation_reports_target_idx").on(
+ t.pluginId,
+ t.targetType,
+ t.targetId,
+ ),
+ index("core_moderation_reports_status_idx").on(t.status, t.createdAt),
+ index("core_moderation_reports_assignee_idx").on(t.assigneeId),
+ index("core_moderation_reports_reporter_idx").on(t.reporterId, t.createdAt),
+ index("core_moderation_reports_scope_idx").on(t.scopeFamily, t.scopeId),
+ ],
+);
+
+export const core_moderation_report_events = camelCase.table.withRLS(
+ "core_moderation_report_events",
+ t => ({
+ id: t.serial().primaryKey(),
+ reportId: t
+ .integer()
+ .notNull()
+ .references(() => core_moderation_reports.id, { onDelete: "cascade" }),
+ type: t
+ .varchar({ enum: MODERATION_REPORT_EVENT_TYPES, length: 32 })
+ .$type()
+ .notNull(),
+ actorId: t.integer().references(() => core_users.id, {
+ onDelete: "set null",
+ }),
+ data: t.jsonb().$type>().notNull().default({}),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ }),
+ t => [index("core_moderation_report_events_report_idx").on(t.reportId, t.id)],
+);
diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts
index c92c79eb4c..8013faa8b2 100644
--- a/packages/vitnode/src/database/relations.ts
+++ b/packages/vitnode/src/database/relations.ts
@@ -8,6 +8,7 @@ import * as dashboard from "./dashboard";
import * as files from "./files";
import * as languages from "./languages";
import * as logs from "./logs";
+import * as moderation from "./moderation";
import * as moderators from "./moderators";
import * as navigation from "./navigation";
import * as notifications from "./notifications";
@@ -28,6 +29,7 @@ export const coreSchema = {
...files,
...languages,
...logs,
+ ...moderation,
...moderators,
...navigation,
...notifications,
diff --git a/packages/vitnode/src/editor/block-shell/block-shell.tsx b/packages/vitnode/src/editor/block-shell/block-shell.tsx
index 5d95872aff..0ae02e400b 100644
--- a/packages/vitnode/src/editor/block-shell/block-shell.tsx
+++ b/packages/vitnode/src/editor/block-shell/block-shell.tsx
@@ -114,7 +114,7 @@ export const EditableBlockShell = ({
diff --git a/packages/vitnode/src/editor/dnd/container-sortable.tsx b/packages/vitnode/src/editor/dnd/container-sortable.tsx
index 612a173af4..f6d0893e58 100644
--- a/packages/vitnode/src/editor/dnd/container-sortable.tsx
+++ b/packages/vitnode/src/editor/dnd/container-sortable.tsx
@@ -9,6 +9,7 @@ import {
import type { ContentNode } from "../../blocks/types";
import type { EditorContainerRef } from "../state/types";
+import { useStableIds } from "../../lib/dnd/use-stable-ids";
import { nodeKindOf } from "../state/reducer";
import { nodeDraggableId } from "./resolve-drop";
@@ -22,18 +23,24 @@ export const ContainerSortable = ({
columns?: number;
container: EditorContainerRef;
nodes: readonly ContentNode[];
-}): ReactElement => (
-
+}): ReactElement => {
+ const items = useStableIds(
+ nodes.map(node =>
nodeDraggableId({
areaId: container.areaId,
kind: nodeKindOf(node),
nodeId: node.id,
zoneId: container.zoneId,
}),
- )}
- strategy={columns > 1 ? rectSortingStrategy : verticalListSortingStrategy}
- >
- {children}
-
-);
+ ),
+ );
+
+ return (
+
1 ? rectSortingStrategy : verticalListSortingStrategy}
+ >
+ {children}
+
+ );
+};
diff --git a/packages/vitnode/src/editor/dnd/context.ts b/packages/vitnode/src/editor/dnd/context.ts
index a473b10924..cab5eb3845 100644
--- a/packages/vitnode/src/editor/dnd/context.ts
+++ b/packages/vitnode/src/editor/dnd/context.ts
@@ -1,3 +1,5 @@
+import type { ReactNode } from "react";
+
import { createContext, use } from "react";
import type { EditorDragSource, EditorDropIndicator } from "./resolve-drop";
@@ -6,12 +8,18 @@ export type { EditorDropIndicator } from "./resolve-drop";
export interface EditorDndContextValue {
dragging: EditorDragSource | null;
+ dragHeight: number;
dropIndicator: EditorDropIndicator | null;
+ dropSlot: EditorDropIndicator | null;
+ slotLabel: null | { icon: ReactNode; name: string };
}
const IDLE: EditorDndContextValue = {
dragging: null,
+ dragHeight: 0,
dropIndicator: null,
+ dropSlot: null,
+ slotLabel: null,
};
export const EditorDndContext = createContext
(IDLE);
diff --git a/packages/vitnode/src/editor/dnd/drop-slot.ts b/packages/vitnode/src/editor/dnd/drop-slot.ts
new file mode 100644
index 0000000000..7edc5e83b9
--- /dev/null
+++ b/packages/vitnode/src/editor/dnd/drop-slot.ts
@@ -0,0 +1,31 @@
+import type { EditorDragSource, EditorDropIndicator } from "./resolve-drop";
+
+import { isCatalogSource } from "./resolve-drop";
+
+export const dropSlotFor = (
+ dragging: EditorDragSource | null,
+ indicator: EditorDropIndicator | null,
+): EditorDropIndicator | null => {
+ if (dragging === null || indicator === null) return null;
+ if (indicator.axis !== "vertical") return null;
+ if (isCatalogSource(dragging)) return indicator;
+
+ const { container } = dragging;
+
+ return container.zoneId === indicator.zoneId &&
+ container.areaId === indicator.areaId
+ ? null
+ : indicator;
+};
+
+export const sameSlot = (
+ left: EditorDropIndicator | null,
+ right: EditorDropIndicator | null,
+): boolean =>
+ left === right ||
+ (left !== null &&
+ right !== null &&
+ left.nodeId === right.nodeId &&
+ left.edge === right.edge &&
+ left.zoneId === right.zoneId &&
+ left.areaId === right.areaId);
diff --git a/packages/vitnode/src/editor/dnd/provider.tsx b/packages/vitnode/src/editor/dnd/provider.tsx
index 0245470838..fc6616e757 100644
--- a/packages/vitnode/src/editor/dnd/provider.tsx
+++ b/packages/vitnode/src/editor/dnd/provider.tsx
@@ -25,14 +25,19 @@ import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { cn } from "cn";
import { Columns2Icon } from "lucide-react";
import { useReducedMotion } from "motion/react";
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "use-intl";
import type {
+ AnyBlockInstance,
BlockIcon,
BlockRegistry,
RegisteredBlock,
} from "../../blocks/types";
+import type {
+ LayoutFlipTarget,
+ LayoutSnapshot,
+} from "../../lib/dnd/layout-flip";
import type {
AreaCatalogEntry,
BlockCatalogEntry,
@@ -52,11 +57,12 @@ import type {
import { isBlockAreaInstance } from "../../blocks/area";
import { parseBlockId } from "../../blocks/namespace";
import { getDefaultBlockRegistry } from "../../blocks/registry";
+import { DragLift } from "../../lib/dnd/drag-lift";
import {
DRAG_OVERLAY_LIFT_CLASS,
- DRAG_OVERLAY_LIFT_STYLE,
LIFTED_DROP_ANIMATION,
} from "../../lib/dnd/drag-motion";
+import { playLayoutFlip, snapshotLayout } from "../../lib/dnd/layout-flip";
import { FinePointerSensor } from "../../lib/dnd/sensors";
import { BlockGlyph } from "../block-picker/block-glyph";
import { toBlockCatalogEntry } from "../block-picker/catalog";
@@ -68,9 +74,11 @@ import { useVisualEditor } from "../context";
import { dropCapacity } from "../state/bounds";
import { targetCapabilities } from "../state/capabilities";
import { containerNodes } from "../state/reducer";
+import { EditableBlockBody } from "../zones/block-body";
import { DND_REJECTION_LABELS } from "../zones/rejection-labels";
import { zoneDisplayName } from "../zones/zone-name";
import { EditorDndContext } from "./context";
+import { dropSlotFor, sameSlot } from "./drop-slot";
import {
decideDrop,
dropEdgeFor,
@@ -95,7 +103,10 @@ type DragOverlayPreview =
| { area: AreaCatalogEntry; kind: "catalog-area" }
| { entry: BlockCatalogEntry; kind: "catalog-block" }
| {
+ entry: RegisteredBlock | undefined;
icon: BlockIcon | undefined;
+ index: number;
+ instance: AnyBlockInstance | undefined;
kind: "existing-block";
name: string;
namespace: null | string;
@@ -124,6 +135,16 @@ const findRegisteredBlock = (
return fallback?.get(type);
};
+const EDITOR_NODES: LayoutFlipTarget = {
+ keyOf: node =>
+ node.dataset.blockId === undefined
+ ? node.dataset.areaId === undefined
+ ? undefined
+ : `area:${node.dataset.areaId}`
+ : `block:${node.dataset.blockId}`,
+ selector: "[data-block-id], [data-area-id]",
+};
+
const sameIndicator = (
left: EditorDropIndicator | null,
right: EditorDropIndicator | null,
@@ -148,6 +169,20 @@ export const EditorDndProvider = ({
useState(null);
const pointerRef = useRef(null);
const reduceMotion = useReducedMotion();
+ const [dragHeight, setDragHeight] = useState(0);
+ const [skipDropAnimation, setSkipDropAnimation] = useState(false);
+ const flipFromRef = useRef(null);
+ const dropSlot = dropSlotFor(dragging, dropIndicator);
+
+ const snapshotForSlot = (next: EditorDropIndicator | null) => {
+ if (reduceMotion || sameSlot(dropSlot, next)) return;
+ flipFromRef.current ??= snapshotLayout(document.body, EDITOR_NODES);
+ };
+
+ useLayoutEffect(() => {
+ playLayoutFlip(document.body, flipFromRef.current, EDITOR_NODES);
+ flipFromRef.current = null;
+ });
const sensors = useSensors(
useSensor(FinePointerSensor, { activationConstraint: { distance: 8 } }),
@@ -171,14 +206,6 @@ export const EditorDndProvider = ({
);
}, []);
- const dnd = useMemo(
- () => ({
- dragging,
- dropIndicator,
- }),
- [dragging, dropIndicator],
- );
-
const overlay = useMemo(() => {
if (!dragging) return null;
@@ -214,13 +241,59 @@ export const EditorDndProvider = ({
dragging.type,
);
+ const instance = containerNodes(state, dragging.container)?.find(
+ node => node.id === dragging.nodeId,
+ );
+
return {
+ entry,
icon: entry?.definition.icon,
+ index: dragging.index,
+ instance:
+ instance === undefined || isBlockAreaInstance(instance)
+ ? undefined
+ : instance,
kind: "existing-block",
name: entry?.definition.name ?? entry?.definition.id ?? dragging.type,
namespace: parseBlockId(dragging.type)?.namespace ?? null,
};
- }, [dragging, state.zones, t]);
+ }, [dragging, state, t]);
+
+ const showsOwnSpot =
+ dragging !== null &&
+ !isCatalogSource(dragging) &&
+ dropIndicator !== null &&
+ dragging.container.zoneId === dropIndicator.zoneId &&
+ dragging.container.areaId === dropIndicator.areaId;
+
+ const slotLabel = useMemo(() => {
+ if (overlay === null) return null;
+ if (overlay.kind === "catalog-area") {
+ return { icon: , name: overlay.area.name };
+ }
+ if (overlay.kind === "catalog-block") {
+ return {
+ icon: ,
+ name: overlay.entry.name,
+ };
+ }
+ if (overlay.kind === "existing-area") {
+ return { icon: , name: overlay.name };
+ }
+
+ return { icon: , name: overlay.name };
+ }, [overlay]);
+
+ const dnd = useMemo(
+ () => ({
+ dragging,
+ dragHeight,
+ dropIndicator: dropSlot !== null || showsOwnSpot ? null : dropIndicator,
+ dropSlot,
+ slotLabel,
+ }),
+ [dragging, dragHeight, dropIndicator, dropSlot, showsOwnSpot, slotLabel],
+ );
const dragName = useCallback(
(source: EditorDragSource): string => {
@@ -270,6 +343,14 @@ export const EditorDndProvider = ({
if (pointer === null) return null;
if (isContainerDroppableId(String(over.id))) return null;
+ if (
+ unplaced !== null &&
+ !isCatalogSource(source) &&
+ source.container.zoneId === unplaced.container.zoneId &&
+ source.container.areaId === unplaced.container.areaId
+ ) {
+ return null;
+ }
return dropEdgeFor({
axis,
@@ -396,6 +477,9 @@ export const EditorDndProvider = ({
const showIndicator = (active: Active, over: null | Over) => {
const next = planDrop(active, over)?.placement?.indicator ?? null;
+ snapshotForSlot(dropSlotFor(dragging, next));
+ const height = active.rect.current.initial?.height ?? 0;
+ if (height !== dragHeight) setDragHeight(height);
setDropIndicator(current =>
sameIndicator(current, next) ? current : next,
@@ -404,6 +488,8 @@ export const EditorDndProvider = ({
const onDragStart = ({ active }: DragStartEvent) => {
setDragging(readDragSource(active.data.current));
+ setDragHeight(active.rect.current.initial?.height ?? 0);
+ setSkipDropAnimation(false);
};
const onDragMove = ({ active, over }: DragMoveEvent) => {
@@ -415,8 +501,13 @@ export const EditorDndProvider = ({
};
const onDragEnd = ({ active, over }: DragEndEvent) => {
- const resolved = planDrop(active, over)?.resolved ?? null;
+ const plan = planDrop(active, over);
+ const resolved = plan?.resolved ?? null;
+ setSkipDropAnimation(
+ resolved !== null && plan !== null && isCatalogSource(plan.source),
+ );
+ snapshotForSlot(null);
reset();
if (!resolved) return;
@@ -463,60 +554,88 @@ export const EditorDndProvider = ({
{children}
{overlay === null ? null : overlay.kind === "catalog-area" ||
overlay.kind === "catalog-block" ? (
// The card the pointer picked up, at the size it was picked up at.
-
-
- ) : (
-
- )
- }
- />
-
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
) : (
// Sized to the node it was picked up from, so what follows the
// pointer keeps the footprint the drop is about to give it.
-
-
- {overlay.kind === "existing-area" ? (
-
- ) : (
-
- )}
-
-
-
- {overlay.name}
-
- {overlay.kind === "existing-block" &&
- overlay.namespace !== null ? (
-
- {overlay.namespace}
+
+ {overlay.kind === "existing-block" &&
+ overlay.instance !== undefined ? (
+
+ ) : (
+
+
+ {overlay.kind === "existing-area" ? (
+
+ ) : (
+
+ )}
- ) : null}
-
-
+
+
+ {overlay.name}
+
+ {overlay.kind === "existing-block" &&
+ overlay.namespace !== null ? (
+
+ {overlay.namespace}
+
+ ) : null}
+
+
+ )}
+
)}
diff --git a/packages/vitnode/src/editor/zones/area-frame.tsx b/packages/vitnode/src/editor/zones/area-frame.tsx
index 3f3b48291e..c37e98a013 100644
--- a/packages/vitnode/src/editor/zones/area-frame.tsx
+++ b/packages/vitnode/src/editor/zones/area-frame.tsx
@@ -375,7 +375,7 @@ export const EditableAreaFrame = ({
+ }
+ >
+ {
+ const keys = applyToModule
+ ? editingModuleScoped.map(item => item.key)
+ : [editingScope.key];
+ setScopes(previous => {
+ const next = { ...previous };
+ for (const key of keys) next[key] = scope;
+
+ return next;
+ });
+ setServerIssues([]);
+ setEditingScope(null);
+ }}
+ onCancel={() => {
+ setEditingScope(null);
+ }}
+ value={scopes[editingScope.key]}
+ />
+
+ >
+ ) : null}
+
+
);
};
diff --git a/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-dialog.tsx b/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-dialog.tsx
new file mode 100644
index 0000000000..b49a959518
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-dialog.tsx
@@ -0,0 +1,293 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { SearchIcon, TriangleAlertIcon, XIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type { PermissionStaffScope } from "@/api/lib/permission-staff";
+import type { StaffScopeFamily } from "@/views/admin/views/core/staff/staff-model";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Spinner } from "@/components/ui/spinner";
+
+import {
+ staffScopeResourcesQueryOptions,
+ staffScopeSelectionQueryOptions,
+} from "./scope-query";
+
+const SEARCH_DELAY_MS = 250;
+
+const useDebounced = (value: string): string => {
+ const [debounced, setDebounced] = React.useState(value);
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebounced(value);
+ }, SEARCH_DELAY_MS);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value]);
+
+ return debounced;
+};
+
+const ResourceLabel = ({
+ context,
+ label,
+}: {
+ context: string[];
+ label: string;
+}) => (
+
+ {label}
+ {context.length > 0 ? (
+
+ {context.join(" › ")}
+
+ ) : null}
+
+);
+
+export interface StaffScopeDialogContentProps {
+ family: StaffScopeFamily;
+ moduleScopedCount: number;
+ onApply: (scope: PermissionStaffScope, applyToModule: boolean) => void;
+ onCancel: () => void;
+ value: PermissionStaffScope | undefined;
+}
+
+export const StaffScopeDialogContent = ({
+ family,
+ moduleScopedCount,
+ onApply,
+ onCancel,
+ value,
+}: StaffScopeDialogContentProps) => {
+ const t = useTranslations("admin.staff.edit.scope");
+ const [mode, setMode] = React.useState<"all" | "selected">(
+ value?.mode ?? "all",
+ );
+ const [selected, setSelected] = React.useState(
+ value?.mode === "selected" ? value.ids : [],
+ );
+ const [applyToModule, setApplyToModule] = React.useState(false);
+ const [search, setSearch] = React.useState("");
+ const debounced = useDebounced(search.trim());
+
+ const list = useInfiniteQuery({
+ ...staffScopeResourcesQueryOptions({
+ family: family.id,
+ search: debounced,
+ }),
+ enabled: mode === "selected",
+ });
+ const initialIds = React.useMemo(
+ () => (value?.mode === "selected" ? value.ids : []),
+ [value],
+ );
+ const saved = useQuery(
+ staffScopeSelectionQueryOptions({ family: family.id, ids: initialIds }),
+ );
+
+ const known = new Map();
+ for (const item of saved.data?.items ?? []) known.set(item.id, item);
+ for (const page of list.data?.pages ?? []) {
+ for (const item of page.items) known.set(item.id, item);
+ }
+ const missing = new Set(saved.data?.missing ?? []);
+ const items = list.data?.pages.flatMap(page => page.items) ?? [];
+
+ const toggle = (id: string, checked: boolean) => {
+ setSelected(previous =>
+ checked
+ ? [...new Set([...previous, id])]
+ : previous.filter(entry => entry !== id),
+ );
+ };
+
+ const canApply = mode === "all" || selected.length > 0;
+
+ return (
+
+
{
+ setMode(next === "selected" ? "selected" : "all");
+ }}
+ value={mode}
+ >
+
+
+
+
+ {mode === "selected" ? (
+
+ {selected.length > 0 ? (
+
+
+ {t("selection_title", { count: selected.length })}
+
+
+ {selected.map(id => {
+ const resource = known.get(id);
+ const gone = missing.has(id);
+ const name = resource?.label ?? `#${id}`;
+
+ return (
+ -
+ {gone ? (
+
+ ) : null}
+
+ {name}
+ {gone ? ` (${t("removed")})` : null}
+
+ {
+ toggle(id, false);
+ }}
+ type="button"
+ >
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+ {t("none_selected")}
+
+ )}
+
+
+
+ {
+ setSearch(event.target.value);
+ }}
+ placeholder={t("search", { family: family.label })}
+ value={search}
+ />
+
+
+
+ {list.isPending ? (
+
+
+ {t("loading")}
+
+ ) : items.length === 0 ? (
+
+ {t("empty")}
+
+ ) : (
+
+ {items.map(item => (
+ -
+
+
+ ))}
+
+ )}
+ {list.hasNextPage ? (
+
+ {
+ await list.fetchNextPage();
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ {t("load_more")}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {moduleScopedCount > 1 ? (
+
+ ) : null}
+
+
+
+ {t("cancel")}
+
+ {
+ onApply(
+ mode === "all"
+ ? { family: family.id, mode: "all" }
+ : { family: family.id, ids: selected, mode: "selected" },
+ applyToModule,
+ );
+ }}
+ >
+ {t("apply")}
+
+
+
+ );
+};
+
+export default StaffScopeDialogContent;
diff --git a/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-query.ts b/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-query.ts
new file mode 100644
index 0000000000..0a9b9b7b3c
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/staff/edit/scope/scope-query.ts
@@ -0,0 +1,90 @@
+import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { fetcher } from "@/tanstack/fetcher";
+import { AdminRequestError } from "@/views/admin/admin-request";
+
+export interface StaffScopeResource {
+ context: string[];
+ id: string;
+ label: string;
+}
+
+interface StaffScopeResourcePage {
+ items: StaffScopeResource[];
+ missing: string[];
+ nextCursor: null | string;
+}
+
+const fetchScopeResources = async ({
+ cursor,
+ family,
+ ids,
+ search,
+}: {
+ cursor?: string;
+ family: string;
+ ids?: string[];
+ search?: string;
+}): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: {
+ params: { family },
+ query: {
+ cursor,
+ ids: ids?.join(","),
+ search: search === "" ? undefined : search,
+ },
+ },
+ method: "get",
+ module: "admin/staff",
+ path: "/scope-resources/{family}",
+ });
+ if (!response.ok) {
+ throw new AdminRequestError(response.status, "moderation scope resources");
+ }
+
+ return await response.json();
+};
+
+export const staffScopeResourcesQueryOptions = ({
+ family,
+ search,
+}: {
+ family: string;
+ search: string;
+}) =>
+ infiniteQueryOptions({
+ getNextPageParam: (page: StaffScopeResourcePage) =>
+ page.nextCursor ?? undefined,
+ initialPageParam: undefined as string | undefined,
+ queryFn: async ({ pageParam }) =>
+ await fetchScopeResources({ cursor: pageParam, family, search }),
+ queryKey: ["vitnode", "admin", "staff", "scope", family, "list", search],
+ retry: false,
+ staleTime: 30_000,
+ });
+
+export const staffScopeSelectionQueryOptions = ({
+ family,
+ ids,
+}: {
+ family: string;
+ ids: string[];
+}) =>
+ queryOptions({
+ enabled: ids.length > 0,
+ queryFn: async () => await fetchScopeResources({ family, ids }),
+ queryKey: [
+ "vitnode",
+ "admin",
+ "staff",
+ "scope",
+ family,
+ "selection",
+ [...ids].sort().join(","),
+ ],
+ retry: false,
+ staleTime: 30_000,
+ });
diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-model.test.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-model.test.ts
index c9e91d9e12..62baf723a1 100644
--- a/packages/vitnode/src/views/admin/views/core/staff/staff-model.test.ts
+++ b/packages/vitnode/src/views/admin/views/core/staff/staff-model.test.ts
@@ -7,6 +7,7 @@ import {
chunkStaffLabelKeys,
countGrantedStaffPermissions,
grantedStaffPermissionKeys,
+ grantedStaffScopes,
isStaffPermissionLocked,
normalizeStaffEntryId,
setStaffPermissionsChecked,
@@ -20,6 +21,7 @@ import {
staffPermissionDependents,
staffPermissionItems,
staffPermissionsForSubmit,
+ staffScopeIssues,
staffTypeFromSegment,
toggleStaffPermission,
} from "./staff-model";
@@ -412,6 +414,117 @@ describe("what is submitted", () => {
});
});
+describe("scoped moderator permissions", () => {
+ const FORUM = "@vitnode/forum";
+ const FAMILY = "forum.section";
+ const VIEW = `${FORUM}:topics:can_view_reports`;
+ const MANAGE = `${FORUM}:topics:can_manage_reports`;
+ const PIN = `${FORUM}:topics:can_pin`;
+ const items = staffPermissionItems(
+ buildStaffPermissionGroups({
+ catalog: [
+ {
+ admin: {},
+ moderator: {
+ topics: [
+ { dependsOn: [], permission: "can_view_reports", scope: FAMILY },
+ {
+ dependsOn: ["can_view_reports"],
+ permission: "can_manage_reports",
+ scope: FAMILY,
+ },
+ { dependsOn: [], permission: "can_pin" },
+ ],
+ },
+ pluginId: FORUM,
+ },
+ ],
+ label: () => undefined,
+ type: "moderator",
+ }),
+ );
+ const selected = (...ids: string[]) => ({
+ family: FAMILY,
+ ids,
+ mode: "selected" as const,
+ });
+ const all = { family: FAMILY, mode: "all" as const };
+
+ it("reads a legacy unscoped grant of a scoped permission as every resource", () => {
+ expect(
+ grantedStaffScopes(
+ [
+ { module: "topics", permission: "can_view_reports", plugin: FORUM },
+ {
+ module: "topics",
+ permission: "can_manage_reports",
+ plugin: FORUM,
+ scope: selected("10"),
+ },
+ { module: "topics", permission: "can_pin", plugin: FORUM },
+ ],
+ items,
+ ),
+ ).toEqual({ [MANAGE]: selected("10"), [VIEW]: all });
+ });
+
+ it.each<
+ [
+ string,
+ Record | typeof all>,
+ unknown[],
+ ]
+ >([
+ ["no scope chosen", {}, [{ key: VIEW, type: "missing_scope" }]],
+ [
+ "an empty selection",
+ { [VIEW]: selected() },
+ [{ key: VIEW, type: "empty_selection" }],
+ ],
+ [
+ "a permission wider than its dependency",
+ { [MANAGE]: selected("10", "20"), [VIEW]: selected("10") },
+ [{ dependency: VIEW, key: MANAGE, type: "dependency_scope" }],
+ ],
+ [
+ "nothing when the dependency covers it",
+ { [MANAGE]: selected("10"), [VIEW]: all },
+ [],
+ ],
+ ])("finds %s", (_label, scopes, expected) => {
+ const checked = new Set(
+ Object.keys(scopes).length > 0 ? Object.keys(scopes) : [VIEW],
+ );
+
+ expect(staffScopeIssues({ checked, items, scopes })).toEqual(expected);
+ });
+
+ it("sends each scoped permission with its own scope, and none on the rest", () => {
+ expect(
+ staffPermissionsForSubmit({
+ checked: new Set([MANAGE, PIN, VIEW]),
+ items,
+ scopes: { [MANAGE]: selected("10"), [PIN]: all, [VIEW]: all },
+ unrestricted: false,
+ }),
+ ).toEqual([
+ {
+ module: "topics",
+ permission: "can_view_reports",
+ plugin: FORUM,
+ scope: all,
+ },
+ {
+ module: "topics",
+ permission: "can_manage_reports",
+ plugin: FORUM,
+ scope: selected("10"),
+ },
+ { module: "topics", permission: "can_pin", plugin: FORUM },
+ ]);
+ });
+});
+
describe("the message keys the labels need", () => {
it("names the plugin, every module and every permission, once", () => {
expect(staffLabelKeys({ catalog, type: "admin" })).toEqual([
diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts
index 9c67b60ee8..00c0a2822c 100644
--- a/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts
+++ b/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts
@@ -1,9 +1,14 @@
import type {
PermissionsStaffArgs,
+ PermissionStaffScope,
PermissionStaffType,
} from "@/api/lib/permission-staff";
-import { staffPermissionKey } from "@/api/lib/staff-permission";
+import {
+ staffPermissionKey,
+ staffScopeCovers,
+} from "@/api/lib/staff-permission";
+import { MODERATION_PERMISSIONS } from "@/lib/moderation/const";
/* -------------------------------------------------------------------------- */
/* Where things live */
@@ -100,6 +105,13 @@ export const normalizeStaffEntryId = (
export interface StaffCatalogEntry {
dependsOn: string[];
permission: string;
+ scope?: string;
+}
+
+export interface StaffCatalogScopeFamily {
+ description: null | string;
+ id: string;
+ label: string;
}
/** `GET /admin/staff/permission-catalog`, as it comes back. */
@@ -107,18 +119,36 @@ export interface StaffCatalogPlugin {
admin: Record;
moderator: Record;
pluginId: string;
+ scopes?: StaffCatalogScopeFamily[];
}
export type StaffCatalog = StaffCatalogPlugin[];
/** One row of the form: a permission, and whether it can be ticked. */
-export interface StaffPermissionItem extends PermissionsStaffArgs {
+export interface StaffPermissionItem extends Omit<
+ PermissionsStaffArgs,
+ "scope"
+> {
/** Every dependency, as full `plugin:module:permission` keys. */
dependsOn: string[];
key: string;
label: string;
+ scopeFamily?: null | string;
+}
+
+export interface StaffScopeFamily {
+ description: null | string;
+ id: string;
+ label: string;
}
+const MODERATION_PERMISSION_NAMES = new Set(
+ Object.values(MODERATION_PERMISSIONS),
+);
+
+export const moderationPermissionLabelKey = (permission: string): string =>
+ `@vitnode/core:moderation:${permission}`;
+
export interface StaffModuleGroup {
label: string;
module: string;
@@ -187,7 +217,14 @@ export const buildStaffPermissionGroups = ({
}),
),
key,
- label: label(key) ?? entry.permission,
+ label:
+ label(key) ??
+ (type === "moderator" &&
+ MODERATION_PERMISSION_NAMES.has(entry.permission)
+ ? label(moderationPermissionLabelKey(entry.permission))
+ : undefined) ??
+ entry.permission,
+ scopeFamily: type === "moderator" ? (entry.scope ?? null) : null,
};
}),
}))
@@ -209,6 +246,83 @@ export const staffPermissionItems = (
plugin.modules.flatMap(module => module.permissions),
);
+export const buildStaffScopeFamilies = ({
+ catalog,
+ label,
+}: {
+ catalog: StaffCatalog;
+ label: StaffLabelLookup;
+}): StaffScopeFamily[] =>
+ catalog.flatMap(plugin =>
+ (plugin.scopes ?? []).map(family => ({
+ description: family.description
+ ? (label(family.description) ?? null)
+ : null,
+ id: family.id,
+ label: label(family.label) ?? family.id,
+ })),
+ );
+
+export const grantedStaffScopes = (
+ permissions: readonly PermissionsStaffArgs[],
+ items: readonly StaffPermissionItem[],
+): Record => {
+ const families = new Map(items.map(item => [item.key, item.scopeFamily]));
+ const scopes: Record = {};
+ for (const permission of permissions) {
+ const key = staffPermissionKey(permission);
+ const family = families.get(key);
+ if (!family) continue;
+ scopes[key] =
+ permission.scope?.family === family
+ ? permission.scope
+ : { family, mode: "all" };
+ }
+
+ return scopes;
+};
+
+export type StaffScopeIssue =
+ | { dependency: string; key: string; type: "dependency_scope" }
+ | { key: string; type: "empty_selection" }
+ | { key: string; type: "missing_scope" };
+
+export const staffScopeIssues = ({
+ checked,
+ items,
+ scopes,
+}: {
+ checked: ReadonlySet;
+ items: readonly StaffPermissionItem[];
+ scopes: Readonly>;
+}): StaffScopeIssue[] => {
+ const issues: StaffScopeIssue[] = [];
+ const byKey = new Map(items.map(item => [item.key, item]));
+ for (const key of checked) {
+ const item = byKey.get(key);
+ if (!item?.scopeFamily) continue;
+ const scope = scopes[key];
+ if (!scope) {
+ issues.push({ key, type: "missing_scope" });
+ continue;
+ }
+ if (scope.mode === "selected" && scope.ids.length === 0) {
+ issues.push({ key, type: "empty_selection" });
+ continue;
+ }
+ for (const dependency of item.dependsOn) {
+ if (!checked.has(dependency) || !byKey.get(dependency)?.scopeFamily) {
+ continue;
+ }
+ if (!staffScopeCovers(scopes[dependency], scope)) {
+ issues.push({ dependency, key, type: "dependency_scope" });
+ }
+ }
+ }
+
+ return issues;
+};
+
/** The keys granted on an entry, as the set the form starts from. */
export const grantedStaffPermissionKeys = (
permissions: readonly PermissionsStaffArgs[],
@@ -357,10 +471,12 @@ export const countGrantedStaffPermissions = (
export const staffPermissionsForSubmit = ({
checked,
items,
+ scopes = {},
unrestricted,
}: {
checked: ReadonlySet;
items: readonly StaffPermissionItem[];
+ scopes?: Readonly>;
unrestricted: boolean;
}): PermissionsStaffArgs[] => {
if (unrestricted) return [];
@@ -385,7 +501,13 @@ export const staffPermissionsForSubmit = ({
// administrators saving the same choices produce the same request body.
return items
.filter(item => granted.has(item.key))
- .map(({ module, permission, plugin }) => ({ module, permission, plugin }));
+ .map(({ key, module, permission, plugin, scopeFamily }) => {
+ const scope = scopeFamily ? scopes[key] : undefined;
+
+ return scope
+ ? { module, permission, plugin, scope }
+ : { module, permission, plugin };
+ });
};
/* -------------------------------------------------------------------------- */
@@ -430,6 +552,20 @@ export const staffLabelKeys = ({
plugin: plugin.pluginId,
}),
);
+ if (
+ type === "moderator" &&
+ MODERATION_PERMISSION_NAMES.has(entry.permission)
+ ) {
+ keys.add(moderationPermissionLabelKey(entry.permission));
+ }
+ }
+ }
+ }
+ if (type === "moderator") {
+ for (const plugin of catalog) {
+ for (const family of plugin.scopes ?? []) {
+ keys.add(family.label);
+ if (family.description) keys.add(family.description);
}
}
}
@@ -477,6 +613,17 @@ export const staffLabelLookupFrom = (
return typeof title === "string" ? title : undefined;
};
+ const nested = (key: string): unknown =>
+ key
+ .split(".")
+ .reduce(
+ (node, segment) =>
+ typeof node === "object" && node !== null
+ ? (node as Record)[segment]
+ : undefined,
+ messages,
+ );
+
return key => {
// `"@vitnode/blog.title"` is how the plugin heading is asked for; the
// messages hold it as `{ "@vitnode/blog": { title } }`.
@@ -484,6 +631,10 @@ export const staffLabelLookupFrom = (
return titleOf(messages[key.slice(0, -".title".length)]);
}
- return titleOf(messages[key]);
+ const flat = titleOf(messages[key]);
+ if (flat !== undefined || !key.includes(".")) return flat;
+ const value = nested(key);
+
+ return typeof value === "string" ? value : undefined;
};
};
diff --git a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts
index e459f687b4..73fc1ea7dc 100644
--- a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts
+++ b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts
@@ -88,6 +88,7 @@ export interface AdminUserRole {
color: null | string;
id: number;
name: { languageCode: string; name: string }[];
+ prefix?: null | string;
}
export interface AdminUserRow {
@@ -176,6 +177,7 @@ export interface AdminUserOption {
id: number;
name: string;
nameCode: string;
+ role: { color: null | string; prefix: null | string };
}
/** The signature every user picker takes, wherever the read comes from. */
@@ -190,11 +192,12 @@ export const ADMIN_USER_SEARCH_LIMIT = 20;
export const adminUserOptionsFrom = (
page: Pick,
): AdminUserOption[] =>
- page.edges.map(({ avatarColor, id, name, nameCode }) => ({
+ page.edges.map(({ avatarColor, id, name, nameCode, role }) => ({
avatarColor,
id,
name,
nameCode,
+ role: { color: role.color, prefix: role.prefix ?? null },
}));
export const searchAdminUsersInBrowser: AdminUserSearchOptions =
diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx
index cda241474e..28d83a2d08 100644
--- a/packages/vitnode/src/views/auth/settings/nav-content.tsx
+++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx
@@ -1,5 +1,3 @@
-import { Link } from "@tanstack/react-router";
-import { cn } from "cn";
import {
BellIcon,
KeyRoundIcon,
@@ -13,6 +11,7 @@ import {
import React from "react";
import { useTranslations } from "use-intl";
+import { SideNavCard, SideNavLink, SideNavList } from "@/components/side-nav";
import { Button } from "@/components/ui/button";
import {
Drawer,
@@ -47,50 +46,21 @@ const SettingsNavList = ({
onNavigate?: () => void;
pathname: string;
}) => {
- const t = useTranslations("core.auth.settings");
const tNav = useTranslations("core.auth.settings.nav");
- const activeIndex = items.findIndex(item =>
- isSettingsNavItemActive(item, pathname),
- );
return (
-
+
+ {items.map(item => (
+
+ ))}
+
);
};
@@ -127,19 +97,37 @@ const SettingsNavDrawer = ({
- {
- setIsOpen(false);
- }}
- pathname={pathname}
- />
+
);
};
+const SettingsNavCard = ({
+ items,
+ pathname,
+}: {
+ items: readonly SettingsNavItem[];
+ pathname: string;
+}) => {
+ const t = useTranslations("core.auth.settings");
+
+ return (
+
+
+
+ );
+};
+
export const SettingsNavContent = ({
items = SETTINGS_NAV_ITEMS,
pathname,
@@ -151,8 +139,6 @@ export const SettingsNavContent = ({
-
-
-
+
>
);
diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
index 6fb1426699..33d76abe5c 100644
--- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
+++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
@@ -1,12 +1,19 @@
import type { LucideIcon } from "lucide-react";
-import { FileIcon, KeyRoundIcon, SettingsIcon, UserIcon } from "lucide-react";
+import {
+ FileIcon,
+ KeyRoundIcon,
+ SettingsIcon,
+ ShieldIcon,
+ UserIcon,
+} from "lucide-react";
export interface UserHeaderUser {
avatarColor: string;
avatarUrl?: null | string;
email: string;
isAdmin: boolean;
+ isModerator?: boolean;
name: string;
nameCode: string;
}
@@ -19,6 +26,7 @@ export type UserHeaderState =
export const USER_HEADER_HREF = {
adminCp: "/admin",
files: "/files",
+ modCp: "/modcp/reports",
settings: "/settings",
signIn: "/login",
signUp: "/register",
@@ -45,6 +53,7 @@ export interface UserHeaderMenuItem {
export type UserHeaderMenuItemKey =
| "admin_cp"
| "files"
+ | "mod_cp"
| "my_profile"
| "settings";
@@ -70,19 +79,29 @@ export const userHeaderMenu = (
{ href: USER_HEADER_HREF.settings, Icon: SettingsIcon, key: "settings" },
];
- if (!user.isAdmin) return [account];
-
- return [
- account,
- [
- {
- href: USER_HEADER_HREF.adminCp,
- Icon: KeyRoundIcon,
- key: "admin_cp",
- newTab: true,
- },
- ],
+ const staff: UserHeaderMenuItem[] = [
+ ...(user.isModerator
+ ? [
+ {
+ href: USER_HEADER_HREF.modCp,
+ Icon: ShieldIcon,
+ key: "mod_cp" as const,
+ },
+ ]
+ : []),
+ ...(user.isAdmin
+ ? [
+ {
+ href: USER_HEADER_HREF.adminCp,
+ Icon: KeyRoundIcon,
+ key: "admin_cp" as const,
+ newTab: true,
+ },
+ ]
+ : []),
];
+
+ return staff.length > 0 ? [account, staff] : [account];
};
export const userHeaderState = ({
diff --git a/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx b/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx
new file mode 100644
index 0000000000..47a6a380f7
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx
@@ -0,0 +1,213 @@
+import { useDraggable } from "@dnd-kit/core";
+import { Link } from "@tanstack/react-router";
+import { cn } from "cn";
+import { FlagIcon, GripVerticalIcon, MoreHorizontalIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { DateFormat } from "@/components/date-format";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { TooltipWithContent } from "@/components/ui/tooltip";
+
+import type { ModcpBoardReport, ModcpBoardStatus } from "../modcp-query";
+
+import { MODCP_BOARD_STATUSES } from "../modcp-query";
+import {
+ Chip,
+ Person,
+ REASON_TONE,
+ StatusLozenge,
+ TONE_SURFACE,
+} from "../report-tone";
+import { useTargetLabel } from "../status-badge";
+
+export const BOARD_CARD_CLASS =
+ "bg-card text-card-foreground ring-foreground/10 relative flex flex-col gap-2 rounded-lg p-3 shadow-xs ring-1";
+
+export const boardCardId = (id: number): string => `report-${id}`;
+
+const ExtraReports = ({ row }: { row: ModcpBoardReport }) => {
+ const t = useTranslations("core.moderation.modcp.reports.board");
+ if (row.relatedCount === 0) return null;
+
+ return (
+
+
+ +{row.relatedCount}
+
+
+ );
+};
+
+const CardMenu = ({
+ onMove,
+ row,
+}: {
+ onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void;
+ row: ModcpBoardReport;
+}) => {
+ const t = useTranslations("core.moderation.modcp");
+
+ return (
+
{
+ event.stopPropagation();
+ }}
+ >
+
+
+ }
+ >
+
+
+
+ {t("detail.status.move_to")}
+ {MODCP_BOARD_STATUSES.filter(status => status !== row.status).map(
+ status => (
+ {
+ onMove(row, status);
+ }}
+ >
+
+
+ ),
+ )}
+
+
+
+ );
+};
+
+export const BoardCardBody = ({
+ handle,
+ onMove,
+ row,
+}: {
+ handle?: React.ReactNode;
+ onMove?: (row: ModcpBoardReport, to: ModcpBoardStatus) => void;
+ row: ModcpBoardReport;
+}) => {
+ const t = useTranslations("core.moderation");
+ const targetLabel = useTargetLabel();
+ const title = row.title ?? t("modcp.reports.untitled");
+
+ return (
+ <>
+
+
+
+ {t(`reasons.${row.reason}`)}
+
+
+
+
+ {handle}
+ {onMove ? : null}
+
+
+
+ {onMove ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ )}
+
+
+ #{row.id} · {targetLabel(row.targetLabel, row.targetType)}
+
+
+
+
+ {row.assignee ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+export const BoardCard = ({
+ onMove,
+ row,
+}: {
+ onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void;
+ row: ModcpBoardReport;
+}) => {
+ const t = useTranslations("core.moderation.modcp.reports.board");
+ const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef } =
+ useDraggable({ data: { reportId: row.id }, id: boardCardId(row.id) });
+
+ return (
+
+
+
+
+ }
+ onMove={onMove}
+ row={row}
+ />
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts b/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts
new file mode 100644
index 0000000000..5cc6bc2064
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts
@@ -0,0 +1,104 @@
+import type { ModcpBoardReport, ModcpBoardStatus } from "../modcp-query";
+
+export type BoardPerson = NonNullable
;
+
+export interface BoardOverride {
+ assignee: BoardPerson | null;
+ status: ModcpBoardStatus;
+}
+
+export type BoardMove =
+ | { kind: "claim" }
+ | { kind: "reopen"; to: Exclude }
+ | { kind: "resolve" }
+ | { kind: "unassign" };
+
+export const planBoardMove = (
+ from: ModcpBoardStatus,
+ to: ModcpBoardStatus,
+): BoardMove | null => {
+ if (from === to) return null;
+ if (to === "done") return { kind: "resolve" };
+ if (from === "done") return { kind: "reopen", to };
+
+ return to === "in_progress" ? { kind: "claim" } : { kind: "unassign" };
+};
+
+export const optimisticOverride = ({
+ me,
+ row,
+ to,
+}: {
+ me: BoardPerson;
+ row: ModcpBoardReport;
+ to: ModcpBoardStatus;
+}): BoardOverride => ({
+ assignee: to === "new" ? null : (row.assignee ?? me),
+ status: to,
+});
+
+const timeOf = (value: Date | string): number => new Date(value).getTime();
+
+export const newestFirst = (
+ left: Pick,
+ right: Pick,
+): number =>
+ timeOf(right.createdAt) - timeOf(left.createdAt) || right.id - left.id;
+
+export type BoardColumns = Record;
+
+export const boardColumns = ({
+ loaded,
+ overrides,
+}: {
+ loaded: BoardColumns;
+ overrides: ReadonlyMap;
+}): BoardColumns => {
+ const latest = new Map();
+ for (const row of [...loaded.new, ...loaded.in_progress, ...loaded.done]) {
+ const known = latest.get(row.id);
+ if (!known || row.version > known.version) latest.set(row.id, row);
+ }
+
+ const columns: BoardColumns = { done: [], in_progress: [], new: [] };
+ for (const row of latest.values()) {
+ const override = overrides.get(row.id);
+ const shown = override ? { ...row, ...override } : row;
+ columns[shown.status].push(shown);
+ }
+
+ for (const rows of Object.values(columns)) rows.sort(newestFirst);
+
+ return columns;
+};
+
+export const columnCount = ({
+ loaded,
+ overrides,
+ status,
+ total,
+}: {
+ loaded: BoardColumns;
+ overrides: ReadonlyMap;
+ status: ModcpBoardStatus;
+ total: number;
+}): number => {
+ let count = total;
+ for (const row of [...loaded.new, ...loaded.in_progress, ...loaded.done]) {
+ const override = overrides.get(row.id);
+ if (!override || override.status === row.status) continue;
+ if (override.status === status) count += 1;
+ if (row.status === status) count -= 1;
+ }
+
+ return Math.max(0, count);
+};
+
+export const slotIndex = (
+ rows: readonly ModcpBoardReport[],
+ dragged: Pick,
+): number => {
+ const index = rows.findIndex(row => newestFirst(dragged, row) < 0);
+
+ return index === -1 ? rows.length : index;
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx b/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx
new file mode 100644
index 0000000000..7559e68285
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx
@@ -0,0 +1,606 @@
+import type {
+ Announcements,
+ CollisionDetection,
+ DragEndEvent,
+ DragOverEvent,
+ DragStartEvent,
+ KeyboardCoordinateGetter,
+} from "@dnd-kit/core";
+
+import {
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ pointerWithin,
+ rectIntersection,
+ TouchSensor,
+ useDroppable,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import { restrictToWindowEdges } from "@dnd-kit/modifiers";
+import {
+ useQueryClient,
+ useSuspenseInfiniteQuery,
+} from "@tanstack/react-query";
+import { cn } from "cn";
+import { useReducedMotion } from "motion/react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { LayoutFlipTarget, LayoutSnapshot } from "@/lib/dnd/layout-flip";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { DragLift } from "@/lib/dnd/drag-lift";
+import {
+ DRAG_OVERLAY_LIFT_CLASS,
+ LIFTED_DROP_ANIMATION,
+} from "@/lib/dnd/drag-motion";
+import { playLayoutFlip, snapshotLayout } from "@/lib/dnd/layout-flip";
+import { FinePointerSensor } from "@/lib/dnd/sensors";
+
+import type { ReportState } from "../../moderation-mutations";
+import type {
+ ModcpBoardFilter,
+ ModcpBoardReport,
+ ModcpBoardStatus,
+} from "../modcp-query";
+import type { BoardOverride, BoardPerson } from "./board-moves";
+
+import { assignReport, claimReport } from "../../moderation-mutations";
+import {
+ invalidateModcp,
+ MODCP_BOARD_STATUSES,
+ modcpBoardColumnQueryOptions,
+} from "../modcp-query";
+import { ModcpPluginMessages } from "../plugin-messages";
+import {
+ ReopenReportForm,
+ ResolveReportForm,
+ useReportFailure,
+} from "../report-forms";
+import { StatusLozenge } from "../report-tone";
+import { BOARD_CARD_CLASS, BoardCard, BoardCardBody } from "./board-card";
+import {
+ boardColumns,
+ columnCount,
+ optimisticOverride,
+ planBoardMove,
+ slotIndex,
+} from "./board-moves";
+
+const COLUMN_PREFIX = "column-";
+
+const CARDS: LayoutFlipTarget = {
+ keyOf: node => node.dataset.reportCard,
+ selector: "[data-report-card]",
+};
+
+const MIN_SLOT_HEIGHT = 96;
+
+const reportIdOf = (id: number | string): null | number => {
+ const match = /^report-(\d+)$/.exec(String(id));
+
+ return match ? Number(match[1]) : null;
+};
+
+const columnStatusOf = (
+ id: null | number | string | undefined,
+): ModcpBoardStatus | null => {
+ if (id === null || id === undefined) return null;
+ const value = String(id);
+ if (!value.startsWith(COLUMN_PREFIX)) return null;
+ const status = value.slice(COLUMN_PREFIX.length);
+
+ return (MODCP_BOARD_STATUSES as readonly string[]).includes(status)
+ ? (status as ModcpBoardStatus)
+ : null;
+};
+
+const KEYBOARD_DROP_OFFSET = 48;
+
+const ARROW_STEP: Record = {
+ ArrowDown: 1,
+ ArrowLeft: -1,
+ ArrowRight: 1,
+ ArrowUp: -1,
+};
+
+const columnCoordinates: KeyboardCoordinateGetter = (
+ event,
+ { context: { collisionRect, droppableContainers, droppableRects } },
+) => {
+ const step = ARROW_STEP[event.code];
+ if (step === undefined || !collisionRect) return undefined;
+ event.preventDefault();
+
+ const columns = droppableContainers
+ .getEnabled()
+ .flatMap(container => {
+ const rect = droppableRects.get(container.id);
+
+ return rect ? [rect] : [];
+ })
+ .sort((left, right) => left.left - right.left || left.top - right.top);
+ if (columns.length === 0) return undefined;
+
+ const centerX = collisionRect.left + collisionRect.width / 2;
+ const centerY = collisionRect.top + collisionRect.height / 2;
+ const current = columns.findIndex(
+ rect =>
+ centerX >= rect.left &&
+ centerX <= rect.left + rect.width &&
+ centerY >= rect.top &&
+ centerY <= rect.top + rect.height,
+ );
+ const target =
+ columns[Math.min(columns.length - 1, Math.max(0, current + step))];
+ if (!target) return undefined;
+
+ return {
+ x: target.left + (target.width - collisionRect.width) / 2,
+ y: target.top + KEYBOARD_DROP_OFFSET,
+ };
+};
+
+const collisionDetection: CollisionDetection = args => {
+ const within = pointerWithin(args);
+
+ return within.length > 0 ? within : rectIntersection(args);
+};
+
+type Pending = null | {
+ kind: "reopen" | "resolve";
+ row: ModcpBoardReport;
+ to: ModcpBoardStatus;
+};
+
+const DropSlot = ({ height, hint }: { height: number; hint: string }) => (
+
+ {hint}
+
+);
+
+const BoardColumn = ({
+ count,
+ hasMore,
+ isLoadingMore,
+ onLoadMore,
+ onMove,
+ rows,
+ slot,
+ status,
+}: {
+ count: number;
+ hasMore: boolean;
+ isLoadingMore: boolean;
+ onLoadMore: () => void;
+ onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void;
+ rows: ModcpBoardReport[];
+ slot: null | { height: number; hint: string; index: number };
+ status: ModcpBoardStatus;
+}) => {
+ const t = useTranslations("core.moderation.modcp.reports.board");
+ const headingId = React.useId();
+ const { setNodeRef } = useDroppable({ id: `${COLUMN_PREFIX}${status}` });
+ const slotElement =
+ slot === null ? null : (
+
+ );
+
+ return (
+
+
+
+
+ {count}
+
+
+
+ {rows.length === 0 && slot === null ? (
+
+ {t("nothing_here")}
+
+ ) : (
+
+ {rows.map((row, index) => (
+
+ {slot?.index === index ? slotElement : null}
+
+
+ ))}
+ {slot !== null && slot.index >= rows.length ? slotElement : null}
+
+ )}
+
+ {hasMore ? (
+
+ {t("show_more")}
+
+ ) : null}
+
+ );
+};
+
+const BoardDialog = ({
+ onCancel,
+ onDone,
+ onReopened,
+ pending,
+}: {
+ onCancel: () => void;
+ onDone: () => Promise;
+ onReopened: (state: ReportState) => Promise;
+ pending: Pending;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const row = pending?.row;
+
+ return (
+
+ );
+};
+
+export const ModcpReportsBoard = ({
+ filter,
+ me,
+}: {
+ filter: ModcpBoardFilter;
+ me: BoardPerson;
+}) => {
+ const t = useTranslations("core.moderation");
+ const queryClient = useQueryClient();
+ const reduceMotion = useReducedMotion();
+ const fail = useReportFailure();
+ const newColumn = useSuspenseInfiniteQuery(
+ modcpBoardColumnQueryOptions("new", filter),
+ );
+ const progressColumn = useSuspenseInfiniteQuery(
+ modcpBoardColumnQueryOptions("in_progress", filter),
+ );
+ const doneColumn = useSuspenseInfiniteQuery(
+ modcpBoardColumnQueryOptions("done", filter),
+ );
+ const queries = {
+ done: doneColumn,
+ in_progress: progressColumn,
+ new: newColumn,
+ };
+
+ const [overrides, setOverrides] = React.useState<
+ ReadonlyMap
+ >(() => new Map());
+ const [activeId, setActiveId] = React.useState(null);
+ const [overStatus, setOverStatus] = React.useState(
+ null,
+ );
+ const [dragHeight, setDragHeight] = React.useState(0);
+ const [pending, setPending] = React.useState(null);
+ const boardRef = React.useRef(null);
+ const flipFromRef = React.useRef(null);
+
+ const loaded = {
+ done: doneColumn.data.pages.flatMap(page => page.items),
+ in_progress: progressColumn.data.pages.flatMap(page => page.items),
+ new: newColumn.data.pages.flatMap(page => page.items),
+ };
+ const columns = boardColumns({ loaded, overrides });
+ const allRows = [...columns.new, ...columns.in_progress, ...columns.done];
+ const active =
+ activeId === null
+ ? null
+ : (allRows.find(row => row.id === activeId) ?? null);
+
+ const prepareFlip = () => {
+ if (reduceMotion) return;
+ flipFromRef.current ??= snapshotLayout(boardRef.current, CARDS);
+ };
+
+ React.useLayoutEffect(() => {
+ playLayoutFlip(boardRef.current, flipFromRef.current, CARDS);
+ flipFromRef.current = null;
+ });
+
+ const setOverride = (id: number, next: BoardOverride | null) => {
+ prepareFlip();
+ setOverrides(current => {
+ const copy = new Map(current);
+ if (next === null) copy.delete(id);
+ else copy.set(id, next);
+
+ return copy;
+ });
+ };
+
+ const settle = async (id: number) => {
+ await invalidateModcp(queryClient);
+ setOverride(id, null);
+ };
+
+ const hintFor = (from: ModcpBoardStatus, to: ModcpBoardStatus): string => {
+ const plan = planBoardMove(from, to);
+ if (plan?.kind === "claim") return t("modcp.reports.board.hint_take");
+ if (plan?.kind === "unassign") {
+ return t("modcp.reports.board.hint_unassign");
+ }
+ if (plan?.kind === "resolve") return t("modcp.reports.board.hint_close");
+
+ return t("modcp.reports.board.hint_reopen");
+ };
+
+ const move = async (row: ModcpBoardReport, to: ModcpBoardStatus) => {
+ const plan = planBoardMove(row.status, to);
+ if (plan === null) return;
+
+ setOverride(row.id, optimisticOverride({ me, row, to }));
+
+ if (plan.kind === "resolve" || plan.kind === "reopen") {
+ setPending({ kind: plan.kind, row, to });
+
+ return;
+ }
+
+ const result =
+ plan.kind === "claim"
+ ? await claimReport({ expectedVersion: row.version, id: row.id })
+ : await assignReport({
+ assigneeId: null,
+ expectedVersion: row.version,
+ id: row.id,
+ });
+
+ if (!fail(result)) {
+ if (plan.kind === "claim") {
+ toast.success(t("modcp.detail.assignment.claimed"), {
+ description: t("modcp.detail.assignment.claimed_desc"),
+ });
+ } else {
+ toast.success(t("modcp.reports.board.unassigned"), {
+ description: t("modcp.reports.board.unassigned_desc"),
+ });
+ }
+ }
+ await settle(row.id);
+ };
+
+ const onReopened = async (state: ReportState) => {
+ if (pending === null) return;
+ const { row, to } = pending;
+ if (to === "in_progress" && state.status === "new") {
+ fail(await claimReport({ expectedVersion: state.version, id: row.id }));
+ }
+ if (to === "new" && state.status === "in_progress") {
+ fail(
+ await assignReport({
+ assigneeId: null,
+ expectedVersion: state.version,
+ id: row.id,
+ }),
+ );
+ }
+ };
+
+ const sensors = useSensors(
+ useSensor(FinePointerSensor, { activationConstraint: { distance: 8 } }),
+ useSensor(TouchSensor, {
+ activationConstraint: { delay: 200, tolerance: 8 },
+ }),
+ useSensor(KeyboardSensor, { coordinateGetter: columnCoordinates }),
+ );
+
+ const statusName = (status: ModcpBoardStatus) => t(`statuses.${status}`);
+
+ const announcements: Announcements = {
+ onDragCancel: ({ active: dragged }) =>
+ t("modcp.reports.board.dnd.cancelled", {
+ id: reportIdOf(dragged.id) ?? 0,
+ }),
+ onDragEnd: ({ active: dragged, over }) => {
+ const status = columnStatusOf(over?.id);
+ const id = reportIdOf(dragged.id) ?? 0;
+
+ return status === null
+ ? t("modcp.reports.board.dnd.cancelled", { id })
+ : t("modcp.reports.board.dnd.dropped", {
+ column: statusName(status),
+ id,
+ });
+ },
+ onDragOver: ({ active: dragged, over }) => {
+ const status = columnStatusOf(over?.id);
+ const id = reportIdOf(dragged.id) ?? 0;
+
+ return status === null
+ ? t("modcp.reports.board.dnd.outside", { id })
+ : t("modcp.reports.board.dnd.over", {
+ column: statusName(status),
+ id,
+ });
+ },
+ onDragStart: ({ active: dragged }) =>
+ t("modcp.reports.board.dnd.picked_up", {
+ id: reportIdOf(dragged.id) ?? 0,
+ }),
+ };
+
+ const onDragStart = ({ active: dragged }: DragStartEvent) => {
+ setActiveId(reportIdOf(dragged.id));
+ setDragHeight(dragged.rect.current.initial?.height ?? 0);
+ };
+
+ const onDragOver = ({ active: dragged, over }: DragOverEvent) => {
+ const status = columnStatusOf(over?.id);
+ const height = dragged.rect.current.initial?.height ?? 0;
+ if (height !== dragHeight) setDragHeight(height);
+ if (status === overStatus) return;
+ prepareFlip();
+ setOverStatus(status);
+ };
+
+ const reset = () => {
+ prepareFlip();
+ setActiveId(null);
+ setOverStatus(null);
+ };
+
+ const onDragEnd = ({ over }: DragEndEvent) => {
+ const status = columnStatusOf(over?.id);
+ const row = active;
+ reset();
+ if (row !== null && status !== null) void move(row, status);
+ };
+
+ const showsSlot =
+ active !== null && overStatus !== null && overStatus !== active.status;
+
+ return (
+ row.targetLabel)}>
+
+
+ {MODCP_BOARD_STATUSES.map(status => {
+ const query = queries[status];
+ const rows = columns[status];
+
+ return (
+ {
+ void query.fetchNextPage();
+ }}
+ onMove={(row, to) => {
+ void move(row, to);
+ }}
+ rows={rows}
+ slot={
+ showsSlot && overStatus === status
+ ? {
+ height: dragHeight,
+ hint: hintFor(active.status, status),
+ index: slotIndex(rows, active),
+ }
+ : null
+ }
+ status={status}
+ />
+ );
+ })}
+
+
+
+ {active === null ? null : (
+
+
+
+
+
+ )}
+
+
+ {
+ if (pending !== null) setOverride(pending.row.id, null);
+ setPending(null);
+ }}
+ onDone={async () => {
+ const id = pending?.row.id;
+ setPending(null);
+ if (id !== undefined) await settle(id);
+ }}
+ onReopened={onReopened}
+ pending={pending}
+ />
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx
new file mode 100644
index 0000000000..2e3c2d596d
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx
@@ -0,0 +1,112 @@
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { cn } from "cn";
+import { FlagIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { SideNavCard, SideNavLink, SideNavList } from "@/components/side-nav";
+
+import { modcpReportsQueryOptions } from "./modcp-query";
+
+export type ModcpNavKey = "reports";
+
+interface ModcpNavItem {
+ href: string;
+ icon: React.ComponentType<{ className?: string }>;
+ key: ModcpNavKey;
+}
+
+export const MODCP_NAV_ITEMS: readonly ModcpNavItem[] = [
+ { href: "/modcp/reports", icon: FlagIcon, key: "reports" },
+];
+
+const OPEN_REPORTS_FILTER = {
+ assigned: "any",
+ page: 1,
+ pageSize: 1,
+ status: "open",
+} as const;
+
+export const isModcpNavItemActive = (
+ item: ModcpNavItem,
+ pathname: string,
+): boolean => pathname === item.href || pathname.startsWith(`${item.href}/`);
+
+const useNavCounts = (): Partial> => {
+ const { data } = useQuery(modcpReportsQueryOptions(OPEN_REPORTS_FILTER));
+
+ return { reports: data?.total };
+};
+
+const ModcpNavChip = ({
+ count,
+ isActive,
+ item,
+}: {
+ count?: number;
+ isActive: boolean;
+ item: ModcpNavItem;
+}) => {
+ const t = useTranslations("core.moderation.modcp");
+ const Icon = item.icon;
+
+ return (
+
+
+ {t(`${item.key}.title`)}
+ {count ? (
+ {count}
+ ) : null}
+
+ );
+};
+
+export const ModcpNavContent = ({ pathname }: { pathname: string }) => {
+ const t = useTranslations("core.moderation.modcp");
+ const counts = useNavCounts();
+
+ return (
+ <>
+
+
+
+
+ {MODCP_NAV_ITEMS.map(item => (
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-query.ts b/packages/vitnode/src/views/moderation/modcp/modcp-query.ts
new file mode 100644
index 0000000000..43efb91edc
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/modcp-query.ts
@@ -0,0 +1,128 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { fetcher } from "@/tanstack/fetcher";
+
+export interface ModcpReportsFilter {
+ assigned: "any" | "me" | "unassigned";
+ page: number;
+ pageSize: number;
+ search?: string;
+ status: "all" | "done" | "in_progress" | "new" | "open";
+}
+
+export const MODCP_BOARD_STATUSES = ["new", "in_progress", "done"] as const;
+
+export type ModcpBoardStatus = (typeof MODCP_BOARD_STATUSES)[number];
+
+export interface ModcpBoardFilter {
+ assigned: "any" | "me" | "unassigned";
+ search?: string;
+}
+
+export const MODCP_BOARD_PAGE_SIZE = 20;
+
+export class ModcpRequestError extends Error {
+ constructor(status: number, what: string) {
+ super(`Reading ${what} answered ${status}.`);
+ this.status = status;
+ }
+
+ readonly status: number;
+}
+
+const MODCP_ROOT = ["vitnode", "moderation"] as const;
+
+const fetchReports = async (filter: ModcpReportsFilter) => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { query: filter },
+ method: "get",
+ module: "moderation",
+ path: "/reports",
+ });
+ if (!response.ok) throw new ModcpRequestError(response.status, "reports");
+
+ return await response.json();
+};
+
+export type ModcpReportsPage = Awaited>;
+
+export type ModcpBoardReport = ModcpReportsPage["items"][number];
+
+export const modcpReportsQueryOptions = (filter: ModcpReportsFilter) =>
+ queryOptions({
+ queryFn: async () => await fetchReports(filter),
+ queryKey: [...MODCP_ROOT, "reports", filter],
+ retry: false,
+ staleTime: 10_000,
+ });
+
+export const modcpBoardColumnQueryOptions = (
+ status: ModcpBoardStatus,
+ filter: ModcpBoardFilter,
+) =>
+ infiniteQueryOptions({
+ getNextPageParam: (last: ModcpReportsPage) =>
+ last.page * last.pageSize < last.total ? last.page + 1 : undefined,
+ initialPageParam: 1,
+ queryFn: async ({ pageParam }) =>
+ await fetchReports({
+ ...filter,
+ page: pageParam,
+ pageSize: MODCP_BOARD_PAGE_SIZE,
+ status,
+ }),
+ queryKey: [...MODCP_ROOT, "board", status, filter],
+ retry: false,
+ staleTime: 10_000,
+ });
+
+const fetchReport = async (id: number) => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "get",
+ module: "moderation",
+ path: "/reports/{id}",
+ });
+ if (!response.ok) throw new ModcpRequestError(response.status, "a report");
+
+ return await response.json();
+};
+
+export type ModcpReportDetail = Awaited>;
+
+export const modcpReportQueryOptions = (id: number) =>
+ queryOptions({
+ queryFn: async () => await fetchReport(id),
+ queryKey: [...MODCP_ROOT, "report", id],
+ retry: false,
+ staleTime: 0,
+ });
+
+const fetchAssignees = async (id: number) => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "get",
+ module: "moderation",
+ path: "/reports/{id}/assignees",
+ });
+ if (!response.ok) return [];
+
+ return await response.json();
+};
+
+export const modcpAssigneesQueryOptions = (id: number) =>
+ queryOptions({
+ queryFn: async () => await fetchAssignees(id),
+ queryKey: [...MODCP_ROOT, "report", id, "assignees"],
+ retry: false,
+ staleTime: 30_000,
+ });
+
+export const invalidateModcp = async (queryClient: QueryClient) =>
+ await queryClient.invalidateQueries({ queryKey: MODCP_ROOT });
diff --git a/packages/vitnode/src/views/moderation/modcp/plugin-messages.tsx b/packages/vitnode/src/views/moderation/modcp/plugin-messages.tsx
new file mode 100644
index 0000000000..918867b615
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/plugin-messages.tsx
@@ -0,0 +1,27 @@
+import { MAX_NAMESPACES } from "@/routing/namespaces";
+import { RouteMessages } from "@/tanstack/i18n/route-messages";
+import { MODCP_NAMESPACES } from "@/tanstack/moderation/namespaces";
+
+export const namespacesForLabels = (
+ keys: readonly (null | string | undefined)[],
+): string[] => {
+ const namespaces = new Set();
+ for (const key of keys) {
+ if (!key || !key.includes(".")) continue;
+ namespaces.add(key.slice(0, key.lastIndexOf(".")));
+ }
+
+ return [...MODCP_NAMESPACES, ...namespaces].slice(0, MAX_NAMESPACES);
+};
+
+export const ModcpPluginMessages = ({
+ children,
+ labels,
+}: {
+ children: React.ReactNode;
+ labels: readonly (null | string | undefined)[];
+}) => (
+
+ {children}
+
+);
diff --git a/packages/vitnode/src/views/moderation/modcp/report-activity.tsx b/packages/vitnode/src/views/moderation/modcp/report-activity.tsx
new file mode 100644
index 0000000000..e1553b0695
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-activity.tsx
@@ -0,0 +1,686 @@
+import { cn } from "cn";
+import {
+ CheckCircle2Icon,
+ EyeIcon,
+ EyeOffIcon,
+ FlagIcon,
+ MessageSquareIcon,
+ MoreHorizontalIcon,
+ PencilIcon,
+ RotateCcwIcon,
+ Trash2Icon,
+ UserCheckIcon,
+ UserMinusIcon,
+ WorkflowIcon,
+} from "lucide-react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+import type { RichTextDocument } from "@/content/rich-text/document";
+
+import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog";
+import { DateFormat } from "@/components/date-format";
+import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form";
+import { AutoFormEditor } from "@/components/form/fields/editor";
+import { RichTextContent } from "@/components/rich-text";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Kbd } from "@/components/ui/kbd";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsPanels,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import {
+ isRichTextDocument,
+ isRichTextEmpty,
+} from "@/content/rich-text/document";
+
+import type { ModcpReportDetail } from "./modcp-query";
+import type { ReportPerson, ToneName } from "./report-tone";
+
+import {
+ addReportNote,
+ deleteReportNote,
+ editReportNote,
+} from "../moderation-mutations";
+import { useReportFailure } from "./report-forms";
+import {
+ PersonAvatar,
+ PersonName,
+ StatusLozenge,
+ TONE_SURFACE,
+} from "./report-tone";
+
+type ReportEvent = ModcpReportDetail["events"][number];
+
+const EVENT_STYLE: Record<
+ ReportEvent["type"],
+ { icon: React.ComponentType<{ className?: string }>; tone: ToneName }
+> = {
+ action: { icon: EyeOffIcon, tone: "destructive" },
+ assigned: { icon: UserCheckIcon, tone: "info" },
+ created: { icon: FlagIcon, tone: "warning" },
+ note: { icon: MessageSquareIcon, tone: "info" },
+ note_deleted: { icon: Trash2Icon, tone: "destructive" },
+ note_edited: { icon: PencilIcon, tone: "info" },
+ reopened: { icon: RotateCcwIcon, tone: "warning" },
+ resolved: { icon: CheckCircle2Icon, tone: "success" },
+ status_changed: { icon: WorkflowIcon, tone: "info" },
+ unassigned: { icon: UserMinusIcon, tone: "muted" },
+};
+
+const isUnhide = (event: ReportEvent) =>
+ event.type === "action" && event.data.action === "unhide";
+
+const isStatus = (value: unknown): value is "done" | "in_progress" | "new" =>
+ value === "new" || value === "in_progress" || value === "done";
+
+const isResolution = (
+ value: unknown,
+): value is "action_taken" | "duplicate" | "no_violation" =>
+ value === "no_violation" || value === "action_taken" || value === "duplicate";
+
+const EventText = ({
+ event,
+ people,
+}: {
+ event: ReportEvent;
+ people: Map;
+}): React.ReactNode => {
+ const t = useTranslations("core.moderation");
+ const tEvents = useTranslations(
+ "core.moderation.modcp.detail.activity.events",
+ );
+ const mention = (id: unknown) => {
+ const person = typeof id === "number" ? people.get(id) : undefined;
+
+ return person ? (
+
+ ) : (
+ t("modcp.detail.timeline.someone")
+ );
+ };
+
+ switch (event.type) {
+ case "action":
+ return tEvents(isUnhide(event) ? "action_unhide" : "action_hide");
+ case "assigned":
+ return event.actor && event.data.assigneeId === event.actor.id
+ ? tEvents("took")
+ : tEvents.rich("assigned", {
+ assignee: () => mention(event.data.assigneeId),
+ });
+ case "resolved":
+ return tEvents("resolved", {
+ resolution: isResolution(event.data.resolution)
+ ? t(`resolutions.${event.data.resolution}`)
+ : "-",
+ });
+ case "status_changed":
+ return tEvents.rich("status_changed", {
+ status: () =>
+ isStatus(event.data.to) ? (
+
+ ) : null,
+ });
+ case "unassigned":
+ return event.data.reason === "lost_access"
+ ? tEvents("lost_access")
+ : tEvents.rich("unassigned", {
+ assignee: () => mention(event.data.previousAssigneeId),
+ });
+ default:
+ return tEvents(event.type);
+ }
+};
+
+const NoteText = ({ body }: { body: unknown }) =>
+ isRichTextDocument(body) ? (
+
+ ) : (
+
+ {typeof body === "string" ? body : ""}
+
+ );
+
+const NoteChange = ({ event }: { event: ReportEvent }) => {
+ const t = useTranslations("core.moderation.modcp.detail.activity");
+ const hasAfter = event.type === "note_edited";
+
+ return (
+
+ {event.data.before ? (
+
+
+ {t("before")}
+
+
+
+
+
+ ) : null}
+ {hasAfter && event.data.after ? (
+
+
+ {t("after")}
+
+
+
+ ) : null}
+
+ );
+};
+
+const EventBody = ({ event }: { event: ReportEvent }) => {
+ if (event.type === "note_edited" || event.type === "note_deleted") {
+ return ;
+ }
+ const body = event.data.body ?? event.data.explanation ?? event.data.reason;
+ if (!body) return null;
+
+ return (
+
+
+
+ );
+};
+
+const SystemAvatar = () => (
+
+
+
+);
+
+const toDocument = (body: unknown): null | RichTextDocument => {
+ if (isRichTextDocument(body)) return body;
+ if (typeof body !== "string" || !body) return null;
+
+ return {
+ content: [{ content: [{ text: body, type: "text" }], type: "paragraph" }],
+ type: "doc",
+ };
+};
+
+const noteBodySchema = z.looseObject({
+ content: z.array(z.unknown()).optional(),
+ type: z.literal("doc"),
+});
+
+const noteFormSchema = (message: string, initial: null | RichTextDocument) =>
+ z.object({
+ body: noteBodySchema
+ .nullable()
+ .default(initial as null | z.infer)
+ .refine(
+ doc => doc !== null && !isRichTextEmpty(doc as RichTextDocument),
+ { message },
+ ),
+ });
+
+const NoteEditForm = ({
+ event,
+ onCancel,
+ onDone,
+ reportId,
+}: {
+ event: ReportEvent;
+ onCancel: () => void;
+ onDone: () => Promise;
+ reportId: number;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const fail = useReportFailure();
+ const formSchema = noteFormSchema(
+ t("activity.note_required"),
+ toDocument(event.data.body),
+ );
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const result = await editReportNote({
+ body: values.body as RichTextDocument,
+ id: reportId,
+ noteId: event.id,
+ });
+ if (fail(result)) {
+ await onDone();
+
+ return;
+ }
+ toast.success(t("notes.updated"));
+ onCancel();
+ await onDone();
+ };
+
+ return (
+ (
+ {
+ editor?.commands.focus("end");
+ }}
+ />
+ ),
+ id: "body",
+ },
+ ]}
+ formSchema={formSchema}
+ layout={fields => (
+ <>
+ {fields.body}
+
+
+ {t("activity.cancel")}
+
+
{t("notes.save")}
+
+ >
+ )}
+ onSubmit={onSubmit}
+ />
+ );
+};
+
+const NoteActions = ({
+ event,
+ onDone,
+ onEdit,
+ reportId,
+}: {
+ event: ReportEvent;
+ onDone: () => Promise;
+ onEdit: () => void;
+ reportId: number;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail.notes");
+ const fail = useReportFailure();
+ const [isConfirming, setIsConfirming] = React.useState(false);
+
+ return (
+ <>
+
+
+ }
+ >
+
+
+
+ {event.canEdit ? (
+
+
+ {t("edit")}
+
+ ) : null}
+ {event.canDelete ? (
+ {
+ setIsConfirming(true);
+ }}
+ variant="destructive"
+ >
+
+ {t("delete")}
+
+ ) : null}
+
+
+ {
+ const result = await deleteReportNote({
+ id: reportId,
+ noteId: event.id,
+ });
+ if (!fail(result)) toast.success(t("deleted"));
+ onClose();
+ await onDone();
+ }}
+ open={isConfirming}
+ textSubmit={t("delete")}
+ title={t("confirm_delete")}
+ />
+ >
+ );
+};
+
+const ActivityItem = ({
+ event,
+ onDone,
+ people,
+ reportId,
+}: {
+ event: ReportEvent;
+ onDone: () => Promise;
+ people: Map;
+ reportId: number;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const [isEditing, setIsEditing] = React.useState(false);
+ const style = EVENT_STYLE[event.type];
+ const tone = isUnhide(event) ? "success" : style.tone;
+ const Icon = isUnhide(event) ? EyeIcon : style.icon;
+ const isEdited = typeof event.data.editedAt === "string";
+
+ return (
+
+
+ {event.actor ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {event.actor ? (
+
+ ) : (
+
+ {t("timeline.system")}
+
+ )}
+
+
+
+
·
+
+
+
+ {isEdited ? (
+
({t("notes.edited")})
+ ) : null}
+ {(event.canEdit || event.canDelete) && !isEditing ? (
+
{
+ setIsEditing(true);
+ }}
+ reportId={reportId}
+ />
+ ) : null}
+
+ {isEditing ? (
+
{
+ setIsEditing(false);
+ }}
+ onDone={onDone}
+ reportId={reportId}
+ />
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const ActivityFeed = ({
+ events,
+ onDone,
+ people,
+ reportId,
+}: {
+ events: ReportEvent[];
+ onDone: () => Promise;
+ people: Map;
+ reportId: number;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+
+ if (events.length === 0) {
+ return (
+
+ {t("activity.empty")}
+
+ );
+ }
+
+ return (
+
+ {[...events].reverse().map(event => (
+
+ ))}
+
+ );
+};
+
+const NoteComposer = ({
+ id,
+ me,
+ onDone,
+}: {
+ id: number;
+ me: null | ReportPerson;
+ onDone: () => Promise;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const fail = useReportFailure();
+ const [isOpen, setIsOpen] = React.useState(false);
+ const formSchema = noteFormSchema(t("activity.note_required"), null);
+
+ React.useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement;
+ if (
+ /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+ if (
+ event.key === "m" &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey
+ ) {
+ event.preventDefault();
+ setIsOpen(true);
+ }
+ };
+ document.addEventListener("keydown", onKey);
+
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ };
+ }, []);
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const result = await addReportNote({
+ body: values.body as RichTextDocument,
+ id,
+ });
+ if (fail(result)) return;
+ toast.success(t("notes.success"), { description: t("notes.desc") });
+ setIsOpen(false);
+ await onDone();
+ };
+
+ return (
+
+ {me ?
: null}
+ {isOpen ? (
+
(
+ {
+ editor?.commands.focus("end");
+ }}
+ placeholder={t("activity.editor_placeholder")}
+ />
+ ),
+ id: "body",
+ },
+ ]}
+ formSchema={formSchema}
+ layout={fields => (
+ <>
+ {fields.body}
+
+
+ {t("activity.hint")}
+
+
+
{
+ setIsOpen(false);
+ }}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ {t("activity.cancel")}
+
+
+ {t("notes.submit")}
+
+
+
+ >
+ )}
+ onSubmit={onSubmit}
+ />
+ ) : (
+ {
+ setIsOpen(true);
+ }}
+ type="button"
+ >
+ {t("activity.placeholder")}
+ M
+
+ )}
+
+ );
+};
+
+export const ReportActivity = ({
+ canNote,
+ events,
+ id,
+ me,
+ onDone,
+ people,
+}: {
+ canNote: boolean;
+ events: ReportEvent[];
+ id: number;
+ me: null | ReportPerson;
+ onDone: () => Promise;
+ people: Map;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const headingId = React.useId();
+ const notes = events.filter(event => event.type === "note");
+ const history = events.filter(event => event.type !== "note");
+
+ return (
+
+
+ {t("sections.activity")}
+
+
+
+
+ {t("activity.notes")}
+
+ {notes.length}
+
+
+ {t("activity.all")}
+ {t("activity.history")}
+
+ {canNote ? : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/report-content.tsx b/packages/vitnode/src/views/moderation/modcp/report-content.tsx
new file mode 100644
index 0000000000..1b5b9da85b
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-content.tsx
@@ -0,0 +1,285 @@
+import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { cn } from "cn";
+import { FlagIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import { DateFormat } from "@/components/date-format";
+
+import type { ModcpReportDetail } from "./modcp-query";
+import type { ReportPerson } from "./report-tone";
+
+import { invalidateModcp, modcpReportQueryOptions } from "./modcp-query";
+import { ModcpPluginMessages } from "./plugin-messages";
+import { ReportActivity } from "./report-activity";
+import { ReportSidebar } from "./report-sidebar";
+import { SnapshotPeek } from "./report-snapshot-peek";
+import {
+ Person,
+ PersonAvatar,
+ PersonName,
+ REASON_TEXT,
+ REASON_TONE,
+ StatusLozenge,
+ TONE_SURFACE,
+} from "./report-tone";
+import { readSnapshot } from "./snapshot-view";
+import { useTargetLabel } from "./status-badge";
+
+export const ModcpReportContent = ({
+ id,
+ userId,
+}: {
+ id: number;
+ userId: number;
+}) => {
+ const { data } = useSuspenseQuery(modcpReportQueryOptions(id));
+ const snapshot = readSnapshot(data.snapshot);
+
+ return (
+ field.label) ?? []),
+ ]}
+ >
+
+
+ );
+};
+
+const Section = ({
+ children,
+ count,
+ title,
+}: {
+ children: React.ReactNode;
+ count?: number;
+ title: string;
+}) => {
+ const headingId = React.useId();
+
+ return (
+
+
+ {title}
+ {count === undefined ? null : (
+
+ {count}
+
+ )}
+
+ {children}
+
+ );
+};
+
+const LinkedReports = ({
+ related,
+}: {
+ related: ModcpReportDetail["related"];
+}) => {
+ const t = useTranslations("core.moderation");
+
+ if (related.length === 0) {
+ return (
+
+ {t("modcp.detail.related.empty")}
+
+ );
+ }
+
+ return (
+
+ {related.map(entry => (
+ -
+
+ {entry.reporter ? (
+
+ ) : null}
+
+
+
+ #{entry.id}
+
+
+ {entry.description ?? t("modcp.detail.linked.no_details")}
+
+
+
+ {entry.reporter ? (
+ <>
+
+ ·
+ >
+ ) : null}
+
+ {t(`reasons.${entry.reason}`)}
+
+ ·
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+};
+
+const snapshotAuthorOf = (
+ snapshot: NonNullable>,
+) => {
+ const first = snapshot.authors.at(0);
+
+ return first
+ ? { name: first.name, role: { color: null, prefix: null } }
+ : null;
+};
+
+const ModcpReportDetailView = ({
+ id,
+ userId,
+}: {
+ id: number;
+ userId: number;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const tModeration = useTranslations("core.moderation");
+ const tRoot = useTranslations();
+ const queryClient = useQueryClient();
+ const { data } = useSuspenseQuery(modcpReportQueryOptions(id));
+ const targetLabel = useTargetLabel();
+ const snapshot = readSnapshot(data.snapshot);
+ const { report } = data;
+ const typeLabel = targetLabel(data.targetLabel, report.targetType);
+ const pluginName = tRoot.has(data.pluginLabel as never)
+ ? tRoot(data.pluginLabel as never)
+ : report.pluginId;
+ const author = data.authors.at(0) ?? null;
+
+ const people = React.useMemo(() => {
+ const map = new Map();
+ for (const person of [
+ data.reporter,
+ data.assignee,
+ ...data.authors,
+ ...data.events.map(event => event.actor),
+ ]) {
+ if (person) map.set(person.id, person);
+ }
+
+ return map;
+ }, [data]);
+
+ const refresh = async () => {
+ await invalidateModcp(queryClient);
+ };
+
+ return (
+
+
+
+
+
+ {tModeration(`reasons.${report.reason}`)}
+ :
+
+ {author
+ ? t.rich("heading", {
+ author: () => ,
+ type: typeLabel,
+ })
+ : (snapshot?.title ?? t("title", { id: report.id }))}
+
+
+
+
+
+
+
+
+ {report.description ?? t("report.no_description")}
+
+
+ {data.reporter ? (
+
+ ) : (
+ t("report.deleted_user")
+ )}
+ ·
+
+
+
+
+
+ {snapshot ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/report-forms.tsx b/packages/vitnode/src/views/moderation/modcp/report-forms.tsx
new file mode 100644
index 0000000000..f678ea71d4
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-forms.tsx
@@ -0,0 +1,178 @@
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+import type { ModerationReportResolution } from "@/lib/moderation/const";
+
+import { AutoForm } from "@/components/form/auto-form";
+import { AutoFormRadioGroup } from "@/components/form/fields/radio-group";
+import { AutoFormTextarea } from "@/components/form/fields/textarea";
+import {
+ MODERATION_LIMITS,
+ MODERATION_REPORT_RESOLUTIONS,
+} from "@/lib/moderation/const";
+
+import type { ModerationResult, ReportState } from "../moderation-mutations";
+
+import { reopenReport, resolveReport } from "../moderation-mutations";
+
+const noteField = z
+ .string()
+ .trim()
+ .min(MODERATION_LIMITS.explanationMinLength)
+ .max(MODERATION_LIMITS.noteMaxLength);
+
+export const useReportFailure = () => {
+ const t = useTranslations("core.moderation.modcp.detail.errors");
+
+ return (result: ModerationResult): boolean => {
+ if (result.ok) return false;
+ const known = [
+ "already_claimed",
+ "assignee_not_eligible",
+ "conflict",
+ "forbidden",
+ "note_not_found",
+ "report_closed",
+ "report_open",
+ "target_gone",
+ ] as const;
+ const key = (known as readonly string[]).includes(result.failure)
+ ? (result.failure as (typeof known)[number])
+ : "unknown";
+ toast.error(t(key));
+
+ return true;
+ };
+};
+
+export const ResolveReportForm = ({
+ defaultResolution,
+ expectedVersion,
+ id,
+ onDone,
+}: {
+ defaultResolution?: ModerationReportResolution;
+ expectedVersion: number;
+ id: number;
+ onDone: () => Promise;
+}) => {
+ const t = useTranslations("core.moderation");
+ const fail = useReportFailure();
+ const resolution = z.enum(MODERATION_REPORT_RESOLUTIONS);
+ const formSchema = z.object({
+ resolution: defaultResolution
+ ? resolution.default(defaultResolution)
+ : resolution,
+ explanation: z
+ .string()
+ .trim()
+ .max(MODERATION_LIMITS.noteMaxLength)
+ .optional(),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const result = await resolveReport({
+ expectedVersion,
+ explanation: values.explanation === "" ? undefined : values.explanation,
+ id,
+ resolution: values.resolution,
+ });
+ if (fail(result)) {
+ await onDone();
+
+ return;
+ }
+ toast.success(t("modcp.detail.resolve.success"), {
+ description: t("modcp.detail.resolve.success_desc"),
+ });
+ await onDone();
+ };
+
+ return (
+ (
+ ({
+ description: t(`resolution_descs.${value}`),
+ label: t(`resolutions.${value}`),
+ value,
+ }))}
+ />
+ ),
+ id: "resolution",
+ },
+ {
+ component: props => (
+
+ ),
+ id: "explanation",
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{ children: t("modcp.detail.resolve.submit") }}
+ />
+ );
+};
+
+export const ReopenReportForm = ({
+ expectedVersion,
+ id,
+ onDone,
+ onReopened,
+}: {
+ expectedVersion: number;
+ id: number;
+ onDone: () => Promise;
+ onReopened?: (state: ReportState) => Promise;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail.reopen");
+ const fail = useReportFailure();
+ const formSchema = z.object({ reason: noteField });
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const result = await reopenReport({
+ expectedVersion,
+ id,
+ reason: values.reason,
+ });
+ if (!fail(result)) {
+ toast.success(t("success"));
+ if (result.ok) await onReopened?.(result.data);
+ }
+ await onDone();
+ };
+
+ return (
+ (
+
+ ),
+ id: "reason",
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{ children: t("submit") }}
+ />
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/report-sidebar.tsx b/packages/vitnode/src/views/moderation/modcp/report-sidebar.tsx
new file mode 100644
index 0000000000..1902129999
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-sidebar.tsx
@@ -0,0 +1,629 @@
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "cn";
+import {
+ ChevronDownIcon,
+ ExternalLinkIcon,
+ EyeIcon,
+ EyeOffIcon,
+ FlagIcon,
+ PuzzleIcon,
+ TrashIcon,
+ UserCheckIcon,
+} from "lucide-react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog";
+import { DateFormat } from "@/components/date-format";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import type { ModcpReportDetail } from "./modcp-query";
+import type { ReportStatus } from "./report-tone";
+
+import {
+ assignReport,
+ claimReport,
+ runReportAction,
+} from "../moderation-mutations";
+import { modcpAssigneesQueryOptions } from "./modcp-query";
+import {
+ ReopenReportForm,
+ ResolveReportForm,
+ useReportFailure,
+} from "./report-forms";
+import {
+ Chip,
+ Person,
+ REASON_TONE,
+ STATUS_TONE,
+ StatusLozenge,
+ TONE_SURFACE,
+} from "./report-tone";
+
+type Dialogs = "close" | "reopen" | null;
+
+interface ReportContext {
+ data: ModcpReportDetail;
+ onDone: () => Promise;
+ userId: number;
+}
+
+const ActionDialogs = ({
+ data,
+ dialog,
+ onDone,
+ setDialog,
+}: ReportContext & {
+ dialog: Dialogs;
+ setDialog: (next: Dialogs) => void;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const { report } = data;
+ const finish = async () => {
+ setDialog(null);
+ await onDone();
+ };
+
+ return (
+
+ );
+};
+
+const StatusButton = ({ data, onDone, userId }: ReportContext) => {
+ const t = useTranslations("core.moderation");
+ const fail = useReportFailure();
+ const [dialog, setDialog] = React.useState(null);
+ const { report } = data;
+ const isMine = data.assignee?.id === userId;
+
+ if (!data.permissions.manage) {
+ return (
+
+ {t(`statuses.${report.status}`)}
+
+ );
+ }
+
+ const claim = async () => {
+ const result = await claimReport({
+ expectedVersion: report.version,
+ id: report.id,
+ });
+ if (!fail(result)) {
+ toast.success(t("modcp.detail.assignment.claimed"), {
+ description: t("modcp.detail.assignment.claimed_desc"),
+ });
+ }
+ await onDone();
+ };
+
+ const stop = async () => {
+ const result = await assignReport({
+ assigneeId: null,
+ expectedVersion: report.version,
+ id: report.id,
+ });
+ if (!fail(result)) toast.success(t("modcp.detail.assignment.assigned"));
+ await onDone();
+ };
+
+ const transitions: {
+ label: string;
+ run: () => Promise | void;
+ to: ReportStatus;
+ }[] =
+ report.status === "done"
+ ? [
+ {
+ label: t("modcp.detail.status.reopen"),
+ run: () => {
+ setDialog("reopen");
+ },
+ to: "in_progress",
+ },
+ ]
+ : [
+ ...(report.status === "new"
+ ? [
+ {
+ label: t("modcp.detail.status.start"),
+ run: claim,
+ to: "in_progress" as const,
+ },
+ ]
+ : []),
+ {
+ label: t("modcp.detail.status.close"),
+ run: () => {
+ setDialog("close");
+ },
+ to: "done",
+ },
+ ...(report.status === "in_progress" && isMine
+ ? [
+ {
+ label: t("modcp.detail.status.stop"),
+ run: stop,
+ to: "new" as const,
+ },
+ ]
+ : []),
+ ];
+
+ return (
+ <>
+
+
+ }
+ >
+ {t(`statuses.${report.status}`)}
+
+
+
+
+ {t("modcp.detail.status.move_to")}
+
+ {transitions.map(transition => (
+ {
+ await transition.run();
+ }}
+ >
+ {transition.label}
+
+
+ ))}
+
+
+
+ >
+ );
+};
+
+const ActionsMenu = ({ data, onDone }: ReportContext) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const fail = useReportFailure();
+ const [confirming, setConfirming] = React.useState<"hide" | "unhide" | null>(
+ null,
+ );
+ const actions = data.permissions.actions.filter(
+ action => action.available && action.allowed,
+ );
+
+ return (
+ <>
+
+ }
+ >
+ {t("actions.menu")}
+
+
+
+ {
+ if (data.current.url) {
+ window.open(data.current.url, "_blank", "noopener");
+ }
+ }}
+ >
+
+ {t("current.open")}
+
+ {actions.length > 0 ? : null}
+ {actions.map(action => (
+ {
+ setConfirming(action.action);
+ }}
+ variant={action.action === "hide" ? "destructive" : "default"}
+ >
+ {action.action === "hide" ? (
+
+ ) : (
+
+ )}
+ {t(`actions.${action.action}`)}
+
+ ))}
+
+
+ {
+ if (!open) setConfirming(null);
+ }}
+ onSubmit={async ({ onClose }) => {
+ if (!confirming) return;
+ const result = await runReportAction({
+ action: confirming,
+ expectedVersion: data.report.version,
+ id: data.report.id,
+ });
+ if (!fail(result) && result.ok) {
+ toast.success(
+ result.data.changed
+ ? t(
+ confirming === "hide"
+ ? "actions.hidden"
+ : "actions.unhidden",
+ )
+ : t("actions.unchanged"),
+ result.data.changed
+ ? { description: t("actions.recorded") }
+ : undefined,
+ );
+ }
+ onClose();
+ setConfirming(null);
+ await onDone();
+ }}
+ open={confirming !== null}
+ submitVariant={confirming === "hide" ? "destructive" : "default"}
+ textSubmit={t("actions.confirm")}
+ title={confirming ? t(`actions.confirm_${confirming}`) : undefined}
+ />
+ >
+ );
+};
+
+const FieldRow = ({
+ children,
+ label,
+}: {
+ children: React.ReactNode;
+ label: string;
+}) => (
+
+
{label}
+ {children}
+
+);
+
+const AssigneeField = ({ data, onDone, userId }: ReportContext) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const fail = useReportFailure();
+ const { report } = data;
+ const canChange = data.permissions.manage && report.status !== "done";
+ const assignees = useQuery({
+ ...modcpAssigneesQueryOptions(report.id),
+ enabled: canChange,
+ });
+ const canTakeIt =
+ canChange &&
+ data.assignee?.id !== userId &&
+ (assignees.data ?? []).some(user => user.id === userId);
+
+ const assign = async (assigneeId: null | number) => {
+ const result =
+ assigneeId === userId && data.assignee === null
+ ? await claimReport({ expectedVersion: report.version, id: report.id })
+ : await assignReport({
+ assigneeId,
+ expectedVersion: report.version,
+ id: report.id,
+ });
+ if (!fail(result)) {
+ toast.success(t("assignment.assigned"), {
+ description: t("assignment.assigned_desc"),
+ });
+ }
+ await onDone();
+ };
+
+ const current = data.assignee ? (
+
+ ) : (
+ <>
+
+
+
+ {t("details.unassigned")}
+ >
+ );
+
+ return (
+
+ {canChange ? (
+
+
+ }
+ >
+ {current}
+
+
+
+ {t("details.assignee")}
+ {
+ await assign(value === "none" ? null : Number(value));
+ }}
+ value={data.assignee ? String(data.assignee.id) : "none"}
+ >
+
+ {t("details.unassigned")}
+
+
+ {(assignees.data ?? []).map(user => (
+
+
+ {user.id === userId ? (
+
+ {t("details.you")}
+
+ ) : null}
+
+ ))}
+
+
+
+ ) : (
+
{current}
+ )}
+ {canTakeIt ? (
+
{
+ await assign(userId);
+ }}
+ type="button"
+ >
+ {t("details.assign_to_me")}
+
+ ) : null}
+
+ );
+};
+
+const ContentCard = ({
+ data,
+ typeLabel,
+}: {
+ data: ModcpReportDetail;
+ typeLabel: string;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail");
+ const title =
+ typeof data.snapshot.title === "string" && data.snapshot.title
+ ? data.snapshot.title
+ : t("content_card.untitled");
+
+ return (
+
+
+
+
+
+
{typeLabel}
+ {data.current.url ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ )}
+
+
+ );
+};
+
+const Visibility = ({ data }: { data: ModcpReportDetail }) => {
+ const t = useTranslations("core.moderation.modcp.detail.current");
+
+ if (!data.current.registered || !data.current.exists) {
+ return (
+
+
+ {t(data.current.registered ? "deleted" : "unregistered")}
+
+ );
+ }
+
+ return data.current.hidden ? (
+
+ {t("hidden")}
+
+ ) : (
+
+ {t("visible")}
+
+ );
+};
+
+export const ReportSidebar = ({
+ data,
+ onDone,
+ pluginName,
+ typeLabel,
+ userId,
+}: ReportContext & { pluginName: string; typeLabel: string }) => {
+ const t = useTranslations("core.moderation");
+ const { report } = data;
+ const author = data.authors.at(0);
+
+ return (
+
+
+
+
+ {t("modcp.detail.details.title")}
+
+
+
+
+
+
+
+
+ {pluginName}
+
+
+
+
+
+
+ {data.reporter ? (
+
+ ) : (
+
+ {t("modcp.detail.report.deleted_user")}
+
+ )}
+
+ {author ? (
+
+
+
+ ) : null}
+
+
+ {t(`reasons.${report.reason}`)}
+
+
+
+
+
+ {report.resolution ? (
+
+
+ {t(`resolutions.${report.resolution}`)}
+
+
+ ) : null}
+
+
+
+ - {t("modcp.detail.details.created")}
+ -
+
+
+ - {t("modcp.detail.details.updated")}
+ -
+
+
+ {report.resolvedAt ? (
+ <>
+ - {t("modcp.detail.details.resolved")}
+ -
+
+
+ >
+ ) : null}
+
+ {!data.permissions.manage ? (
+
+ {t("modcp.detail.read_only")}
+
+ ) : null}
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/report-snapshot-peek.tsx b/packages/vitnode/src/views/moderation/modcp/report-snapshot-peek.tsx
new file mode 100644
index 0000000000..53f4106302
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-snapshot-peek.tsx
@@ -0,0 +1,104 @@
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type {
+ ModerationReportSnapshot,
+ ModerationSnapshotField,
+} from "@/lib/moderation/snapshot";
+
+import { DateFormat } from "@/components/date-format";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { isRichTextDocument } from "@/content/rich-text/document";
+
+import type { ReportPerson } from "./report-tone";
+
+import { PersonName } from "./report-tone";
+import { SnapshotView } from "./snapshot-view";
+
+interface TextNode {
+ content?: TextNode[];
+ text?: string;
+ type: string;
+}
+
+const plainText = (node: TextNode): string =>
+ node.text ?? (node.content ?? []).map(plainText).join(" ");
+
+const excerptOf = (fields: ModerationSnapshotField[]): string => {
+ for (const field of fields) {
+ if (field.kind === "richText" && isRichTextDocument(field.value)) {
+ const text = plainText(field.value).replaceAll(/\s+/g, " ").trim();
+ if (text) return text;
+ }
+ if (field.kind === "text" && field.value) return field.value;
+ }
+
+ return "";
+};
+
+export const SnapshotPeek = ({
+ author,
+ reportId,
+ snapshot,
+ typeLabel,
+}: {
+ author: null | Pick;
+ reportId: number;
+ snapshot: ModerationReportSnapshot;
+ typeLabel: string;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail.snapshot_card");
+ const [isOpen, setIsOpen] = React.useState(false);
+ const excerpt = excerptOf(snapshot.fields);
+
+ return (
+
+ {
+ setIsOpen(true);
+ }}
+ type="button"
+ >
+
+ {t.rich("meta", {
+ author: () =>
+ author ? : "-",
+ date: () => ,
+ type: typeLabel,
+ })}
+
+ {snapshot.title ? (
+
+ {snapshot.title}
+
+ ) : null}
+ {excerpt ? (
+
+ {excerpt}
+
+ ) : null}
+
+ {t("view")}
+
+
+
+
+ {t("sheet_title")}
+
+ {t("sheet_desc", { id: reportId })}
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/report-tone.tsx b/packages/vitnode/src/views/moderation/modcp/report-tone.tsx
new file mode 100644
index 0000000000..1b908830ab
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/report-tone.tsx
@@ -0,0 +1,129 @@
+import { cn } from "cn";
+import { useTranslations } from "use-intl";
+
+import type { ModerationReportReason } from "@/lib/moderation/const";
+
+import { Avatar } from "@/components/avatar";
+import { FEEDBACK_TONE_TEXT } from "@/components/ui/feedback-tone";
+import { UserFormat } from "@/components/user-format";
+
+import type { ModcpReportDetail } from "./modcp-query";
+
+export type ReportPerson = NonNullable;
+export type ReportStatus = ModcpReportDetail["report"]["status"];
+
+export const TONE_SURFACE = {
+ destructive: cn("bg-destructive/10", FEEDBACK_TONE_TEXT.destructive),
+ info: cn("bg-primary/10", FEEDBACK_TONE_TEXT.info),
+ muted: "bg-muted text-muted-foreground",
+ success: cn("bg-success/12", FEEDBACK_TONE_TEXT.success),
+ warning: cn("bg-warn/12", FEEDBACK_TONE_TEXT.warning),
+} as const;
+
+export type ToneName = keyof typeof TONE_SURFACE;
+
+export const STATUS_TONE: Record = {
+ done: "success",
+ in_progress: "info",
+ new: "muted",
+};
+
+export const REASON_TONE: Record = {
+ abusive: "destructive",
+ other: "muted",
+ rule_violation: "warning",
+ spam: "warning",
+};
+
+export const REASON_TEXT: Record = {
+ abusive: FEEDBACK_TONE_TEXT.destructive,
+ other: "text-muted-foreground",
+ rule_violation: FEEDBACK_TONE_TEXT.warning,
+ spam: FEEDBACK_TONE_TEXT.warning,
+};
+
+export const Chip = ({
+ children,
+ icon: Icon,
+ tone,
+}: {
+ children: React.ReactNode;
+ icon?: React.ComponentType<{ className?: string }>;
+ tone: ToneName;
+}) => (
+
+ {Icon ? : null}
+ {children}
+
+);
+
+export const StatusLozenge = ({ status }: { status: ReportStatus }) => {
+ const t = useTranslations("core.moderation.statuses");
+
+ return (
+
+ {t(status)}
+
+ );
+};
+
+export const PersonAvatar = ({
+ className,
+ person,
+ size = 24,
+}: {
+ className?: string;
+ person: ReportPerson;
+ size?: number;
+}) => (
+
+);
+
+export const PersonName = ({
+ className,
+ link = false,
+ person,
+}: {
+ className?: string;
+ link?: boolean;
+ person: Partial> &
+ Pick;
+}) => (
+
+);
+
+export const Person = ({
+ link = false,
+ person,
+ size = 24,
+}: {
+ link?: boolean;
+ person: ReportPerson;
+ size?: number;
+}) => (
+
+
+
+
+);
diff --git a/packages/vitnode/src/views/moderation/modcp/reports-content.tsx b/packages/vitnode/src/views/moderation/modcp/reports-content.tsx
new file mode 100644
index 0000000000..f3d096d12b
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/reports-content.tsx
@@ -0,0 +1,59 @@
+import { useTranslations } from "use-intl";
+
+import { FiltersDataTable } from "@/components/table/filters";
+import { SearchDataTable } from "@/components/table/search";
+
+import type { BoardPerson } from "./board/board-moves";
+import type { ModcpBoardFilter } from "./modcp-query";
+
+import { ModcpReportsBoard } from "./board/reports-board";
+
+export const ModcpReportsContent = ({
+ description,
+ filter,
+ me,
+ title,
+}: {
+ description: string;
+ filter: ModcpBoardFilter;
+ me: BoardPerson;
+ title: string;
+}) => {
+ const t = useTranslations("core.moderation.modcp.reports");
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/snapshot-view.tsx b/packages/vitnode/src/views/moderation/modcp/snapshot-view.tsx
new file mode 100644
index 0000000000..ace74cfde8
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/snapshot-view.tsx
@@ -0,0 +1,173 @@
+import { CameraIcon, FileIcon, ScissorsIcon } from "lucide-react";
+import { useFormatter, useTranslations } from "use-intl";
+
+import type {
+ ModerationReportSnapshot,
+ ModerationSnapshotField,
+} from "@/lib/moderation/snapshot";
+
+import { RichTextContent } from "@/components/rich-text";
+import { Badge } from "@/components/ui/badge";
+import { isRichTextDocument } from "@/content/rich-text/document";
+import { formatBytes } from "@/lib/format-bytes";
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isSnapshot = (value: unknown): value is ModerationReportSnapshot =>
+ isRecord(value) && Array.isArray(value.fields);
+
+export const readSnapshot = (value: unknown) =>
+ isSnapshot(value) ? value : null;
+
+const FieldValue = ({ field }: { field: ModerationSnapshotField }) => {
+ const t = useTranslations("core.moderation.modcp.detail.snapshot");
+
+ switch (field.kind) {
+ case "file":
+ return field.value.length === 0 ? (
+ {t("empty")}
+ ) : (
+
+ {field.value.map(file => (
+ -
+
+
+
+ {file.name ?? t("file_missing")}
+
+
+ {[file.mimeType, file.size ? formatBytes(file.size) : null]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ {t("file")}
+
+
+
+ ))}
+
+ );
+ case "richText":
+ return isRichTextDocument(field.value) ? (
+
+ ) : (
+ {t("empty")}
+ );
+ case "text":
+ return field.value ? (
+
+ {field.value}
+
+ ) : (
+ {t("empty")}
+ );
+ default:
+ return (
+
+ {field.value === null || field.value === ""
+ ? t("empty")
+ : String(field.value)}
+
+ );
+ }
+};
+
+export const SnapshotView = ({
+ snapshot,
+}: {
+ snapshot: ModerationReportSnapshot;
+}) => {
+ const t = useTranslations("core.moderation.modcp.detail.snapshot");
+ const tRoot = useTranslations();
+ const format = useFormatter();
+
+ const labelOf = (field: ModerationSnapshotField) =>
+ field.label && tRoot.has(field.label as never)
+ ? tRoot(field.label as never)
+ : field.name;
+
+ return (
+
+
+
+
+
+ {t("badge")}
+
+
+ {t("title")}
+
+
+
+ {t("desc", {
+ date: format.dateTime(new Date(snapshot.capturedAt), {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }),
+ })}
+
+
+ {snapshot.locale?.served ? (
+
+ {snapshot.locale.fallback && snapshot.locale.requested
+ ? t("fallback", {
+ requested: snapshot.locale.requested,
+ served: snapshot.locale.served,
+ })
+ : t("locale", { served: snapshot.locale.served })}
+
+ ) : null}
+ {snapshot.source?.version ? (
+ {t("version", { version: snapshot.source.version })}
+ ) : null}
+ {snapshot.authors?.length ? (
+
+ {t("by", {
+ names: snapshot.authors.map(author => author.name).join(", "),
+ })}
+
+ ) : null}
+
+ {snapshot.truncated ? (
+
+
+ {t("truncated")}
+
+ ) : null}
+
+
+ {snapshot.title ? (
+
+ {snapshot.title}
+
+ ) : null}
+
+
+ {snapshot.fields.map(field => (
+
+
-
+ {labelOf(field)}
+
+ -
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/moderation/modcp/status-badge.tsx b/packages/vitnode/src/views/moderation/modcp/status-badge.tsx
new file mode 100644
index 0000000000..16c004f384
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/modcp/status-badge.tsx
@@ -0,0 +1,34 @@
+import { useTranslations } from "use-intl";
+
+import { Badge } from "@/components/ui/badge";
+
+export const ReportStatusBadge = ({
+ status,
+}: {
+ status: "done" | "in_progress" | "new";
+}) => {
+ const t = useTranslations("core.moderation.statuses");
+
+ return (
+
+ {t(status)}
+
+ );
+};
+
+export const useTargetLabel = () => {
+ const tRoot = useTranslations();
+
+ return (label: null | string, fallback: string): string =>
+ label && tRoot.has(label as never)
+ ? tRoot(label as never, { count: 1 } as never)
+ : fallback;
+};
diff --git a/packages/vitnode/src/views/moderation/moderation-mutations.ts b/packages/vitnode/src/views/moderation/moderation-mutations.ts
new file mode 100644
index 0000000000..e9b7621351
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/moderation-mutations.ts
@@ -0,0 +1,279 @@
+import type { RichTextDocument } from "@/content/rich-text/document";
+import type { ModerationReportReason } from "@/lib/moderation/const";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { fetcherClient } from "@/lib/fetcher-client";
+
+const REPORTED_FAILURES = [
+ "already_claimed",
+ "assignee_not_eligible",
+ "conflict",
+ "duplicate",
+ "forbidden",
+ "not_found",
+ "note_not_found",
+ "rate_limited",
+ "report_closed",
+ "report_open",
+ "target_gone",
+] as const;
+
+export type ModerationFailure =
+ | "unknown"
+ | "validation"
+ | (typeof REPORTED_FAILURES)[number];
+
+export type ModerationResult =
+ | { data: T; ok: true }
+ | { failure: ModerationFailure; ok: false };
+
+const isReportedFailure = (
+ value: unknown,
+): value is (typeof REPORTED_FAILURES)[number] =>
+ REPORTED_FAILURES.some(failure => failure === value);
+
+const reportedFailureOf = async (response: Response) => {
+ const body = (await response.json().catch(() => null)) as null | {
+ error?: unknown;
+ };
+
+ return isReportedFailure(body?.error) ? body.error : null;
+};
+
+const failureOf = async (response: Response): Promise => {
+ if (response.status === 429) return "rate_limited";
+ const reported = await reportedFailureOf(response);
+ if (reported) return reported;
+ if (response.status === 400) return "validation";
+ if (response.status === 403) return "forbidden";
+ if (response.status === 404) return "not_found";
+
+ return "unknown";
+};
+
+const settle = async (
+ request: () => Promise,
+ expected: number,
+): Promise> => {
+ try {
+ const response = await request();
+ if (response.status !== expected) {
+ return { failure: await failureOf(response), ok: false };
+ }
+
+ return { data: (await response.json()) as T, ok: true };
+ } catch {
+ return { failure: "unknown", ok: false };
+ }
+};
+
+const options = { credentials: "include" } as const;
+
+export interface CreateReportInput {
+ description: null | string;
+ locale: null | string;
+ pluginId: string;
+ reason: ModerationReportReason;
+ requestedLocale?: null | string;
+ targetId: string;
+ targetType: string;
+}
+
+export const createReport = async (input: CreateReportInput) =>
+ await settle<{ id: number }>(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: input },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports",
+ }),
+ 201,
+ );
+
+export interface ReportState {
+ assigneeId: null | number;
+ status: "done" | "in_progress" | "new";
+ version: number;
+}
+
+export const claimReport = async ({
+ expectedVersion,
+ id,
+}: {
+ expectedVersion: number;
+ id: number;
+}) =>
+ await settle(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { expectedVersion }, params: { id } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/claim",
+ }),
+ 200,
+ );
+
+export const assignReport = async ({
+ assigneeId,
+ expectedVersion,
+ id,
+}: {
+ assigneeId: null | number;
+ expectedVersion: number;
+ id: number;
+}) =>
+ await settle(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { assigneeId, expectedVersion }, params: { id } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/assign",
+ }),
+ 200,
+ );
+
+export const addReportNote = async ({
+ body,
+ id,
+}: {
+ body: RichTextDocument | string;
+ id: number;
+}) =>
+ await settle<{ ok: boolean }>(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { body }, params: { id } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/notes",
+ }),
+ 201,
+ );
+
+export const editReportNote = async ({
+ body,
+ id,
+ noteId,
+}: {
+ body: RichTextDocument | string;
+ id: number;
+ noteId: number;
+}) =>
+ await settle<{ ok: boolean }>(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { body }, params: { id, noteId } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/notes/{noteId}/edit",
+ }),
+ 200,
+ );
+
+export const deleteReportNote = async ({
+ id,
+ noteId,
+}: {
+ id: number;
+ noteId: number;
+}) =>
+ await settle<{ ok: boolean }>(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id, noteId } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/notes/{noteId}/delete",
+ }),
+ 200,
+ );
+
+export const resolveReport = async ({
+ expectedVersion,
+ explanation,
+ id,
+ resolution,
+}: {
+ expectedVersion: number;
+ explanation?: string;
+ id: number;
+ resolution: "action_taken" | "duplicate" | "no_violation";
+}) =>
+ await settle(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: {
+ body: {
+ expectedVersion,
+ explanation: explanation ?? null,
+ resolution,
+ },
+ params: { id },
+ },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/resolve",
+ }),
+ 200,
+ );
+
+export const reopenReport = async ({
+ expectedVersion,
+ id,
+ reason,
+}: {
+ expectedVersion: number;
+ id: number;
+ reason: string;
+}) =>
+ await settle(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { expectedVersion, reason }, params: { id } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/reopen",
+ }),
+ 200,
+ );
+
+export const runReportAction = async ({
+ action,
+ expectedVersion,
+ id,
+}: {
+ action: "hide" | "unhide";
+ expectedVersion: number;
+ id: number;
+}) =>
+ await settle(
+ async () =>
+ await fetcherClient({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: { expectedVersion }, params: { action, id } },
+ method: "post",
+ module: "moderation",
+ options,
+ path: "/reports/{id}/actions/{action}",
+ }),
+ 200,
+ );
diff --git a/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx
new file mode 100644
index 0000000000..1aba88467d
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx
@@ -0,0 +1,146 @@
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form";
+import { AutoFormRadioGroup } from "@/components/form/fields/radio-group";
+import { AutoFormTextarea } from "@/components/form/fields/textarea";
+import { Button } from "@/components/ui/button";
+import {
+ MODERATION_LIMITS,
+ MODERATION_REPORT_REASONS,
+} from "@/lib/moderation/const";
+
+import { createReport } from "../moderation-mutations";
+
+export interface ReportDialogContentProps {
+ locale: null | string;
+ onClose: () => void;
+ pluginId: string;
+ requestedLocale: null | string;
+ targetId: string;
+ targetType: string;
+}
+
+export const ReportDialogContent = ({
+ locale,
+ onClose,
+ pluginId,
+ requestedLocale,
+ targetId,
+ targetType,
+}: ReportDialogContentProps) => {
+ const t = useTranslations("core.moderation");
+
+ const formSchema = z
+ .object({
+ reason: z.enum(MODERATION_REPORT_REASONS),
+ description: z
+ .string()
+ .trim()
+ .max(MODERATION_LIMITS.descriptionMaxLength)
+ .optional(),
+ })
+ .superRefine((values, ctx) => {
+ if (
+ values.reason === "other" &&
+ (values.description?.length ?? 0) <
+ MODERATION_LIMITS.otherDescriptionMinLength
+ ) {
+ ctx.addIssue({
+ code: "too_small",
+ minimum: MODERATION_LIMITS.otherDescriptionMinLength,
+ origin: "string",
+ path: ["description"],
+ message: t("report.errors.validation"),
+ });
+ }
+ });
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const result = await createReport({
+ description:
+ values.description?.trim() === ""
+ ? null
+ : (values.description?.trim() ?? null),
+ locale,
+ pluginId,
+ reason: values.reason,
+ requestedLocale,
+ targetId,
+ targetType,
+ });
+
+ if (!result.ok) {
+ const key =
+ result.failure === "duplicate" ||
+ result.failure === "not_found" ||
+ result.failure === "rate_limited" ||
+ result.failure === "validation"
+ ? result.failure
+ : "unknown";
+ toast.error(t("report.errors.title"), {
+ description: t(`report.errors.${key}`),
+ });
+ if (key === "duplicate" || key === "not_found") onClose();
+
+ return;
+ }
+
+ toast.success(t("report.success"), {
+ description: t("report.success_desc"),
+ });
+ onClose();
+ };
+
+ return (
+ (
+ ({
+ description: t(`reason_descs.${reason}`),
+ label: t(`reasons.${reason}`),
+ value: reason,
+ }))}
+ />
+ ),
+ id: "reason",
+ },
+ {
+ component: props => (
+
+ ),
+ id: "description",
+ },
+ ]}
+ formSchema={formSchema}
+ layout={rendered => (
+ <>
+ {rendered.reason}
+ {rendered.description}
+
+
+ {t("report.cancel")}
+
+
{t("report.submit")}
+
+ >
+ )}
+ onSubmit={onSubmit}
+ />
+ );
+};
+
+export default ReportDialogContent;
diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts
index c96773f7e1..72ba7df7e4 100644
--- a/plugins/blog/src/content/post.ts
+++ b/plugins/blog/src/content/post.ts
@@ -18,6 +18,8 @@ export const blogPostContentType = defineContentType({
visibility: true,
+ reports: true,
+
editorial: {
revisions: { retention: 20 },
preview: { expiresInMinutes: 30 },
diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json
index 04552565b5..c99e58b908 100644
--- a/plugins/blog/src/locales/en.json
+++ b/plugins/blog/src/locales/en.json
@@ -179,8 +179,9 @@
"hidden": {
"label": "Article hidden",
"title": "This article is hidden",
- "desc": "Only its authors and editors can see it. Everyone else gets a page not found, and it stays out of search and the sitemap."
- }
+ "desc": "Only its authors, editors and moderators can see it. Everyone else gets a page not found, and it stays out of search and the sitemap."
+ },
+ "report_type": "article"
}
},
"@vitnode/blog:posts": "Articles",
@@ -191,6 +192,9 @@
"@vitnode/blog:posts:can_publish": "Publish and unpublish articles",
"@vitnode/blog:posts:can_restore": "Restore an earlier version of an article",
"@vitnode/blog:posts:can_hide": "Hide and unhide articles",
+ "@vitnode/blog:posts:can_view_reports": "View article reports",
+ "@vitnode/blog:posts:can_manage_reports": "Claim, assign and resolve article reports",
+ "@vitnode/blog:posts:can_view_hidden": "Open hidden articles",
"@vitnode/blog:categories": "Categories",
"@vitnode/blog:categories:can_view": "View categories list",
"@vitnode/blog:categories:can_create": "Create categories",
diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx
index 3f385379e9..624aedca13 100644
--- a/plugins/blog/src/pages/post-page.tsx
+++ b/plugins/blog/src/pages/post-page.tsx
@@ -21,11 +21,13 @@ import {
} from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { getIntlRuntime } from "@vitnode/core/tanstack/i18n";
+import { ReportButton } from "@vitnode/core/tanstack/moderation";
import { EyeOffIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
+import { blogPostContentType } from "@/content/post";
const zodBlogPost = z.object({
content: zodRichTextDocument,
@@ -100,14 +102,30 @@ const BlogPostPage = ({
lang={metadata.locale ?? undefined}
>
- {item.publishedAt ? (
-
- {t("published")}{" "}
-
-
- ) : null}
+
+ {item.publishedAt ? (
+
+ {t("published")}{" "}
+
+
+ ) : (
+
+ )}
+
+ {!metadata.hidden && metadata.itemId !== null ? (
+
+ ) : null}
+
{metadata.hidden ? (
diff --git a/plugins/example/src/api/lib/moderation.ts b/plugins/example/src/api/lib/moderation.ts
new file mode 100644
index 0000000000..45ad479ec4
--- /dev/null
+++ b/plugins/example/src/api/lib/moderation.ts
@@ -0,0 +1,43 @@
+import { defineModerationScope } from "@vitnode/core/api/moderation";
+import { and, asc, gt, ilike, inArray } from "drizzle-orm";
+
+import { example_categories } from "@/database/categories";
+
+export const exampleCategoryScope = defineModerationScope({
+ id: "example.category",
+ label: "@vitnode/example.moderation.category.label",
+ description: "@vitnode/example.moderation.category.desc",
+ list: async (c, { cursor, limit, search }) => {
+ const rows = await c
+ .get("db")
+ .select({ id: example_categories.id, name: example_categories.name })
+ .from(example_categories)
+ .where(
+ and(
+ cursor ? gt(example_categories.id, Number(cursor)) : undefined,
+ search ? ilike(example_categories.name, `%${search}%`) : undefined,
+ ),
+ )
+ .orderBy(asc(example_categories.id))
+ .limit(limit + 1);
+
+ const page = rows.slice(0, limit);
+
+ return {
+ items: page.map(row => ({ id: String(row.id), label: row.name })),
+ nextCursor: rows.length > limit ? String(page.at(-1)?.id) : null,
+ };
+ },
+ find: async (c, ids) => {
+ const numeric = ids.map(Number).filter(id => Number.isSafeInteger(id));
+ if (numeric.length === 0) return [];
+
+ const rows = await c
+ .get("db")
+ .select({ id: example_categories.id, name: example_categories.name })
+ .from(example_categories)
+ .where(inArray(example_categories.id, numeric));
+
+ return rows.map(row => ({ id: String(row.id), label: row.name }));
+ },
+});
diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts
index 777f0e63e1..c5b016fd63 100644
--- a/plugins/example/src/config.api.ts
+++ b/plugins/example/src/config.api.ts
@@ -4,6 +4,7 @@ import { buildApiPlugin } from "@vitnode/core/api/lib/plugin";
import { buildContentPublicModule } from "@vitnode/core/content/server";
import { articleExcerptAiAction } from "@/api/ai/actions";
+import { exampleCategoryScope } from "@/api/lib/moderation";
import { adminModule } from "@/api/modules/admin/admin.module";
import { CONFIG_PLUGIN } from "@/const";
import { settingsPage } from "@/content/settings-page";
@@ -25,6 +26,7 @@ export const exampleApiPlugin = () =>
blocks: widgets,
editablePages: [settingsPage],
navigation: [{ href: "/example/browse", icon: "list", id: "browse" }],
+ moderation: { scopes: [exampleCategoryScope] },
permissionStaff: {
moderator: {
widgets: ["can_edit"],
diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts
index bd73421b05..dfe6ed0baf 100644
--- a/plugins/example/src/content/article.ts
+++ b/plugins/example/src/content/article.ts
@@ -51,6 +51,8 @@ export const articleContentType = defineContentType({
publication: true,
+ reports: true,
+
publicApi: {
path: "articles",
fields: [
diff --git a/plugins/example/src/database/articles.ts b/plugins/example/src/database/articles.ts
index 7a87aec887..97c6a8012d 100644
--- a/plugins/example/src/database/articles.ts
+++ b/plugins/example/src/database/articles.ts
@@ -13,6 +13,7 @@ export const articleContent = createContentModel(articleContentType, {
// `core_files.id` itself and asking a plugin to name it would be a line of
// boilerplate with one correct value.
references: { category: () => example_categories.id },
+ moderationScope: { column: "category", family: "example.category" },
});
export const example_articles = articleContent.table;
diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json
index 0da3175550..536a54d678 100644
--- a/plugins/example/src/locales/en.json
+++ b/plugins/example/src/locales/en.json
@@ -131,6 +131,12 @@
},
"events": {
"sessions": "Sessions"
+ },
+ "moderation": {
+ "category": {
+ "label": "Categories",
+ "desc": "Limit this permission to articles in some categories."
+ }
}
},
"@vitnode/example:article": "Articles",