@flaredev/cli 0.1.2 → 0.2.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 +340 -163
- package/package.json +3 -2
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +38 -0
- package/templates/app/app/dashboard/layout.tsx +39 -0
- package/templates/app/app/dashboard/page.tsx +42 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/components/account/security-settings.tsx +525 -0
- package/templates/app/components/admin/fields/country-field.tsx +152 -0
- package/templates/app/components/admin/fields/field-widget.tsx +186 -128
- package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/admin/fields/phone-field.tsx +86 -0
- package/templates/app/components/admin/resource-nav.tsx +120 -109
- package/templates/app/components/admin/resource-table.tsx +31 -1
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/password-forms.tsx +79 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +315 -0
- package/templates/app/components/auth/sign-up-form.tsx +101 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +124 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/lib/admin-nav.ts +1 -1
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +33 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +81 -6
- package/templates/app/lib/site.ts +47 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +32 -1
- package/templates/app/migrations/meta/0000_snapshot.json +215 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/components/auth-form.tsx +0 -120
|
@@ -1,128 +1,186 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { optionLabel, type FileField as FileFieldDef, type StoredField } from "@flaredev/core";
|
|
4
|
-
import { Input } from "@/components/ui/input";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { normalizeDomain, optionLabel, slugify, type FileField as FileFieldDef, type MultiSelectField as MultiSelectDef, type StoredField } from "@flaredev/core";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
7
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
8
|
+
import { Switch } from "@/components/ui/switch";
|
|
9
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
10
|
+
import { DateField, DateTimeField } from "./date-field";
|
|
11
|
+
import { CountryField } from "./country-field";
|
|
12
|
+
import { FileField } from "./file-field";
|
|
13
|
+
import { MultiSelectField } from "./multi-select-field";
|
|
14
|
+
import { PhoneField } from "./phone-field";
|
|
15
|
+
import { RelationField } from "./relation-field";
|
|
16
|
+
|
|
17
|
+
export interface RelationMeta {
|
|
18
|
+
name: string;
|
|
19
|
+
label: string;
|
|
20
|
+
pluralLabel: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
titleField: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WidgetProps {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
/** Resource name and field key, for upload actions. */
|
|
29
|
+
resourceName: string;
|
|
30
|
+
fieldKey: string;
|
|
31
|
+
field: StoredField & { label: string };
|
|
32
|
+
value: string | boolean;
|
|
33
|
+
onChange: (value: string | boolean) => void;
|
|
34
|
+
invalid: boolean;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** belongsTo: the related resource and the current value's title. */
|
|
37
|
+
relation?: RelationMeta & { initialTitle?: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NONE = "__none__";
|
|
41
|
+
|
|
42
|
+
/** The input for a field, chosen from its kind in the descriptor. */
|
|
43
|
+
export function FieldWidget(props: WidgetProps) {
|
|
44
|
+
const { id, name, field, value, onChange, invalid, disabled, relation, resourceName, fieldKey } = props;
|
|
45
|
+
const text = typeof value === "string" ? value : "";
|
|
46
|
+
const common = { id, name, disabled, "aria-invalid": invalid || undefined, placeholder: field.placeholder };
|
|
47
|
+
|
|
48
|
+
switch (field.kind) {
|
|
49
|
+
case "text":
|
|
50
|
+
return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
|
|
51
|
+
|
|
52
|
+
case "int":
|
|
53
|
+
case "float":
|
|
54
|
+
return (
|
|
55
|
+
<Input
|
|
56
|
+
{...common}
|
|
57
|
+
type="text"
|
|
58
|
+
inputMode={field.kind === "int" ? "numeric" : "decimal"}
|
|
59
|
+
className="tabular-nums"
|
|
60
|
+
value={text}
|
|
61
|
+
onChange={(event) => onChange(event.target.value)}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
case "boolean":
|
|
66
|
+
return (
|
|
67
|
+
<Switch
|
|
68
|
+
id={id}
|
|
69
|
+
name={name}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
aria-invalid={invalid || undefined}
|
|
72
|
+
checked={value === true}
|
|
73
|
+
onCheckedChange={(checked) => onChange(checked)}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
case "enum":
|
|
78
|
+
if (field.widget === "radio") {
|
|
79
|
+
return (
|
|
80
|
+
<RadioGroup id={id} name={name} value={text} onValueChange={onChange} disabled={disabled} aria-invalid={invalid || undefined} className="flex flex-wrap gap-x-5 gap-y-2.5">
|
|
81
|
+
{field.options.map((option) => (
|
|
82
|
+
<div key={option} className="flex items-center gap-2">
|
|
83
|
+
<RadioGroupItem id={`${id}-${option}`} value={option} aria-invalid={invalid || undefined} />
|
|
84
|
+
<Label htmlFor={`${id}-${option}`} className="font-normal">
|
|
85
|
+
{optionLabel(field, option)}
|
|
86
|
+
</Label>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</RadioGroup>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
// No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
|
|
94
|
+
<Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
|
|
95
|
+
<SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
|
|
96
|
+
<SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
|
|
97
|
+
</SelectTrigger>
|
|
98
|
+
<SelectContent>
|
|
99
|
+
<SelectGroup>
|
|
100
|
+
{!field.required && <SelectItem value={NONE}>None</SelectItem>}
|
|
101
|
+
{field.options.map((option) => (
|
|
102
|
+
<SelectItem key={option} value={option}>
|
|
103
|
+
{optionLabel(field, option)}
|
|
104
|
+
</SelectItem>
|
|
105
|
+
))}
|
|
106
|
+
</SelectGroup>
|
|
107
|
+
</SelectContent>
|
|
108
|
+
</Select>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
case "multiselect":
|
|
112
|
+
return (
|
|
113
|
+
<MultiSelectField id={id} field={field as MultiSelectDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
case "date":
|
|
117
|
+
return (
|
|
118
|
+
<DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
case "datetime":
|
|
122
|
+
return (
|
|
123
|
+
<DateTimeField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
case "belongsTo":
|
|
127
|
+
return relation ? (
|
|
128
|
+
<RelationField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} relation={relation} />
|
|
129
|
+
) : (
|
|
130
|
+
<Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
case "file":
|
|
134
|
+
return (
|
|
135
|
+
<FileField
|
|
136
|
+
id={id}
|
|
137
|
+
value={text}
|
|
138
|
+
onChange={onChange}
|
|
139
|
+
invalid={invalid}
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
field={field as FileFieldDef & { label: string }}
|
|
142
|
+
resourceName={resourceName}
|
|
143
|
+
fieldKey={fieldKey}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
case "string":
|
|
148
|
+
switch (field.format) {
|
|
149
|
+
case "tel":
|
|
150
|
+
return <PhoneField id={id} name={name} label={field.label} value={text} onChange={onChange} invalid={invalid} disabled={disabled} placeholder={field.placeholder} />;
|
|
151
|
+
case "country":
|
|
152
|
+
return <CountryField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />;
|
|
153
|
+
case "color":
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<input
|
|
157
|
+
type="color"
|
|
158
|
+
aria-label={`${field.label} picker`}
|
|
159
|
+
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#000000"}
|
|
160
|
+
onChange={(event) => onChange(event.target.value)}
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
className="h-9 w-11 shrink-0 cursor-pointer rounded-md border border-input bg-transparent p-1"
|
|
163
|
+
/>
|
|
164
|
+
<Input {...common} className="font-mono" placeholder={field.placeholder ?? "#f2541d"} value={text} onChange={(event) => onChange(event.target.value)} />
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
case "slug":
|
|
168
|
+
return (
|
|
169
|
+
<Input {...common} className="font-mono" placeholder={field.placeholder ?? "my-first-post"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(slugify(text))} />
|
|
170
|
+
);
|
|
171
|
+
case "domain":
|
|
172
|
+
return (
|
|
173
|
+
<Input {...common} inputMode="url" autoCapitalize="none" spellCheck={false} placeholder={field.placeholder ?? "example.com"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(normalizeDomain(text))} />
|
|
174
|
+
);
|
|
175
|
+
case "email":
|
|
176
|
+
return <Input {...common} type="email" autoComplete="email" value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
177
|
+
case "url":
|
|
178
|
+
return <Input {...common} type="url" placeholder={field.placeholder ?? "https://"} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
179
|
+
default:
|
|
180
|
+
return <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
return <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { optionLabel, parseMultiValue, type MultiSelectField as MultiSelectDef } from "@flaredev/core";
|
|
4
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
id: string;
|
|
9
|
+
field: MultiSelectDef & { label: string };
|
|
10
|
+
/** Form state: a JSON array of the picked values. */
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
invalid: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A `multiselect` field: one checkbox per option, in the descriptor's order. */
|
|
18
|
+
export function MultiSelectField({ id, field, value, onChange, invalid, disabled }: Props) {
|
|
19
|
+
const picked = parseMultiValue(value);
|
|
20
|
+
const toggle = (option: string, on: boolean) => {
|
|
21
|
+
const next = field.options.filter((item) => (item === option ? on : picked.includes(item)));
|
|
22
|
+
onChange(JSON.stringify(next));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div id={id} role="group" aria-label={field.label} aria-invalid={invalid || undefined} className="grid gap-2.5 sm:grid-cols-2">
|
|
27
|
+
{field.options.map((option) => {
|
|
28
|
+
const optionId = `${id}-${option}`;
|
|
29
|
+
return (
|
|
30
|
+
<div key={option} className="flex items-center gap-2">
|
|
31
|
+
<Checkbox
|
|
32
|
+
id={optionId}
|
|
33
|
+
checked={picked.includes(option)}
|
|
34
|
+
onCheckedChange={(checked) => toggle(option, checked === true)}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
aria-invalid={invalid || undefined}
|
|
37
|
+
/>
|
|
38
|
+
<Label htmlFor={optionId} className="font-normal">
|
|
39
|
+
{optionLabel(field, option)}
|
|
40
|
+
</Label>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { splitPhone, toE164 } from "@flaredev/core";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { DialCodePicker } from "./country-field";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
invalid: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The viewer's region from their browser language ("en-UG" → "UG"), else US. */
|
|
20
|
+
function localRegion(): string {
|
|
21
|
+
try {
|
|
22
|
+
return new Intl.Locale(navigator.language).maximize().region ?? "US";
|
|
23
|
+
} catch {
|
|
24
|
+
return "US";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A `tel` field: a searchable country-code picker and the number. Stores E.164
|
|
30
|
+
* ("+256772123456"); a number pasted with its own "+code" switches the country.
|
|
31
|
+
*/
|
|
32
|
+
export function PhoneField({ id, name, label, value, onChange, invalid, disabled, placeholder }: Props) {
|
|
33
|
+
const initial = splitPhone(value);
|
|
34
|
+
const [country, setCountry] = useState(initial.country ?? "US");
|
|
35
|
+
const [national, setNational] = useState(initial.national);
|
|
36
|
+
|
|
37
|
+
// On a new record, start from the viewer's own country (after hydration, so server and client agree).
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!value && !initial.country) setCountry(localRegion());
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const emit = (nextCountry: string, nextNational: string) => onChange(nextNational.trim() ? toE164(nextCountry, nextNational) : "");
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex">
|
|
47
|
+
<DialCodePicker
|
|
48
|
+
value={country}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
invalid={invalid}
|
|
51
|
+
label={`Country code for ${label}`}
|
|
52
|
+
onChange={(next) => {
|
|
53
|
+
setCountry(next.code);
|
|
54
|
+
emit(next.code, national);
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<Input
|
|
58
|
+
id={id}
|
|
59
|
+
name={name}
|
|
60
|
+
type="tel"
|
|
61
|
+
inputMode="tel"
|
|
62
|
+
autoComplete="tel-national"
|
|
63
|
+
className="rounded-l-none tabular-nums"
|
|
64
|
+
placeholder={placeholder ?? "772 123456"}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
aria-invalid={invalid || undefined}
|
|
67
|
+
value={national}
|
|
68
|
+
onChange={(event) => {
|
|
69
|
+
const text = event.target.value;
|
|
70
|
+
if (text.trim().startsWith("+")) {
|
|
71
|
+
// A full international number: take its country and keep the rest.
|
|
72
|
+
const parsed = splitPhone(toE164(country, text));
|
|
73
|
+
if (parsed.country) {
|
|
74
|
+
setCountry(parsed.country);
|
|
75
|
+
setNational(parsed.national);
|
|
76
|
+
onChange(toE164(parsed.country, parsed.national));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
setNational(text);
|
|
81
|
+
emit(country, text);
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|