blacksmith-cli 0.1.1

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 (103) hide show
  1. package/README.md +210 -0
  2. package/bin/blacksmith.js +20 -0
  3. package/dist/index.js +4404 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +51 -0
  6. package/src/templates/backend/.env.example.hbs +10 -0
  7. package/src/templates/backend/apps/__init__.py.hbs +0 -0
  8. package/src/templates/backend/apps/users/__init__.py.hbs +0 -0
  9. package/src/templates/backend/apps/users/admin.py.hbs +26 -0
  10. package/src/templates/backend/apps/users/managers.py.hbs +25 -0
  11. package/src/templates/backend/apps/users/models.py.hbs +25 -0
  12. package/src/templates/backend/apps/users/serializers.py.hbs +94 -0
  13. package/src/templates/backend/apps/users/tests.py.hbs +47 -0
  14. package/src/templates/backend/apps/users/urls.py.hbs +10 -0
  15. package/src/templates/backend/apps/users/views.py.hbs +175 -0
  16. package/src/templates/backend/config/__init__.py.hbs +0 -0
  17. package/src/templates/backend/config/asgi.py.hbs +9 -0
  18. package/src/templates/backend/config/settings/__init__.py.hbs +13 -0
  19. package/src/templates/backend/config/settings/base.py.hbs +117 -0
  20. package/src/templates/backend/config/settings/development.py.hbs +19 -0
  21. package/src/templates/backend/config/settings/production.py.hbs +31 -0
  22. package/src/templates/backend/config/urls.py.hbs +26 -0
  23. package/src/templates/backend/config/wsgi.py.hbs +9 -0
  24. package/src/templates/backend/manage.py.hbs +22 -0
  25. package/src/templates/backend/requirements.txt.hbs +7 -0
  26. package/src/templates/frontend/.env.hbs +1 -0
  27. package/src/templates/frontend/index.html.hbs +13 -0
  28. package/src/templates/frontend/openapi-ts.config.ts.hbs +29 -0
  29. package/src/templates/frontend/package.json.hbs +44 -0
  30. package/src/templates/frontend/postcss.config.js.hbs +6 -0
  31. package/src/templates/frontend/src/api/client.ts.hbs +110 -0
  32. package/src/templates/frontend/src/api/generated/.gitkeep +0 -0
  33. package/src/templates/frontend/src/api/generated/client.gen.ts +13 -0
  34. package/src/templates/frontend/src/api/query-client.ts.hbs +22 -0
  35. package/src/templates/frontend/src/app.tsx.hbs +30 -0
  36. package/src/templates/frontend/src/features/auth/adapter.ts.hbs +198 -0
  37. package/src/templates/frontend/src/features/auth/components/auth-provider.tsx.hbs +32 -0
  38. package/src/templates/frontend/src/features/auth/hooks/use-auth.ts.hbs +27 -0
  39. package/src/templates/frontend/src/features/auth/index.ts.hbs +3 -0
  40. package/src/templates/frontend/src/features/auth/pages/forgot-password-page.tsx.hbs +37 -0
  41. package/src/templates/frontend/src/features/auth/pages/login-page.tsx.hbs +36 -0
  42. package/src/templates/frontend/src/features/auth/pages/register-page.tsx.hbs +36 -0
  43. package/src/templates/frontend/src/features/auth/pages/reset-password-page.tsx.hbs +41 -0
  44. package/src/templates/frontend/src/features/auth/routes.tsx.hbs +13 -0
  45. package/src/templates/frontend/src/main.tsx.hbs +10 -0
  46. package/src/templates/frontend/src/pages/dashboard/components/quick-start-card.tsx.hbs +36 -0
  47. package/src/templates/frontend/src/pages/dashboard/components/stack-cards.tsx.hbs +69 -0
  48. package/src/templates/frontend/src/pages/dashboard/components/welcome-header.tsx.hbs +14 -0
  49. package/src/templates/frontend/src/pages/dashboard/dashboard.tsx.hbs +21 -0
  50. package/src/templates/frontend/src/pages/dashboard/index.ts.hbs +1 -0
  51. package/src/templates/frontend/src/pages/dashboard/routes.tsx.hbs +7 -0
  52. package/src/templates/frontend/src/pages/home/components/features-grid.tsx.hbs +88 -0
  53. package/src/templates/frontend/src/pages/home/components/getting-started.tsx.hbs +88 -0
  54. package/src/templates/frontend/src/pages/home/components/hero-section.tsx.hbs +47 -0
  55. package/src/templates/frontend/src/pages/home/components/resources-section.tsx.hbs +34 -0
  56. package/src/templates/frontend/src/pages/home/home.tsx.hbs +20 -0
  57. package/src/templates/frontend/src/pages/home/index.ts.hbs +1 -0
  58. package/src/templates/frontend/src/pages/home/routes.tsx.hbs +7 -0
  59. package/src/templates/frontend/src/router/auth-guard.tsx.hbs +57 -0
  60. package/src/templates/frontend/src/router/error-boundary.tsx.hbs +61 -0
  61. package/src/templates/frontend/src/router/index.tsx.hbs +12 -0
  62. package/src/templates/frontend/src/router/layouts/auth-layout.tsx.hbs +68 -0
  63. package/src/templates/frontend/src/router/layouts/main-layout.tsx.hbs +137 -0
  64. package/src/templates/frontend/src/router/paths.ts.hbs +38 -0
  65. package/src/templates/frontend/src/router/routes.tsx.hbs +64 -0
  66. package/src/templates/frontend/src/shared/components/loading-spinner.tsx.hbs +20 -0
  67. package/src/templates/frontend/src/shared/components/not-found-page.tsx.hbs +31 -0
  68. package/src/templates/frontend/src/shared/hooks/api-error.ts.hbs +147 -0
  69. package/src/templates/frontend/src/shared/hooks/use-api-mutation.ts.hbs +88 -0
  70. package/src/templates/frontend/src/shared/hooks/use-api-query.ts.hbs +66 -0
  71. package/src/templates/frontend/src/shared/hooks/use-debounce.ts.hbs +10 -0
  72. package/src/templates/frontend/src/styles/globals.css.hbs +62 -0
  73. package/src/templates/frontend/src/vite-env.d.ts.hbs +1 -0
  74. package/src/templates/frontend/tailwind.config.js.hbs +73 -0
  75. package/src/templates/frontend/tsconfig.app.json.hbs +25 -0
  76. package/src/templates/frontend/tsconfig.json.hbs +7 -0
  77. package/src/templates/frontend/tsconfig.node.json.hbs +18 -0
  78. package/src/templates/frontend/vite.config.ts.hbs +21 -0
  79. package/src/templates/resource/backend/__init__.py.hbs +0 -0
  80. package/src/templates/resource/backend/admin.py.hbs +10 -0
  81. package/src/templates/resource/backend/models.py.hbs +24 -0
  82. package/src/templates/resource/backend/serializers.py.hbs +21 -0
  83. package/src/templates/resource/backend/tests.py.hbs +35 -0
  84. package/src/templates/resource/backend/urls.py.hbs +10 -0
  85. package/src/templates/resource/backend/views.py.hbs +32 -0
  86. package/src/templates/resource/frontend/components/{{kebab}}-card.tsx.hbs +39 -0
  87. package/src/templates/resource/frontend/components/{{kebab}}-form.tsx.hbs +106 -0
  88. package/src/templates/resource/frontend/components/{{kebab}}-list.tsx.hbs +49 -0
  89. package/src/templates/resource/frontend/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
  90. package/src/templates/resource/frontend/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
  91. package/src/templates/resource/frontend/index.ts.hbs +6 -0
  92. package/src/templates/resource/frontend/pages/{{kebabs}}-page.tsx.hbs +33 -0
  93. package/src/templates/resource/frontend/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
  94. package/src/templates/resource/frontend/routes.tsx.hbs +15 -0
  95. package/src/templates/resource/pages/components/{{kebab}}-card.tsx.hbs +39 -0
  96. package/src/templates/resource/pages/components/{{kebab}}-form.tsx.hbs +106 -0
  97. package/src/templates/resource/pages/components/{{kebab}}-list.tsx.hbs +49 -0
  98. package/src/templates/resource/pages/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
  99. package/src/templates/resource/pages/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
  100. package/src/templates/resource/pages/index.ts.hbs +6 -0
  101. package/src/templates/resource/pages/routes.tsx.hbs +15 -0
  102. package/src/templates/resource/pages/{{kebabs}}-page.tsx.hbs +33 -0
  103. package/src/templates/resource/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
@@ -0,0 +1,62 @@
1
+ @import '@blacksmith-ui/react/styles.css';
2
+
3
+ {{#if (ne themePreset "default")}}
4
+ @layer base {
5
+ :root {
6
+ {{#if (eq themePreset "blue")}}
7
+ --primary: 221.2 83.2% 53.3%;
8
+ --primary-foreground: 210 40% 98%;
9
+ --ring: 221.2 83.2% 53.3%;
10
+ {{/if}}
11
+ {{#if (eq themePreset "green")}}
12
+ --primary: 142.1 76.2% 36.3%;
13
+ --primary-foreground: 355.7 100% 97.3%;
14
+ --ring: 142.1 76.2% 36.3%;
15
+ {{/if}}
16
+ {{#if (eq themePreset "red")}}
17
+ --primary: 0 72.2% 50.6%;
18
+ --primary-foreground: 60 9.1% 97.8%;
19
+ --ring: 0 72.2% 50.6%;
20
+ {{/if}}
21
+ {{#if (eq themePreset "violet")}}
22
+ --primary: 263.4 70% 50.4%;
23
+ --primary-foreground: 210 40% 98%;
24
+ --ring: 263.4 70% 50.4%;
25
+ {{/if}}
26
+ {{#if (eq themePreset "neutral")}}
27
+ --primary: 240 5.9% 10%;
28
+ --primary-foreground: 0 0% 98%;
29
+ --ring: 240 5.9% 10%;
30
+ --radius: 0rem;
31
+ {{/if}}
32
+ }
33
+
34
+ .dark {
35
+ {{#if (eq themePreset "blue")}}
36
+ --primary: 217.2 91.2% 59.8%;
37
+ --primary-foreground: 222.2 47.4% 11.2%;
38
+ --ring: 217.2 91.2% 59.8%;
39
+ {{/if}}
40
+ {{#if (eq themePreset "green")}}
41
+ --primary: 142.1 70.6% 45.3%;
42
+ --primary-foreground: 144.9 80.4% 10%;
43
+ --ring: 142.1 70.6% 45.3%;
44
+ {{/if}}
45
+ {{#if (eq themePreset "red")}}
46
+ --primary: 0 72.2% 50.6%;
47
+ --primary-foreground: 60 9.1% 97.8%;
48
+ --ring: 0 72.2% 50.6%;
49
+ {{/if}}
50
+ {{#if (eq themePreset "violet")}}
51
+ --primary: 263.4 70% 50.4%;
52
+ --primary-foreground: 210 40% 98%;
53
+ --ring: 263.4 70% 50.4%;
54
+ {{/if}}
55
+ {{#if (eq themePreset "neutral")}}
56
+ --primary: 0 0% 98%;
57
+ --primary-foreground: 240 5.9% 10%;
58
+ --ring: 0 0% 98%;
59
+ {{/if}}
60
+ }
61
+ }
62
+ {{/if}}
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,73 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: ['class'],
4
+ content: [
5
+ './index.html',
6
+ './src/**/*.{js,ts,jsx,tsx}',
7
+ './node_modules/@blacksmith-ui/**/*.{js,ts,jsx,tsx}',
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ colors: {
12
+ border: 'hsl(var(--border))',
13
+ input: 'hsl(var(--input))',
14
+ ring: 'hsl(var(--ring))',
15
+ background: 'hsl(var(--background))',
16
+ foreground: 'hsl(var(--foreground))',
17
+ primary: {
18
+ DEFAULT: 'hsl(var(--primary))',
19
+ foreground: 'hsl(var(--primary-foreground))',
20
+ },
21
+ secondary: {
22
+ DEFAULT: 'hsl(var(--secondary))',
23
+ foreground: 'hsl(var(--secondary-foreground))',
24
+ },
25
+ destructive: {
26
+ DEFAULT: 'hsl(var(--destructive))',
27
+ foreground: 'hsl(var(--destructive-foreground))',
28
+ },
29
+ muted: {
30
+ DEFAULT: 'hsl(var(--muted))',
31
+ foreground: 'hsl(var(--muted-foreground))',
32
+ },
33
+ accent: {
34
+ DEFAULT: 'hsl(var(--accent))',
35
+ foreground: 'hsl(var(--accent-foreground))',
36
+ },
37
+ popover: {
38
+ DEFAULT: 'hsl(var(--popover))',
39
+ foreground: 'hsl(var(--popover-foreground))',
40
+ },
41
+ card: {
42
+ DEFAULT: 'hsl(var(--card))',
43
+ foreground: 'hsl(var(--card-foreground))',
44
+ },
45
+ sidebar: {
46
+ DEFAULT: 'hsl(var(--sidebar-background))',
47
+ foreground: 'hsl(var(--sidebar-foreground))',
48
+ primary: 'hsl(var(--sidebar-primary))',
49
+ 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
50
+ accent: 'hsl(var(--sidebar-accent))',
51
+ 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
52
+ border: 'hsl(var(--sidebar-border))',
53
+ ring: 'hsl(var(--sidebar-ring))',
54
+ },
55
+ chart: {
56
+ 1: 'hsl(var(--chart-1))',
57
+ 2: 'hsl(var(--chart-2))',
58
+ 3: 'hsl(var(--chart-3))',
59
+ 4: 'hsl(var(--chart-4))',
60
+ 5: 'hsl(var(--chart-5))',
61
+ },
62
+ },
63
+ borderRadius: {
64
+ lg: 'var(--radius)',
65
+ md: 'calc(var(--radius) - 2px)',
66
+ sm: 'calc(var(--radius) - 4px)',
67
+ },
68
+ },
69
+ },
70
+ plugins: [
71
+ require('tailwindcss-animate'),
72
+ ],
73
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "baseUrl": ".",
20
+ "paths": {
21
+ "@/*": ["./src/*"]
22
+ }
23
+ },
24
+ "include": ["src"]
25
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023"],
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+ "moduleResolution": "bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "isolatedModules": true,
10
+ "moduleDetection": "force",
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noFallthroughCasesInSwitch": true
16
+ },
17
+ "include": ["vite.config.ts", "openapi-ts.config.ts"]
18
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import { resolve } from 'path'
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ '@': resolve(__dirname, 'src'),
10
+ },
11
+ },
12
+ server: {
13
+ port: 5173,
14
+ proxy: {
15
+ '/api': {
16
+ target: 'http://localhost:8000',
17
+ changeOrigin: true,
18
+ },
19
+ },
20
+ },
21
+ })
File without changes
@@ -0,0 +1,10 @@
1
+ from django.contrib import admin
2
+ from .models import {{Name}}
3
+
4
+
5
+ @admin.register({{Name}})
6
+ class {{Name}}Admin(admin.ModelAdmin):
7
+ list_display = ('title', 'created_by', 'created_at')
8
+ list_filter = ('created_at',)
9
+ search_fields = ('title', 'description')
10
+ readonly_fields = ('created_at', 'updated_at')
@@ -0,0 +1,24 @@
1
+ from django.db import models
2
+ from django.conf import settings
3
+
4
+
5
+ class {{Name}}(models.Model):
6
+ """{{Name}} model."""
7
+
8
+ title = models.CharField(max_length=255)
9
+ description = models.TextField(blank=True)
10
+ created_by = models.ForeignKey(
11
+ settings.AUTH_USER_MODEL,
12
+ on_delete=models.CASCADE,
13
+ related_name='{{snakes}}',
14
+ )
15
+ created_at = models.DateTimeField(auto_now_add=True)
16
+ updated_at = models.DateTimeField(auto_now=True)
17
+
18
+ class Meta:
19
+ ordering = ['-created_at']
20
+ verbose_name = '{{name}}'
21
+ verbose_name_plural = '{{names}}'
22
+
23
+ def __str__(self):
24
+ return self.title
@@ -0,0 +1,21 @@
1
+ from rest_framework import serializers
2
+ from .models import {{Name}}
3
+
4
+
5
+ class {{Name}}Serializer(serializers.ModelSerializer):
6
+ """Serializer for {{Name}}."""
7
+
8
+ created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
9
+
10
+ class Meta:
11
+ model = {{Name}}
12
+ fields = [
13
+ 'id',
14
+ 'title',
15
+ 'description',
16
+ 'created_by',
17
+ 'created_by_email',
18
+ 'created_at',
19
+ 'updated_at',
20
+ ]
21
+ read_only_fields = ['id', 'created_by', 'created_by_email', 'created_at', 'updated_at']
@@ -0,0 +1,35 @@
1
+ from django.test import TestCase
2
+ from django.contrib.auth import get_user_model
3
+ from rest_framework.test import APIClient
4
+ from rest_framework import status
5
+ from .models import {{Name}}
6
+
7
+ User = get_user_model()
8
+
9
+
10
+ class {{Name}}Tests(TestCase):
11
+ def setUp(self):
12
+ self.client = APIClient()
13
+ self.user = User.objects.create_user(
14
+ email='test@example.com',
15
+ password='TestPass123!',
16
+ first_name='Test',
17
+ )
18
+ self.client.force_authenticate(user=self.user)
19
+
20
+ def test_create_{{snake}}(self):
21
+ response = self.client.post('/api/{{snakes}}/', {
22
+ 'title': 'Test {{Name}}',
23
+ 'description': 'A test {{name}}.',
24
+ })
25
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
26
+ self.assertEqual(response.data['title'], 'Test {{Name}}')
27
+
28
+ def test_list_{{snakes}}(self):
29
+ {{Name}}.objects.create(
30
+ title='Test {{Name}}',
31
+ created_by=self.user,
32
+ )
33
+ response = self.client.get('/api/{{snakes}}/')
34
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
35
+ self.assertEqual(len(response.data['results']), 1)
@@ -0,0 +1,10 @@
1
+ from django.urls import path, include
2
+ from rest_framework.routers import DefaultRouter
3
+ from .views import {{Name}}ViewSet
4
+
5
+ router = DefaultRouter()
6
+ router.register('', {{Name}}ViewSet, basename='{{snake}}')
7
+
8
+ urlpatterns = [
9
+ path('', include(router.urls)),
10
+ ]
@@ -0,0 +1,32 @@
1
+ from rest_framework import viewsets
2
+ from rest_framework.filters import SearchFilter, OrderingFilter
3
+ from rest_framework.permissions import IsAuthenticated
4
+ from drf_spectacular.utils import extend_schema_view, extend_schema
5
+
6
+ from .models import {{Name}}
7
+ from .serializers import {{Name}}Serializer
8
+
9
+
10
+ @extend_schema_view(
11
+ list=extend_schema(description='List all {{names}}.', tags=['{{names}}']),
12
+ retrieve=extend_schema(description='Get a single {{name}}.', tags=['{{names}}']),
13
+ create=extend_schema(description='Create a new {{name}}.', tags=['{{names}}']),
14
+ update=extend_schema(description='Update a {{name}}.', tags=['{{names}}']),
15
+ partial_update=extend_schema(description='Partially update a {{name}}.', tags=['{{names}}']),
16
+ destroy=extend_schema(description='Delete a {{name}}.', tags=['{{names}}']),
17
+ )
18
+ class {{Name}}ViewSet(viewsets.ModelViewSet):
19
+ """CRUD endpoints for {{Name}}."""
20
+
21
+ serializer_class = {{Name}}Serializer
22
+ permission_classes = [IsAuthenticated]
23
+ filter_backends = [SearchFilter, OrderingFilter]
24
+ search_fields = ['title', 'description']
25
+ ordering_fields = ['created_at', 'updated_at', 'title']
26
+ ordering = ['-created_at']
27
+
28
+ def get_queryset(self):
29
+ return {{Name}}.objects.filter(created_by=self.request.user)
30
+
31
+ def perform_create(self, serializer):
32
+ serializer.save(created_by=self.request.user)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * {{Name}} Card Component
3
+ *
4
+ * Displays a single {{name}} in a card format.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { Link } from 'react-router-dom'
9
+ import { Path } from '@/router/paths'
10
+
11
+ interface {{Name}}CardProps {
12
+ {{name}}: {
13
+ id: number
14
+ title: string
15
+ description?: string
16
+ created_at: string
17
+ }
18
+ }
19
+
20
+ export function {{Name}}Card({ {{name}} }: {{Name}}CardProps) {
21
+ return (
22
+ <Link
23
+ to={`${Path.{{Names}}}/${ {{name}}.id}`}
24
+ className="block p-6 bg-white rounded-lg border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all"
25
+ >
26
+ <h3 className="text-lg font-semibold text-gray-900">
27
+ { {{name}}.title}
28
+ </h3>
29
+ { {{name}}.description && (
30
+ <p className="mt-2 text-sm text-gray-600 line-clamp-2">
31
+ { {{name}}.description}
32
+ </p>
33
+ )}
34
+ <p className="mt-3 text-xs text-gray-400">
35
+ {new Date({{name}}.created_at).toLocaleDateString()}
36
+ </p>
37
+ </Link>
38
+ )
39
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * {{Name}} Form Component
3
+ *
4
+ * Form for creating and editing {{names}}.
5
+ * Supports both client-side (Zod) and server-side (DRF) field errors.
6
+ * Generated by Blacksmith. You own this file — customize as needed.
7
+ */
8
+
9
+ import { useForm } from 'react-hook-form'
10
+ import { z } from 'zod'
11
+ import { zodResolver } from '@hookform/resolvers/zod'
12
+ import { Alert, AlertDescription } from '@blacksmith-ui/react'
13
+
14
+ const {{name}}Schema = z.object({
15
+ title: z.string().min(1, 'Title is required').max(255),
16
+ description: z.string().optional(),
17
+ })
18
+
19
+ type {{Name}}FormData = z.infer<typeof {{name}}Schema>
20
+
21
+ interface {{Name}}FormProps {
22
+ defaultValues?: Partial<{{Name}}FormData>
23
+ onSubmit: (data: {{Name}}FormData) => void | Promise<void>
24
+ isSubmitting?: boolean
25
+ /** General error message (e.g. from useApiMutation's errorMessage) */
26
+ errorMessage?: string | null
27
+ /** Server-side field errors (e.g. from useApiMutation's fieldErrors) */
28
+ fieldErrors?: Record<string, string[]>
29
+ }
30
+
31
+ export function {{Name}}Form({
32
+ defaultValues,
33
+ onSubmit,
34
+ isSubmitting,
35
+ errorMessage,
36
+ fieldErrors = {},
37
+ }: {{Name}}FormProps) {
38
+ const form = useForm<{{Name}}FormData>({
39
+ resolver: zodResolver({{name}}Schema),
40
+ defaultValues: {
41
+ title: '',
42
+ description: '',
43
+ ...defaultValues,
44
+ },
45
+ })
46
+
47
+ const getFieldError = (field: string): string | undefined => {
48
+ // Client-side errors take priority
49
+ const clientError = form.formState.errors[field as keyof {{Name}}FormData]?.message
50
+ if (clientError) return clientError
51
+ // Fall back to server-side errors
52
+ return fieldErrors[field]?.[0]
53
+ }
54
+
55
+ return (
56
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
57
+ {errorMessage && (
58
+ <Alert variant="destructive">
59
+ <AlertDescription>{errorMessage}</AlertDescription>
60
+ </Alert>
61
+ )}
62
+
63
+ <div>
64
+ <label htmlFor="title" className="block text-sm font-medium">
65
+ Title
66
+ </label>
67
+ <input
68
+ {...form.register('title')}
69
+ id="title"
70
+ type="text"
71
+ className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
72
+ />
73
+ {getFieldError('title') && (
74
+ <p className="mt-1 text-sm text-destructive">
75
+ {getFieldError('title')}
76
+ </p>
77
+ )}
78
+ </div>
79
+
80
+ <div>
81
+ <label htmlFor="description" className="block text-sm font-medium">
82
+ Description
83
+ </label>
84
+ <textarea
85
+ {...form.register('description')}
86
+ id="description"
87
+ rows={4}
88
+ className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
89
+ />
90
+ {getFieldError('description') && (
91
+ <p className="mt-1 text-sm text-destructive">
92
+ {getFieldError('description')}
93
+ </p>
94
+ )}
95
+ </div>
96
+
97
+ <button
98
+ type="submit"
99
+ disabled={isSubmitting}
100
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
101
+ >
102
+ {isSubmitting ? 'Saving...' : 'Save'}
103
+ </button>
104
+ </form>
105
+ )
106
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * {{Name}} List Component
3
+ *
4
+ * Renders a list of {{names}} with loading and empty states.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { {{Name}}Card } from './{{kebab}}-card'
9
+
10
+ interface {{Name}}ListProps {
11
+ {{names}}: Array<{
12
+ id: number
13
+ title: string
14
+ description?: string
15
+ created_at: string
16
+ }>
17
+ isLoading?: boolean
18
+ }
19
+
20
+ export function {{Name}}List({ {{names}}, isLoading }: {{Name}}ListProps) {
21
+ if (isLoading) {
22
+ return (
23
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
24
+ {Array.from({ length: 6 }).map((_, i) => (
25
+ <div
26
+ key={i}
27
+ className="h-32 bg-gray-100 rounded-lg animate-pulse"
28
+ />
29
+ ))}
30
+ </div>
31
+ )
32
+ }
33
+
34
+ if ({{names}}.length === 0) {
35
+ return (
36
+ <div className="text-center py-12">
37
+ <p className="text-gray-500">No {{names}} yet. Create your first one!</p>
38
+ </div>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
44
+ { {{names}}.map(({{name}}) => (
45
+ <{{Name}}Card key={ {{name}}.id} {{name}}={ {{name}}} />
46
+ ))}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * use{{Names}} Hook
3
+ *
4
+ * Wraps the generated list query with smart retry and error parsing.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { useApiQuery } from '@/shared/hooks/use-api-query'
9
+ import {
10
+ {{snakes}}ListOptions,
11
+ } from '@/api/generated/@tanstack/react-query.gen'
12
+
13
+ interface Use{{Names}}Params {
14
+ page?: number
15
+ search?: string
16
+ ordering?: string
17
+ }
18
+
19
+ export function use{{Names}}(params: Use{{Names}}Params = {}) {
20
+ return useApiQuery({
21
+ ...{{snakes}}ListOptions({
22
+ query: {
23
+ page: params.page ?? 1,
24
+ search: params.search,
25
+ ordering: params.ordering ?? '-created_at',
26
+ },
27
+ }),
28
+ select: (data: any) => ({
29
+ {{names}}: data.results ?? [],
30
+ total: data.count ?? 0,
31
+ hasNext: !!data.next,
32
+ hasPrev: !!data.previous,
33
+ }),
34
+ })
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * {{Name}} Mutation Hooks
3
+ *
4
+ * Create, update, and delete with cache invalidation and error parsing.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { useApiMutation } from '@/shared/hooks/use-api-mutation'
9
+ import {
10
+ {{snakes}}CreateMutation,
11
+ {{snakes}}UpdateMutation,
12
+ {{snakes}}DestroyMutation,
13
+ {{snakes}}ListQueryKey,
14
+ {{snakes}}RetrieveQueryKey,
15
+ } from '@/api/generated/@tanstack/react-query.gen'
16
+
17
+ export function useCreate{{Name}}() {
18
+ return useApiMutation({
19
+ ...{{snakes}}CreateMutation(),
20
+ invalidateKeys: [{{snakes}}ListQueryKey()],
21
+ })
22
+ }
23
+
24
+ export function useUpdate{{Name}}(id: number) {
25
+ return useApiMutation({
26
+ ...{{snakes}}UpdateMutation(),
27
+ invalidateKeys: [
28
+ {{snakes}}ListQueryKey(),
29
+ {{snakes}}RetrieveQueryKey({ path: { id } }),
30
+ ],
31
+ })
32
+ }
33
+
34
+ export function useDelete{{Name}}() {
35
+ return useApiMutation({
36
+ ...{{snakes}}DestroyMutation(),
37
+ invalidateKeys: [{{snakes}}ListQueryKey()],
38
+ })
39
+ }
@@ -0,0 +1,6 @@
1
+ export { {{names}}Routes } from './routes'
2
+ export { use{{Names}} } from './hooks/use-{{kebabs}}-query'
3
+ export { useCreate{{Name}}, useUpdate{{Name}}, useDelete{{Name}} } from './hooks/use-{{kebab}}-mutations'
4
+ export { {{Name}}Card } from './components/{{kebab}}-card'
5
+ export { {{Name}}List } from './components/{{kebab}}-list'
6
+ export { {{Name}}Form } from './components/{{kebab}}-form'
@@ -0,0 +1,33 @@
1
+ /**
2
+ * {{Names}} List Page
3
+ *
4
+ * Lists all {{names}} with loading and error states.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { use{{Names}} } from '../hooks/use-{{kebabs}}-query'
9
+ import { {{Name}}List } from '../components/{{kebab}}-list'
10
+ import { Alert, AlertDescription } from '@blacksmith-ui/react'
11
+
12
+ export default function {{Names}}Page() {
13
+ const { data, isLoading, errorMessage } = use{{Names}}()
14
+
15
+ return (
16
+ <div>
17
+ <div className="flex items-center justify-between mb-6">
18
+ <h1 className="text-2xl font-bold">{{Names}}</h1>
19
+ </div>
20
+
21
+ {errorMessage && (
22
+ <Alert variant="destructive" className="mb-4">
23
+ <AlertDescription>{errorMessage}</AlertDescription>
24
+ </Alert>
25
+ )}
26
+
27
+ <{{Name}}List
28
+ {{names}}={data?.{{names}} ?? []}
29
+ isLoading={isLoading}
30
+ />
31
+ </div>
32
+ )
33
+ }