create-nevela 0.7.1 → 0.8.0
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 +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
|
@@ -253,3 +253,49 @@
|
|
|
253
253
|
letter-spacing: var(--heading-tracking);
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
* Rich text: what a rich text field's editor holds and what a record's page shows of it.
|
|
259
|
+
* Tailwind's reset takes the looks off headings, lists and quotes; this gives them back.
|
|
260
|
+
*/
|
|
261
|
+
.rich-text {
|
|
262
|
+
line-height: 1.6;
|
|
263
|
+
overflow-wrap: anywhere;
|
|
264
|
+
}
|
|
265
|
+
.rich-text > * + * {
|
|
266
|
+
margin-top: 0.75em;
|
|
267
|
+
}
|
|
268
|
+
.rich-text h2 {
|
|
269
|
+
font-size: 1.25em;
|
|
270
|
+
line-height: 1.3;
|
|
271
|
+
}
|
|
272
|
+
.rich-text h3 {
|
|
273
|
+
font-size: 1.1em;
|
|
274
|
+
line-height: 1.35;
|
|
275
|
+
}
|
|
276
|
+
.rich-text ul,
|
|
277
|
+
.rich-text ol {
|
|
278
|
+
padding-left: 1.5em;
|
|
279
|
+
}
|
|
280
|
+
.rich-text ul {
|
|
281
|
+
list-style: disc;
|
|
282
|
+
}
|
|
283
|
+
.rich-text ol {
|
|
284
|
+
list-style: decimal;
|
|
285
|
+
}
|
|
286
|
+
.rich-text li + li {
|
|
287
|
+
margin-top: 0.25em;
|
|
288
|
+
}
|
|
289
|
+
.rich-text blockquote {
|
|
290
|
+
border-left: 3px solid var(--border);
|
|
291
|
+
padding-left: 1em;
|
|
292
|
+
color: var(--muted-foreground);
|
|
293
|
+
}
|
|
294
|
+
.rich-text a {
|
|
295
|
+
color: var(--primary);
|
|
296
|
+
text-decoration: underline;
|
|
297
|
+
text-underline-offset: 2px;
|
|
298
|
+
}
|
|
299
|
+
.rich-text s {
|
|
300
|
+
text-decoration: line-through;
|
|
301
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useMemo, useState, useTransition } from "react";
|
|
5
|
+
import { Fragment, useMemo, useState, useTransition } from "react";
|
|
6
6
|
import { toast } from "sonner";
|
|
7
7
|
import { createRoleAction, deleteRoleAction, updateRoleAction } from "@/app/dashboard/access/actions";
|
|
8
8
|
import {
|
|
@@ -27,6 +27,12 @@ import type { ManagedRole, PermissionCatalog, PermissionFeature } from "@/lib/ac
|
|
|
27
27
|
const LIST = "/dashboard/access/roles";
|
|
28
28
|
const ACTIONS = ["create", "view", "edit", "delete"] as const;
|
|
29
29
|
const ACTION_LABELS: Record<string, string> = { create: "Create", view: "View", edit: "Edit", delete: "Delete" };
|
|
30
|
+
/**
|
|
31
|
+
* Whether a permission is one of a resource's own actions ("applications.accept") and not
|
|
32
|
+
* one of the four. Those are given by name only: a whole section, or a pattern, doesn't
|
|
33
|
+
* carry them, which is how Laravel reads a grant too.
|
|
34
|
+
*/
|
|
35
|
+
const isOwn = (key: string) => !(ACTIONS as readonly string[]).includes(key.split(".")[1] ?? "");
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* A role's name and what it allows.
|
|
@@ -84,8 +90,9 @@ export function RoleForm({
|
|
|
84
90
|
}, [kept.length]);
|
|
85
91
|
|
|
86
92
|
const holds = (key: string) => mine === null || mine.includes(key);
|
|
87
|
-
const
|
|
88
|
-
const
|
|
93
|
+
const inWhole = (key: string) => !isOwn(key) && whole.has(moduleOf.get(key.split(".")[0]!) ?? "");
|
|
94
|
+
const on = (key: string) => everything || inWhole(key) || byPattern.has(key) || picked.has(key);
|
|
95
|
+
const fixed = (key: string) => locked || inWhole(key) || byPattern.has(key) || (!picked.has(key) && !holds(key));
|
|
89
96
|
|
|
90
97
|
const total = catalog.total;
|
|
91
98
|
const count = useMemo(() => {
|
|
@@ -116,7 +123,7 @@ export function RoleForm({
|
|
|
116
123
|
// A permission already covered by a whole section doesn't need saying twice.
|
|
117
124
|
const grants = everything
|
|
118
125
|
? ["*"]
|
|
119
|
-
: [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !
|
|
126
|
+
: [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !inWhole(key))];
|
|
120
127
|
const input = { name: name.trim(), description: description.trim() || null, grants };
|
|
121
128
|
|
|
122
129
|
startTransition(async () => {
|
|
@@ -349,8 +356,11 @@ function FeatureRows({
|
|
|
349
356
|
const keys = feature.actions.map((action) => `${feature.key}.${action}`);
|
|
350
357
|
const every = keys.every(on);
|
|
351
358
|
const some = keys.some(on);
|
|
359
|
+
// The resource's own actions: accept, send an announcement. They have no column, so they get a line.
|
|
360
|
+
const own = feature.actions.filter((action) => !(ACTIONS as readonly string[]).includes(action));
|
|
352
361
|
return (
|
|
353
|
-
<
|
|
362
|
+
<Fragment key={feature.key}>
|
|
363
|
+
<tr className={own.length > 0 ? undefined : "border-b last:border-b-0"}>
|
|
354
364
|
<th scope="row" className="px-3 py-2 text-left font-normal">
|
|
355
365
|
{feature.name}
|
|
356
366
|
</th>
|
|
@@ -382,6 +392,31 @@ function FeatureRows({
|
|
|
382
392
|
/>
|
|
383
393
|
</td>
|
|
384
394
|
</tr>
|
|
395
|
+
{own.length > 0 && (
|
|
396
|
+
<tr className="border-b last:border-b-0">
|
|
397
|
+
<td colSpan={ACTIONS.length + 2} className="px-3 pt-0 pb-2">
|
|
398
|
+
<span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
399
|
+
<span className="text-xs">Also</span>
|
|
400
|
+
{own.map((action) => {
|
|
401
|
+
const key = `${feature.key}.${action}`;
|
|
402
|
+
return (
|
|
403
|
+
<label key={action} className="flex items-center gap-2">
|
|
404
|
+
{/* Named with its resource, as the four are: two resources can both have an "Archive". */}
|
|
405
|
+
<Checkbox
|
|
406
|
+
aria-label={`${feature.labels?.[action] ?? action} ${feature.name.toLowerCase()}`}
|
|
407
|
+
checked={on(key)}
|
|
408
|
+
disabled={fixed(key)}
|
|
409
|
+
onCheckedChange={(value) => set([key], value === true)}
|
|
410
|
+
/>
|
|
411
|
+
{feature.labels?.[action] ?? action}
|
|
412
|
+
</label>
|
|
413
|
+
);
|
|
414
|
+
})}
|
|
415
|
+
</span>
|
|
416
|
+
</td>
|
|
417
|
+
</tr>
|
|
418
|
+
)}
|
|
419
|
+
</Fragment>
|
|
385
420
|
);
|
|
386
421
|
})}
|
|
387
422
|
</>
|
|
@@ -12,6 +12,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|
|
12
12
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
13
13
|
import { Input } from "@/components/ui/input";
|
|
14
14
|
import { Spinner } from "@/components/ui/spinner";
|
|
15
|
+
import { isRich } from "@/lib/rich";
|
|
15
16
|
import { cn } from "@/lib/utils";
|
|
16
17
|
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
17
18
|
|
|
@@ -41,6 +42,8 @@ const MOST_ROWS = 500;
|
|
|
41
42
|
|
|
42
43
|
/** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
|
|
43
44
|
const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
|
|
45
|
+
/** A file needs an upload and rich text needs an editor: neither is something to type into a cell. */
|
|
46
|
+
const fitsACell = (def: { kind: string }) => IN_A_CELL.has(def.kind) && !isRich(def);
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
49
|
* Add several records at once, in a grid.
|
|
@@ -82,10 +85,10 @@ function Grid({ resource, relations, onDone }: { resource: ClientResource; relat
|
|
|
82
85
|
const [pending, startTransition] = useTransition();
|
|
83
86
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
84
87
|
const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
|
|
85
|
-
const fields = useMemo(() => all.filter(([, def]) =>
|
|
88
|
+
const fields = useMemo(() => all.filter(([, def]) => fitsACell(def)), [all]);
|
|
86
89
|
// A field this grid can't show and Laravel requires would fail every row, with nothing
|
|
87
90
|
// on screen to fix. Say so, and leave those records to the form.
|
|
88
|
-
const missing = useMemo(() => all.filter(([, def]) => !
|
|
91
|
+
const missing = useMemo(() => all.filter(([, def]) => !fitsACell(def) && def.required !== false), [all]);
|
|
89
92
|
|
|
90
93
|
const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
|
|
91
94
|
const nextKey = useRef(STARTING_ROWS);
|
|
@@ -10,12 +10,14 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
|
|
|
10
10
|
import { Switch } from "@/components/ui/switch";
|
|
11
11
|
import { Textarea } from "@/components/ui/textarea";
|
|
12
12
|
import { groupDigits, ungroup } from "@/lib/number";
|
|
13
|
+
import { isRich } from "@/lib/rich";
|
|
13
14
|
import { DateField, DateTimeField } from "./date-field";
|
|
14
15
|
import { CountryField } from "./country-field";
|
|
15
16
|
import { FileField } from "./file-field";
|
|
16
17
|
import { MultiSelectField } from "./multi-select-field";
|
|
17
18
|
import { PhoneField } from "./phone-field";
|
|
18
19
|
import { RelationField } from "./relation-field";
|
|
20
|
+
import { RichTextField } from "./rich-text-field";
|
|
19
21
|
|
|
20
22
|
export interface RelationMeta {
|
|
21
23
|
name: string;
|
|
@@ -56,6 +58,9 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
56
58
|
|
|
57
59
|
switch (field.kind) {
|
|
58
60
|
case "text":
|
|
61
|
+
if (isRich(field)) {
|
|
62
|
+
return <RichTextField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} placeholder={field.placeholder} />;
|
|
63
|
+
}
|
|
59
64
|
return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
|
|
60
65
|
|
|
61
66
|
case "int":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { FileIcon } from "lucide-react";
|
|
2
|
-
import { fileName, fileUrl, isImageKey } from "@/lib/files";
|
|
1
|
+
import { DownloadIcon, FileIcon, LockIcon } from "lucide-react";
|
|
2
|
+
import { fileName, fileUrl, isImageKey, isPrivateKey } from "@/lib/files";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A stored file in a table cell or a record's detail.
|
|
@@ -8,8 +8,29 @@ import { fileName, fileUrl, isImageKey } from "@/lib/files";
|
|
|
8
8
|
* uploaded, so a table of fifty products downloads fifty small pictures, not fifty
|
|
9
9
|
* full-size ones. Anything else is its filename, which is the useful part of a key
|
|
10
10
|
* nobody wants to read in full.
|
|
11
|
+
*
|
|
12
|
+
* A private file is its name and a lock, picture or not: it has no thumbnail and no
|
|
13
|
+
* address of its own. With `download`, where it is fetched through its record, the name
|
|
14
|
+
* is the link that saves it.
|
|
11
15
|
*/
|
|
12
|
-
export function FileCell({ value, size = 36 }: { resourceName?: string; fieldKey?: string; value: string; size?: number }) {
|
|
16
|
+
export function FileCell({ value, size = 36, download }: { resourceName?: string; fieldKey?: string; value: string; size?: number; download?: string }) {
|
|
17
|
+
if (isPrivateKey(value)) {
|
|
18
|
+
const name = <span className="max-w-[24ch] truncate">{fileName(value)}</span>;
|
|
19
|
+
return (
|
|
20
|
+
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
|
21
|
+
<LockIcon aria-label="Private" className="size-3.5" />
|
|
22
|
+
{download ? (
|
|
23
|
+
<a href={download} download className="inline-flex items-center gap-1.5 text-primary hover:underline">
|
|
24
|
+
{name}
|
|
25
|
+
<DownloadIcon aria-hidden="true" className="size-3.5" />
|
|
26
|
+
</a>
|
|
27
|
+
) : (
|
|
28
|
+
name
|
|
29
|
+
)}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
if (!isImageKey(value)) {
|
|
14
35
|
return (
|
|
15
36
|
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { createUploadUrlAction } from "@/app/dashboard/actions";
|
|
15
15
|
import { Button } from "@/components/ui/button";
|
|
16
16
|
import { Progress } from "@/components/ui/progress";
|
|
17
|
-
import { fileName, fileUrl, isImageKey } from "@/lib/files";
|
|
17
|
+
import { fileName, fileUrl, isImageKey, isPrivateKey } from "@/lib/files";
|
|
18
18
|
import { cn } from "@/lib/utils";
|
|
19
19
|
|
|
20
20
|
interface Props {
|
|
@@ -107,7 +107,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
|
|
|
107
107
|
|
|
108
108
|
// Thumbnail for a stored image (a local preview replaces it while uploading).
|
|
109
109
|
useEffect(() => {
|
|
110
|
-
|
|
110
|
+
// A private file has no thumbnail, and nothing to fetch one from.
|
|
111
|
+
setPreview(value && isImageKey(value) && !isPrivateKey(value) ? fileUrl(value, "thumb") : null);
|
|
111
112
|
}, [value]);
|
|
112
113
|
|
|
113
114
|
async function check(file: File): Promise<string | null> {
|
|
@@ -236,9 +237,12 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
|
|
|
236
237
|
{thumbnail}
|
|
237
238
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{fileName(value)}</span>
|
|
238
239
|
<div className="flex shrink-0 gap-1">
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
{/* A private file is fetched through its record, from the record's page, not from here. */}
|
|
241
|
+
{!isPrivateKey(value) && (
|
|
242
|
+
<Button type="button" variant="ghost" size="sm" onClick={open}>
|
|
243
|
+
Open<span className="sr-only"> {fileName(value)}</span>
|
|
244
|
+
</Button>
|
|
245
|
+
)}
|
|
242
246
|
{/* The field label points here; aria-label keeps the button named for what it does. */}
|
|
243
247
|
<Button
|
|
244
248
|
id={id}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { BoldIcon, Heading2Icon, Heading3Icon, ItalicIcon, LinkIcon, ListIcon, ListOrderedIcon, Redo2Icon, StrikethroughIcon, TextQuoteIcon, Undo2Icon } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
8
|
+
import { isBlankHtml, linkAddress, tidyHtml } from "@/lib/rich";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
type Mark = "bold" | "italic" | "strike" | "link" | "h2" | "h3" | "bullets" | "numbers" | "quote";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The editor for a rich text field: a page to type on, and a toolbar of the eleven
|
|
15
|
+
* things that can be done to it.
|
|
16
|
+
*
|
|
17
|
+
* It is the browser's own editing (`contentEditable` and `document.execCommand`), with
|
|
18
|
+
* no editor library behind it, so it adds nothing to install or to keep up to date.
|
|
19
|
+
* Browsers disagree on the HTML they write (`<b>` or `<strong>`, `<div>` or `<p>`);
|
|
20
|
+
* Laravel settles that when it cleans what is saved, and sends back the one spelling.
|
|
21
|
+
*/
|
|
22
|
+
export function RichTextField({
|
|
23
|
+
id,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
invalid,
|
|
27
|
+
disabled,
|
|
28
|
+
placeholder,
|
|
29
|
+
}: {
|
|
30
|
+
id: string;
|
|
31
|
+
value: string;
|
|
32
|
+
onChange: (value: string) => void;
|
|
33
|
+
invalid?: boolean;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const page = useRef<HTMLDivElement>(null);
|
|
38
|
+
/** What the page last said it holds. A value that differs came from outside: a record loading, a form resetting. */
|
|
39
|
+
const said = useRef<string | null>(null);
|
|
40
|
+
/** Where the cursor was when the link box opened, since typing an address moves it away. */
|
|
41
|
+
const place = useRef<Range | null>(null);
|
|
42
|
+
const [marks, setMarks] = useState<Partial<Record<Mark, boolean>>>({});
|
|
43
|
+
const [linking, setLinking] = useState(false);
|
|
44
|
+
const [address, setAddress] = useState("");
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const element = page.current;
|
|
48
|
+
if (element && value !== said.current) {
|
|
49
|
+
element.innerHTML = value;
|
|
50
|
+
said.current = value;
|
|
51
|
+
}
|
|
52
|
+
}, [value]);
|
|
53
|
+
|
|
54
|
+
/** The nearest tag of this name around the cursor, inside the page. */
|
|
55
|
+
const around = useCallback((tag: string): Element | null => {
|
|
56
|
+
const selection = window.getSelection();
|
|
57
|
+
const node = selection && selection.rangeCount > 0 ? selection.anchorNode : null;
|
|
58
|
+
const element = node instanceof Element ? node : (node?.parentElement ?? null);
|
|
59
|
+
const found = element?.closest(tag) ?? null;
|
|
60
|
+
return found && page.current?.contains(found) ? found : null;
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const look = useCallback(() => {
|
|
64
|
+
if (!page.current || !page.current.contains(window.getSelection()?.anchorNode ?? null)) return;
|
|
65
|
+
setMarks({
|
|
66
|
+
bold: document.queryCommandState("bold"),
|
|
67
|
+
italic: document.queryCommandState("italic"),
|
|
68
|
+
strike: document.queryCommandState("strikeThrough"),
|
|
69
|
+
bullets: document.queryCommandState("insertUnorderedList"),
|
|
70
|
+
numbers: document.queryCommandState("insertOrderedList"),
|
|
71
|
+
link: around("a") !== null,
|
|
72
|
+
h2: around("h2") !== null,
|
|
73
|
+
h3: around("h3") !== null,
|
|
74
|
+
quote: around("blockquote") !== null,
|
|
75
|
+
});
|
|
76
|
+
}, [around]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
document.addEventListener("selectionchange", look);
|
|
80
|
+
return () => document.removeEventListener("selectionchange", look);
|
|
81
|
+
}, [look]);
|
|
82
|
+
|
|
83
|
+
const say = useCallback(() => {
|
|
84
|
+
const element = page.current;
|
|
85
|
+
if (!element) return;
|
|
86
|
+
// Emptied, a page still holds the paragraph the cursor sits in.
|
|
87
|
+
const html = isBlankHtml(element.innerHTML) ? "" : element.innerHTML;
|
|
88
|
+
said.current = html;
|
|
89
|
+
onChange(html);
|
|
90
|
+
}, [onChange]);
|
|
91
|
+
|
|
92
|
+
const run = useCallback(
|
|
93
|
+
(command: string, argument?: string) => {
|
|
94
|
+
page.current?.focus();
|
|
95
|
+
document.execCommand(command, false, argument);
|
|
96
|
+
say();
|
|
97
|
+
look();
|
|
98
|
+
},
|
|
99
|
+
[say, look],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/** A heading is a kind of line: pressed again, the line goes back to being a paragraph. */
|
|
103
|
+
const heading = (tag: "h2" | "h3") => run("formatBlock", around(tag) ? "p" : tag);
|
|
104
|
+
const quote = () => (around("blockquote") ? run("outdent") : run("formatBlock", "blockquote"));
|
|
105
|
+
|
|
106
|
+
function openLink(open: boolean) {
|
|
107
|
+
if (open) {
|
|
108
|
+
const selection = window.getSelection();
|
|
109
|
+
place.current = selection && selection.rangeCount > 0 && page.current?.contains(selection.anchorNode) ? selection.getRangeAt(0).cloneRange() : null;
|
|
110
|
+
setAddress(around("a")?.getAttribute("href") ?? "");
|
|
111
|
+
}
|
|
112
|
+
setLinking(open);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyLink(typed: string) {
|
|
116
|
+
setLinking(false);
|
|
117
|
+
const href = linkAddress(typed);
|
|
118
|
+
// After the box has closed and handed the cursor back.
|
|
119
|
+
window.setTimeout(() => {
|
|
120
|
+
const element = page.current;
|
|
121
|
+
if (!element) return;
|
|
122
|
+
element.focus();
|
|
123
|
+
const selection = window.getSelection();
|
|
124
|
+
if (place.current && selection) {
|
|
125
|
+
selection.removeAllRanges();
|
|
126
|
+
selection.addRange(place.current);
|
|
127
|
+
}
|
|
128
|
+
if (href === "") {
|
|
129
|
+
run("unlink");
|
|
130
|
+
} else if (selection?.isCollapsed && !around("a")) {
|
|
131
|
+
// Nothing selected to make a link of: the address is its own words.
|
|
132
|
+
const text = href.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
133
|
+
run("insertHTML", `<a href="${text}">${text}</a>`);
|
|
134
|
+
} else {
|
|
135
|
+
run("createLink", href);
|
|
136
|
+
}
|
|
137
|
+
}, 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tools: { mark?: Mark; label: string; icon: React.ReactNode; press: () => void }[][] = [
|
|
141
|
+
[
|
|
142
|
+
{ mark: "bold", label: "Bold", icon: <BoldIcon />, press: () => run("bold") },
|
|
143
|
+
{ mark: "italic", label: "Italic", icon: <ItalicIcon />, press: () => run("italic") },
|
|
144
|
+
{ mark: "strike", label: "Strikethrough", icon: <StrikethroughIcon />, press: () => run("strikeThrough") },
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
{ mark: "h2", label: "Heading", icon: <Heading2Icon />, press: () => heading("h2") },
|
|
148
|
+
{ mark: "h3", label: "Smaller heading", icon: <Heading3Icon />, press: () => heading("h3") },
|
|
149
|
+
{ mark: "bullets", label: "Bullet list", icon: <ListIcon />, press: () => run("insertUnorderedList") },
|
|
150
|
+
{ mark: "numbers", label: "Numbered list", icon: <ListOrderedIcon />, press: () => run("insertOrderedList") },
|
|
151
|
+
{ mark: "quote", label: "Quote", icon: <TextQuoteIcon />, press: quote },
|
|
152
|
+
],
|
|
153
|
+
];
|
|
154
|
+
const history = [
|
|
155
|
+
{ label: "Undo", icon: <Undo2Icon />, press: () => run("undo") },
|
|
156
|
+
{ label: "Redo", icon: <Redo2Icon />, press: () => run("redo") },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const tool = (item: { mark?: Mark; label: string; icon: React.ReactNode; press: () => void }) => (
|
|
160
|
+
<Button
|
|
161
|
+
key={item.label}
|
|
162
|
+
type="button"
|
|
163
|
+
variant="ghost"
|
|
164
|
+
size="icon-sm"
|
|
165
|
+
title={item.label}
|
|
166
|
+
aria-label={item.label}
|
|
167
|
+
aria-pressed={item.mark ? Boolean(marks[item.mark]) : undefined}
|
|
168
|
+
disabled={disabled}
|
|
169
|
+
className={cn("size-7", item.mark && marks[item.mark] && "bg-accent text-accent-foreground")}
|
|
170
|
+
// Pressing a tool must not take the cursor out of the page.
|
|
171
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
172
|
+
onClick={item.press}
|
|
173
|
+
>
|
|
174
|
+
{item.icon}
|
|
175
|
+
</Button>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
"rounded-md border border-input shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30",
|
|
182
|
+
invalid && "border-destructive ring-destructive/20 dark:ring-destructive/40",
|
|
183
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<div role="toolbar" aria-label="Formatting" className="flex flex-wrap items-center gap-0.5 border-b border-input px-1.5 py-1">
|
|
187
|
+
{tools[0].map(tool)}
|
|
188
|
+
<Popover open={linking} onOpenChange={openLink}>
|
|
189
|
+
<PopoverTrigger asChild>
|
|
190
|
+
<Button
|
|
191
|
+
type="button"
|
|
192
|
+
variant="ghost"
|
|
193
|
+
size="icon-sm"
|
|
194
|
+
title="Link"
|
|
195
|
+
aria-label="Link"
|
|
196
|
+
aria-pressed={Boolean(marks.link)}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
className={cn("size-7", marks.link && "bg-accent text-accent-foreground")}
|
|
199
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
200
|
+
>
|
|
201
|
+
<LinkIcon />
|
|
202
|
+
</Button>
|
|
203
|
+
</PopoverTrigger>
|
|
204
|
+
<PopoverContent align="start" className="w-80">
|
|
205
|
+
{/* Not a form: this sits inside the record's own form, and a form can't hold another. */}
|
|
206
|
+
<div className="flex flex-col gap-2">
|
|
207
|
+
<Input
|
|
208
|
+
autoFocus
|
|
209
|
+
value={address}
|
|
210
|
+
placeholder="https://example.com"
|
|
211
|
+
aria-label="Link address"
|
|
212
|
+
onChange={(event) => setAddress(event.target.value)}
|
|
213
|
+
onKeyDown={(event) => {
|
|
214
|
+
if (event.key === "Enter") {
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
applyLink(address);
|
|
217
|
+
}
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
<div className="flex justify-end gap-2">
|
|
221
|
+
{marks.link && (
|
|
222
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => applyLink("")}>
|
|
223
|
+
Remove link
|
|
224
|
+
</Button>
|
|
225
|
+
)}
|
|
226
|
+
<Button type="button" size="sm" onClick={() => applyLink(address)}>
|
|
227
|
+
Apply
|
|
228
|
+
</Button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</PopoverContent>
|
|
232
|
+
</Popover>
|
|
233
|
+
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
|
|
234
|
+
{tools[1].map(tool)}
|
|
235
|
+
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
|
|
236
|
+
{history.map(tool)}
|
|
237
|
+
</div>
|
|
238
|
+
<div
|
|
239
|
+
ref={page}
|
|
240
|
+
id={id}
|
|
241
|
+
role="textbox"
|
|
242
|
+
aria-multiline="true"
|
|
243
|
+
aria-invalid={invalid || undefined}
|
|
244
|
+
aria-placeholder={placeholder}
|
|
245
|
+
data-placeholder={placeholder}
|
|
246
|
+
contentEditable={!disabled}
|
|
247
|
+
suppressContentEditableWarning
|
|
248
|
+
className="rich-text max-h-[32rem] min-h-40 overflow-y-auto px-3 py-2 text-base outline-none empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] md:text-sm"
|
|
249
|
+
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
|
|
250
|
+
onInput={say}
|
|
251
|
+
onBlur={say}
|
|
252
|
+
onPaste={(event) => {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
const html = event.clipboardData.getData("text/html");
|
|
255
|
+
if (html) run("insertHTML", tidyHtml(html));
|
|
256
|
+
else run("insertText", event.clipboardData.getData("text/plain"));
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -19,7 +19,9 @@ 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
23
|
import { keptFor, type TrashInfo } from "@/lib/trash-info";
|
|
24
|
+
import { ActionButtons } from "./resource-action";
|
|
23
25
|
import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
|
|
24
26
|
import { toastMovedToTrash } from "./trash-actions";
|
|
25
27
|
|
|
@@ -36,10 +38,12 @@ interface Props {
|
|
|
36
38
|
overlayForms: boolean;
|
|
37
39
|
/** This resource's trash, when a deleted record goes there instead of going for good. */
|
|
38
40
|
trash?: TrashInfo;
|
|
41
|
+
/** The resource's own actions that this person may run and that apply to this record as it is. */
|
|
42
|
+
actions?: ResourceAction[];
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
/**
|
|
42
|
-
export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash }: Props) {
|
|
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) {
|
|
43
47
|
const router = useRouter();
|
|
44
48
|
const [editing, setEditing] = useState(false);
|
|
45
49
|
const [confirming, setConfirming] = useState(false);
|
|
@@ -63,6 +67,7 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
|
|
|
63
67
|
|
|
64
68
|
return (
|
|
65
69
|
<>
|
|
70
|
+
<ActionButtons resourceName={resource.name} actions={actions} id={id} />
|
|
66
71
|
{canUpdate &&
|
|
67
72
|
(overlayForms ? (
|
|
68
73
|
<Button variant="outline" onClick={() => setEditing(true)}>
|
|
@@ -5,7 +5,10 @@ import { Badge } from "@/components/ui/badge";
|
|
|
5
5
|
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
|
-
import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
|
|
8
|
+
import { adminPermissions, allowedActions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
|
|
9
|
+
import { actionApplies } from "@/lib/extras";
|
|
10
|
+
import { privateFileUrl } from "@/lib/files";
|
|
11
|
+
import { isRich } from "@/lib/rich";
|
|
9
12
|
import { site } from "@/lib/site";
|
|
10
13
|
import { trashFor } from "@/lib/trash";
|
|
11
14
|
import { LocalTime } from "./local-time";
|
|
@@ -93,6 +96,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
|
|
|
93
96
|
canDelete={permissions.delete}
|
|
94
97
|
overlayForms={site.dashboard.forms === "sheet"}
|
|
95
98
|
trash={trash}
|
|
99
|
+
actions={(await allowedActions(resource.name)).filter((action) => action.on === "record" && actionApplies(action, record))}
|
|
96
100
|
/>
|
|
97
101
|
}
|
|
98
102
|
/>
|
|
@@ -111,9 +115,12 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
|
|
|
111
115
|
} else if (def.kind === "enum" && value != null) {
|
|
112
116
|
const tone = statusTone(value);
|
|
113
117
|
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
118
|
+
} else if (isRich(def) && typeof value === "string" && value) {
|
|
119
|
+
// Shown as it was written. Laravel cleaned it when it was saved and again when it was read.
|
|
120
|
+
content = <div className="rich-text" dangerouslySetInnerHTML={{ __html: value }} />;
|
|
114
121
|
} else if (def.kind === "file" && typeof value === "string" && value) {
|
|
115
122
|
// Bigger here than in the table: this is the page for looking at one record.
|
|
116
|
-
content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} />;
|
|
123
|
+
content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} download={privateFileUrl(resource.slug, id, key)} />;
|
|
117
124
|
} else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
|
|
118
125
|
content = (
|
|
119
126
|
<span className="flex flex-wrap gap-1">
|