stackloom-cli 1.0.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/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- package/src/utils/sanitize.js +200 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import api from '../api/<%= resource.kebabName %>.api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* use<%= resource.pascalName %> hook
|
|
6
|
+
* Handles data fetching and state for <%= resource.name %> resource
|
|
7
|
+
*/
|
|
8
|
+
export const use<%= resource.pascalName %> = (id = null) => {
|
|
9
|
+
const [data, setData] = useState(id ? null : []);
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
|
|
13
|
+
const fetchAll = useCallback(async (params) => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
try {
|
|
16
|
+
const res = await api.getAll(params);
|
|
17
|
+
setData(res.data);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
setError(err);
|
|
20
|
+
} finally {
|
|
21
|
+
setLoading(false);
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const fetchOne = useCallback(async (uid) => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
try {
|
|
28
|
+
const res = await api.getById(uid);
|
|
29
|
+
setData(res.data);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
setError(err);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (id) {
|
|
39
|
+
fetchOne(id);
|
|
40
|
+
} else {
|
|
41
|
+
fetchAll();
|
|
42
|
+
}
|
|
43
|
+
}, [id, fetchOne, fetchAll]);
|
|
44
|
+
|
|
45
|
+
return { data, loading, error, refresh: id ? () => fetchOne(id) : fetchAll };
|
|
46
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
// ── Mongoose Field Definitions ──────────────────────────────────────────────
|
|
4
|
+
<% const fields = resource.fields; %>
|
|
5
|
+
const <%= resource.pascalName %>Schema = new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
<% fields.forEach(function(field) { -%>
|
|
8
|
+
<%- field.name %>: { <%- field.mongooseDef %> },
|
|
9
|
+
<% }); -%>
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
timestamps: true,
|
|
13
|
+
toJSON: {
|
|
14
|
+
virtuals: true,
|
|
15
|
+
transform: (doc, ret) => {
|
|
16
|
+
delete ret._id;
|
|
17
|
+
delete ret.__v;
|
|
18
|
+
return ret;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
toObject: { virtuals: true },
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// ── Indexes ── (text fields indexed for search; unique fields are indexed inline)
|
|
26
|
+
<% fields.forEach(function(field) { -%>
|
|
27
|
+
<% if (field.type === 'text' || field.type === 'richtext') { -%>
|
|
28
|
+
<%= resource.pascalName %>Schema.index({ <%- field.name %>: 'text' });
|
|
29
|
+
<% } -%>
|
|
30
|
+
<% }); -%>
|
|
31
|
+
|
|
32
|
+
<% if (resource.features.softDelete) { %>
|
|
33
|
+
// Soft delete plugin (adds deletedAt, isDeleted virtual)
|
|
34
|
+
<%= resource.pascalName %>Schema.plugin(require('mongoose-delete'), { deletedAt: true });
|
|
35
|
+
<% } %>
|
|
36
|
+
|
|
37
|
+
<% if (resource.relations && resource.relations.hasMany && resource.relations.hasMany.length > 0) { %>
|
|
38
|
+
// Virtual populate for hasMany relations
|
|
39
|
+
<% resource.relations.hasMany.forEach(function(rel) { %>
|
|
40
|
+
<%= resource.pascalName %>Schema.virtual('<%- rel.field %>', {
|
|
41
|
+
ref: '<%- rel.model %>',
|
|
42
|
+
localField: '_id',
|
|
43
|
+
foreignField: '<%- rel.foreignKey || (rel.field + 'Id') %>',
|
|
44
|
+
justOne: false
|
|
45
|
+
});
|
|
46
|
+
<% }); %>
|
|
47
|
+
<% } %>
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
// ✎ CUSTOM CODE ZONE — YOUR CODE HERE
|
|
51
|
+
// Add virtuals, methods, statics, plugins below:
|
|
52
|
+
//
|
|
53
|
+
// <%= resource.pascalName %>Schema.virtual('fullName').get(function() { ... });
|
|
54
|
+
// <%= resource.pascalName %>Schema.methods.comparePassword = function(candidate) { ... };
|
|
55
|
+
// <%= resource.pascalName %>Schema.statics.findActive = function() { ... };
|
|
56
|
+
// <%= resource.pascalName %>Schema.pre('save', function(next) { ... });
|
|
57
|
+
//
|
|
58
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
module.exports = mongoose.model('<%= resource.pascalName %>', <%= resource.pascalName %>Schema);
|
|
61
|
+
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
// END AUTO-GENERATED
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
3
|
+
import { use<%= resource.pascalName %> } from "@/hooks/use<%= resource.pascalName %>";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { ArrowLeft, Edit } from "lucide-react";
|
|
6
|
+
import { PageWrapper } from "@/components/layout/PageWrapper";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
|
|
9
|
+
export default function <%= resource.pascalName %>DetailPage() {
|
|
10
|
+
const { id } = useParams();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const { data: item, loading, error } = use<%= resource.pascalName %>(id);
|
|
13
|
+
|
|
14
|
+
if (loading) return <div>Loading...</div>;
|
|
15
|
+
if (error) return <div>Error loading <%= resource.name.toLowerCase() %></div>;
|
|
16
|
+
if (!item) return <div><%= resource.name %> not found</div>;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<PageWrapper
|
|
20
|
+
title={`<%= resource.name %> Details`}
|
|
21
|
+
actions={
|
|
22
|
+
<div className="flex gap-2">
|
|
23
|
+
<Button variant="outline" onClick={() => navigate(-1)}>
|
|
24
|
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
|
25
|
+
</Button>
|
|
26
|
+
<Button onClick={() => navigate(`/admin/<%= resource.kebabName %>/edit/${id}`)}>
|
|
27
|
+
<Edit className="mr-2 h-4 w-4" /> Edit
|
|
28
|
+
</Button>
|
|
29
|
+
</div>
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
33
|
+
<Card>
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle>Information</CardTitle>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
<CardContent className="space-y-2">
|
|
38
|
+
<% resource.fields.forEach(function(field) { %>
|
|
39
|
+
<div className="flex justify-between border-b pb-2">
|
|
40
|
+
<span className="font-medium text-muted-foreground"><%- field.name.charAt(0).toUpperCase() + field.name.slice(1) %></span>
|
|
41
|
+
<span>{<% if (field.type === 'boolean') { %>
|
|
42
|
+
item.<%- field.name %> ? 'Yes' : 'No'
|
|
43
|
+
<% } else if (field.type === 'date' || field.type === 'datetime') { %>
|
|
44
|
+
new Date(item.<%- field.name %>).toLocaleString()
|
|
45
|
+
<% } else { %>
|
|
46
|
+
item.<%- field.name %>
|
|
47
|
+
<% } %>}</span>
|
|
48
|
+
</div>
|
|
49
|
+
<% }); %>
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
</div>
|
|
53
|
+
</PageWrapper>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { <%= resource.pascalName %>Form } from "@/components/forms/<%= resource.pascalName %>Form";
|
|
10
|
+
|
|
11
|
+
export const FormModal = ({ open, onOpenChange, initialData, onSubmit, isLoading }) => {
|
|
12
|
+
return (
|
|
13
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
14
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
15
|
+
<DialogHeader>
|
|
16
|
+
<DialogTitle>{initialData ? 'Edit' : 'Create'} <%= resource.name %></DialogTitle>
|
|
17
|
+
<DialogDescription>
|
|
18
|
+
{initialData ? 'Update the details below.' : 'Fill in the details for the new <%= resource.name.toLowerCase() %>.'}
|
|
19
|
+
</DialogDescription>
|
|
20
|
+
</DialogHeader>
|
|
21
|
+
<<%= resource.pascalName %>Form
|
|
22
|
+
initialData={initialData}
|
|
23
|
+
onSubmit={onSubmit}
|
|
24
|
+
onCancel={() => onOpenChange(false)}
|
|
25
|
+
isLoading={isLoading}
|
|
26
|
+
/>
|
|
27
|
+
</DialogContent>
|
|
28
|
+
</Dialog>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { use<%= resource.pascalName %> } from "@/hooks/use<%= resource.pascalName %>";
|
|
3
|
+
import { <%= resource.pascalName %>Table } from "@/components/tables/<%= resource.pascalName %>Table";
|
|
4
|
+
import { <%= resource.pascalName %>Form } from "@/components/forms/<%= resource.pascalName %>Form";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { RefreshCcw } from "lucide-react";
|
|
7
|
+
import { PageWrapper } from "@/components/layout/PageWrapper";
|
|
8
|
+
import api from "@/api/<%= resource.kebabName %>.api";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
|
|
11
|
+
// form-mode: inline — the create/edit form is always visible above the table.
|
|
12
|
+
export default function <%= resource.pascalName %>ListPage() {
|
|
13
|
+
const { data, loading, refresh } = use<%= resource.pascalName %>();
|
|
14
|
+
const [editingItem, setEditingItem] = useState(null);
|
|
15
|
+
|
|
16
|
+
const handleEdit = (item) => setEditingItem(item);
|
|
17
|
+
|
|
18
|
+
const handleDelete = async (item) => {
|
|
19
|
+
if (!confirm("Are you sure you want to delete this <%= resource.name.toLowerCase() %>?")) return;
|
|
20
|
+
try {
|
|
21
|
+
await api.delete(item._id);
|
|
22
|
+
toast.success("<%= resource.name %> deleted successfully");
|
|
23
|
+
refresh();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
toast.error("Failed to delete <%= resource.name.toLowerCase() %>");
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleSubmit = async (formData) => {
|
|
30
|
+
try {
|
|
31
|
+
if (editingItem) {
|
|
32
|
+
await api.update(editingItem._id, formData);
|
|
33
|
+
toast.success("<%= resource.name %> updated successfully");
|
|
34
|
+
} else {
|
|
35
|
+
await api.create(formData);
|
|
36
|
+
toast.success("<%= resource.name %> created successfully");
|
|
37
|
+
}
|
|
38
|
+
setEditingItem(null);
|
|
39
|
+
refresh();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error(editingItem ? "Failed to update <%= resource.name.toLowerCase() %>" : "Failed to create <%= resource.name.toLowerCase() %>");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<PageWrapper
|
|
47
|
+
title="<%= resource.pluralPascal %>"
|
|
48
|
+
subtitle="Manage your <%= resource.pluralPascal.toLowerCase() %>"
|
|
49
|
+
actions={
|
|
50
|
+
<Button variant="outline" size="icon" onClick={() => refresh()} disabled={loading}>
|
|
51
|
+
<RefreshCcw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
52
|
+
</Button>
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<div className="space-y-6">
|
|
56
|
+
<section className="rounded-lg border bg-card p-6">
|
|
57
|
+
<h2 className="mb-4 text-lg font-medium">
|
|
58
|
+
{editingItem ? "Edit <%= resource.name %>" : "Create <%= resource.name %>"}
|
|
59
|
+
</h2>
|
|
60
|
+
<<%= resource.pascalName %>Form
|
|
61
|
+
initialData={editingItem}
|
|
62
|
+
onSubmit={handleSubmit}
|
|
63
|
+
onCancel={editingItem ? () => setEditingItem(null) : undefined}
|
|
64
|
+
isLoading={loading}
|
|
65
|
+
/>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<section>
|
|
69
|
+
<<%= resource.pascalName %>Table data={data} onEdit={handleEdit} onDelete={handleDelete} />
|
|
70
|
+
</section>
|
|
71
|
+
</div>
|
|
72
|
+
</PageWrapper>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { use<%= resource.pascalName %> } from "@/hooks/use<%= resource.pascalName %>";
|
|
3
|
+
import { <%= resource.pascalName %>Table } from "@/components/tables/<%= resource.pascalName %>Table";
|
|
4
|
+
import { <%= resource.pascalName %>Form } from "@/components/forms/<%= resource.pascalName %>Form";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Plus, RefreshCcw } from "lucide-react";
|
|
14
|
+
import { PageWrapper } from "@/components/layout/PageWrapper";
|
|
15
|
+
import api from "@/api/<%= resource.kebabName %>.api";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
|
|
18
|
+
// form-mode: modal — create/edit form is mounted inside a Dialog.
|
|
19
|
+
export default function <%= resource.pascalName %>ListPage() {
|
|
20
|
+
const { data, loading, refresh } = use<%= resource.pascalName %>();
|
|
21
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
22
|
+
const [editingItem, setEditingItem] = useState(null);
|
|
23
|
+
|
|
24
|
+
const handleCreate = () => {
|
|
25
|
+
setEditingItem(null);
|
|
26
|
+
setFormOpen(true);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleEdit = (item) => {
|
|
30
|
+
setEditingItem(item);
|
|
31
|
+
setFormOpen(true);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleDelete = async (item) => {
|
|
35
|
+
if (!confirm("Are you sure you want to delete this <%= resource.name.toLowerCase() %>?")) return;
|
|
36
|
+
try {
|
|
37
|
+
await api.delete(item._id);
|
|
38
|
+
toast.success("<%= resource.name %> deleted successfully");
|
|
39
|
+
refresh();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error("Failed to delete <%= resource.name.toLowerCase() %>");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleSubmit = async (formData) => {
|
|
46
|
+
try {
|
|
47
|
+
if (editingItem) {
|
|
48
|
+
await api.update(editingItem._id, formData);
|
|
49
|
+
toast.success("<%= resource.name %> updated successfully");
|
|
50
|
+
} else {
|
|
51
|
+
await api.create(formData);
|
|
52
|
+
toast.success("<%= resource.name %> created successfully");
|
|
53
|
+
}
|
|
54
|
+
setFormOpen(false);
|
|
55
|
+
refresh();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
toast.error(editingItem ? "Failed to update <%= resource.name.toLowerCase() %>" : "Failed to create <%= resource.name.toLowerCase() %>");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<PageWrapper
|
|
63
|
+
title="<%= resource.pluralPascal %>"
|
|
64
|
+
subtitle="Manage your <%= resource.pluralPascal.toLowerCase() %>"
|
|
65
|
+
actions={
|
|
66
|
+
<div className="flex gap-2">
|
|
67
|
+
<Button variant="outline" size="icon" onClick={() => refresh()} disabled={loading}>
|
|
68
|
+
<RefreshCcw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
69
|
+
</Button>
|
|
70
|
+
<Button onClick={handleCreate}>
|
|
71
|
+
<Plus className="mr-2 h-4 w-4" /> Add <%= resource.name %>
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
<<%= resource.pascalName %>Table data={data} onEdit={handleEdit} onDelete={handleDelete} />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
|
81
|
+
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>{editingItem ? "Edit <%= resource.name %>" : "Create <%= resource.name %>"}</DialogTitle>
|
|
84
|
+
<DialogDescription>
|
|
85
|
+
{editingItem ? "Update the details below." : "Fill in the details to create a new <%= resource.name.toLowerCase() %>."}
|
|
86
|
+
</DialogDescription>
|
|
87
|
+
</DialogHeader>
|
|
88
|
+
<<%= resource.pascalName %>Form
|
|
89
|
+
initialData={editingItem}
|
|
90
|
+
onSubmit={handleSubmit}
|
|
91
|
+
onCancel={() => setFormOpen(false)}
|
|
92
|
+
isLoading={loading}
|
|
93
|
+
/>
|
|
94
|
+
</DialogContent>
|
|
95
|
+
</Dialog>
|
|
96
|
+
</PageWrapper>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { use<%= resource.pascalName %> } from "@/hooks/use<%= resource.pascalName %>";
|
|
3
|
+
import { <%= resource.pascalName %>Table } from "@/components/tables/<%= resource.pascalName %>Table";
|
|
4
|
+
import { <%= resource.pascalName %>Form } from "@/components/forms/<%= resource.pascalName %>Form";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Plus, RefreshCcw } from "lucide-react";
|
|
7
|
+
import { PageWrapper } from "@/components/layout/PageWrapper";
|
|
8
|
+
import api from "@/api/<%= resource.kebabName %>.api";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
|
|
11
|
+
// form-mode: page — the create/edit form takes over the page as a dedicated
|
|
12
|
+
// section. A matching FormPage.jsx is also generated for routed create/edit.
|
|
13
|
+
export default function <%= resource.pascalName %>ListPage() {
|
|
14
|
+
const { data, loading, refresh } = use<%= resource.pascalName %>();
|
|
15
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
16
|
+
const [editingItem, setEditingItem] = useState(null);
|
|
17
|
+
|
|
18
|
+
const handleCreate = () => {
|
|
19
|
+
setEditingItem(null);
|
|
20
|
+
setFormOpen(true);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const handleEdit = (item) => {
|
|
24
|
+
setEditingItem(item);
|
|
25
|
+
setFormOpen(true);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleClose = () => {
|
|
29
|
+
setFormOpen(false);
|
|
30
|
+
setEditingItem(null);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleDelete = async (item) => {
|
|
34
|
+
if (!confirm("Are you sure you want to delete this <%= resource.name.toLowerCase() %>?")) return;
|
|
35
|
+
try {
|
|
36
|
+
await api.delete(item._id);
|
|
37
|
+
toast.success("<%= resource.name %> deleted successfully");
|
|
38
|
+
refresh();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
toast.error("Failed to delete <%= resource.name.toLowerCase() %>");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleSubmit = async (formData) => {
|
|
45
|
+
try {
|
|
46
|
+
if (editingItem) {
|
|
47
|
+
await api.update(editingItem._id, formData);
|
|
48
|
+
toast.success("<%= resource.name %> updated successfully");
|
|
49
|
+
} else {
|
|
50
|
+
await api.create(formData);
|
|
51
|
+
toast.success("<%= resource.name %> created successfully");
|
|
52
|
+
}
|
|
53
|
+
handleClose();
|
|
54
|
+
refresh();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
toast.error(editingItem ? "Failed to update <%= resource.name.toLowerCase() %>" : "Failed to create <%= resource.name.toLowerCase() %>");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (formOpen) {
|
|
61
|
+
return (
|
|
62
|
+
<PageWrapper
|
|
63
|
+
title={editingItem ? "Edit <%= resource.name %>" : "Create <%= resource.name %>"}
|
|
64
|
+
subtitle="Fill in the details below"
|
|
65
|
+
actions={<Button variant="outline" onClick={handleClose}>Back to list</Button>}
|
|
66
|
+
>
|
|
67
|
+
<section className="max-w-2xl rounded-lg border bg-card p-6">
|
|
68
|
+
<<%= resource.pascalName %>Form
|
|
69
|
+
initialData={editingItem}
|
|
70
|
+
onSubmit={handleSubmit}
|
|
71
|
+
onCancel={handleClose}
|
|
72
|
+
isLoading={loading}
|
|
73
|
+
/>
|
|
74
|
+
</section>
|
|
75
|
+
</PageWrapper>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<PageWrapper
|
|
81
|
+
title="<%= resource.pluralPascal %>"
|
|
82
|
+
subtitle="Manage your <%= resource.pluralPascal.toLowerCase() %>"
|
|
83
|
+
actions={
|
|
84
|
+
<div className="flex gap-2">
|
|
85
|
+
<Button variant="outline" size="icon" onClick={() => refresh()} disabled={loading}>
|
|
86
|
+
<RefreshCcw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
87
|
+
</Button>
|
|
88
|
+
<Button onClick={handleCreate}>
|
|
89
|
+
<Plus className="mr-2 h-4 w-4" /> Add <%= resource.name %>
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
}
|
|
93
|
+
>
|
|
94
|
+
<div className="space-y-4">
|
|
95
|
+
<<%= resource.pascalName %>Table data={data} onEdit={handleEdit} onDelete={handleDelete} />
|
|
96
|
+
</div>
|
|
97
|
+
</PageWrapper>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { use<%= resource.pascalName %> } from "@/hooks/use<%= resource.pascalName %>";
|
|
3
|
+
import { <%= resource.pascalName %>Table } from "@/components/tables/<%= resource.pascalName %>Table";
|
|
4
|
+
import { <%= resource.pascalName %>Form } from "@/components/forms/<%= resource.pascalName %>Form";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Sheet,
|
|
8
|
+
SheetContent,
|
|
9
|
+
SheetHeader,
|
|
10
|
+
SheetTitle,
|
|
11
|
+
SheetDescription,
|
|
12
|
+
} from "@/components/ui/sheet";
|
|
13
|
+
import { Plus, RefreshCcw } from "lucide-react";
|
|
14
|
+
import { PageWrapper } from "@/components/layout/PageWrapper";
|
|
15
|
+
import api from "@/api/<%= resource.kebabName %>.api";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
|
|
18
|
+
// form-mode: sidepanel — create/edit form is mounted inside a slide-over Sheet.
|
|
19
|
+
export default function <%= resource.pascalName %>ListPage() {
|
|
20
|
+
const { data, loading, refresh } = use<%= resource.pascalName %>();
|
|
21
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
22
|
+
const [editingItem, setEditingItem] = useState(null);
|
|
23
|
+
|
|
24
|
+
const handleCreate = () => {
|
|
25
|
+
setEditingItem(null);
|
|
26
|
+
setFormOpen(true);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleEdit = (item) => {
|
|
30
|
+
setEditingItem(item);
|
|
31
|
+
setFormOpen(true);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleDelete = async (item) => {
|
|
35
|
+
if (!confirm("Are you sure you want to delete this <%= resource.name.toLowerCase() %>?")) return;
|
|
36
|
+
try {
|
|
37
|
+
await api.delete(item._id);
|
|
38
|
+
toast.success("<%= resource.name %> deleted successfully");
|
|
39
|
+
refresh();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error("Failed to delete <%= resource.name.toLowerCase() %>");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleSubmit = async (formData) => {
|
|
46
|
+
try {
|
|
47
|
+
if (editingItem) {
|
|
48
|
+
await api.update(editingItem._id, formData);
|
|
49
|
+
toast.success("<%= resource.name %> updated successfully");
|
|
50
|
+
} else {
|
|
51
|
+
await api.create(formData);
|
|
52
|
+
toast.success("<%= resource.name %> created successfully");
|
|
53
|
+
}
|
|
54
|
+
setFormOpen(false);
|
|
55
|
+
refresh();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
toast.error(editingItem ? "Failed to update <%= resource.name.toLowerCase() %>" : "Failed to create <%= resource.name.toLowerCase() %>");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<PageWrapper
|
|
63
|
+
title="<%= resource.pluralPascal %>"
|
|
64
|
+
subtitle="Manage your <%= resource.pluralPascal.toLowerCase() %>"
|
|
65
|
+
actions={
|
|
66
|
+
<div className="flex gap-2">
|
|
67
|
+
<Button variant="outline" size="icon" onClick={() => refresh()} disabled={loading}>
|
|
68
|
+
<RefreshCcw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
69
|
+
</Button>
|
|
70
|
+
<Button onClick={handleCreate}>
|
|
71
|
+
<Plus className="mr-2 h-4 w-4" /> Add <%= resource.name %>
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
<<%= resource.pascalName %>Table data={data} onEdit={handleEdit} onDelete={handleDelete} />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<Sheet open={formOpen} onOpenChange={setFormOpen}>
|
|
81
|
+
<SheetContent className="overflow-y-auto sm:max-w-lg">
|
|
82
|
+
<SheetHeader>
|
|
83
|
+
<SheetTitle>{editingItem ? "Edit <%= resource.name %>" : "Create <%= resource.name %>"}</SheetTitle>
|
|
84
|
+
<SheetDescription>
|
|
85
|
+
{editingItem ? "Update the details below." : "Fill in the details to create a new <%= resource.name.toLowerCase() %>."}
|
|
86
|
+
</SheetDescription>
|
|
87
|
+
</SheetHeader>
|
|
88
|
+
<div className="mt-4">
|
|
89
|
+
<<%= resource.pascalName %>Form
|
|
90
|
+
initialData={editingItem}
|
|
91
|
+
onSubmit={handleSubmit}
|
|
92
|
+
onCancel={() => setFormOpen(false)}
|
|
93
|
+
isLoading={loading}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</SheetContent>
|
|
97
|
+
</Sheet>
|
|
98
|
+
</PageWrapper>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const controller = require('../controllers/<%= resource.name %>.controller');
|
|
3
|
+
const validate = require('../../../middlewares/validate');
|
|
4
|
+
const { authenticateSession } = require('../../../middlewares/auth.middleware');
|
|
5
|
+
const validator = require('../../../utils/validators/<%= resource.name %>.validator');
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
// All routes require authentication by default — opt out per-route if needed.
|
|
10
|
+
router.use(authenticateSession);
|
|
11
|
+
|
|
12
|
+
// ── Create ───────────────────────────────────────────────────────────────────
|
|
13
|
+
router.post(
|
|
14
|
+
'/',
|
|
15
|
+
validate(validator.create<%= resource.pascalName %>Schema),
|
|
16
|
+
controller.create
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// ── List (query params handled in controller/service) ─────────────────────────
|
|
20
|
+
router.get('/', controller.list);
|
|
21
|
+
|
|
22
|
+
// ── Get One ──────────────────────────────────────────────────────────────────
|
|
23
|
+
router.get('/:id', controller.getOne);
|
|
24
|
+
|
|
25
|
+
// ── Update ───────────────────────────────────────────────────────────────────
|
|
26
|
+
router.put(
|
|
27
|
+
'/:id',
|
|
28
|
+
validate(validator.update<%= resource.pascalName %>Schema),
|
|
29
|
+
controller.update
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
33
|
+
router.delete('/:id', controller.remove);
|
|
34
|
+
|
|
35
|
+
module.exports = router;
|