create-nevela 0.8.0 → 0.9.2

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.
Files changed (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
@@ -8,7 +8,7 @@ import { revalidateResource } from "@/lib/cache";
8
8
  import { fileUrl } from "@/lib/files";
9
9
  import { laravel } from "@/lib/laravel";
10
10
  import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
11
- import { actionsOf } from "@/lib/extras";
11
+ import { actionsOf, formOf, publishFieldOf } from "@/lib/extras";
12
12
 
13
13
  export type ActionResult<T = unknown> =
14
14
  | { ok: true; data: T }
@@ -111,8 +111,11 @@ export async function exportRecordsAction(resourceName: string, query: string):
111
111
 
112
112
  const rows: Record<string, unknown>[] = [];
113
113
  let truncated = false;
114
+ // Someone who joined a list and hasn't proved their address isn't on it yet.
115
+ const confirmedOnly = formOf(resourceName)?.confirm !== undefined && !new URLSearchParams(query).has("filter[confirmed]");
114
116
  for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
115
117
  const params = new URLSearchParams(query);
118
+ if (confirmedOnly) params.set("filter[confirmed]", "true");
116
119
  params.set("page", String(page));
117
120
  params.set("perPage", String(EXPORT_PAGE));
118
121
  const result = await store.list(params);
@@ -226,6 +229,17 @@ export async function updateRecordAction(resourceName: string, id: string, input
226
229
  return result;
227
230
  }
228
231
 
232
+ /**
233
+ * Publish a record now, or take it back to a draft: its publish date is set to this
234
+ * moment, or cleared. An edit like any other, so it takes the permission to edit, and
235
+ * Laravel's policy has the last word.
236
+ */
237
+ export async function publishRecordAction(resourceName: string, id: string, publish: boolean): Promise<ActionResult<Record<string, unknown>>> {
238
+ const field = publishFieldOf(resourceName);
239
+ if (!field) return { ok: false, status: 404, error: "This isn't something that is published." };
240
+ return updateRecordAction(resourceName, id, { [field]: publish ? new Date().toISOString() : null });
241
+ }
242
+
229
243
  /**
230
244
  * Where the browser sends a file for a file or image field.
231
245
  *
@@ -0,0 +1,53 @@
1
+ import type { Resource } from "@flaredev/core";
2
+ import { ShieldCheckIcon } from "lucide-react";
3
+ import { Alert, AlertDescription } from "@/components/ui/alert";
4
+ import { formOf } from "@/lib/extras";
5
+ import { laravel } from "@/lib/laravel";
6
+
7
+ /** GET _nevela/forms/<slug>: what a resource's public form checks and has turned away. */
8
+ interface FormReport {
9
+ robotCheck: "on" | "off" | "skipped" | "refusing";
10
+ blocked: { total: number; days: number; reasons: { reason: string; label: string; count: number }[] };
11
+ }
12
+
13
+ /**
14
+ * What a resource's public form turned away this week, above its table: "Blocked this
15
+ * week: 132 (118 robot check, 14 throwaway address)". Without it a form that is well
16
+ * protected and a form nobody uses look the same from in here.
17
+ *
18
+ * Shown for a resource whose descriptor has "protect", and says so plainly when the
19
+ * robot check it asks for isn't being made.
20
+ */
21
+ export async function FormActivity({ resource }: { resource: Resource }) {
22
+ if (!formOf(resource.name)?.protect) return null;
23
+ const response = await laravel(`_nevela/forms/${resource.slug}`);
24
+ if (!response.ok) return null;
25
+ const { robotCheck, blocked } = response.body as FormReport;
26
+
27
+ return (
28
+ <div className="flex flex-col gap-2">
29
+ {robotCheck === "refusing" && (
30
+ <Alert variant="destructive">
31
+ <AlertDescription>
32
+ The public form is refusing everyone: it asks for the robot check, and TURNSTILE_SECRET_KEY isn&apos;t set in the API. Set it, or take
33
+ &quot;turnstile&quot; out of the descriptor.
34
+ </AlertDescription>
35
+ </Alert>
36
+ )}
37
+ {robotCheck === "skipped" && (
38
+ <Alert>
39
+ <AlertDescription>
40
+ The robot check is skipped while TURNSTILE_SECRET_KEY isn&apos;t set. That is fine in development. Set it before the site goes live.
41
+ </AlertDescription>
42
+ </Alert>
43
+ )}
44
+ <p className="flex items-center gap-2 text-sm text-muted-foreground">
45
+ <ShieldCheckIcon className="size-4 shrink-0" aria-hidden="true" />
46
+ <span>
47
+ Blocked this week: <span className="font-medium text-foreground tabular-nums">{blocked.total === 0 ? "none" : blocked.total.toLocaleString()}</span>
48
+ {blocked.total > 0 && ` (${blocked.reasons.map((row) => `${row.count.toLocaleString()} ${row.label}`).join(", ")})`}
49
+ </span>
50
+ </p>
51
+ </div>
52
+ );
53
+ }
@@ -3,10 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
- import { PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { EyeOffIcon, PencilIcon, SendIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
- import { deleteRecordAction } from "@/app/dashboard/actions";
9
+ import { deleteRecordAction, publishRecordAction } from "@/app/dashboard/actions";
10
10
  import { restoreRecordAction } from "@/app/dashboard/trash/actions";
11
11
  import {
12
12
  AlertDialog,
@@ -19,7 +19,7 @@ import {
19
19
  } from "@/components/ui/alert-dialog";
20
20
  import { Button } from "@/components/ui/button";
21
21
  import { Spinner } from "@/components/ui/spinner";
22
- import type { ResourceAction } from "@/lib/extras";
22
+ import type { PublishState, ResourceAction } from "@/lib/extras";
23
23
  import { keptFor, type TrashInfo } from "@/lib/trash-info";
24
24
  import { ActionButtons } from "./resource-action";
25
25
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
@@ -40,11 +40,23 @@ interface Props {
40
40
  trash?: TrashInfo;
41
41
  /** The resource's own actions that this person may run and that apply to this record as it is. */
42
42
  actions?: ResourceAction[];
43
+ /** For a resource with a publish date: what this record is now, so the right one of Publish and Unpublish is offered. */
44
+ published?: PublishState;
43
45
  }
44
46
 
45
- /** The resource's own actions, then edit and delete, at the top of a record's own page. Deleting goes back to the list. */
46
- export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash, actions = [] }: Props) {
47
+ /** Publishing, the resource's own actions, then edit and delete, at the top of a record's own page. Deleting goes back to the list. */
48
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash, actions = [], published }: Props) {
47
49
  const router = useRouter();
50
+ const [publishing, startPublishing] = useTransition();
51
+
52
+ const publish = (now: boolean) => {
53
+ startPublishing(async () => {
54
+ const result = await publishRecordAction(resource.name, id, now);
55
+ if (result.ok) toast.success(now ? `${resource.label} published.` : `${resource.label} is a draft again.`);
56
+ else toast.error(result.error);
57
+ router.refresh();
58
+ });
59
+ };
48
60
  const [editing, setEditing] = useState(false);
49
61
  const [confirming, setConfirming] = useState(false);
50
62
  const [pending, startTransition] = useTransition();
@@ -67,6 +79,18 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
67
79
 
68
80
  return (
69
81
  <>
82
+ {canUpdate && published && published !== "published" && (
83
+ <Button disabled={publishing} onClick={() => publish(true)}>
84
+ {publishing ? <Spinner data-icon="inline-start" /> : <SendIcon data-icon="inline-start" />}
85
+ {published === "scheduled" ? "Publish now" : "Publish"}
86
+ </Button>
87
+ )}
88
+ {canUpdate && published === "published" && (
89
+ <Button variant="outline" disabled={publishing} onClick={() => publish(false)}>
90
+ {publishing ? <Spinner data-icon="inline-start" /> : <EyeOffIcon data-icon="inline-start" />}
91
+ Unpublish
92
+ </Button>
93
+ )}
70
94
  <ActionButtons resourceName={resource.name} actions={actions} id={id} />
71
95
  {canUpdate &&
72
96
  (overlayForms ? (
@@ -6,7 +6,7 @@ import { FileCell } from "@/components/dashboard/fields/file-cell";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
8
  import { adminPermissions, allowedActions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
9
- import { actionApplies } from "@/lib/extras";
9
+ import { actionApplies, publishFieldOf, publishState } from "@/lib/extras";
10
10
  import { privateFileUrl } from "@/lib/files";
11
11
  import { isRich } from "@/lib/rich";
12
12
  import { site } from "@/lib/site";
@@ -97,6 +97,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
97
97
  overlayForms={site.dashboard.forms === "sheet"}
98
98
  trash={trash}
99
99
  actions={(await allowedActions(resource.name)).filter((action) => action.on === "record" && actionApplies(action, record))}
100
+ published={publishFieldOf(resource.name) ? publishState(record[publishFieldOf(resource.name)!]) : undefined}
100
101
  />
101
102
  }
102
103
  />
@@ -3,7 +3,7 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
6
- import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, slugify, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
7
7
  import { toast } from "sonner";
8
8
  import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
9
9
  import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -70,8 +70,25 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
70
70
  const last = step === steps.length - 1;
71
71
  const visible = steps[step] ?? [];
72
72
 
73
+ /**
74
+ * Fields made from another (a slug from a title) and not yet typed in by hand. On a new
75
+ * record they follow their source as it is typed, so what Laravel would make is on
76
+ * show; once someone types in one, it is theirs. Never on an edit: a slug that is
77
+ * already an address doesn't move.
78
+ */
79
+ const [following, setFollowing] = useState<Set<string>>(
80
+ () => new Set(mode === "create" ? fields.filter(([, def]) => typeof (def as { from?: string }).from === "string").map(([key]) => key) : []),
81
+ );
82
+
73
83
  const setValue = (key: string, value: string | boolean) => {
74
- setValues((current) => ({ ...current, [key]: value }));
84
+ setValues((current) => {
85
+ const next = { ...current, [key]: value };
86
+ for (const [made, def] of fields) {
87
+ if (following.has(made) && (def as { from?: string }).from === key) next[made] = slugify(String(value));
88
+ }
89
+ return next;
90
+ });
91
+ if (following.has(key)) setFollowing((current) => new Set([...current].filter((made) => made !== key)));
75
92
  if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
76
93
  };
77
94
 
@@ -12,7 +12,7 @@ import { hrefWith } from "./query";
12
12
  const ALL = "__all__";
13
13
 
14
14
  /** Search box and enum/boolean filters for ResourceTable; all state goes into the URL. */
15
- export function ResourceTableToolbar({ resource }: { resource: ClientResource }) {
15
+ export function ResourceTableToolbar({ resource, publish }: { resource: ClientResource; publish?: { field: string; label: string } }) {
16
16
  const router = useRouter();
17
17
  const pathname = usePathname();
18
18
  const params = new URLSearchParams(useSearchParams().toString());
@@ -31,7 +31,7 @@ export function ResourceTableToolbar({ resource }: { resource: ClientResource })
31
31
  go({ q: q.trim() || null });
32
32
  }
33
33
 
34
- if (!searchable && filters.length === 0) return null;
34
+ if (!searchable && filters.length === 0 && !publish) return null;
35
35
 
36
36
  return (
37
37
  <div className="flex flex-wrap items-center gap-2">
@@ -52,6 +52,24 @@ export function ResourceTableToolbar({ resource }: { resource: ClientResource })
52
52
  </form>
53
53
  )}
54
54
 
55
+ {/* For a resource with a publish date: which of the three a record is, not a date to match. */}
56
+ {publish && (
57
+ <Select value={params.get(`filter[${publish.field}]`) ?? ALL} onValueChange={(value) => go({ [`filter[${publish.field}]`]: value === ALL ? null : value })}>
58
+ <SelectTrigger className="min-w-36" aria-label="Filter by whether it is published">
59
+ <span className="text-muted-foreground">Status:</span>
60
+ <SelectValue />
61
+ </SelectTrigger>
62
+ <SelectContent>
63
+ <SelectGroup>
64
+ <SelectItem value={ALL}>Any</SelectItem>
65
+ <SelectItem value="published">Published</SelectItem>
66
+ <SelectItem value="scheduled">Scheduled</SelectItem>
67
+ <SelectItem value="draft">Draft</SelectItem>
68
+ </SelectGroup>
69
+ </SelectContent>
70
+ </Select>
71
+ )}
72
+
55
73
  {filters.map(([key, def]) => {
56
74
  const param = `filter[${key}]`;
57
75
  const options = def.kind === "enum" ? def.options.map((value) => ({ value, label: optionLabel(def, value) })) : [
@@ -19,12 +19,13 @@ import {
19
19
  } from "@/components/ui/pagination";
20
20
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
21
21
  import { resourcePath, adminPermissions, allowedActions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
22
- import { actionApplies } from "@/lib/extras";
22
+ import { actionApplies, formOf, PUBLISH_LABELS, publishFieldOf, publishState } from "@/lib/extras";
23
23
  import { site } from "@/lib/site";
24
24
  import { trashFor } from "@/lib/trash";
25
25
  import { cn } from "@/lib/utils";
26
26
  import { ColumnMenu } from "./column-menu";
27
27
  import { ExportButton } from "./export-button";
28
+ import { FormActivity } from "./form-activity";
28
29
  import { isInlineEditable } from "./editable";
29
30
  import { InlineCell } from "./inline-cell";
30
31
  import type { RelationMeta } from "./fields/field-widget";
@@ -100,6 +101,11 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
100
101
  }
101
102
 
102
103
  const overlayForms = site.dashboard.forms === "sheet";
104
+ // The field this resource's records are published by, where it has one, and the time to judge them by.
105
+ const publishField = publishFieldOf(resource.name);
106
+ const now = new Date();
107
+ // The field holding an address its owner has to prove, where the public form asks for that.
108
+ const confirmField = formOf(resource.name)?.confirm;
103
109
  // The resource's own actions this person may run: on the list as a whole, and on a record.
104
110
  const actions = await allowedActions(resource.name);
105
111
  const onRecords = actions.filter((action) => action.on === "record");
@@ -117,8 +123,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
117
123
  <TableSelection ids={rowIds}>
118
124
  <div className="flex flex-col gap-4">
119
125
  <ResourceInsights resource={forClient} total={meta.total} />
126
+ <FormActivity resource={resource} />
120
127
  <div className="flex flex-wrap items-center justify-between gap-2">
121
- <ResourceTableToolbar resource={forClient} />
128
+ <ResourceTableToolbar resource={forClient} publish={publishField ? { field: publishField, label: resource.fields[publishField]?.label ?? "Published" } : undefined} />
122
129
  <div className="flex flex-wrap items-center gap-2">
123
130
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
124
131
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
@@ -216,7 +223,28 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
216
223
  let content: React.ReactNode = formatValue(column.def, value);
217
224
  // Timestamps are formatted in the browser: the Worker's clock is UTC,
218
225
  // so a date rendered here would be in nobody's timezone.
219
- if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
226
+ if (column.key === publishField) {
227
+ // Not the raw date: what it means. A post is published, waiting for its time, or neither.
228
+ const state = publishState(value, now);
229
+ content = (
230
+ <span className="inline-flex items-center gap-2">
231
+ <Badge variant={state === "published" ? "success" : state === "scheduled" ? "warning" : "secondary"}>{PUBLISH_LABELS[state]}</Badge>
232
+ {state !== "draft" && (
233
+ <span className="text-xs text-muted-foreground">
234
+ <LocalTime value={value as string} />
235
+ </span>
236
+ )}
237
+ </span>
238
+ );
239
+ } else if (column.key === confirmField && row.confirmedAt == null) {
240
+ // Joined through the form and hasn't followed the link yet: listed, and not counted or exported.
241
+ content = (
242
+ <span className="inline-flex items-center gap-2">
243
+ {content}
244
+ <Badge variant="warning">Not confirmed</Badge>
245
+ </span>
246
+ );
247
+ } else if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
220
248
  content = <LocalTime value={value as string | number | Date} />;
221
249
  } else if (column.def.kind === "enum" && value != null) {
222
250
  const tone = statusTone(value);
@@ -316,6 +344,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
316
344
  canDelete={permissions.delete}
317
345
  trash={trash}
318
346
  actions={onRecords.filter((action) => actionApplies(action, row))}
347
+ published={publishField ? publishState(row[publishField], now) : undefined}
319
348
  />
320
349
  </TableCell>
321
350
  </TableRow>
@@ -3,10 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
- import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { ExternalLinkIcon, EyeOffIcon, MoreHorizontalIcon, PencilIcon, SendIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
- import { deleteRecordAction } from "@/app/dashboard/actions";
9
+ import { deleteRecordAction, publishRecordAction } from "@/app/dashboard/actions";
10
10
  import { restoreRecordAction } from "@/app/dashboard/trash/actions";
11
11
  import {
12
12
  AlertDialog,
@@ -27,7 +27,7 @@ import {
27
27
  DropdownMenuTrigger,
28
28
  } from "@/components/ui/dropdown-menu";
29
29
  import { Spinner } from "@/components/ui/spinner";
30
- import type { ResourceAction } from "@/lib/extras";
30
+ import type { PublishState, ResourceAction } from "@/lib/extras";
31
31
  import { keptFor, type TrashInfo } from "@/lib/trash-info";
32
32
  import { actionIcon, useResourceActions } from "./resource-action";
33
33
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
@@ -49,6 +49,7 @@ export function RowActions({
49
49
  canDelete = true,
50
50
  trash,
51
51
  actions = [],
52
+ published,
52
53
  }: {
53
54
  resource: ClientResource;
54
55
  id: string;
@@ -65,8 +66,19 @@ export function RowActions({
65
66
  trash?: TrashInfo;
66
67
  /** The resource's own actions that this person may run and that apply to this row. */
67
68
  actions?: ResourceAction[];
69
+ /** For a resource with a publish date: what this row is now. */
70
+ published?: PublishState;
68
71
  }) {
69
72
  const router = useRouter();
73
+ const [, startPublishing] = useTransition();
74
+ const publish = (now: boolean) => {
75
+ startPublishing(async () => {
76
+ const result = await publishRecordAction(resource.name, id, now);
77
+ if (result.ok) toast.success(now ? `${resource.label} published.` : `${resource.label} is a draft again.`);
78
+ else toast.error(result.error);
79
+ router.refresh();
80
+ });
81
+ };
70
82
  const own = useResourceActions(resource.name, { one: resource.label.toLowerCase(), many: resource.pluralLabel.toLowerCase() });
71
83
  const [confirming, setConfirming] = useState(false);
72
84
  const [editing, setEditing] = useState(false);
@@ -105,6 +117,24 @@ export function RowActions({
105
117
  </DropdownMenuItem>
106
118
  </DropdownMenuGroup>
107
119
  <DropdownMenuSeparator />
120
+ {canUpdate && published && (
121
+ <>
122
+ <DropdownMenuGroup>
123
+ {published === "published" ? (
124
+ <DropdownMenuItem onSelect={() => publish(false)}>
125
+ <EyeOffIcon />
126
+ Unpublish
127
+ </DropdownMenuItem>
128
+ ) : (
129
+ <DropdownMenuItem onSelect={() => publish(true)}>
130
+ <SendIcon />
131
+ {published === "scheduled" ? "Publish now" : "Publish"}
132
+ </DropdownMenuItem>
133
+ )}
134
+ </DropdownMenuGroup>
135
+ <DropdownMenuSeparator />
136
+ </>
137
+ )}
108
138
  {actions.length > 0 && (
109
139
  <>
110
140
  <DropdownMenuGroup>
@@ -1,5 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { BrandMark } from "@/components/auth/auth-shell";
3
+ import { siteLinks } from "@/lib/extras";
3
4
  import { site } from "@/lib/site";
4
5
 
5
6
  /** The public site's top bar. Static on purpose: it doesn't read the session, so the home page can be cached. */
@@ -12,12 +13,18 @@ export function SiteHeader() {
12
13
  {site.name}
13
14
  </Link>
14
15
  <nav aria-label="Main" className="hidden items-center gap-7 text-sm text-foreground-muted md:flex">
15
- <a href="#features" className="hover:text-foreground">
16
+ <a href="/#features" className="hover:text-foreground">
16
17
  Features
17
18
  </a>
18
- <a href="#customers" className="hover:text-foreground">
19
+ <a href="/#customers" className="hover:text-foreground">
19
20
  Customers
20
21
  </a>
22
+ {/* What the app's resources have put on the site: a blog, when `nevela blog` has made one. */}
23
+ {siteLinks().map((link) => (
24
+ <Link key={link.href} href={link.href} className="hover:text-foreground">
25
+ {link.label}
26
+ </Link>
27
+ ))}
21
28
  <a href={site.links.support} className="hover:text-foreground">
22
29
  Contact
23
30
  </a>
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import type { PublicForm } from "@/hooks/use-public-form";
5
+
6
+ /** The part of Cloudflare's script this uses. */
7
+ interface Turnstile {
8
+ render: (
9
+ element: HTMLElement,
10
+ options: { sitekey: string; action?: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void },
11
+ ) => string;
12
+ reset: (id: string) => void;
13
+ remove: (id: string) => void;
14
+ }
15
+
16
+ const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
17
+ const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
18
+
19
+ let loading: Promise<Turnstile> | null = null;
20
+
21
+ /** Cloudflare's script, asked for once however many forms a visit shows. */
22
+ function turnstile(): Promise<Turnstile> {
23
+ const loaded = () => (window as unknown as { turnstile?: Turnstile }).turnstile;
24
+ loading ??= new Promise<Turnstile>((resolve, reject) => {
25
+ const existing = loaded();
26
+ if (existing) return resolve(existing);
27
+ const script = document.createElement("script");
28
+ script.src = SCRIPT;
29
+ script.async = true;
30
+ script.defer = true;
31
+ script.onload = () => {
32
+ const ready = loaded();
33
+ if (ready) resolve(ready);
34
+ else reject(new Error("Turnstile didn't load."));
35
+ };
36
+ script.onerror = () => {
37
+ // Not kept: the next form to appear tries again.
38
+ loading = null;
39
+ script.remove();
40
+ reject(new Error("Turnstile didn't load."));
41
+ };
42
+ document.head.appendChild(script);
43
+ });
44
+ return loading;
45
+ }
46
+
47
+ /**
48
+ * What a protected form carries besides its own fields. Put it inside the form:
49
+ *
50
+ * ```tsx
51
+ * <PublicFormGuard form={form} />
52
+ * ```
53
+ *
54
+ * - The honeypot: a field a person never sees or reaches, and a bot fills in.
55
+ * - The robot check (Cloudflare Turnstile), where the descriptor asks for it and
56
+ * NEXT_PUBLIC_TURNSTILE_SITE_KEY is set. Its answer is good once, so it starts over
57
+ * after every attempt. With no site key there is no widget, and the form carries the
58
+ * honeypot and its token only.
59
+ *
60
+ * The form's token is the hook's to send. Nothing here is the check itself: Laravel
61
+ * makes every one of them.
62
+ */
63
+ export function PublicFormGuard({ form, className }: { form: PublicForm; className?: string }) {
64
+ const { honeypot, turnstile: asked, action, attempt, robot } = form.guard;
65
+ const holder = React.useRef<HTMLDivElement>(null);
66
+ const widget = React.useRef<string | null>(null);
67
+ const shown = asked && SITE_KEY !== "";
68
+
69
+ // Rendered by hand, not by the script on load: moving between pages here doesn't reload
70
+ // the document, so a widget the script found once would not be found again.
71
+ React.useEffect(() => {
72
+ if (!shown || !holder.current) return;
73
+ let current = true;
74
+ let api: Turnstile | null = null;
75
+ turnstile()
76
+ .then((ready) => {
77
+ if (!current || !holder.current) return;
78
+ api = ready;
79
+ widget.current = ready.render(holder.current, {
80
+ sitekey: SITE_KEY,
81
+ // Which form this check is for. Laravel refuses a token solved for another.
82
+ ...(action ? { action } : {}),
83
+ callback: (token) => robot(token),
84
+ "expired-callback": () => robot(null),
85
+ "error-callback": () => robot(null),
86
+ });
87
+ })
88
+ .catch(() => robot(null));
89
+ return () => {
90
+ current = false;
91
+ if (api && widget.current) api.remove(widget.current);
92
+ widget.current = null;
93
+ robot(null);
94
+ };
95
+ }, [shown, action, robot]);
96
+
97
+ // A used answer is refused the second time, so a corrected form needs a new one.
98
+ React.useEffect(() => {
99
+ if (attempt === 0 || !widget.current) return;
100
+ const api = (window as unknown as { turnstile?: Turnstile }).turnstile;
101
+ api?.reset(widget.current);
102
+ }, [attempt]);
103
+
104
+ React.useEffect(() => {
105
+ if (asked && SITE_KEY === "" && process.env.NODE_ENV !== "production") {
106
+ console.warn("This form asks for the robot check, and NEXT_PUBLIC_TURNSTILE_SITE_KEY isn't set, so no widget is shown and Laravel will refuse it.");
107
+ }
108
+ }, [asked]);
109
+
110
+ return (
111
+ <>
112
+ {honeypot && (
113
+ // Off the page, and out of the way of a keyboard and a screen reader. Not `hidden`
114
+ // or display:none: a bot that skips those would skip this too.
115
+ <div aria-hidden="true" style={{ position: "absolute", left: "-10000px", top: "auto", width: 1, height: 1, overflow: "hidden" }}>
116
+ <label>
117
+ Leave this empty
118
+ <input type="text" name={honeypot} tabIndex={-1} autoComplete="off" defaultValue="" />
119
+ </label>
120
+ </div>
121
+ )}
122
+ {shown && <div ref={holder} className={className} />}
123
+ </>
124
+ );
125
+ }