feat(shims): add targeted client-side injections
This commit is contained in:
parent
0771424446
commit
83f203d736
7 changed files with 599 additions and 172 deletions
|
|
@ -20,10 +20,11 @@ import { CommandPalette } from "@/components/command-palette/CommandPalette";
|
||||||
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
|
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
import { Rank } from "@/payload-types";
|
import { Rank, Shim } from "@/payload-types";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
||||||
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
||||||
|
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
// The layout calls `getPayload()` at render time to resolve the current user,
|
||||||
|
|
@ -67,6 +68,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
let isLogistics = false;
|
let isLogistics = false;
|
||||||
let currentRank = "";
|
let currentRank = "";
|
||||||
|
|
||||||
|
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
|
||||||
|
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
[isIntelligence, isLogistics] = await Promise.all([
|
[isIntelligence, isLogistics] = await Promise.all([
|
||||||
hasIntelligenceQualification(payload, user).catch(() => false),
|
hasIntelligenceQualification(payload, user).catch(() => false),
|
||||||
|
|
@ -152,6 +156,11 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
{user && <GameTickRealtime />}
|
{user && <GameTickRealtime />}
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<ShimLoader
|
||||||
|
shims={shimEntries}
|
||||||
|
userId={user?.id ?? null}
|
||||||
|
userRoles={user?.roles ?? []}
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/component
|
||||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
||||||
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
|
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
||||||
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
|
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
|
||||||
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
|
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
|
||||||
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
|
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
|
||||||
|
|
@ -34,6 +35,7 @@ export const importMap = {
|
||||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||||
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
|
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
||||||
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
|
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
|
||||||
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
|
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
|
||||||
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
|
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
export const Shims: GlobalConfig = {
|
export const Shims: GlobalConfig = {
|
||||||
slug: "shims",
|
slug: "shims",
|
||||||
access: {
|
access: {
|
||||||
update: requirePermission("shims:update"),
|
|
||||||
read: requirePermission("shims:read"),
|
read: requirePermission("shims:read"),
|
||||||
|
update: requirePermission("shims:update"),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|
@ -17,22 +17,137 @@ export const Shims: GlobalConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
unique: true,
|
unique: true,
|
||||||
|
admin: {
|
||||||
|
description: "Unique identifier for this shim. Used internally for stable hashing.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "summary",
|
name: "summary",
|
||||||
type: "text",
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
description: "Brief description of what this shim does (for admin reference).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description: "Disabled shims are never injected, regardless of targeting rules.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "CSS", value: "css" },
|
||||||
|
{ label: "JavaScript", value: "js" },
|
||||||
|
],
|
||||||
|
required: true,
|
||||||
|
defaultValue: "css",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "content",
|
name: "content",
|
||||||
type: "code",
|
type: "code",
|
||||||
required: true,
|
required: true,
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: "@/components/admin/shims/ShimContentField",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "type",
|
name: "targeting",
|
||||||
type: "select",
|
type: "group",
|
||||||
options: ["css", "js"],
|
label: "Targeting",
|
||||||
required: true,
|
admin: {
|
||||||
defaultValue: "css",
|
description: "Who should this shim be applied to?",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "audience",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "all",
|
||||||
|
options: [
|
||||||
|
{ label: "All Users", value: "all" },
|
||||||
|
{ label: "Percentage of Users", value: "percentage" },
|
||||||
|
{ label: "Specific Users", value: "users" },
|
||||||
|
{ label: "By Role", value: "roles" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "percentage",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
defaultValue: 50,
|
||||||
|
admin: {
|
||||||
|
condition: (_data, siblingData) => siblingData?.audience === "percentage",
|
||||||
|
description:
|
||||||
|
"Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "users",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
hasMany: true,
|
||||||
|
admin: {
|
||||||
|
condition: (_data, siblingData) => siblingData?.audience === "users",
|
||||||
|
description: "Only these specific users will receive this shim.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "roles",
|
||||||
|
type: "select",
|
||||||
|
hasMany: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Guest", value: "guest" },
|
||||||
|
{ label: "User", value: "user" },
|
||||||
|
{ label: "Trusted", value: "trusted" },
|
||||||
|
{ label: "Admin", value: "admin" },
|
||||||
|
{ label: "Developer", value: "developer" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
condition: (_data, siblingData) => siblingData?.audience === "roles",
|
||||||
|
description: "Users with any of these roles will receive this shim.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paths",
|
||||||
|
type: "group",
|
||||||
|
label: "Path Matching",
|
||||||
|
admin: {
|
||||||
|
description: "Which pages should this shim be active on?",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "matchType",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "all",
|
||||||
|
options: [
|
||||||
|
{ label: "All Pages", value: "all" },
|
||||||
|
{ label: "Include Only", value: "include" },
|
||||||
|
{ label: "Exclude", value: "exclude" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paths",
|
||||||
|
type: "text",
|
||||||
|
hasMany: true,
|
||||||
|
admin: {
|
||||||
|
condition: (_data, siblingData) => siblingData?.matchType !== "all",
|
||||||
|
description:
|
||||||
|
"URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
30
src/components/admin/shims/ShimContentField.tsx
Normal file
30
src/components/admin/shims/ShimContentField.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CodeField, useFormFields } from "@payloadcms/ui";
|
||||||
|
import type { CodeFieldClientComponent } from "payload";
|
||||||
|
|
||||||
|
const languageForShimType = (type: unknown) =>
|
||||||
|
type === "css" ? "css" : "javascript";
|
||||||
|
|
||||||
|
/** Keeps Payload's standard code editor while selecting its language per shim row. */
|
||||||
|
export const ShimContentField: CodeFieldClientComponent = (props) => {
|
||||||
|
const typePath = props.path.replace(/\.content$/, ".type");
|
||||||
|
const shimType = useFormFields(([fields]) => fields[typePath]?.value);
|
||||||
|
const language = languageForShimType(shimType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeField
|
||||||
|
{...props}
|
||||||
|
key={language}
|
||||||
|
field={{
|
||||||
|
...props.field,
|
||||||
|
admin: {
|
||||||
|
...props.field.admin,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShimContentField;
|
||||||
56
src/components/frontend/shims/ShimLoader.tsx
Normal file
56
src/components/frontend/shims/ShimLoader.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { isShimActive, type ShimEntry } from "@/lib/shims/evaluate";
|
||||||
|
|
||||||
|
interface ShimLoaderProps {
|
||||||
|
shims: ShimEntry[];
|
||||||
|
userId: number | null;
|
||||||
|
userRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIM_ATTR = "data-ptf-shim";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client component that evaluates active shims and injects their CSS/JS
|
||||||
|
* into the document <head>. Shims are re-evaluated on route changes.
|
||||||
|
*
|
||||||
|
* Shims are injected as <style data-ptf-shim="name"> (CSS) or
|
||||||
|
* <script data-ptf-shim="name"> (JS) elements. On cleanup or route change,
|
||||||
|
* previously injected elements are removed before new ones are added.
|
||||||
|
*/
|
||||||
|
export function ShimLoader({ shims, userId, userRoles }: ShimLoaderProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Remove any previously injected shims
|
||||||
|
const existing = document.querySelectorAll(`[${SHIM_ATTR}]`);
|
||||||
|
existing.forEach((el) => el.remove());
|
||||||
|
|
||||||
|
// Evaluate and inject active shims
|
||||||
|
for (const shim of shims) {
|
||||||
|
if (!isShimActive(shim, userId, userRoles, pathname)) continue;
|
||||||
|
|
||||||
|
if (shim.type === "css") {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.setAttribute(SHIM_ATTR, shim.name);
|
||||||
|
style.textContent = shim.content;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
} else if (shim.type === "js") {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.setAttribute(SHIM_ATTR, shim.name);
|
||||||
|
script.textContent = shim.content;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: remove all injected shims on unmount
|
||||||
|
return () => {
|
||||||
|
document.querySelectorAll(`[${SHIM_ATTR}]`).forEach((el) => el.remove());
|
||||||
|
};
|
||||||
|
}, [shims, userId, userRoles, pathname]);
|
||||||
|
|
||||||
|
// No visible output — all work happens in the DOM
|
||||||
|
return null;
|
||||||
|
}
|
||||||
153
src/lib/shims/evaluate.ts
Normal file
153
src/lib/shims/evaluate.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* Shims targeting evaluation — pure functions, no server dependencies.
|
||||||
|
*
|
||||||
|
* Determines whether a given shim should be active for a specific user on
|
||||||
|
* a specific path. All functions are side-effect free and safe to call in
|
||||||
|
* client components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types (mirrors what Payload will generate for the shims global)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ShimAudience = "all" | "percentage" | "users" | "roles";
|
||||||
|
export type ShimPathMatch = "all" | "include" | "exclude";
|
||||||
|
|
||||||
|
export interface ShimTargeting {
|
||||||
|
audience: ShimAudience;
|
||||||
|
/** 0–100, used when audience = "percentage". */
|
||||||
|
percentage?: number | null;
|
||||||
|
/** User IDs, used when audience = "users". */
|
||||||
|
users?: Array<number | { id: number }> | null;
|
||||||
|
/** Role strings, used when audience = "roles". */
|
||||||
|
roles?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShimPathConfig {
|
||||||
|
matchType: ShimPathMatch;
|
||||||
|
paths?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShimEntry {
|
||||||
|
name: string;
|
||||||
|
summary?: string | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
type: "css" | "js";
|
||||||
|
content: string;
|
||||||
|
targeting: ShimTargeting;
|
||||||
|
paths?: ShimPathConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic hash — djb2
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple string hash → unsigned 32-bit integer.
|
||||||
|
* Deterministic: same input always produces the same output.
|
||||||
|
* Used for stable per-user percentage assignments.
|
||||||
|
*/
|
||||||
|
export function djb2Hash(str: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a value in [0, 100) for a given userId + shimName combination.
|
||||||
|
* Deterministic and uniform enough for percentage rollouts.
|
||||||
|
*/
|
||||||
|
export function hashForUser(userId: number, shimName: string): number {
|
||||||
|
const hash = djb2Hash(`${userId}:${shimName}`);
|
||||||
|
return hash % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path matching
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the current pathname matches the shim's path config.
|
||||||
|
*
|
||||||
|
* - matchType "all" → always matches.
|
||||||
|
* - matchType "include" → pathname must start with one of the listed prefixes.
|
||||||
|
* - matchType "exclude" → pathname must NOT start with any listed prefix.
|
||||||
|
*/
|
||||||
|
export function matchesPath(pathname: string, pathConfig?: ShimPathConfig | null): boolean {
|
||||||
|
if (!pathConfig || pathConfig.matchType === "all") return true;
|
||||||
|
|
||||||
|
const prefixes = pathConfig.paths ?? [];
|
||||||
|
if (prefixes.length === 0) return true;
|
||||||
|
|
||||||
|
const matched = prefixes.some((prefix) => {
|
||||||
|
const normalized = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
||||||
|
return pathname === normalized || pathname.startsWith(`${normalized}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pathConfig.matchType === "include" ? matched : !matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audience targeting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a user is in the shim's target audience.
|
||||||
|
* Does NOT check enabled or path — call matchesPath() and check enabled separately.
|
||||||
|
*/
|
||||||
|
export function matchesAudience(
|
||||||
|
shim: ShimEntry,
|
||||||
|
userId: number | null,
|
||||||
|
userRoles: string[],
|
||||||
|
): boolean {
|
||||||
|
if (!shim.targeting) return true;
|
||||||
|
|
||||||
|
const { audience } = shim.targeting;
|
||||||
|
|
||||||
|
switch (audience) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case "percentage": {
|
||||||
|
if (userId === null) return false;
|
||||||
|
const threshold = shim.targeting.percentage ?? 0;
|
||||||
|
return hashForUser(userId, shim.name) < threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "users": {
|
||||||
|
if (userId === null) return false;
|
||||||
|
const targetUsers = shim.targeting.users ?? [];
|
||||||
|
return targetUsers.some((u) => (typeof u === "object" ? u.id : u) === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "roles": {
|
||||||
|
const targetRoles = shim.targeting.roles ?? [];
|
||||||
|
return targetRoles.some((role) => userRoles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full evaluation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the shim should be active for this user on this path.
|
||||||
|
* Checks: enabled → audience targeting → path matching.
|
||||||
|
*/
|
||||||
|
export function isShimActive(
|
||||||
|
shim: ShimEntry,
|
||||||
|
userId: number | null,
|
||||||
|
userRoles: string[],
|
||||||
|
pathname: string,
|
||||||
|
): boolean {
|
||||||
|
if (!shim.enabled) return false;
|
||||||
|
if (!matchesAudience(shim, userId, userRoles)) return false;
|
||||||
|
if (!matchesPath(pathname, shim.paths)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -1118,171 +1118,10 @@ export interface Campaign {
|
||||||
* Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.
|
* Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.
|
||||||
*/
|
*/
|
||||||
coverImage?: (number | null) | Media;
|
coverImage?: (number | null) | Media;
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "game-vehicles".
|
|
||||||
*/
|
|
||||||
export interface GameVehicle {
|
|
||||||
id: number;
|
|
||||||
/**
|
/**
|
||||||
* Display name or callsign for this vehicle instance.
|
* User who created this campaign. Set automatically on create.
|
||||||
*/
|
*/
|
||||||
name: string;
|
owner?: (number | null) | User;
|
||||||
/**
|
|
||||||
* The vehicle type/template this instance is based on.
|
|
||||||
*/
|
|
||||||
type: number | Vehicle;
|
|
||||||
/**
|
|
||||||
* The structure where this vehicle is currently stationed. Null if in transit.
|
|
||||||
*/
|
|
||||||
deployedAt?: (number | null) | GameStructure;
|
|
||||||
/**
|
|
||||||
* The faction that owns this vehicle.
|
|
||||||
*/
|
|
||||||
faction?: (number | null) | Faction;
|
|
||||||
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
|
|
||||||
/**
|
|
||||||
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
|
|
||||||
*/
|
|
||||||
currentFuel: number;
|
|
||||||
/**
|
|
||||||
* Current health. Must be between 0 and the vehicle type's max health.
|
|
||||||
*/
|
|
||||||
currentHealth: number;
|
|
||||||
storedResources?:
|
|
||||||
| {
|
|
||||||
resource: number | Resource;
|
|
||||||
amount: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
rotated?: boolean | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
|
||||||
* Items displaced by grid resizing.
|
|
||||||
*/
|
|
||||||
voidStorage?:
|
|
||||||
| {
|
|
||||||
resource: number | Resource;
|
|
||||||
amount: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
rotated?: boolean | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "game-npcs".
|
|
||||||
*/
|
|
||||||
export interface GameNpc {
|
|
||||||
id: number;
|
|
||||||
/**
|
|
||||||
* The NPC's name as shown in-game and on the frontend.
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
|
|
||||||
*/
|
|
||||||
occupation?: string | null;
|
|
||||||
/**
|
|
||||||
* Where this NPC is stationed in-game.
|
|
||||||
*/
|
|
||||||
location?: (number | null) | GameStructure;
|
|
||||||
/**
|
|
||||||
* The faction this NPC belongs to, if any.
|
|
||||||
*/
|
|
||||||
faction?: (number | null) | Faction;
|
|
||||||
/**
|
|
||||||
* A short background/profile for this NPC.
|
|
||||||
*/
|
|
||||||
bio?: string | null;
|
|
||||||
/**
|
|
||||||
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
|
|
||||||
*/
|
|
||||||
isGenerated?: boolean | null;
|
|
||||||
/**
|
|
||||||
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
|
|
||||||
*/
|
|
||||||
isInGame?: boolean | null;
|
|
||||||
vendor?: {
|
|
||||||
/**
|
|
||||||
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
|
|
||||||
*/
|
|
||||||
enabled?: boolean | null;
|
|
||||||
/**
|
|
||||||
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
|
|
||||||
*/
|
|
||||||
priceModifier?: number | null;
|
|
||||||
/**
|
|
||||||
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
|
|
||||||
*/
|
|
||||||
sells?: (number | Asset)[] | null;
|
|
||||||
};
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "game-event-logs".
|
|
||||||
*/
|
|
||||||
export interface GameEventLog {
|
|
||||||
id: number;
|
|
||||||
/**
|
|
||||||
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
|
|
||||||
*/
|
|
||||||
system?: boolean | null;
|
|
||||||
timestamp: string;
|
|
||||||
type: string;
|
|
||||||
message: string;
|
|
||||||
actor?: (number | null) | User;
|
|
||||||
structure?: (number | null) | GameStructure;
|
|
||||||
/**
|
|
||||||
* Which collection the event primarily relates to.
|
|
||||||
*/
|
|
||||||
targetCollection?:
|
|
||||||
| (
|
|
||||||
| 'game-structures'
|
|
||||||
| 'game-vehicles'
|
|
||||||
| 'game-npcs'
|
|
||||||
| 'structures'
|
|
||||||
| 'resources'
|
|
||||||
| 'assets'
|
|
||||||
| 'vehicles'
|
|
||||||
| 'factions'
|
|
||||||
| 'missions'
|
|
||||||
| 'campaigns'
|
|
||||||
| 'users'
|
|
||||||
| 'technologies'
|
|
||||||
| 'maps'
|
|
||||||
| 'shipments'
|
|
||||||
| 'bank-accounts'
|
|
||||||
| 'bank-transactions'
|
|
||||||
| 'ledger-entries'
|
|
||||||
| 'locker-storages'
|
|
||||||
| 'loadouts'
|
|
||||||
| 'market-listings'
|
|
||||||
| 'market-negotiations'
|
|
||||||
| 'tickets'
|
|
||||||
)
|
|
||||||
| null;
|
|
||||||
targetId?: number | null;
|
|
||||||
data?:
|
|
||||||
| {
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
| unknown[]
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null;
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1454,6 +1293,7 @@ export interface Role {
|
||||||
| 'campaigns:read'
|
| 'campaigns:read'
|
||||||
| 'campaigns:update'
|
| 'campaigns:update'
|
||||||
| 'campaigns:delete'
|
| 'campaigns:delete'
|
||||||
|
| 'campaigns:manage'
|
||||||
| 'factions:create'
|
| 'factions:create'
|
||||||
| 'factions:read'
|
| 'factions:read'
|
||||||
| 'factions:update'
|
| 'factions:update'
|
||||||
|
|
@ -1594,6 +1434,171 @@ export interface Role {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "game-vehicles".
|
||||||
|
*/
|
||||||
|
export interface GameVehicle {
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Display name or callsign for this vehicle instance.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The vehicle type/template this instance is based on.
|
||||||
|
*/
|
||||||
|
type: number | Vehicle;
|
||||||
|
/**
|
||||||
|
* The structure where this vehicle is currently stationed. Null if in transit.
|
||||||
|
*/
|
||||||
|
deployedAt?: (number | null) | GameStructure;
|
||||||
|
/**
|
||||||
|
* The faction that owns this vehicle.
|
||||||
|
*/
|
||||||
|
faction?: (number | null) | Faction;
|
||||||
|
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
|
||||||
|
/**
|
||||||
|
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
|
||||||
|
*/
|
||||||
|
currentFuel: number;
|
||||||
|
/**
|
||||||
|
* Current health. Must be between 0 and the vehicle type's max health.
|
||||||
|
*/
|
||||||
|
currentHealth: number;
|
||||||
|
storedResources?:
|
||||||
|
| {
|
||||||
|
resource: number | Resource;
|
||||||
|
amount: number;
|
||||||
|
gridX: number;
|
||||||
|
gridY: number;
|
||||||
|
rotated?: boolean | null;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* Items displaced by grid resizing.
|
||||||
|
*/
|
||||||
|
voidStorage?:
|
||||||
|
| {
|
||||||
|
resource: number | Resource;
|
||||||
|
amount: number;
|
||||||
|
gridX: number;
|
||||||
|
gridY: number;
|
||||||
|
rotated?: boolean | null;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "game-npcs".
|
||||||
|
*/
|
||||||
|
export interface GameNpc {
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* The NPC's name as shown in-game and on the frontend.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
|
||||||
|
*/
|
||||||
|
occupation?: string | null;
|
||||||
|
/**
|
||||||
|
* Where this NPC is stationed in-game.
|
||||||
|
*/
|
||||||
|
location?: (number | null) | GameStructure;
|
||||||
|
/**
|
||||||
|
* The faction this NPC belongs to, if any.
|
||||||
|
*/
|
||||||
|
faction?: (number | null) | Faction;
|
||||||
|
/**
|
||||||
|
* A short background/profile for this NPC.
|
||||||
|
*/
|
||||||
|
bio?: string | null;
|
||||||
|
/**
|
||||||
|
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
|
||||||
|
*/
|
||||||
|
isGenerated?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
|
||||||
|
*/
|
||||||
|
isInGame?: boolean | null;
|
||||||
|
vendor?: {
|
||||||
|
/**
|
||||||
|
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
|
||||||
|
*/
|
||||||
|
enabled?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
|
||||||
|
*/
|
||||||
|
priceModifier?: number | null;
|
||||||
|
/**
|
||||||
|
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
|
||||||
|
*/
|
||||||
|
sells?: (number | Asset)[] | null;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "game-event-logs".
|
||||||
|
*/
|
||||||
|
export interface GameEventLog {
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
|
||||||
|
*/
|
||||||
|
system?: boolean | null;
|
||||||
|
timestamp: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
actor?: (number | null) | User;
|
||||||
|
structure?: (number | null) | GameStructure;
|
||||||
|
/**
|
||||||
|
* Which collection the event primarily relates to.
|
||||||
|
*/
|
||||||
|
targetCollection?:
|
||||||
|
| (
|
||||||
|
| 'game-structures'
|
||||||
|
| 'game-vehicles'
|
||||||
|
| 'game-npcs'
|
||||||
|
| 'structures'
|
||||||
|
| 'resources'
|
||||||
|
| 'assets'
|
||||||
|
| 'vehicles'
|
||||||
|
| 'factions'
|
||||||
|
| 'missions'
|
||||||
|
| 'campaigns'
|
||||||
|
| 'users'
|
||||||
|
| 'technologies'
|
||||||
|
| 'maps'
|
||||||
|
| 'shipments'
|
||||||
|
| 'bank-accounts'
|
||||||
|
| 'bank-transactions'
|
||||||
|
| 'ledger-entries'
|
||||||
|
| 'locker-storages'
|
||||||
|
| 'loadouts'
|
||||||
|
| 'market-listings'
|
||||||
|
| 'market-negotiations'
|
||||||
|
| 'tickets'
|
||||||
|
)
|
||||||
|
| null;
|
||||||
|
targetId?: number | null;
|
||||||
|
data?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "ranks".
|
* via the `definition` "ranks".
|
||||||
|
|
@ -3663,6 +3668,7 @@ export interface CampaignsSelect<T extends boolean = true> {
|
||||||
startDate?: T;
|
startDate?: T;
|
||||||
endDate?: T;
|
endDate?: T;
|
||||||
coverImage?: T;
|
coverImage?: T;
|
||||||
|
owner?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
@ -4530,10 +4536,51 @@ export interface Shim {
|
||||||
id: number;
|
id: number;
|
||||||
shims?:
|
shims?:
|
||||||
| {
|
| {
|
||||||
|
/**
|
||||||
|
* Unique identifier for this shim. Used internally for stable hashing.
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Brief description of what this shim does (for admin reference).
|
||||||
|
*/
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
content: string;
|
/**
|
||||||
|
* Disabled shims are never injected, regardless of targeting rules.
|
||||||
|
*/
|
||||||
|
enabled?: boolean | null;
|
||||||
type: 'css' | 'js';
|
type: 'css' | 'js';
|
||||||
|
/**
|
||||||
|
* CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.
|
||||||
|
*/
|
||||||
|
content: string;
|
||||||
|
/**
|
||||||
|
* Who should this shim be applied to?
|
||||||
|
*/
|
||||||
|
targeting: {
|
||||||
|
audience: 'all' | 'percentage' | 'users' | 'roles';
|
||||||
|
/**
|
||||||
|
* Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.
|
||||||
|
*/
|
||||||
|
percentage?: number | null;
|
||||||
|
/**
|
||||||
|
* Only these specific users will receive this shim.
|
||||||
|
*/
|
||||||
|
users?: (number | User)[] | null;
|
||||||
|
/**
|
||||||
|
* Users with any of these roles will receive this shim.
|
||||||
|
*/
|
||||||
|
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Which pages should this shim be active on?
|
||||||
|
*/
|
||||||
|
paths: {
|
||||||
|
matchType: 'all' | 'include' | 'exclude';
|
||||||
|
/**
|
||||||
|
* URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.
|
||||||
|
*/
|
||||||
|
paths?: string[] | null;
|
||||||
|
};
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
|
@ -4566,8 +4613,23 @@ export interface ShimsSelect<T extends boolean = true> {
|
||||||
| {
|
| {
|
||||||
name?: T;
|
name?: T;
|
||||||
summary?: T;
|
summary?: T;
|
||||||
content?: T;
|
enabled?: T;
|
||||||
type?: T;
|
type?: T;
|
||||||
|
content?: T;
|
||||||
|
targeting?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
audience?: T;
|
||||||
|
percentage?: T;
|
||||||
|
users?: T;
|
||||||
|
roles?: T;
|
||||||
|
};
|
||||||
|
paths?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
matchType?: T;
|
||||||
|
paths?: T;
|
||||||
|
};
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue