@anthusai/papyrus 1.0.0-next.50 → 1.0.0-next.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +16 -10
- package/amplify/data/resource.ts +16 -10
- package/amplify/data/schema.js +14 -2
- package/amplify/data/schema.ts +16 -2
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/site-backend-config.ts +7 -0
- package/amplify/site-backend.js +9 -2
- package/amplify/site-backend.ts +10 -2
- package/components/article-video.tsx +19 -7
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +21 -1
- package/components/use-near-viewport.ts +30 -0
- package/lib/plugin-settings.ts +11 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/video-loading.ts +22 -0
- package/package.json +6 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { signupSubmit } from "./function/resource";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SignupEntry is never guest-readable and has no create rule for anyone: only the
|
|
6
|
+
* submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
|
|
7
|
+
*/
|
|
8
|
+
export function signupSchemaDefinitions() {
|
|
9
|
+
return {
|
|
10
|
+
SignupEntry: a
|
|
11
|
+
.model({
|
|
12
|
+
id: a.id().required(),
|
|
13
|
+
form: a.string().required(),
|
|
14
|
+
value: a.string().required(),
|
|
15
|
+
submittedAt: a.datetime().required(),
|
|
16
|
+
})
|
|
17
|
+
.disableOperations(["subscriptions"])
|
|
18
|
+
.secondaryIndexes((index) => [
|
|
19
|
+
index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
|
|
20
|
+
])
|
|
21
|
+
.authorization((allow) => [
|
|
22
|
+
allow.group("editor").to(["read"]),
|
|
23
|
+
allow.group("admin").to(["read", "delete"]),
|
|
24
|
+
]),
|
|
25
|
+
SignupReceipt: a.customType({
|
|
26
|
+
status: a.string().required(),
|
|
27
|
+
message: a.string(),
|
|
28
|
+
}),
|
|
29
|
+
submitSignup: a
|
|
30
|
+
.mutation()
|
|
31
|
+
.arguments({
|
|
32
|
+
form: a.string().required(),
|
|
33
|
+
value: a.string().required(),
|
|
34
|
+
renderedAt: a.string().required(),
|
|
35
|
+
website: a.string(),
|
|
36
|
+
})
|
|
37
|
+
.returns(a.ref("SignupReceipt"))
|
|
38
|
+
.authorization((allow) => [allow.guest()])
|
|
39
|
+
.handler(a.handler.function(signupSubmit)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
|
|
2
|
+
export const SIGNUP_MUTATION_FIELD = "submitSignup";
|
|
3
|
+
export const WAF_MINIMUM_RATE_LIMIT = 10;
|
|
4
|
+
export const WAF_EVALUATION_WINDOW_SECONDS = 60;
|
|
5
|
+
/**
|
|
6
|
+
* A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
|
|
7
|
+
* submitSignup requests from one address in a minute are blocked with a 429 before
|
|
8
|
+
* the function runs. Only requests whose body names the mutation count, so reads,
|
|
9
|
+
* newsroom saves and CLI imports from the same address are never limited.
|
|
10
|
+
*/
|
|
11
|
+
export function addSignupRateLimit(scope, input) {
|
|
12
|
+
if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
|
|
13
|
+
throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
|
|
14
|
+
}
|
|
15
|
+
const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
|
|
16
|
+
scope: "REGIONAL",
|
|
17
|
+
defaultAction: { allow: {} },
|
|
18
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
|
|
19
|
+
rules: [
|
|
20
|
+
{
|
|
21
|
+
name: "SignupRateLimitPerAddress",
|
|
22
|
+
priority: 0,
|
|
23
|
+
action: { block: { customResponse: { responseCode: 429 } } },
|
|
24
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
|
|
25
|
+
statement: {
|
|
26
|
+
rateBasedStatement: {
|
|
27
|
+
limit: input.limit,
|
|
28
|
+
evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
|
|
29
|
+
aggregateKeyType: "IP",
|
|
30
|
+
scopeDownStatement: {
|
|
31
|
+
byteMatchStatement: {
|
|
32
|
+
searchString: SIGNUP_MUTATION_FIELD,
|
|
33
|
+
positionalConstraint: "CONTAINS",
|
|
34
|
+
fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
|
|
35
|
+
textTransformations: [{ priority: 0, type: "NONE" }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
|
|
44
|
+
resourceArn: input.graphqlApiArn,
|
|
45
|
+
webAclArn: acl.attrArn,
|
|
46
|
+
});
|
|
47
|
+
return acl;
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
|
|
2
|
+
import type { Construct } from "constructs";
|
|
3
|
+
|
|
4
|
+
export const SIGNUP_MUTATION_FIELD = "submitSignup";
|
|
5
|
+
export const WAF_MINIMUM_RATE_LIMIT = 10;
|
|
6
|
+
export const WAF_EVALUATION_WINDOW_SECONDS = 60;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
|
|
10
|
+
* submitSignup requests from one address in a minute are blocked with a 429 before
|
|
11
|
+
* the function runs. Only requests whose body names the mutation count, so reads,
|
|
12
|
+
* newsroom saves and CLI imports from the same address are never limited.
|
|
13
|
+
*/
|
|
14
|
+
export function addSignupRateLimit(scope: Construct, input: { graphqlApiArn: string; limit: number }): CfnWebACL {
|
|
15
|
+
if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
|
|
16
|
+
throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
|
|
17
|
+
}
|
|
18
|
+
const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
|
|
19
|
+
scope: "REGIONAL",
|
|
20
|
+
defaultAction: { allow: {} },
|
|
21
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
|
|
22
|
+
rules: [
|
|
23
|
+
{
|
|
24
|
+
name: "SignupRateLimitPerAddress",
|
|
25
|
+
priority: 0,
|
|
26
|
+
action: { block: { customResponse: { responseCode: 429 } } },
|
|
27
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
|
|
28
|
+
statement: {
|
|
29
|
+
rateBasedStatement: {
|
|
30
|
+
limit: input.limit,
|
|
31
|
+
evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
|
|
32
|
+
aggregateKeyType: "IP",
|
|
33
|
+
scopeDownStatement: {
|
|
34
|
+
byteMatchStatement: {
|
|
35
|
+
searchString: SIGNUP_MUTATION_FIELD,
|
|
36
|
+
positionalConstraint: "CONTAINS",
|
|
37
|
+
fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
|
|
38
|
+
textTransformations: [{ priority: 0, type: "NONE" }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
|
|
47
|
+
resourceArn: input.graphqlApiArn,
|
|
48
|
+
webAclArn: acl.attrArn,
|
|
49
|
+
});
|
|
50
|
+
return acl;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Construct } from "constructs";
|
|
2
|
+
|
|
3
|
+
/** How a site asks for a plugin in `papyrus.config.ts`: `plugins: ["signup"]` or `plugins: [{ name: "signup", forms: ["news"] }]`. */
|
|
4
|
+
export type PluginRequest = string | ({ name: string } & Record<string, unknown>);
|
|
5
|
+
|
|
6
|
+
/** What a plugin declares about itself. A test pins the schema, the guest rules and the static files to this. */
|
|
7
|
+
export type PluginManifest = {
|
|
8
|
+
name: string;
|
|
9
|
+
/** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
|
|
10
|
+
contract: number;
|
|
11
|
+
models: string[];
|
|
12
|
+
mutations: string[];
|
|
13
|
+
/** What an anonymous visitor may call. Everything else is closed to guests. */
|
|
14
|
+
guest: { mutations: string[] };
|
|
15
|
+
newsroomPanels: { id: string; label: string }[];
|
|
16
|
+
/** Keys the projector writes into the site bucket when the plugin is enabled. */
|
|
17
|
+
staticAssets: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Where the deploy-time wiring puts its resources. Filled in by `defineSiteBackend`. */
|
|
21
|
+
export type PluginWiringContext = {
|
|
22
|
+
createStack: (name: string) => Construct;
|
|
23
|
+
graphqlApiId: string;
|
|
24
|
+
graphqlEndpoint: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PapyrusPlugin<Config, Functions = unknown> = {
|
|
28
|
+
manifest: PluginManifest;
|
|
29
|
+
/** Validates the request and fills in defaults. Throws a readable error. */
|
|
30
|
+
resolveConfig: (request: PluginRequest) => Config;
|
|
31
|
+
/** Amplify function resources the plugin adds to `defineBackend`. */
|
|
32
|
+
functions: () => Functions;
|
|
33
|
+
/** Models and custom operations merged into the site schema. */
|
|
34
|
+
schemaDefinitions: () => Record<string, unknown>;
|
|
35
|
+
/** The settings the newsroom and the page component may read from `amplify_outputs.json` (`custom.papyrusPlugins.<name>`). Never secrets. */
|
|
36
|
+
publicSettings: (config: Config) => Record<string, unknown>;
|
|
37
|
+
/** Everything that needs the backend to exist: IAM, environment, WAF, outputs. */
|
|
38
|
+
wire: (backend: any, config: Config, context: PluginWiringContext) => void;
|
|
39
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
|
|
2
|
+
export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
|
|
3
|
+
/**
|
|
4
|
+
* Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
|
|
5
|
+
* nothing is added to the stacks, the environment or the outputs.
|
|
6
|
+
*/
|
|
7
|
+
export function wirePlugins(backend, plugins, contentActionsLambda) {
|
|
8
|
+
if (plugins.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
const graphqlApi = backend.data.resources.graphqlApi;
|
|
11
|
+
const context = {
|
|
12
|
+
createStack: (name) => backend.createStack(name),
|
|
13
|
+
graphqlApiId: graphqlApi.apiId,
|
|
14
|
+
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
|
|
15
|
+
};
|
|
16
|
+
for (const { plugin, config } of plugins)
|
|
17
|
+
plugin.wire(backend, config, context);
|
|
18
|
+
contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
|
|
19
|
+
backend.addOutput({
|
|
20
|
+
custom: {
|
|
21
|
+
[PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
2
|
+
import type { ResolvedPlugin } from "./registry";
|
|
3
|
+
|
|
4
|
+
export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
|
|
5
|
+
export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
|
|
9
|
+
* nothing is added to the stacks, the environment or the outputs.
|
|
10
|
+
*/
|
|
11
|
+
export function wirePlugins(backend: any, plugins: ResolvedPlugin[], contentActionsLambda: LambdaFunction): void {
|
|
12
|
+
if (plugins.length === 0) return;
|
|
13
|
+
const graphqlApi = backend.data.resources.graphqlApi;
|
|
14
|
+
const context = {
|
|
15
|
+
createStack: (name: string) => backend.createStack(name),
|
|
16
|
+
graphqlApiId: graphqlApi.apiId as string,
|
|
17
|
+
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string,
|
|
18
|
+
};
|
|
19
|
+
for (const { plugin, config } of plugins) plugin.wire(backend, config, context);
|
|
20
|
+
contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
|
|
21
|
+
backend.addOutput({
|
|
22
|
+
custom: {
|
|
23
|
+
[PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PluginRequest } from "./plugins/types";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Per-site backend configuration: the argument of `defineSiteBackend(site)`.
|
|
3
5
|
* Config is passed in; the environment is only a fallback so Papyrus's own
|
|
@@ -171,6 +173,11 @@ export type PapyrusSiteBackendConfig = {
|
|
|
171
173
|
* site out. See `resolveSitePublication`.
|
|
172
174
|
*/
|
|
173
175
|
publication?: PapyrusPublicationConfig | false;
|
|
176
|
+
/**
|
|
177
|
+
* Deploy-time plugins (docs/plugins.md): `["signup"]`, or `[{ name: "signup", forms: ["news"] }]`.
|
|
178
|
+
* Unset: no plugin, and the synthesized backend is exactly what it was before plugins existed.
|
|
179
|
+
*/
|
|
180
|
+
plugins?: PluginRequest[];
|
|
174
181
|
features?: {
|
|
175
182
|
consoleResponder?: boolean;
|
|
176
183
|
inboundEmail?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -18,7 +18,9 @@ import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
|
|
|
18
18
|
import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
|
|
19
19
|
import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
|
|
20
20
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
|
|
21
|
-
import {
|
|
21
|
+
import { defineSiteData } from "./data/resource.js";
|
|
22
|
+
import { resolvePlugins } from "./plugins/registry.js";
|
|
23
|
+
import { wirePlugins } from "./plugins/wire.js";
|
|
22
24
|
import { assignmentAction } from "./functions/assignment-action/resource.js";
|
|
23
25
|
import { categoryAction } from "./functions/category-action/resource.js";
|
|
24
26
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
|
|
@@ -76,12 +78,16 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
76
78
|
const publicationPlan = planS3ProjectionBackend(publication);
|
|
77
79
|
const renditionsPlan = planRenditions(publication, process.cwd());
|
|
78
80
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
81
|
+
const plugins = resolvePlugins(site.plugins);
|
|
82
|
+
const pluginDefinitions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.schemaDefinitions()));
|
|
83
|
+
const pluginFunctions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.functions()));
|
|
79
84
|
const backend = defineBackend({
|
|
85
|
+
...pluginFunctions,
|
|
80
86
|
assignmentAction,
|
|
81
87
|
auth: defineSiteAuth(authConfig),
|
|
82
88
|
categoryAction,
|
|
83
89
|
contentActions,
|
|
84
|
-
data,
|
|
90
|
+
data: defineSiteData(pluginDefinitions),
|
|
85
91
|
knowledgeQuery,
|
|
86
92
|
manageUserRole,
|
|
87
93
|
modelAttachmentUpload,
|
|
@@ -571,5 +577,6 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
571
577
|
: null,
|
|
572
578
|
},
|
|
573
579
|
});
|
|
580
|
+
wirePlugins(backend, plugins, contentActionsLambda);
|
|
574
581
|
return backend;
|
|
575
582
|
}
|
package/amplify/site-backend.ts
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
} from "./revalidate-invalidation-settings";
|
|
26
26
|
import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
|
|
27
27
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
|
|
28
|
-
import {
|
|
28
|
+
import { defineSiteData } from "./data/resource";
|
|
29
|
+
import { resolvePlugins } from "./plugins/registry";
|
|
30
|
+
import { wirePlugins } from "./plugins/wire";
|
|
29
31
|
import { assignmentAction } from "./functions/assignment-action/resource";
|
|
30
32
|
import { categoryAction } from "./functions/category-action/resource";
|
|
31
33
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
|
|
@@ -97,12 +99,16 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
97
99
|
const publicationPlan = planS3ProjectionBackend(publication);
|
|
98
100
|
const renditionsPlan = planRenditions(publication, process.cwd());
|
|
99
101
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
102
|
+
const plugins = resolvePlugins(site.plugins);
|
|
103
|
+
const pluginDefinitions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.schemaDefinitions()));
|
|
104
|
+
const pluginFunctions: Record<never, never> = Object.assign({}, ...plugins.map(({ plugin }) => plugin.functions()));
|
|
100
105
|
const backend = defineBackend({
|
|
106
|
+
...pluginFunctions,
|
|
101
107
|
assignmentAction,
|
|
102
108
|
auth: defineSiteAuth(authConfig),
|
|
103
109
|
categoryAction,
|
|
104
110
|
contentActions,
|
|
105
|
-
data,
|
|
111
|
+
data: defineSiteData(pluginDefinitions),
|
|
106
112
|
knowledgeQuery,
|
|
107
113
|
manageUserRole,
|
|
108
114
|
modelAttachmentUpload,
|
|
@@ -707,5 +713,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
707
713
|
: null,
|
|
708
714
|
},
|
|
709
715
|
});
|
|
716
|
+
wirePlugins(backend, plugins, contentActionsLambda);
|
|
717
|
+
|
|
710
718
|
return backend;
|
|
711
719
|
}
|
|
@@ -7,6 +7,8 @@ import { resolveThemedVideoSrc } from "../lib/themed-image";
|
|
|
7
7
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
8
8
|
import { getSiteBrand } from "../lib/site-brand";
|
|
9
9
|
import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
|
|
10
|
+
import { useNearViewport } from "./use-near-viewport";
|
|
11
|
+
import { planVideoLoading, videoElementKey } from "../lib/video-loading";
|
|
10
12
|
import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
|
|
11
13
|
|
|
12
14
|
type ArticleVideoFigureProps = {
|
|
@@ -18,6 +20,7 @@ type ArticleVideoFigureProps = {
|
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
|
|
23
|
+
const VIDEO_LOAD_ROOT_MARGIN = "200px 0px";
|
|
21
24
|
|
|
22
25
|
export function ArticleVideoFigure({
|
|
23
26
|
video,
|
|
@@ -34,6 +37,8 @@ export function ArticleVideoFigure({
|
|
|
34
37
|
const [previewReady, setPreviewReady] = useState(false);
|
|
35
38
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
36
39
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
40
|
+
const figureRef = useRef<HTMLElement>(null);
|
|
41
|
+
const isNearViewport = useNearViewport(figureRef, VIDEO_LOAD_ROOT_MARGIN);
|
|
37
42
|
const isFramed = getSiteBrand().videoPlayer === "framed";
|
|
38
43
|
|
|
39
44
|
useEffect(() => {
|
|
@@ -44,6 +49,12 @@ export function ArticleVideoFigure({
|
|
|
44
49
|
// Match SSR to the default dark MP4 in `video.src` until after hydration.
|
|
45
50
|
const resolvedTheme = hasHydrated ? theme : "dark";
|
|
46
51
|
const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
|
|
52
|
+
const loadingPlan = planVideoLoading({
|
|
53
|
+
hasHydrated,
|
|
54
|
+
isNearViewport,
|
|
55
|
+
hasPoster: Boolean(video.posterSrc),
|
|
56
|
+
});
|
|
57
|
+
const videoKey = videoElementKey(loadingPlan.attachSource, src);
|
|
47
58
|
const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
|
|
48
59
|
const previewDsl = videoScript?.dsl
|
|
49
60
|
? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
|
|
@@ -191,6 +202,7 @@ export function ArticleVideoFigure({
|
|
|
191
202
|
if (isFramed) {
|
|
192
203
|
return (
|
|
193
204
|
<figure
|
|
205
|
+
ref={figureRef}
|
|
194
206
|
className={`${figureClassName} article-video--framed`}
|
|
195
207
|
data-media-type="video"
|
|
196
208
|
data-video-theme={resolvedTheme}
|
|
@@ -200,11 +212,11 @@ export function ArticleVideoFigure({
|
|
|
200
212
|
<video
|
|
201
213
|
ref={videoRef}
|
|
202
214
|
playsInline
|
|
203
|
-
preload=
|
|
215
|
+
preload={loadingPlan.preload}
|
|
204
216
|
poster={video.posterSrc}
|
|
205
217
|
aria-label={video.alt}
|
|
206
218
|
className="article-video__player"
|
|
207
|
-
key={
|
|
219
|
+
key={videoKey}
|
|
208
220
|
onPlay={() => setIsPlaying(true)}
|
|
209
221
|
onPause={() => setIsPlaying(false)}
|
|
210
222
|
onEnded={() => setIsPlaying(false)}
|
|
@@ -214,7 +226,7 @@ export function ArticleVideoFigure({
|
|
|
214
226
|
if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
|
|
215
227
|
}}
|
|
216
228
|
>
|
|
217
|
-
<source src={src} type="video/mp4" />
|
|
229
|
+
{loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
|
|
218
230
|
</video>
|
|
219
231
|
</div>
|
|
220
232
|
{framedControls}
|
|
@@ -226,17 +238,17 @@ export function ArticleVideoFigure({
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
return (
|
|
229
|
-
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
|
|
241
|
+
<figure ref={figureRef} className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
|
|
230
242
|
<video
|
|
231
243
|
controls
|
|
232
244
|
playsInline
|
|
233
|
-
preload=
|
|
245
|
+
preload={loadingPlan.preload}
|
|
234
246
|
poster={video.posterSrc}
|
|
235
247
|
aria-label={video.alt}
|
|
236
248
|
className="article-video__player"
|
|
237
|
-
key={
|
|
249
|
+
key={videoKey}
|
|
238
250
|
>
|
|
239
|
-
<source src={src} type="video/mp4" />
|
|
251
|
+
{loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
|
|
240
252
|
</video>
|
|
241
253
|
{video.caption ? <figcaption>{video.caption}</figcaption> : null}
|
|
242
254
|
{video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { signupForms, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import { signupEntriesToCsv, type SignupEntryRecord } from "../lib/signup-csv";
|
|
8
|
+
|
|
9
|
+
const LIST_QUERY = `query ListSignupEntries($form: String!, $nextToken: String) {
|
|
10
|
+
listSignupEntriesByFormAndSubmittedAt(form: $form, sortDirection: DESC, limit: 200, nextToken: $nextToken) {
|
|
11
|
+
items { id form value submittedAt }
|
|
12
|
+
nextToken
|
|
13
|
+
}
|
|
14
|
+
}`;
|
|
15
|
+
|
|
16
|
+
type ListResult = { data?: { listSignupEntriesByFormAndSubmittedAt?: { items: SignupEntryRecord[]; nextToken?: string | null } }; errors?: { message?: string }[] };
|
|
17
|
+
|
|
18
|
+
async function loadAllEntries(form: string): Promise<SignupEntryRecord[]> {
|
|
19
|
+
const client = generateClient({ authMode: "userPool" });
|
|
20
|
+
const entries: SignupEntryRecord[] = [];
|
|
21
|
+
let nextToken: string | null | undefined;
|
|
22
|
+
do {
|
|
23
|
+
const result = (await client.graphql({ query: LIST_QUERY, variables: { form, nextToken } })) as ListResult;
|
|
24
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load signups.");
|
|
25
|
+
const page = result.data?.listSignupEntriesByFormAndSubmittedAt;
|
|
26
|
+
entries.push(...(page?.items ?? []));
|
|
27
|
+
nextToken = page?.nextToken;
|
|
28
|
+
} while (nextToken);
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SignupEntriesPanel() {
|
|
33
|
+
const forms = useMemo(() => signupForms(amplifyOutputs as PluginOutputs), []);
|
|
34
|
+
const [form, setForm] = useState(forms[0] ?? "");
|
|
35
|
+
const [entries, setEntries] = useState<SignupEntryRecord[] | null>(null);
|
|
36
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const refresh = useCallback(async () => {
|
|
39
|
+
if (!form) return;
|
|
40
|
+
setEntries(null);
|
|
41
|
+
setProblem(null);
|
|
42
|
+
try {
|
|
43
|
+
setEntries(await loadAllEntries(form));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
setProblem(error instanceof Error ? error.message : "Could not load signups.");
|
|
46
|
+
}
|
|
47
|
+
}, [form]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
void refresh();
|
|
51
|
+
}, [refresh]);
|
|
52
|
+
|
|
53
|
+
const exportCsv = () => {
|
|
54
|
+
if (!entries) return;
|
|
55
|
+
const url = URL.createObjectURL(new Blob([signupEntriesToCsv(entries)], { type: "text/csv;charset=utf-8" }));
|
|
56
|
+
const link = document.createElement("a");
|
|
57
|
+
link.href = url;
|
|
58
|
+
link.download = `signups-${form}.csv`;
|
|
59
|
+
link.click();
|
|
60
|
+
URL.revokeObjectURL(url);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section aria-labelledby="newsroom-signups-title" data-news-desk-admin-panel="signups">
|
|
65
|
+
<h3 id="newsroom-signups-title">Signups</h3>
|
|
66
|
+
<p>Addresses people entered in the site's signup forms. Only editors and admins can see them.</p>
|
|
67
|
+
<label>
|
|
68
|
+
Form{" "}
|
|
69
|
+
<select value={form} onChange={(event) => setForm(event.target.value)} disabled={forms.length < 2}>
|
|
70
|
+
{forms.map((name) => (
|
|
71
|
+
<option key={name} value={name}>{name}</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</label>{" "}
|
|
75
|
+
<button type="button" onClick={() => void refresh()}>Refresh</button>{" "}
|
|
76
|
+
<button type="button" onClick={exportCsv} disabled={!entries || entries.length === 0}>Export CSV</button>
|
|
77
|
+
<p role="status" aria-live="polite">
|
|
78
|
+
{problem ? `Error: ${problem}` : entries === null ? "Loading..." : `${entries.length} ${entries.length === 1 ? "entry" : "entries"}`}
|
|
79
|
+
</p>
|
|
80
|
+
{entries && entries.length > 0 ? (
|
|
81
|
+
<table>
|
|
82
|
+
<thead>
|
|
83
|
+
<tr><th scope="col">Submitted</th><th scope="col">Address</th></tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody>
|
|
86
|
+
{entries.map((entry) => (
|
|
87
|
+
<tr key={entry.id}><td>{entry.submittedAt}</td><td>{entry.value}</td></tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
) : null}
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -10,6 +10,9 @@ import Link from "next/link";
|
|
|
10
10
|
import { usePathname, useRouter } from "next/navigation";
|
|
11
11
|
import type { ReactNode, RefObject } from "react";
|
|
12
12
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
13
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
14
|
+
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
15
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
13
16
|
import { generateClient } from "aws-amplify/data";
|
|
14
17
|
import type { Schema } from "../amplify/data/resource";
|
|
15
18
|
import {
|
|
@@ -235,7 +238,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
235
238
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
236
239
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
237
240
|
type UserRoleAction = "grant" | "revoke";
|
|
238
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures";
|
|
241
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "signups";
|
|
239
242
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "insights" | "messages" | "assignments" | "administration" | "search";
|
|
240
243
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
241
244
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -4452,8 +4455,20 @@ function AdministrationDeskView({
|
|
|
4452
4455
|
<strong>Procedures</strong>
|
|
4453
4456
|
<span>Procedure registry and versioned Tactus code</span>
|
|
4454
4457
|
</Link>
|
|
4458
|
+
{signupPluginEnabled() ? (
|
|
4459
|
+
<Link
|
|
4460
|
+
href={administrationPanelHref("signups", isDemo)}
|
|
4461
|
+
data-news-desk-admin-nav="signups"
|
|
4462
|
+
data-active={administrationPanel === "signups" || undefined}
|
|
4463
|
+
onClick={() => onPanelChange("signups")}
|
|
4464
|
+
>
|
|
4465
|
+
<strong>Signups</strong>
|
|
4466
|
+
<span>Addresses from the site's signup forms</span>
|
|
4467
|
+
</Link>
|
|
4468
|
+
) : null}
|
|
4455
4469
|
</nav>
|
|
4456
4470
|
<div className="news-desk-settings-panel">
|
|
4471
|
+
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4457
4472
|
{administrationPanel === "users" ? (
|
|
4458
4473
|
<AdministrationUsersPanel
|
|
4459
4474
|
canManageUsers={canManageUsers}
|
|
@@ -13646,8 +13661,13 @@ function semanticVersionKey(kind: string, id: string): string {
|
|
|
13646
13661
|
return `${kind}#${id}`;
|
|
13647
13662
|
}
|
|
13648
13663
|
|
|
13664
|
+
function signupPluginEnabled(): boolean {
|
|
13665
|
+
return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
|
|
13666
|
+
}
|
|
13667
|
+
|
|
13649
13668
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
13650
13669
|
if (value === "policies" || value === "sections" || value === "procedures") return value;
|
|
13670
|
+
if (value === "signups" && signupPluginEnabled()) return value;
|
|
13651
13671
|
return "users";
|
|
13652
13672
|
}
|
|
13653
13673
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
export function useNearViewport(targetRef: RefObject<Element | null>, rootMargin: string): boolean {
|
|
6
|
+
const [isNearViewport, setIsNearViewport] = useState(false);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (isNearViewport) return;
|
|
10
|
+
const target = targetRef.current;
|
|
11
|
+
if (!target) return;
|
|
12
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
13
|
+
setIsNearViewport(true);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const observer = new IntersectionObserver(
|
|
17
|
+
(entries) => {
|
|
18
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
19
|
+
setIsNearViewport(true);
|
|
20
|
+
observer.disconnect();
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{ rootMargin },
|
|
24
|
+
);
|
|
25
|
+
observer.observe(target);
|
|
26
|
+
return () => observer.disconnect();
|
|
27
|
+
}, [isNearViewport, rootMargin, targetRef]);
|
|
28
|
+
|
|
29
|
+
return isNearViewport;
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type PluginOutputs = { custom?: { papyrusPlugins?: Record<string, Record<string, unknown>> } };
|
|
2
|
+
|
|
3
|
+
/** The settings a deployed plugin published in `amplify_outputs.json`, or null when the site did not enable it. */
|
|
4
|
+
export function pluginSettings(outputs: PluginOutputs | null | undefined, name: string): Record<string, unknown> | null {
|
|
5
|
+
return outputs?.custom?.papyrusPlugins?.[name] ?? null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function signupForms(outputs: PluginOutputs | null | undefined): string[] {
|
|
9
|
+
const forms = pluginSettings(outputs, "signup")?.forms;
|
|
10
|
+
return Array.isArray(forms) ? forms.filter((form): form is string => typeof form === "string") : [];
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type SignupEntryRecord = {
|
|
2
|
+
id: string;
|
|
3
|
+
form: string;
|
|
4
|
+
value: string;
|
|
5
|
+
submittedAt: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const SIGNUP_CSV_HEADER = ["form", "value", "submittedAt"] as const;
|
|
9
|
+
const FORMULA_STARTS = new Set(["=", "+", "-", "@", "\t", "\r"]);
|
|
10
|
+
|
|
11
|
+
/** A spreadsheet runs a cell that starts with one of these as a formula; a leading quote makes it text. */
|
|
12
|
+
export function neutralizeFormula(cell: string): string {
|
|
13
|
+
return cell !== "" && FORMULA_STARTS.has(cell[0]) ? `'${cell}` : cell;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function csvCell(cell: string): string {
|
|
17
|
+
const safe = neutralizeFormula(cell);
|
|
18
|
+
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function signupEntriesToCsv(entries: SignupEntryRecord[]): string {
|
|
22
|
+
const rows = entries.map((entry) => SIGNUP_CSV_HEADER.map((column) => csvCell(String(entry[column] ?? ""))).join(","));
|
|
23
|
+
return `${[SIGNUP_CSV_HEADER.join(","), ...rows].join("\r\n")}\r\n`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type VideoPreload = "none" | "metadata";
|
|
2
|
+
|
|
3
|
+
export type VideoLoadingPlan = {
|
|
4
|
+
attachSource: boolean;
|
|
5
|
+
preload: VideoPreload;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type VideoLoadingInput = {
|
|
9
|
+
hasHydrated: boolean;
|
|
10
|
+
isNearViewport: boolean;
|
|
11
|
+
hasPoster: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function planVideoLoading({ hasHydrated, isNearViewport, hasPoster }: VideoLoadingInput): VideoLoadingPlan {
|
|
15
|
+
const attachSource = hasHydrated && isNearViewport;
|
|
16
|
+
const preload: VideoPreload = attachSource && !hasPoster ? "metadata" : "none";
|
|
17
|
+
return { attachSource, preload };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function videoElementKey(attachSource: boolean, resolvedSource: string): string {
|
|
21
|
+
return attachSource ? resolvedSource : "unattached";
|
|
22
|
+
}
|