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,12 @@
1
+ /**
2
+ * Router Setup
3
+ *
4
+ * Creates the browser router from the route configuration.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ import { createBrowserRouter } from 'react-router-dom'
9
+ import { routes } from './routes'
10
+
11
+ export { Path, buildPath } from './paths'
12
+ export const router = createBrowserRouter(routes)
@@ -0,0 +1,68 @@
1
+ import { Outlet, Link } from 'react-router-dom'
2
+ import { Separator, Button } from '@blacksmith-ui/react'
3
+ import { Anvil } from 'lucide-react'
4
+ import { Path } from '@/router/paths'
5
+
6
+ export function AuthLayoutWrapper() {
7
+ return (
8
+ <div className="grid min-h-screen grid-cols-1 lg:grid-cols-2">
9
+ {/* Left Column — Branding Panel */}
10
+ <div className="hidden lg:flex flex-col justify-between bg-primary text-primary-foreground p-10">
11
+ <div className="flex items-center gap-2">
12
+ <Anvil className="h-6 w-6" />
13
+ <span className="text-lg font-semibold">{{projectName}}</span>
14
+ </div>
15
+
16
+ <div className="flex flex-col items-center gap-4">
17
+ <blockquote className="text-2xl font-medium">
18
+ &ldquo;Build faster. Ship with confidence.&rdquo;
19
+ </blockquote>
20
+ <p className="text-primary-foreground/70">
21
+ A fullstack platform powered by Django, React, and Blacksmith.
22
+ </p>
23
+ </div>
24
+
25
+ <p className="text-sm text-primary-foreground/50">
26
+ &copy; {new Date().getFullYear()} {{projectName}}. All rights reserved.
27
+ </p>
28
+ </div>
29
+
30
+ {/* Right Column — Auth Form */}
31
+ <div className="flex flex-col">
32
+ <div className="flex items-center justify-between p-6 lg:p-8">
33
+ <div className="flex items-center gap-2 lg:hidden">
34
+ <Anvil className="h-5 w-5" />
35
+ <span className="font-semibold">{{projectName}}</span>
36
+ </div>
37
+ <div className="lg:ml-auto">
38
+ <Button variant="ghost" size="sm" asChild>
39
+ <Link to={Path.Home}>Back to Home</Link>
40
+ </Button>
41
+ </div>
42
+ </div>
43
+
44
+ <div className="flex flex-1 items-center justify-center px-6 pb-10">
45
+ <div className="w-full max-w-[420px]">
46
+ <Outlet />
47
+ </div>
48
+ </div>
49
+
50
+ <Separator />
51
+
52
+ <div className="flex items-center justify-center gap-4 p-6">
53
+ <Button variant="link" size="sm" className="text-muted-foreground" asChild>
54
+ <Link to="/terms">Terms</Link>
55
+ </Button>
56
+ <Separator orientation="vertical" className="h-4" />
57
+ <Button variant="link" size="sm" className="text-muted-foreground" asChild>
58
+ <Link to="/privacy">Privacy</Link>
59
+ </Button>
60
+ <Separator orientation="vertical" className="h-4" />
61
+ <Button variant="link" size="sm" className="text-muted-foreground" asChild>
62
+ <Link to="/support">Support</Link>
63
+ </Button>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,137 @@
1
+ import { Outlet, Link, useNavigate } from 'react-router-dom'
2
+ import { Path } from '@/router/paths'
3
+ import {
4
+ Button,
5
+ Separator,
6
+ Tooltip,
7
+ DropdownMenuPrimitives,
8
+ DropdownMenuTrigger,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuLabel,
13
+ Avatar,
14
+ AvatarFallback,
15
+ } from '@blacksmith-ui/react'
16
+ import { LogOut, User, Settings, Sun, Moon, Anvil } from 'lucide-react'
17
+ import { useAuth } from '@/features/auth/hooks/use-auth'
18
+ import { useDarkMode } from '@blacksmith-ui/react'
19
+
20
+ function getInitials(user: any): string {
21
+ if (user?.displayName) {
22
+ const parts = user.displayName.split(' ')
23
+ const first = parts[0]?.[0] || ''
24
+ const last = parts[1]?.[0] || ''
25
+ return (first + last).toUpperCase()
26
+ }
27
+ return user?.email?.[0]?.toUpperCase() || 'U'
28
+ }
29
+
30
+ export function MainLayout() {
31
+ const { user, isAuthenticated, logout } = useAuth()
32
+ const { isDark, toggle } = useDarkMode()
33
+ const navigate = useNavigate()
34
+
35
+ const handleLogout = () => {
36
+ logout()
37
+ navigate(Path.Login)
38
+ }
39
+
40
+ return (
41
+ <div className="flex min-h-screen flex-col bg-background">
42
+ <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
43
+ <div className="max-w-7xl mx-auto flex h-14 items-center px-4 sm:px-6 lg:px-8">
44
+ <Link to={Path.Home} className="flex items-center gap-2 mr-6">
45
+ <Anvil className="h-5 w-5" />
46
+ <span className="text-lg font-semibold tracking-tight">{{projectName}}</span>
47
+ </Link>
48
+
49
+ <div className="flex-1" />
50
+
51
+ <div className="flex items-center gap-2">
52
+ <Tooltip content={isDark ? 'Light mode' : 'Dark mode'}>
53
+ <Button variant="ghost" size="icon" onClick={toggle}>
54
+ {isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
55
+ </Button>
56
+ </Tooltip>
57
+
58
+ {isAuthenticated ? (
59
+ <DropdownMenuPrimitives.Root>
60
+ <DropdownMenuTrigger asChild>
61
+ <Button variant="ghost" className="relative h-8 w-8 rounded-full">
62
+ <Avatar className="h-8 w-8">
63
+ <AvatarFallback className="text-xs">
64
+ {getInitials(user)}
65
+ </AvatarFallback>
66
+ </Avatar>
67
+ </Button>
68
+ </DropdownMenuTrigger>
69
+ <DropdownMenuContent align="end" className="w-56">
70
+ <DropdownMenuLabel className="font-normal">
71
+ <div className="flex flex-col space-y-1">
72
+ <p className="text-sm font-medium leading-none">
73
+ {user?.displayName || 'User'}
74
+ </p>
75
+ <p className="text-xs leading-none text-muted-foreground">
76
+ {user?.email}
77
+ </p>
78
+ </div>
79
+ </DropdownMenuLabel>
80
+ <DropdownMenuSeparator />
81
+ <DropdownMenuItem>
82
+ <User className="mr-2 h-4 w-4" />
83
+ Profile
84
+ </DropdownMenuItem>
85
+ <DropdownMenuItem>
86
+ <Settings className="mr-2 h-4 w-4" />
87
+ Settings
88
+ </DropdownMenuItem>
89
+ <DropdownMenuSeparator />
90
+ <DropdownMenuItem onClick={handleLogout}>
91
+ <LogOut className="mr-2 h-4 w-4" />
92
+ Sign Out
93
+ </DropdownMenuItem>
94
+ </DropdownMenuContent>
95
+ </DropdownMenuPrimitives.Root>
96
+ ) : (
97
+ <>
98
+ <Button variant="ghost" size="sm" asChild>
99
+ <Link to={Path.Login}>Sign In</Link>
100
+ </Button>
101
+ <Button size="sm" asChild>
102
+ <Link to={Path.Register}>Sign Up</Link>
103
+ </Button>
104
+ </>
105
+ )}
106
+ </div>
107
+ </div>
108
+ </header>
109
+
110
+ <main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
111
+ <Outlet />
112
+ </main>
113
+
114
+ <footer className="border-t">
115
+ <div className="max-w-7xl mx-auto flex flex-col items-center gap-4 px-4 py-6 sm:flex-row sm:justify-between sm:px-6 lg:px-8">
116
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
117
+ <Anvil className="h-4 w-4" />
118
+ <span>&copy; {new Date().getFullYear()} {{projectName}}. All rights reserved.</span>
119
+ </div>
120
+ <div className="flex items-center gap-4">
121
+ <Button variant="link" size="sm" className="text-muted-foreground h-auto p-0" asChild>
122
+ <Link to="/terms">Terms</Link>
123
+ </Button>
124
+ <Separator orientation="vertical" className="h-4" />
125
+ <Button variant="link" size="sm" className="text-muted-foreground h-auto p-0" asChild>
126
+ <Link to="/privacy">Privacy</Link>
127
+ </Button>
128
+ <Separator orientation="vertical" className="h-4" />
129
+ <Button variant="link" size="sm" className="text-muted-foreground h-auto p-0" asChild>
130
+ <Link to="/support">Support</Link>
131
+ </Button>
132
+ </div>
133
+ </div>
134
+ </footer>
135
+ </div>
136
+ )
137
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Application Route Paths
3
+ *
4
+ * Central registry of all route paths used in the application.
5
+ * Use these enums instead of hardcoded strings for type-safe navigation.
6
+ *
7
+ * Generated by Blacksmith. You own this file — customize as needed.
8
+ */
9
+
10
+ export enum Path {
11
+ Home = '/',
12
+ Login = '/login',
13
+ Register = '/register',
14
+ ForgotPassword = '/forgot-password',
15
+ ResetPassword = '/reset-password/:token',
16
+ Dashboard = '/dashboard',
17
+ // blacksmith:path
18
+ }
19
+
20
+ /**
21
+ * Build a path with dynamic parameters replaced.
22
+ *
23
+ * @example
24
+ * buildPath(Path.ResetPassword, { token: 'abc123' })
25
+ * // => '/reset-password/abc123'
26
+ */
27
+ export function buildPath(
28
+ path: Path,
29
+ params?: Record<string, string>,
30
+ ): string {
31
+ let result: string = path
32
+ if (params) {
33
+ for (const [key, value] of Object.entries(params)) {
34
+ result = result.replace(`:${key}`, encodeURIComponent(value))
35
+ }
36
+ }
37
+ return result
38
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Application Routes
3
+ *
4
+ * Assembles route objects exported from each page/feature.
5
+ * Each page owns its own routes — this file just composes them.
6
+ *
7
+ * Generated by Blacksmith. You own this file — customize as needed.
8
+ */
9
+
10
+ import { Outlet, type RouteObject } from 'react-router-dom'
11
+ import { AuthGuard } from '@/router/auth-guard'
12
+ import { MainLayout } from '@/router/layouts/main-layout'
13
+ import { AuthLayoutWrapper } from '@/router/layouts/auth-layout'
14
+ import { RouteErrorBoundary } from '@/router/error-boundary'
15
+ import NotFoundPage from '@/shared/components/not-found-page'
16
+ import { homeRoutes } from '@/pages/home'
17
+ import { dashboardRoutes } from '@/pages/dashboard'
18
+ import { authRoutes } from '@/features/auth'
19
+ // blacksmith:import
20
+
21
+ /**
22
+ * Public routes — accessible without authentication.
23
+ */
24
+ const publicRoutes: RouteObject[] = [
25
+ ...homeRoutes,
26
+ ]
27
+
28
+ /**
29
+ * Private routes — wrapped in AuthGuard, requires authentication.
30
+ * Add your authenticated routes here.
31
+ */
32
+ const privateRoutes: RouteObject[] = [
33
+ ...dashboardRoutes,
34
+ // blacksmith:routes
35
+ ]
36
+
37
+ export const routes: RouteObject[] = [
38
+ // Auth pages (login, register, etc.)
39
+ {
40
+ element: <AuthLayoutWrapper />,
41
+ errorElement: <RouteErrorBoundary />,
42
+ children: authRoutes,
43
+ },
44
+
45
+ // App pages
46
+ {
47
+ element: <MainLayout />,
48
+ errorElement: <RouteErrorBoundary />,
49
+ children: [
50
+ ...publicRoutes,
51
+ {
52
+ element: (
53
+ <AuthGuard>
54
+ <Outlet />
55
+ </AuthGuard>
56
+ ),
57
+ children: privateRoutes,
58
+ },
59
+ ],
60
+ },
61
+
62
+ // Catch-all 404
63
+ { path: '*', element: <NotFoundPage /> },
64
+ ]
@@ -0,0 +1,20 @@
1
+ import { Spinner } from '@blacksmith-ui/react'
2
+
3
+ interface LoadingSpinnerProps {
4
+ size?: 'sm' | 'md' | 'lg'
5
+ className?: string
6
+ }
7
+
8
+ const sizeMap = {
9
+ sm: 'h-4 w-4',
10
+ md: 'h-8 w-8',
11
+ lg: 'h-12 w-12',
12
+ }
13
+
14
+ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
15
+ return (
16
+ <div className={`flex items-center justify-center ${className}`}>
17
+ <Spinner className={sizeMap[size]} />
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,31 @@
1
+ import { useNavigate } from 'react-router-dom'
2
+ import { Button } from '@blacksmith-ui/react'
3
+ import { ArrowLeft, FileQuestion } from 'lucide-react'
4
+ import { Path } from '@/router/paths'
5
+
6
+ export default function NotFoundPage() {
7
+ const navigate = useNavigate()
8
+
9
+ return (
10
+ <div className="flex items-center justify-center min-h-screen bg-background">
11
+ <div className="text-center space-y-4">
12
+ <div className="flex justify-center">
13
+ <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
14
+ <FileQuestion className="h-10 w-10 text-muted-foreground" />
15
+ </div>
16
+ </div>
17
+ <h1 className="text-5xl font-bold tracking-tight">404</h1>
18
+ <p className="text-xl text-muted-foreground">Page not found</p>
19
+ <p className="text-sm text-muted-foreground max-w-sm mx-auto">
20
+ The page you're looking for doesn't exist or has been moved.
21
+ </p>
22
+ <div className="pt-2">
23
+ <Button onClick={() => navigate(Path.Home)}>
24
+ <ArrowLeft className="mr-2 h-4 w-4" />
25
+ Back to Home
26
+ </Button>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * API Error Parsing
3
+ *
4
+ * Parses Django REST Framework error responses into user-friendly messages.
5
+ * Generated by Blacksmith. You own this file — customize as needed.
6
+ */
7
+
8
+ export interface ApiError {
9
+ status: number
10
+ message: string
11
+ fieldErrors: Record<string, string[]>
12
+ }
13
+
14
+ const HTTP_ERROR_MESSAGES: Record<number, string> = {
15
+ 400: 'The request was invalid. Please check your input.',
16
+ 401: 'You need to sign in to continue.',
17
+ 403: 'You don\'t have permission to perform this action.',
18
+ 404: 'The requested resource was not found.',
19
+ 405: 'This action is not allowed.',
20
+ 408: 'The request timed out. Please try again.',
21
+ 409: 'This conflicts with an existing resource.',
22
+ 429: 'Too many requests. Please slow down and try again.',
23
+ 500: 'Something went wrong on our end. Please try again later.',
24
+ 502: 'The server is temporarily unavailable. Please try again.',
25
+ 503: 'The service is currently unavailable. Please try again later.',
26
+ }
27
+
28
+ /**
29
+ * Parse an error thrown by the API client into a structured ApiError.
30
+ *
31
+ * Handles DRF response shapes:
32
+ * - { "detail": "Error message" }
33
+ * - { "non_field_errors": ["Error 1", "Error 2"] }
34
+ * - { "field_name": ["Error 1"], "other_field": ["Error 2"] }
35
+ * - { "field_name": [{ "message": "...", "code": "..." }] }
36
+ * - Plain string responses
37
+ */
38
+ export function parseApiError(error: unknown): ApiError {
39
+ const status = getStatusCode(error)
40
+ const body = getErrorBody(error)
41
+
42
+ if (!body) {
43
+ return {
44
+ status,
45
+ message: HTTP_ERROR_MESSAGES[status] || 'An unexpected error occurred.',
46
+ fieldErrors: {},
47
+ }
48
+ }
49
+
50
+ // String body
51
+ if (typeof body === 'string') {
52
+ return { status, message: body, fieldErrors: {} }
53
+ }
54
+
55
+ // Object body — parse DRF error shape
56
+ if (typeof body === 'object' && body !== null) {
57
+ const fieldErrors: Record<string, string[]> = {}
58
+ const generalMessages: string[] = []
59
+
60
+ for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
61
+ const messages = normalizeMessages(value)
62
+
63
+ if (key === 'detail') {
64
+ generalMessages.push(...messages)
65
+ } else if (key === 'non_field_errors') {
66
+ generalMessages.push(...messages)
67
+ } else {
68
+ fieldErrors[key] = messages
69
+ }
70
+ }
71
+
72
+ // If no general message was found, summarize field errors
73
+ const message =
74
+ generalMessages.length > 0
75
+ ? generalMessages.join(' ')
76
+ : Object.keys(fieldErrors).length > 0
77
+ ? 'Please fix the errors below.'
78
+ : HTTP_ERROR_MESSAGES[status] || 'An unexpected error occurred.'
79
+
80
+ return { status, message, fieldErrors }
81
+ }
82
+
83
+ return {
84
+ status,
85
+ message: HTTP_ERROR_MESSAGES[status] || 'An unexpected error occurred.',
86
+ fieldErrors: {},
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get a single human-readable error message from any error.
92
+ */
93
+ export function getErrorMessage(error: unknown): string {
94
+ return parseApiError(error).message
95
+ }
96
+
97
+ function getStatusCode(error: unknown): number {
98
+ if (error && typeof error === 'object') {
99
+ // @hey-api/client-fetch error shape
100
+ if ('status' in error && typeof (error as any).status === 'number') {
101
+ return (error as any).status
102
+ }
103
+ // Nested response
104
+ if ('response' in error && (error as any).response?.status) {
105
+ return (error as any).response.status
106
+ }
107
+ }
108
+ return 0
109
+ }
110
+
111
+ function getErrorBody(error: unknown): unknown {
112
+ if (error && typeof error === 'object') {
113
+ // @hey-api/client-fetch puts parsed body in `error` or `body`
114
+ if ('error' in error && (error as any).error != null) {
115
+ return (error as any).error
116
+ }
117
+ if ('body' in error && (error as any).body != null) {
118
+ return (error as any).body
119
+ }
120
+ // Some shapes put it in `data`
121
+ if ('data' in error && (error as any).data != null) {
122
+ return (error as any).data
123
+ }
124
+ }
125
+ // Native Error
126
+ if (error instanceof Error) {
127
+ return error.message
128
+ }
129
+ return null
130
+ }
131
+
132
+ function normalizeMessages(value: unknown): string[] {
133
+ if (typeof value === 'string') return [value]
134
+ if (Array.isArray(value)) {
135
+ return value.flatMap((item) => {
136
+ if (typeof item === 'string') return [item]
137
+ if (item && typeof item === 'object' && 'message' in item) {
138
+ return [String(item.message)]
139
+ }
140
+ return [String(item)]
141
+ })
142
+ }
143
+ if (value && typeof value === 'object' && 'message' in value) {
144
+ return [String((value as any).message)]
145
+ }
146
+ return [String(value)]
147
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * useApiMutation — Enhanced useMutation for API calls
3
+ *
4
+ * Wraps TanStack useMutation with:
5
+ * - DRF error parsing with field-level and general error messages
6
+ * - Optional query invalidation after success
7
+ *
8
+ * Error state is derived from the mutation's own `error` — no local state.
9
+ *
10
+ * Usage:
11
+ * const createPost = useApiMutation({
12
+ * ...blogPostsCreateMutation(),
13
+ * invalidateKeys: [blogPostsListQueryKey()],
14
+ * })
15
+ *
16
+ * // In a form submit handler:
17
+ * createPost.mutate({ body: { title: 'Hello' } })
18
+ *
19
+ * // Display the general error message:
20
+ * {createPost.errorMessage && <Alert variant="destructive">{createPost.errorMessage}</Alert>}
21
+ *
22
+ * // Access field errors for inline form display:
23
+ * createPost.fieldErrors.title // ["This field is required."]
24
+ *
25
+ * Generated by Blacksmith. You own this file — customize as needed.
26
+ */
27
+
28
+ import {
29
+ useMutation,
30
+ useQueryClient,
31
+ type QueryKey,
32
+ type UseMutationOptions,
33
+ type UseMutationResult,
34
+ } from '@tanstack/react-query'
35
+ import { parseApiError, type ApiError } from './api-error'
36
+ import { useMemo } from 'react'
37
+
38
+ export interface UseApiMutationOptions<TData, TVariables, TOnMutateResult>
39
+ extends UseMutationOptions<TData, Error, TVariables, TOnMutateResult> {
40
+ /** Query keys to invalidate after a successful mutation */
41
+ invalidateKeys?: QueryKey[]
42
+ }
43
+
44
+ export type UseApiMutationResult<TData, TVariables, TOnMutateResult> =
45
+ UseMutationResult<TData, Error, TVariables, TOnMutateResult> & {
46
+ /** Parsed field-level errors from the last failed mutation (e.g. { email: ["Already exists."] }) */
47
+ fieldErrors: Record<string, string[]>
48
+ /** Parsed API error from the last failed mutation */
49
+ apiError: ApiError | null
50
+ /** Single error message string from the last failed mutation */
51
+ errorMessage: string | null
52
+ }
53
+
54
+ export function useApiMutation<
55
+ TData = unknown,
56
+ TVariables = void,
57
+ TOnMutateResult = unknown,
58
+ >(
59
+ options: UseApiMutationOptions<TData, TVariables, TOnMutateResult>,
60
+ ): UseApiMutationResult<TData, TVariables, TOnMutateResult> {
61
+ const queryClient = useQueryClient()
62
+
63
+ const { invalidateKeys, ...mutationOptions } = options
64
+
65
+ const mutation = useMutation<TData, Error, TVariables, TOnMutateResult>({
66
+ ...mutationOptions,
67
+ onSuccess: (...args) => {
68
+ if (invalidateKeys?.length) {
69
+ for (const key of invalidateKeys) {
70
+ queryClient.invalidateQueries({ queryKey: key })
71
+ }
72
+ }
73
+ mutationOptions.onSuccess?.(...args)
74
+ },
75
+ })
76
+
77
+ const apiError = useMemo(
78
+ () => (mutation.error ? parseApiError(mutation.error) : null),
79
+ [mutation.error],
80
+ )
81
+
82
+ return {
83
+ ...mutation,
84
+ fieldErrors: apiError?.fieldErrors ?? {},
85
+ apiError,
86
+ errorMessage: apiError?.message ?? null,
87
+ }
88
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * useApiQuery — Enhanced useQuery for API calls
3
+ *
4
+ * Wraps TanStack useQuery with:
5
+ * - Smart retry (skips 401, 403, 404)
6
+ * - Parsed error messages from DRF responses
7
+ * - Typed error state
8
+ *
9
+ * Usage:
10
+ * const { data, errorMessage } = useApiQuery({
11
+ * ...blogPostsListOptions({ query: { page: 1 } }),
12
+ * })
13
+ *
14
+ * Generated by Blacksmith. You own this file — customize as needed.
15
+ */
16
+
17
+ import {
18
+ useQuery,
19
+ type UseQueryOptions,
20
+ type UseQueryResult,
21
+ } from '@tanstack/react-query'
22
+ import { parseApiError, type ApiError } from './api-error'
23
+
24
+ const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 405, 409, 422])
25
+
26
+ export type UseApiQueryResult<TData> = UseQueryResult<TData> & {
27
+ errorMessage: string | null
28
+ apiError: ApiError | null
29
+ }
30
+
31
+ export function useApiQuery<
32
+ TQueryFnData = unknown,
33
+ TData = TQueryFnData,
34
+ TQueryKey extends readonly unknown[] = readonly unknown[],
35
+ >(
36
+ options: UseQueryOptions<TQueryFnData, Error, TData, TQueryKey>,
37
+ ): UseApiQueryResult<TData> {
38
+ const result = useQuery<TQueryFnData, Error, TData, TQueryKey>({
39
+ retry: (failureCount, error) => {
40
+ const status = getStatusFromError(error)
41
+ if (NON_RETRYABLE_STATUSES.has(status)) return false
42
+ return failureCount < 2
43
+ },
44
+ ...options,
45
+ })
46
+
47
+ const apiError = result.error ? parseApiError(result.error) : null
48
+
49
+ return {
50
+ ...result,
51
+ errorMessage: apiError?.message ?? null,
52
+ apiError,
53
+ }
54
+ }
55
+
56
+ function getStatusFromError(error: unknown): number {
57
+ if (error && typeof error === 'object') {
58
+ if ('status' in error && typeof (error as any).status === 'number') {
59
+ return (error as any).status
60
+ }
61
+ if ('response' in error && (error as any).response?.status) {
62
+ return (error as any).response.status
63
+ }
64
+ }
65
+ return 0
66
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * useDebounce Hook
3
+ *
4
+ * Re-exports useDebounce from @blacksmith-ui/hooks.
5
+ * Import from here so your app has a single import path.
6
+ *
7
+ * Generated by Blacksmith. You own this file — customize as needed.
8
+ */
9
+
10
+ export { useDebounce } from '@blacksmith-ui/hooks'