forge-anvil-app 0.1.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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # create-anvil-app
2
+
3
+ Create a new Next-anvil application with a single command.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-anvil-app my-app
9
+ ```
10
+
11
+ This will:
12
+ 1. Create a new Next.js app with TypeScript and Tailwind CSS
13
+ 2. Install `next-anvil` package
14
+ 3. Set up Anvil-specific configuration
15
+ 4. Create example Prisma schema
16
+ 5. Create an admin dashboard page
17
+
18
+ ## What Gets Created
19
+
20
+ - Next.js app with TypeScript and Tailwind CSS
21
+ - `next-anvil` package installed
22
+ - `lib/resources/` directory for your resources
23
+ - Example Prisma schema
24
+ - Example admin page at `/admin`
25
+ - `.env.local` with database configuration
26
+
27
+ ## Next Steps
28
+
29
+ After running `create-anvil-app`:
30
+
31
+ 1. `cd my-app`
32
+ 2. Update `.env.local` with your database URL
33
+ 3. Run `anvil db:migrate` to set up the database
34
+ 4. Run `npm run dev` to start the development server
35
+ 5. Visit `http://localhost:3000/admin`
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Admin page for deleting resources
3
+ * Server Component - handles deletion with confirmation
4
+ */
5
+
6
+ import { deleteRecord, getRecord } from "@/lib/anvil/actions";
7
+ import { AnvilResource } from "@/lib/anvil/resource";
8
+ import { redirect } from "next/navigation";
9
+ import { notFound } from "next/navigation";
10
+ import { DeleteResourceForm } from "@/components/admin/DeleteResourceForm";
11
+
12
+ export default async function DeleteResourcePage({
13
+ params,
14
+ }: {
15
+ params: Promise<{ slug: string; id: string }>;
16
+ }) {
17
+ const { slug, id } = await params;
18
+ const resource = (await import(`@/lib/resources/${slug}`))
19
+ .default as AnvilResource;
20
+
21
+ // Fetch the existing record
22
+ const record = await getRecord(resource, id);
23
+ if (!record) {
24
+ notFound();
25
+ }
26
+
27
+ async function handleDelete() {
28
+ "use server";
29
+ const result = await deleteRecord(resource, id);
30
+ if (result.success) {
31
+ redirect(`/admin/${resource.slug}`);
32
+ } else {
33
+ throw new Error(result.errors?.[0]?.message || "Failed to delete record");
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div className="space-y-6">
39
+ <div>
40
+ <h1 className="text-3xl font-bold text-gray-900">
41
+ Delete {resource.label.slice(0, -1)}
42
+ </h1>
43
+ <p className="mt-2 text-sm text-gray-600">
44
+ This action cannot be undone. Please confirm that you want to delete
45
+ this {resource.model.toLowerCase()}.
46
+ </p>
47
+ </div>
48
+ <DeleteResourceForm
49
+ record={record}
50
+ resourceLabel={resource.model.toLowerCase()}
51
+ onDelete={handleDelete}
52
+ />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Admin page for editing resources
3
+ * Server Component - handles data fetching and server actions
4
+ */
5
+
6
+ import { EditResourceForm } from "@/components/admin/EditResourceForm";
7
+ import { updateRecord, getRecord } from "@/lib/anvil/actions";
8
+ import { AnvilResource } from "@/lib/anvil/resource";
9
+ import { redirect } from "next/navigation";
10
+ import { notFound } from "next/navigation";
11
+
12
+ export default async function EditResourcePage({
13
+ params,
14
+ }: {
15
+ params: Promise<{ slug: string; id: string }>;
16
+ }) {
17
+ const { slug, id } = await params;
18
+ const resource = (await import(`@/lib/resources/${slug}`))
19
+ .default as AnvilResource;
20
+
21
+ // Fetch the existing record
22
+ const record = await getRecord(resource, id);
23
+ if (!record) {
24
+ notFound();
25
+ }
26
+
27
+ async function handleUpdate(data: Record<string, any>) {
28
+ "use server";
29
+ const result = await updateRecord(resource, id, data);
30
+ if (result.success) {
31
+ redirect(`/admin/${resource.slug}`);
32
+ } else {
33
+ throw new Error(result.errors?.[0]?.message || "Failed to update record");
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div className="space-y-6">
39
+ <div>
40
+ <h1 className="text-3xl font-bold text-gray-900">
41
+ Edit {resource.model.toLowerCase()}
42
+ </h1>
43
+ <p className="mt-2 text-sm text-gray-600">
44
+ Update the information below to modify this{" "}
45
+ {resource.model.toLowerCase()}.
46
+ </p>
47
+ </div>
48
+ <EditResourceForm
49
+ schema={resource.editForm}
50
+ initialData={record}
51
+ onSubmit={handleUpdate}
52
+ />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,12 @@
1
+ import { AnvilResource } from "@/lib/anvil/resource";
2
+
3
+ export default async function ResourcePage({
4
+ params,
5
+ }: {
6
+ params: Promise<{ slug: string; id: string }>;
7
+ }) {
8
+ const { slug, id } = await params;
9
+ const resource = (await import(`@/lib/resources/${slug}`))
10
+ .default as AnvilResource;
11
+ return <div>ResourcePage</div>;
12
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Admin page for creating resources
3
+ * Server Component - handles data fetching and server actions
4
+ */
5
+
6
+ import { CreateResourceForm } from "@/components/admin/CreateResourceForm";
7
+ import { createRecord } from "@/lib/anvil/actions";
8
+ import { redirect } from "next/navigation";
9
+ import { AnvilResource } from "@/lib/anvil/resource";
10
+
11
+ export default async function CreateResourcePage({
12
+ params,
13
+ }: {
14
+ params: Promise<{ slug: string }>;
15
+ }) {
16
+ const { slug } = await params;
17
+ const resource = (await import(`@/lib/resources/${slug}`))
18
+ .default as AnvilResource;
19
+
20
+ async function handleCreate(data: Record<string, any>) {
21
+ "use server";
22
+ const result = await createRecord(resource, data);
23
+ if (result.success) {
24
+ redirect(`/admin/${resource.slug}`);
25
+ } else {
26
+ throw new Error(result.errors?.[0]?.message || "Failed to create record");
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div className="space-y-6">
32
+ <div>
33
+ <h1 className="text-3xl font-bold text-gray-900">
34
+ Create {resource.label.slice(0, -1)}
35
+ </h1>
36
+ <p className="mt-2 text-sm text-gray-600">
37
+ Fill in the form below to create a new {resource.label.toLowerCase()}.
38
+ </p>
39
+ </div>
40
+ <CreateResourceForm schema={resource.form} onSubmit={handleCreate} />
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Admin page for Users resource
3
+ * Generic renderer that reads from resource.ts
4
+ */
5
+
6
+ import AnvilTable from "@/components/admin/AnvilTable";
7
+ import Link from "next/link";
8
+ import { AnvilResource } from "@/lib/anvil/resource";
9
+
10
+ export default async function ResourceAdminPage({
11
+ params,
12
+ }: {
13
+ params: Promise<{ slug: string }>;
14
+ }) {
15
+ const { slug } = await params;
16
+ const resource = (await import(`@/lib/resources/${slug}`))
17
+ .default as AnvilResource;
18
+ return (
19
+ <div className="space-y-6">
20
+ <div className="flex justify-between items-center">
21
+ <h1 className="text-3xl font-bold text-gray-900">{resource.label}</h1>
22
+ <Link
23
+ href={`/admin/${resource.slug}/create`}
24
+ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
25
+ >
26
+ Create {resource.label.slice(0, -1)}
27
+ </Link>
28
+ </div>
29
+ <AnvilTable resource={resource} />
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,29 @@
1
+ import Link from "next/link";
2
+
3
+ export default function AdminLayout({
4
+ children,
5
+ }: {
6
+ children: React.ReactNode;
7
+ }) {
8
+ return (
9
+ <div className="min-h-screen bg-gray-50">
10
+ <nav className="bg-white border-b border-gray-200">
11
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
12
+ <div className="flex justify-between h-16">
13
+ <div className="flex">
14
+ <Link
15
+ href="/admin"
16
+ className="flex items-center px-4 text-lg font-semibold text-gray-900"
17
+ >
18
+ Admin
19
+ </Link>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </nav>
24
+ <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
25
+ {children}
26
+ </main>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Admin dashboard/index page
3
+ * Lists all available resources
4
+ */
5
+
6
+ import Link from "next/link";
7
+
8
+ // TODO: Auto-discover resources from /app/admin/*/resource.ts
9
+ const resources = [{ slug: "users", label: "Users" }];
10
+
11
+ export default function AdminDashboard() {
12
+ return (
13
+ <div className="space-y-6">
14
+ <div>
15
+ <h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
16
+ <p className="mt-2 text-sm text-gray-600">
17
+ Manage your application resources
18
+ </p>
19
+ </div>
20
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
21
+ {resources.map((resource) => (
22
+ <Link
23
+ key={resource.slug}
24
+ href={`/admin/${resource.slug}`}
25
+ className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-all border border-gray-200 hover:border-blue-300"
26
+ >
27
+ <h2 className="text-lg font-semibold text-gray-900">
28
+ {resource.label}
29
+ </h2>
30
+ <p className="mt-2 text-sm text-gray-500">
31
+ Manage {resource.label.toLowerCase()}
32
+ </p>
33
+ </Link>
34
+ ))}
35
+ </div>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { FieldConfig } from "next-anvil/fields";
5
+ import { FormSchema } from "next-anvil/form";
6
+ import {
7
+ TextField,
8
+ EmailField,
9
+ SelectField,
10
+ DateField,
11
+ NumberField,
12
+ TextareaField,
13
+ } from "next-anvil/components/fields";
14
+
15
+ interface AnvilFormProps {
16
+ schema: FormSchema;
17
+ initialData?: Record<string, any>;
18
+ onSubmit: (data: Record<string, any>) => Promise<void>;
19
+ onCancel?: () => void;
20
+ submitting?: boolean;
21
+ errors?: Record<string, string>;
22
+ submitLabel?: string;
23
+ cancelLabel?: string;
24
+ }
25
+
26
+ export function AnvilForm({
27
+ schema,
28
+ initialData = {},
29
+ onSubmit,
30
+ onCancel,
31
+ submitting: externalSubmitting,
32
+ errors: externalErrors = {},
33
+ submitLabel = "Save",
34
+ cancelLabel = "Cancel",
35
+ }: AnvilFormProps) {
36
+ const [formData, setFormData] = useState<Record<string, any>>(initialData);
37
+ const [internalSubmitting, setInternalSubmitting] = useState(false);
38
+ const [internalErrors, setInternalErrors] = useState<Record<string, string>>(
39
+ {}
40
+ );
41
+
42
+ const submitting = externalSubmitting ?? internalSubmitting;
43
+ const errors = { ...internalErrors, ...externalErrors };
44
+
45
+ async function handleSubmit(e: React.FormEvent) {
46
+ e.preventDefault();
47
+ setInternalSubmitting(true);
48
+ setInternalErrors({});
49
+
50
+ try {
51
+ await onSubmit(formData);
52
+ } catch (error: any) {
53
+ const errorMap: Record<string, string> = {};
54
+ if (error.field) {
55
+ errorMap[error.field] = error.message;
56
+ } else {
57
+ errorMap._general = error.message || "An error occurred";
58
+ }
59
+ setInternalErrors(errorMap);
60
+ } finally {
61
+ setInternalSubmitting(false);
62
+ }
63
+ }
64
+
65
+ function renderField(
66
+ fieldName: string,
67
+ fieldConfig: FieldConfig,
68
+ value: any
69
+ ) {
70
+ // Skip hidden fields in form rendering
71
+ if (fieldConfig.type === "hidden") {
72
+ return null;
73
+ }
74
+
75
+ const fieldError = errors[fieldName];
76
+ const isDisabled = submitting || fieldConfig.readOnly;
77
+
78
+ const commonProps = {
79
+ fieldName,
80
+ fieldConfig: fieldConfig as any,
81
+ value,
82
+ error: fieldError,
83
+ disabled: isDisabled,
84
+ };
85
+
86
+ switch (fieldConfig.type) {
87
+ case "text":
88
+ return (
89
+ <TextField
90
+ key={fieldName}
91
+ {...commonProps}
92
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
93
+ />
94
+ );
95
+
96
+ case "email":
97
+ return (
98
+ <EmailField
99
+ key={fieldName}
100
+ {...commonProps}
101
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
102
+ />
103
+ );
104
+
105
+ case "select":
106
+ return (
107
+ <SelectField
108
+ key={fieldName}
109
+ {...commonProps}
110
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
111
+ />
112
+ );
113
+
114
+ case "date":
115
+ return (
116
+ <DateField
117
+ key={fieldName}
118
+ {...commonProps}
119
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
120
+ />
121
+ );
122
+
123
+ case "number":
124
+ return (
125
+ <NumberField
126
+ key={fieldName}
127
+ {...commonProps}
128
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
129
+ />
130
+ );
131
+
132
+ case "textarea":
133
+ return (
134
+ <TextareaField
135
+ key={fieldName}
136
+ {...commonProps}
137
+ onChange={(val) => setFormData({ ...formData, [fieldName]: val })}
138
+ />
139
+ );
140
+
141
+ default:
142
+ return null;
143
+ }
144
+ }
145
+
146
+ return (
147
+ <form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6">
148
+ {errors._general && (
149
+ <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
150
+ <p className="font-medium">Error</p>
151
+ <p className="text-sm mt-1">{errors._general}</p>
152
+ </div>
153
+ )}
154
+
155
+ <div className="grid grid-cols-4 gap-6">
156
+ {Object.entries(schema.fields || {}).map(([fieldName, fieldConfig]) =>
157
+ renderField(fieldName, fieldConfig, formData[fieldName])
158
+ )}
159
+ </div>
160
+
161
+ <div className="mt-8 flex gap-3 border-t border-gray-200 pt-6">
162
+ <button
163
+ type="submit"
164
+ disabled={submitting}
165
+ className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
166
+ >
167
+ {submitting ? "Saving..." : submitLabel}
168
+ </button>
169
+ {onCancel && (
170
+ <button
171
+ type="button"
172
+ onClick={onCancel}
173
+ disabled={submitting}
174
+ className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 disabled:opacity-50 transition-colors"
175
+ >
176
+ {cancelLabel}
177
+ </button>
178
+ )}
179
+ </div>
180
+ </form>
181
+ );
182
+ }
@@ -0,0 +1,113 @@
1
+ import { AnvilResource } from "next-anvil/resource";
2
+ import { TableColumn } from "next-anvil/table";
3
+ import { prisma } from "@/lib/prisma";
4
+ import Link from "next/link";
5
+
6
+ export default async function AnvilTable({
7
+ resource,
8
+ }: {
9
+ resource: AnvilResource;
10
+ }) {
11
+ const records = await (prisma as any)[resource.model].findMany();
12
+
13
+ if (records.length === 0) {
14
+ return (
15
+ <div className="bg-white shadow rounded-lg p-12 text-center">
16
+ <p className="text-gray-500 text-lg">No records found</p>
17
+ <p className="text-gray-400 text-sm mt-2">
18
+ Get started by creating a new {resource.label.toLowerCase()}
19
+ </p>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ return (
25
+ <div className="bg-white shadow rounded-lg overflow-hidden">
26
+ <div className="overflow-x-auto">
27
+ <table className="min-w-full divide-y divide-gray-200">
28
+ <thead className="bg-gray-50">
29
+ <tr>
30
+ {resource.table.columns.map((col: TableColumn) => {
31
+ if (col.visible === "never") return null;
32
+ return (
33
+ <th
34
+ key={col.name}
35
+ className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
36
+ >
37
+ {col.label || col.name}
38
+ </th>
39
+ );
40
+ })}
41
+ <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
42
+ Actions
43
+ </th>
44
+ </tr>
45
+ </thead>
46
+ <tbody className="bg-white divide-y divide-gray-200">
47
+ {records.map((record: any) => (
48
+ <tr
49
+ key={record.id}
50
+ className="hover:bg-gray-50 transition-colors"
51
+ >
52
+ {resource.table.columns.map((col: TableColumn) => {
53
+ if (col.visible === "never") return null;
54
+ const value = record[col.name];
55
+ let displayValue = value;
56
+
57
+ if (value === null || value === undefined) {
58
+ displayValue = (
59
+ <span className="text-gray-400 italic">-</span>
60
+ );
61
+ } else if (value instanceof Date) {
62
+ displayValue = new Date(value).toLocaleDateString("en-US", {
63
+ year: "numeric",
64
+ month: "short",
65
+ day: "numeric",
66
+ });
67
+ } else if (typeof value === "boolean") {
68
+ displayValue = (
69
+ <span
70
+ className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
71
+ value
72
+ ? "bg-green-100 text-green-800"
73
+ : "bg-gray-100 text-gray-800"
74
+ }`}
75
+ >
76
+ {value ? "Yes" : "No"}
77
+ </span>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <td
83
+ key={col.name}
84
+ className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
85
+ >
86
+ {displayValue}
87
+ </td>
88
+ );
89
+ })}
90
+ <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
91
+ <div className="flex justify-end gap-3">
92
+ <Link
93
+ href={`/admin/${resource.slug}/${record.id}/edit`}
94
+ className="text-blue-600 hover:text-blue-900 font-medium"
95
+ >
96
+ Edit
97
+ </Link>
98
+ <Link
99
+ href={`/admin/${resource.slug}/${record.id}/delete`}
100
+ className="text-red-600 hover:text-red-900 font-medium"
101
+ >
102
+ Delete
103
+ </Link>
104
+ </div>
105
+ </td>
106
+ </tr>
107
+ ))}
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CreateResourceForm - Client component wrapper for creating resources
3
+ * Separates client-side form logic from server-side data operations
4
+ */
5
+
6
+ "use client";
7
+
8
+ import { FormSchema } from "next-anvil/form";
9
+ import { AnvilForm } from "./AnvilForm";
10
+ import { useRouter } from "next/navigation";
11
+
12
+ interface CreateResourceFormProps {
13
+ schema: FormSchema;
14
+ onSubmit: (data: Record<string, any>) => Promise<void>;
15
+ }
16
+
17
+ export function CreateResourceForm({
18
+ schema,
19
+ onSubmit,
20
+ }: CreateResourceFormProps) {
21
+ const router = useRouter();
22
+
23
+ async function handleSubmit(data: Record<string, any>) {
24
+ try {
25
+ await onSubmit(data);
26
+ // Server action handles redirect, but refresh in case of client-side navigation
27
+ router.refresh();
28
+ } catch (error: any) {
29
+ // Error handling is done in the server action
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ function handleCancel() {
35
+ router.back();
36
+ }
37
+
38
+ return (
39
+ <AnvilForm
40
+ schema={schema}
41
+ onSubmit={handleSubmit}
42
+ onCancel={handleCancel}
43
+ submitLabel="Create"
44
+ cancelLabel="Cancel"
45
+ />
46
+ );
47
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * DeleteResourceForm - Client component for confirming deletion
3
+ */
4
+
5
+ "use client";
6
+
7
+ import { useRouter } from "next/navigation";
8
+ import { useState } from "react";
9
+
10
+ interface DeleteResourceFormProps {
11
+ record: Record<string, any>;
12
+ resourceLabel: string;
13
+ onDelete: () => Promise<void>;
14
+ }
15
+
16
+ export function DeleteResourceForm({
17
+ record,
18
+ resourceLabel,
19
+ onDelete,
20
+ }: DeleteResourceFormProps) {
21
+ const router = useRouter();
22
+ const [isDeleting, setIsDeleting] = useState(false);
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ async function handleDelete() {
26
+ setIsDeleting(true);
27
+ setError(null);
28
+
29
+ try {
30
+ await onDelete();
31
+ router.push(`/admin/`);
32
+ router.refresh();
33
+ } catch (error: any) {
34
+ setError(error.message || "Failed to delete record");
35
+ setIsDeleting(false);
36
+ }
37
+ }
38
+
39
+ function handleCancel() {
40
+ router.back();
41
+ }
42
+
43
+ // Get a display value for the record (prefer name, email, or id)
44
+ const displayValue =
45
+ record.name ||
46
+ record.email ||
47
+ record.title ||
48
+ (record.firstName && record.lastName
49
+ ? `${record.firstName} ${record.lastName}`
50
+ : record.firstName || record.lastName) ||
51
+ `#${record.id}`;
52
+
53
+ return (
54
+ <div className="bg-white shadow rounded-lg p-6">
55
+ {error && (
56
+ <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
57
+ <p className="font-medium">Error</p>
58
+ <p className="text-sm mt-1">{error}</p>
59
+ </div>
60
+ )}
61
+
62
+ <div className="mb-6">
63
+ <p className="text-gray-700 mb-4">
64
+ Are you sure you want to delete this {resourceLabel.toLowerCase()}?
65
+ This action cannot be undone.
66
+ </p>
67
+ <div className="bg-gray-50 border border-gray-200 p-4 rounded-md">
68
+ <p className="font-medium text-gray-900">{displayValue}</p>
69
+ {record.email && (
70
+ <p className="text-sm text-gray-600 mt-1">{record.email}</p>
71
+ )}
72
+ </div>
73
+ </div>
74
+
75
+ <div className="flex gap-3 border-t border-gray-200 pt-6">
76
+ <button
77
+ type="button"
78
+ onClick={handleDelete}
79
+ disabled={isDeleting}
80
+ className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
81
+ >
82
+ {isDeleting ? "Deleting..." : "Delete"}
83
+ </button>
84
+ <button
85
+ type="button"
86
+ onClick={handleCancel}
87
+ disabled={isDeleting}
88
+ className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 disabled:opacity-50 transition-colors"
89
+ >
90
+ Cancel
91
+ </button>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
@@ -0,0 +1,51 @@
1
+ /**
2
+ * EditResourceForm - Client component wrapper for editing resources
3
+ * Separates client-side form logic from server-side data operations
4
+ */
5
+
6
+ "use client";
7
+
8
+ import { FormSchema } from "next-anvil/form";
9
+ import { AnvilForm } from "./AnvilForm";
10
+ import { useRouter } from "next/navigation";
11
+
12
+ interface EditResourceFormProps {
13
+ schema: FormSchema;
14
+ initialData: Record<string, any>;
15
+ onSubmit: (data: Record<string, any>) => Promise<void>;
16
+ }
17
+
18
+ export function EditResourceForm({
19
+ schema,
20
+ initialData,
21
+ onSubmit,
22
+ }: EditResourceFormProps) {
23
+ const router = useRouter();
24
+
25
+ async function handleSubmit(data: Record<string, any>) {
26
+ try {
27
+ await onSubmit(data);
28
+ router.push(`/admin/`);
29
+ router.refresh();
30
+ } catch (error: any) {
31
+ // Error handling is done in the server action
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ function handleCancel() {
37
+ router.back();
38
+ }
39
+
40
+ return (
41
+ <AnvilForm
42
+ schema={schema}
43
+ initialData={initialData}
44
+ onSubmit={handleSubmit}
45
+ onCancel={handleCancel}
46
+ submitLabel="Update"
47
+ cancelLabel="Cancel"
48
+ />
49
+ );
50
+ }
51
+
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "child_process";
3
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ const projectName = process.argv[2];
9
+ if (!projectName) {
10
+ console.error("Please provide a project name:");
11
+ console.error(" npx create-anvil-app <project-name>");
12
+ process.exit(1);
13
+ }
14
+ const projectPath = join(process.cwd(), projectName);
15
+ if (existsSync(projectPath)) {
16
+ console.error(`Directory ${projectName} already exists.`);
17
+ process.exit(1);
18
+ }
19
+ console.log(`Creating Next-anvil app: ${projectName}\n`);
20
+ // Step 1: Create Next.js app
21
+ console.log("šŸ“¦ Creating Next.js app...");
22
+ try {
23
+ execSync(`npx create-next-app@latest ${projectName} --typescript --tailwind --app --import-alias "@/*" --use-npm --yes`, { stdio: "inherit", cwd: process.cwd() });
24
+ }
25
+ catch (error) {
26
+ console.error("Failed to create Next.js app:", error);
27
+ process.exit(1);
28
+ }
29
+ console.log("\nāœ… Next.js app created successfully!");
30
+ // Step 2: Install next-anvil
31
+ console.log("\nšŸ“¦ Installing next-anvil...");
32
+ try {
33
+ execSync("npm install next-anvil", {
34
+ stdio: "inherit",
35
+ cwd: projectPath,
36
+ });
37
+ }
38
+ catch (error) {
39
+ console.error("Failed to install next-anvil:", error);
40
+ process.exit(1);
41
+ }
42
+ // Step 2.5: Copy bootstrap files
43
+ console.log("\nšŸ“‹ Copying Anvil bootstrap files...");
44
+ // When installed, __dirname points to dist/, so go up one level to find bootstrap
45
+ const bootstrapDir = join(__dirname, "..", "bootstrap");
46
+ // Copy admin folder to app/admin
47
+ const bootstrapAdminDir = join(bootstrapDir, "admin");
48
+ const targetAdminDir = join(projectPath, "app", "admin");
49
+ if (existsSync(bootstrapAdminDir)) {
50
+ cpSync(bootstrapAdminDir, targetAdminDir, { recursive: true });
51
+ console.log(" āœ… Copied admin folder to app/admin");
52
+ }
53
+ // Copy components folder to src/components
54
+ const bootstrapComponentsDir = join(bootstrapDir, "components");
55
+ const targetComponentsDir = join(projectPath, "src", "components");
56
+ if (existsSync(bootstrapComponentsDir)) {
57
+ mkdirSync(join(projectPath, "src"), { recursive: true });
58
+ cpSync(bootstrapComponentsDir, targetComponentsDir, { recursive: true });
59
+ console.log(" āœ… Copied components folder to src/components");
60
+ }
61
+ // Step 3: Add Anvil-specific files and configuration
62
+ console.log("\nšŸ”Ø Setting up Anvil configuration...");
63
+ // Create lib/resources directory
64
+ const resourcesDir = join(projectPath, "anvil", "resources");
65
+ mkdirSync(resourcesDir, { recursive: true });
66
+ // Create .gitkeep to ensure directory exists
67
+ writeFileSync(join(resourcesDir, ".gitkeep"), "");
68
+ // Update package.json to add anvil script
69
+ const packageJsonPath = join(projectPath, "package.json");
70
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
71
+ if (!packageJson.scripts) {
72
+ packageJson.scripts = {};
73
+ }
74
+ packageJson.scripts.anvil = "anvil";
75
+ writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
76
+ // Create app layout with Anvil styles import
77
+ const appLayoutPath = join(projectPath, "app", "layout.tsx");
78
+ if (existsSync(appLayoutPath)) {
79
+ let layoutContent = readFileSync(appLayoutPath, "utf-8");
80
+ // Add Anvil styles import if not already present
81
+ if (!layoutContent.includes("next-anvil/styles.css")) {
82
+ // Find the last import statement
83
+ const lastImportIndex = layoutContent.lastIndexOf("import");
84
+ const nextLineAfterLastImport = layoutContent.indexOf("\n", lastImportIndex) + 1;
85
+ layoutContent =
86
+ layoutContent.slice(0, nextLineAfterLastImport) +
87
+ 'import "next-anvil/styles.css";\n' +
88
+ layoutContent.slice(nextLineAfterLastImport);
89
+ writeFileSync(appLayoutPath, layoutContent);
90
+ }
91
+ }
92
+ // Create example .env.local with Prisma DATABASE_URL
93
+ const envLocalPath = join(projectPath, ".env.local");
94
+ if (!existsSync(envLocalPath)) {
95
+ writeFileSync(envLocalPath, `# Database
96
+ DATABASE_URL="postgresql://user:password@localhost:5432/anvil?schema=public"
97
+
98
+ # Next.js
99
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
100
+ `);
101
+ }
102
+ // Create example prisma schema
103
+ const prismaDir = join(projectPath, "prisma");
104
+ mkdirSync(prismaDir, { recursive: true });
105
+ const prismaSchemaPath = join(prismaDir, "schema.prisma");
106
+ if (!existsSync(prismaSchemaPath)) {
107
+ writeFileSync(prismaSchemaPath, `// This is your Prisma schema file,
108
+ // learn more about it in the docs: https://pris.ly/d/prisma-schema
109
+
110
+ generator client {
111
+ provider = "prisma-client-js"
112
+ }
113
+
114
+ datasource db {
115
+ provider = "postgresql"
116
+ url = env("DATABASE_URL")
117
+ }
118
+
119
+ // Example model - customize as needed
120
+ model User {
121
+ id String @id @default(cuid())
122
+ name String
123
+ email String @unique
124
+ createdAt DateTime @default(now())
125
+ updatedAt DateTime @updatedAt
126
+ }
127
+ `);
128
+ }
129
+ console.log("\nāœ… Anvil setup complete!");
130
+ console.log("\nšŸ“ Next steps:");
131
+ console.log(` 1. cd ${projectName}`);
132
+ console.log(" 2. Update .env.local with your database URL");
133
+ console.log(" 3. Run: anvil db:migrate");
134
+ console.log(" 4. Run: npm run dev");
135
+ console.log(" 5. Visit: http://localhost:3000/admin");
136
+ console.log("\nšŸŽ‰ Happy coding!");
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "forge-anvil-app",
3
+ "version": "0.1.0",
4
+ "description": "Create a new Next-anvil application",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-anvil-app": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "bootstrap"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build && node ../../scripts/postbuild-create-anvil-app.js"
16
+ },
17
+ "dependencies": {
18
+ "create-next-app": "^15.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^20.0.0",
22
+ "typescript": "^5.0.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }