@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.
Files changed (50) hide show
  1. package/dist/index.js +340 -163
  2. package/package.json +3 -2
  3. package/templates/app/app/api/openapi.json/route.ts +23 -0
  4. package/templates/app/app/api/reference/route.ts +36 -0
  5. package/templates/app/app/dashboard/account/page.tsx +38 -0
  6. package/templates/app/app/dashboard/layout.tsx +39 -0
  7. package/templates/app/app/dashboard/page.tsx +42 -9
  8. package/templates/app/app/forgot-password/page.tsx +22 -0
  9. package/templates/app/app/globals.css +109 -4
  10. package/templates/app/app/layout.tsx +28 -26
  11. package/templates/app/app/page.tsx +126 -13
  12. package/templates/app/app/reset-password/page.tsx +31 -0
  13. package/templates/app/app/sign-in/page.tsx +49 -33
  14. package/templates/app/app/sign-up/page.tsx +42 -23
  15. package/templates/app/app/two-factor/page.tsx +25 -0
  16. package/templates/app/components/account/security-settings.tsx +525 -0
  17. package/templates/app/components/admin/fields/country-field.tsx +152 -0
  18. package/templates/app/components/admin/fields/field-widget.tsx +186 -128
  19. package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
  20. package/templates/app/components/admin/fields/phone-field.tsx +86 -0
  21. package/templates/app/components/admin/resource-nav.tsx +120 -109
  22. package/templates/app/components/admin/resource-table.tsx +31 -1
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/password-forms.tsx +79 -0
  25. package/templates/app/components/auth/provider-icons.tsx +39 -0
  26. package/templates/app/components/auth/sign-in-flow.tsx +315 -0
  27. package/templates/app/components/auth/sign-up-form.tsx +101 -0
  28. package/templates/app/components/auth/social-buttons.tsx +56 -0
  29. package/templates/app/components/auth/two-factor-form.tsx +124 -0
  30. package/templates/app/components/auth/ui.tsx +96 -0
  31. package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
  32. package/templates/app/components/marketing/product-preview.tsx +70 -0
  33. package/templates/app/components/marketing/site-header.tsx +39 -0
  34. package/templates/app/components/sign-out-button.tsx +1 -1
  35. package/templates/app/components/ui/checkbox.tsx +25 -0
  36. package/templates/app/components/ui/radio-group.tsx +29 -0
  37. package/templates/app/db/auth-schema.ts +167 -118
  38. package/templates/app/lib/admin-nav.ts +1 -1
  39. package/templates/app/lib/api-docs.ts +16 -0
  40. package/templates/app/lib/auth-client.ts +16 -1
  41. package/templates/app/lib/auth-config.ts +33 -0
  42. package/templates/app/lib/auth-emails.ts +94 -0
  43. package/templates/app/lib/auth.ts +81 -6
  44. package/templates/app/lib/site.ts +47 -0
  45. package/templates/app/lib/theme.ts +79 -0
  46. package/templates/app/migrations/0000_init.sql +32 -1
  47. package/templates/app/migrations/meta/0000_snapshot.json +215 -1
  48. package/templates/app/migrations/meta/_journal.json +1 -1
  49. package/templates/app/vite.config.ts +5 -3
  50. 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 { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
- import { Switch } from "@/components/ui/switch";
7
- import { Textarea } from "@/components/ui/textarea";
8
- import { DateField, DateTimeField } from "./date-field";
9
- import { FileField } from "./file-field";
10
- import { RelationField } from "./relation-field";
11
-
12
- export interface RelationMeta {
13
- name: string;
14
- label: string;
15
- pluralLabel: string;
16
- slug: string;
17
- titleField: string;
18
- }
19
-
20
- export interface WidgetProps {
21
- id: string;
22
- name: string;
23
- /** Resource name and field key, for upload actions. */
24
- resourceName: string;
25
- fieldKey: string;
26
- field: StoredField & { label: string };
27
- value: string | boolean;
28
- onChange: (value: string | boolean) => void;
29
- invalid: boolean;
30
- disabled?: boolean;
31
- /** belongsTo: the related resource and the current value's title. */
32
- relation?: RelationMeta & { initialTitle?: string };
33
- }
34
-
35
- const NONE = "__none__";
36
-
37
- /** The input for a field, chosen from its kind in the descriptor. */
38
- export function FieldWidget(props: WidgetProps) {
39
- const { id, name, field, value, onChange, invalid, disabled, relation, resourceName, fieldKey } = props;
40
- const text = typeof value === "string" ? value : "";
41
- const common = { id, name, disabled, "aria-invalid": invalid || undefined, placeholder: field.placeholder };
42
-
43
- switch (field.kind) {
44
- case "text":
45
- return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
46
-
47
- case "int":
48
- case "float":
49
- return (
50
- <Input
51
- {...common}
52
- type="text"
53
- inputMode={field.kind === "int" ? "numeric" : "decimal"}
54
- className="tabular-nums"
55
- value={text}
56
- onChange={(event) => onChange(event.target.value)}
57
- />
58
- );
59
-
60
- case "boolean":
61
- return (
62
- <Switch
63
- id={id}
64
- name={name}
65
- disabled={disabled}
66
- aria-invalid={invalid || undefined}
67
- checked={value === true}
68
- onCheckedChange={(checked) => onChange(checked)}
69
- />
70
- );
71
-
72
- case "enum":
73
- return (
74
- // No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
75
- <Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
76
- <SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
77
- <SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
78
- </SelectTrigger>
79
- <SelectContent>
80
- <SelectGroup>
81
- {!field.required && <SelectItem value={NONE}>None</SelectItem>}
82
- {field.options.map((option) => (
83
- <SelectItem key={option} value={option}>
84
- {optionLabel(field, option)}
85
- </SelectItem>
86
- ))}
87
- </SelectGroup>
88
- </SelectContent>
89
- </Select>
90
- );
91
-
92
- case "date":
93
- return (
94
- <DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
95
- );
96
-
97
- case "datetime":
98
- return (
99
- <DateTimeField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
100
- );
101
-
102
- case "belongsTo":
103
- return relation ? (
104
- <RelationField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} relation={relation} />
105
- ) : (
106
- <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />
107
- );
108
-
109
- case "file":
110
- return (
111
- <FileField
112
- id={id}
113
- value={text}
114
- onChange={onChange}
115
- invalid={invalid}
116
- disabled={disabled}
117
- field={field as FileFieldDef & { label: string }}
118
- resourceName={resourceName}
119
- fieldKey={fieldKey}
120
- />
121
- );
122
-
123
- default: {
124
- const type = field.kind === "string" && field.format === "email" ? "email" : field.kind === "string" && field.format === "url" ? "url" : "text";
125
- return <Input {...common} type={type} value={text} onChange={(event) => onChange(event.target.value)} />;
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
+ }