@flaredev/cli 0.4.0 → 0.5.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/dist/index.js +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -1,165 +1,261 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
|
6
|
-
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type
|
|
7
|
-
import { toast } from "sonner";
|
|
8
|
-
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
|
-
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
10
|
-
import { Button } from "@/components/ui/button";
|
|
11
|
-
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
|
12
|
-
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
13
|
-
import { Spinner } from "@/components/ui/spinner";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
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";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
|
12
|
+
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
13
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
14
|
+
import { canSuggest, suggestValue } from "@/lib/suggest";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
17
|
+
|
|
18
|
+
export interface ResourceFormProps {
|
|
19
|
+
resource: ClientResource;
|
|
20
|
+
mode: "create" | "edit";
|
|
21
|
+
/** Record id (edit mode). */
|
|
22
|
+
id?: string;
|
|
23
|
+
record?: Record<string, unknown> | null;
|
|
24
|
+
/** belongsTo field key → related resource metadata and the current value's title. */
|
|
25
|
+
relations?: Record<string, RelationMeta & { initialTitle?: string }>;
|
|
26
|
+
/** Where Cancel and a successful save go, when the form owns the page. */
|
|
27
|
+
listHref: string;
|
|
28
|
+
/** In a sheet: close it instead of navigating, and drop the card around the fields. */
|
|
29
|
+
onDone?: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WIDE = new Set(["text", "file"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* More fields than this and the form is split into steps.
|
|
36
|
+
*
|
|
37
|
+
* Six inputs in one column is where a form starts to read as a chore rather than a
|
|
38
|
+
* question, and a step at a time also means the first error appears before everything
|
|
39
|
+
* else has been filled in.
|
|
40
|
+
*/
|
|
41
|
+
const FIELDS_PER_STEP = 5;
|
|
42
|
+
|
|
43
|
+
type Entry = [string, StoredField & { label: string }];
|
|
44
|
+
|
|
45
|
+
/** Fields in declaration order, cut into steps of at most {@link FIELDS_PER_STEP}. */
|
|
46
|
+
function toSteps(fields: Entry[]): Entry[][] {
|
|
47
|
+
if (fields.length <= FIELDS_PER_STEP) return [fields];
|
|
48
|
+
const steps: Entry[][] = [];
|
|
49
|
+
for (let index = 0; index < fields.length; index += FIELDS_PER_STEP) {
|
|
50
|
+
steps.push(fields.slice(index, index + FIELDS_PER_STEP));
|
|
51
|
+
}
|
|
52
|
+
return steps;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create/edit form rendered from field metadata. Values are validated in the browser
|
|
57
|
+
* with the descriptor's zod schemas, then again on the server by the same store the
|
|
58
|
+
* REST API uses; server errors (e.g. unique conflicts) land on their fields.
|
|
59
|
+
*/
|
|
60
|
+
export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone }: ResourceFormProps) {
|
|
61
|
+
const router = useRouter();
|
|
62
|
+
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
63
|
+
const fields = useMemo(() => storedFields(resource), [resource]);
|
|
64
|
+
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
65
|
+
const [step, setStep] = useState(0);
|
|
66
|
+
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
67
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
68
|
+
const [pending, startTransition] = useTransition();
|
|
69
|
+
|
|
70
|
+
const last = step === steps.length - 1;
|
|
71
|
+
const visible = steps[step] ?? [];
|
|
72
|
+
|
|
73
|
+
const setValue = (key: string, value: string | boolean) => {
|
|
74
|
+
setValues((current) => ({ ...current, [key]: value }));
|
|
75
|
+
if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function focusFirstError(fieldErrors: Record<string, string>, within: Entry[] = fields) {
|
|
79
|
+
const first = within.find(([key]) => fieldErrors[key]);
|
|
80
|
+
if (first) document.getElementById(`field-${first[0]}`)?.focus();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Everything the descriptor objects to, whichever step it is on. */
|
|
84
|
+
function validate(): Record<string, string> {
|
|
85
|
+
const input = formValuesToInput(resource, values, mode);
|
|
86
|
+
const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
|
|
87
|
+
return parsed.success ? {} : issuesByField(parsed.error.issues);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Move on only when this step is right; problems further on can wait their turn.
|
|
92
|
+
*
|
|
93
|
+
* The click is cancelled first. This button and the submit button on the last step are
|
|
94
|
+
* the same node to the browser, and React has already turned this one into the submit
|
|
95
|
+
* button by the time the click's default action runs — without this, going to the last
|
|
96
|
+
* step also saves the record.
|
|
97
|
+
*/
|
|
98
|
+
function next(event: MouseEvent<HTMLButtonElement>) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
const found = validate();
|
|
101
|
+
const here = Object.fromEntries(Object.entries(found).filter(([key]) => visible.some(([field]) => field === key)));
|
|
102
|
+
if (Object.keys(here).length > 0) {
|
|
103
|
+
setErrors(here);
|
|
104
|
+
focusFirstError(here, visible);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setErrors({});
|
|
108
|
+
setStep((current) => Math.min(current + 1, steps.length - 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onSubmit(event: FormEvent) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
const input = formValuesToInput(resource, values, mode);
|
|
114
|
+
const found = validate();
|
|
115
|
+
if (Object.keys(found).length > 0) {
|
|
116
|
+
setErrors(found);
|
|
117
|
+
// Send them to the step that holds the problem, not just to the field.
|
|
118
|
+
const broken = steps.findIndex((entries) => entries.some(([key]) => found[key]));
|
|
119
|
+
if (broken >= 0 && broken !== step) setStep(broken);
|
|
120
|
+
focusFirstError(found, broken >= 0 ? steps[broken]! : fields);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
startTransition(async () => {
|
|
125
|
+
const result = mode === "create" ? await createRecordAction(resource.name, input) : await updateRecordAction(resource.name, id!, input);
|
|
126
|
+
if (result.ok) {
|
|
127
|
+
toast.success(`${resource.label} ${mode === "create" ? "created" : "saved"}.`);
|
|
128
|
+
if (onDone) onDone();
|
|
129
|
+
else router.push(listHref);
|
|
130
|
+
router.refresh();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
|
|
134
|
+
setErrors(fieldErrors);
|
|
135
|
+
const broken = steps.findIndex((entries) => entries.some(([key]) => fieldErrors[key]));
|
|
136
|
+
if (broken >= 0 && broken !== step) setStep(broken);
|
|
137
|
+
focusFirstError(fieldErrors, broken >= 0 ? steps[broken]! : fields);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const Frame = onDone ? SheetFrame : Card;
|
|
142
|
+
const Body = onDone ? SheetBody : CardContent;
|
|
143
|
+
const Footer = onDone ? SheetFooterRow : CardFooter;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<form onSubmit={onSubmit} noValidate className={onDone ? "flex min-h-0 flex-1 flex-col" : undefined}>
|
|
147
|
+
<Frame>
|
|
148
|
+
{steps.length > 1 && <Steps count={steps.length} current={step} onGo={setStep} />}
|
|
149
|
+
<Body>
|
|
150
|
+
{errors._form && (
|
|
151
|
+
<Alert variant="destructive" className="mb-6">
|
|
152
|
+
<AlertDescription>{errors._form}</AlertDescription>
|
|
153
|
+
</Alert>
|
|
154
|
+
)}
|
|
155
|
+
<FieldGroup className="grid gap-6 md:grid-cols-2">
|
|
156
|
+
{visible.map(([key, def]) => {
|
|
157
|
+
const error = errors[key];
|
|
158
|
+
const widgetId = `field-${key}`;
|
|
159
|
+
const horizontal = def.kind === "boolean";
|
|
160
|
+
return (
|
|
161
|
+
<Field key={key} data-invalid={error ? true : undefined} className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}>
|
|
162
|
+
<FieldLabel htmlFor={widgetId}>
|
|
163
|
+
{def.label}
|
|
164
|
+
{def.required && def.kind !== "boolean" && (
|
|
165
|
+
<span className="ml-0.5 text-muted-foreground" aria-hidden>
|
|
166
|
+
*
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
</FieldLabel>
|
|
170
|
+
<div className={horizontal ? "flex h-9 items-center" : undefined}>
|
|
171
|
+
<FieldWidget
|
|
172
|
+
id={widgetId}
|
|
173
|
+
name={key}
|
|
174
|
+
resourceName={resource.name}
|
|
175
|
+
fieldKey={key}
|
|
176
|
+
field={def}
|
|
177
|
+
value={values[key] ?? ""}
|
|
178
|
+
onChange={(value) => setValue(key, value)}
|
|
179
|
+
invalid={Boolean(error)}
|
|
180
|
+
disabled={pending}
|
|
181
|
+
relation={relations[key]}
|
|
182
|
+
suggest={canSuggest(def) ? () => suggestValue(key, def, String(values[resource.titleField] ?? "")) : undefined}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
{def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
|
|
186
|
+
{error && <FieldError>{error}</FieldError>}
|
|
187
|
+
</Field>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</FieldGroup>
|
|
191
|
+
</Body>
|
|
192
|
+
<Footer className="flex items-center justify-end gap-2 border-t pt-4">
|
|
193
|
+
{step > 0 ? (
|
|
194
|
+
<Button type="button" variant="outline" onClick={() => setStep((current) => current - 1)} disabled={pending}>
|
|
195
|
+
Back
|
|
196
|
+
</Button>
|
|
197
|
+
) : onDone ? (
|
|
198
|
+
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
|
|
199
|
+
Cancel
|
|
200
|
+
</Button>
|
|
201
|
+
) : (
|
|
202
|
+
<Button variant="outline" asChild>
|
|
203
|
+
<Link href={listHref}>Cancel</Link>
|
|
204
|
+
</Button>
|
|
205
|
+
)}
|
|
206
|
+
{last ? (
|
|
207
|
+
<Button key="submit" type="submit" disabled={pending}>
|
|
208
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
209
|
+
{mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
|
|
210
|
+
</Button>
|
|
211
|
+
) : (
|
|
212
|
+
<Button key="next" type="button" onClick={next} disabled={pending}>
|
|
213
|
+
Next
|
|
214
|
+
</Button>
|
|
215
|
+
)}
|
|
216
|
+
</Footer>
|
|
217
|
+
</Frame>
|
|
218
|
+
</form>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Where you are in a multi-step form, and a way back to a step already passed. */
|
|
223
|
+
function Steps({ count, current, onGo }: { count: number; current: number; onGo: (step: number) => void }) {
|
|
224
|
+
return (
|
|
225
|
+
<div className="flex items-center gap-3 pb-4">
|
|
226
|
+
<div className="flex flex-1 gap-1.5" role="presentation">
|
|
227
|
+
{Array.from({ length: count }, (_, index) => (
|
|
228
|
+
<button
|
|
229
|
+
key={index}
|
|
230
|
+
type="button"
|
|
231
|
+
aria-label={`Step ${index + 1} of ${count}`}
|
|
232
|
+
aria-current={index === current ? "step" : undefined}
|
|
233
|
+
disabled={index > current}
|
|
234
|
+
onClick={() => onGo(index)}
|
|
235
|
+
className={cn(
|
|
236
|
+
"h-1 flex-1 rounded-full transition-colors",
|
|
237
|
+
index <= current ? "bg-primary" : "bg-muted",
|
|
238
|
+
index < current && "cursor-pointer",
|
|
239
|
+
)}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
244
|
+
Step {current + 1} of {count}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Plain wrappers so the same form works in a page (a card) and in a sheet (no card). */
|
|
251
|
+
function SheetFrame({ children }: { children: React.ReactNode }) {
|
|
252
|
+
return <div className="flex min-h-0 flex-1 flex-col gap-4">{children}</div>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function SheetBody({ children }: { children: React.ReactNode }) {
|
|
256
|
+
return <div className="min-h-0 flex-1 overflow-y-auto px-1">{children}</div>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function SheetFooterRow({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
260
|
+
return <div className={className}>{children}</div>;
|
|
261
|
+
}
|
|
@@ -1,85 +1,87 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BellIcon,
|
|
3
|
-
BookIcon,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
1
|
+
import {
|
|
2
|
+
BellIcon,
|
|
3
|
+
BookIcon,
|
|
4
|
+
WalletIcon,
|
|
5
|
+
BoxIcon,
|
|
6
|
+
BriefcaseIcon,
|
|
7
|
+
Building2Icon,
|
|
8
|
+
CalendarIcon,
|
|
9
|
+
ChartBarIcon,
|
|
10
|
+
ClipboardListIcon,
|
|
11
|
+
CreditCardIcon,
|
|
12
|
+
DatabaseIcon,
|
|
13
|
+
DollarSignIcon,
|
|
14
|
+
FileTextIcon,
|
|
15
|
+
FolderIcon,
|
|
16
|
+
GlobeIcon,
|
|
17
|
+
HeartIcon,
|
|
18
|
+
ImageIcon,
|
|
19
|
+
LayersIcon,
|
|
20
|
+
MailIcon,
|
|
21
|
+
MapPinIcon,
|
|
22
|
+
MessageSquareIcon,
|
|
23
|
+
PackageIcon,
|
|
24
|
+
PhoneIcon,
|
|
25
|
+
ReceiptIcon,
|
|
26
|
+
SettingsIcon,
|
|
27
|
+
ShieldAlertIcon,
|
|
28
|
+
RadioIcon,
|
|
29
|
+
ShieldIcon,
|
|
30
|
+
ShoppingCartIcon,
|
|
31
|
+
StarIcon,
|
|
32
|
+
TagIcon,
|
|
33
|
+
TicketIcon,
|
|
34
|
+
TruckIcon,
|
|
35
|
+
UserIcon,
|
|
36
|
+
UsersIcon,
|
|
37
|
+
WrenchIcon,
|
|
38
|
+
type LucideIcon,
|
|
39
|
+
} from "lucide-react";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Icons a resource descriptor can name (`icon: "users"`). Imported by name so unused
|
|
43
|
+
* ones are tree-shaken; add the lucide icons your app needs.
|
|
44
|
+
*/
|
|
45
|
+
export const RESOURCE_ICONS: Record<string, LucideIcon> = {
|
|
46
|
+
bell: BellIcon,
|
|
47
|
+
book: BookIcon,
|
|
48
|
+
wallet: WalletIcon,
|
|
49
|
+
box: BoxIcon,
|
|
50
|
+
briefcase: BriefcaseIcon,
|
|
51
|
+
building: Building2Icon,
|
|
52
|
+
calendar: CalendarIcon,
|
|
53
|
+
chart: ChartBarIcon,
|
|
54
|
+
clipboard: ClipboardListIcon,
|
|
55
|
+
"credit-card": CreditCardIcon,
|
|
56
|
+
database: DatabaseIcon,
|
|
57
|
+
dollar: DollarSignIcon,
|
|
58
|
+
file: FileTextIcon,
|
|
59
|
+
folder: FolderIcon,
|
|
60
|
+
globe: GlobeIcon,
|
|
61
|
+
heart: HeartIcon,
|
|
62
|
+
image: ImageIcon,
|
|
63
|
+
layers: LayersIcon,
|
|
64
|
+
mail: MailIcon,
|
|
65
|
+
"map-pin": MapPinIcon,
|
|
66
|
+
message: MessageSquareIcon,
|
|
67
|
+
package: PackageIcon,
|
|
68
|
+
phone: PhoneIcon,
|
|
69
|
+
receipt: ReceiptIcon,
|
|
70
|
+
settings: SettingsIcon,
|
|
71
|
+
radio: RadioIcon,
|
|
72
|
+
shield: ShieldIcon,
|
|
73
|
+
"shield-alert": ShieldAlertIcon,
|
|
74
|
+
cart: ShoppingCartIcon,
|
|
75
|
+
star: StarIcon,
|
|
76
|
+
tag: TagIcon,
|
|
77
|
+
ticket: TicketIcon,
|
|
78
|
+
truck: TruckIcon,
|
|
79
|
+
user: UserIcon,
|
|
80
|
+
users: UsersIcon,
|
|
81
|
+
wrench: WrenchIcon,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The icon for a resource, falling back to a generic document icon. */
|
|
85
|
+
export function resourceIcon(name: string | undefined): LucideIcon {
|
|
86
|
+
return (name && RESOURCE_ICONS[name]) || FileTextIcon;
|
|
87
|
+
}
|