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.
- package/index.mjs +24 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +41 -0
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -0
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +9 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +2 -1
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +89 -4
- package/template/laravel/src/Http/ResourceRequest.php +43 -3
- package/template/laravel/src/Http/TrashController.php +2 -1
- package/template/laravel/src/Http/UploadController.php +2 -2
- package/template/laravel/src/Nevela.php +30 -1
- package/template/laravel/src/NevelaServiceProvider.php +11 -2
- package/template/laravel/src/Support/Descriptor.php +24 -0
- package/template/laravel/src/Support/Field.php +17 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +75 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +15 -1
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +29 -5
- package/template/web/components/dashboard/record-detail.tsx +2 -1
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +32 -3
- package/template/web/components/dashboard/row-actions.tsx +33 -3
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +263 -0
- package/template/web/lib/extras.ts +39 -0
- 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't set in the API. Set it, or take
|
|
33
|
+
"turnstile" 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'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
|
-
/**
|
|
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) =>
|
|
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 (
|
|
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="
|
|
16
|
+
<a href="/#features" className="hover:text-foreground">
|
|
16
17
|
Features
|
|
17
18
|
</a>
|
|
18
|
-
<a href="
|
|
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
|
+
}
|