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.
- package/README.md +210 -0
- package/bin/blacksmith.js +20 -0
- package/dist/index.js +4404 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/templates/backend/.env.example.hbs +10 -0
- package/src/templates/backend/apps/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/admin.py.hbs +26 -0
- package/src/templates/backend/apps/users/managers.py.hbs +25 -0
- package/src/templates/backend/apps/users/models.py.hbs +25 -0
- package/src/templates/backend/apps/users/serializers.py.hbs +94 -0
- package/src/templates/backend/apps/users/tests.py.hbs +47 -0
- package/src/templates/backend/apps/users/urls.py.hbs +10 -0
- package/src/templates/backend/apps/users/views.py.hbs +175 -0
- package/src/templates/backend/config/__init__.py.hbs +0 -0
- package/src/templates/backend/config/asgi.py.hbs +9 -0
- package/src/templates/backend/config/settings/__init__.py.hbs +13 -0
- package/src/templates/backend/config/settings/base.py.hbs +117 -0
- package/src/templates/backend/config/settings/development.py.hbs +19 -0
- package/src/templates/backend/config/settings/production.py.hbs +31 -0
- package/src/templates/backend/config/urls.py.hbs +26 -0
- package/src/templates/backend/config/wsgi.py.hbs +9 -0
- package/src/templates/backend/manage.py.hbs +22 -0
- package/src/templates/backend/requirements.txt.hbs +7 -0
- package/src/templates/frontend/.env.hbs +1 -0
- package/src/templates/frontend/index.html.hbs +13 -0
- package/src/templates/frontend/openapi-ts.config.ts.hbs +29 -0
- package/src/templates/frontend/package.json.hbs +44 -0
- package/src/templates/frontend/postcss.config.js.hbs +6 -0
- package/src/templates/frontend/src/api/client.ts.hbs +110 -0
- package/src/templates/frontend/src/api/generated/.gitkeep +0 -0
- package/src/templates/frontend/src/api/generated/client.gen.ts +13 -0
- package/src/templates/frontend/src/api/query-client.ts.hbs +22 -0
- package/src/templates/frontend/src/app.tsx.hbs +30 -0
- package/src/templates/frontend/src/features/auth/adapter.ts.hbs +198 -0
- package/src/templates/frontend/src/features/auth/components/auth-provider.tsx.hbs +32 -0
- package/src/templates/frontend/src/features/auth/hooks/use-auth.ts.hbs +27 -0
- package/src/templates/frontend/src/features/auth/index.ts.hbs +3 -0
- package/src/templates/frontend/src/features/auth/pages/forgot-password-page.tsx.hbs +37 -0
- package/src/templates/frontend/src/features/auth/pages/login-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/register-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/reset-password-page.tsx.hbs +41 -0
- package/src/templates/frontend/src/features/auth/routes.tsx.hbs +13 -0
- package/src/templates/frontend/src/main.tsx.hbs +10 -0
- package/src/templates/frontend/src/pages/dashboard/components/quick-start-card.tsx.hbs +36 -0
- package/src/templates/frontend/src/pages/dashboard/components/stack-cards.tsx.hbs +69 -0
- package/src/templates/frontend/src/pages/dashboard/components/welcome-header.tsx.hbs +14 -0
- package/src/templates/frontend/src/pages/dashboard/dashboard.tsx.hbs +21 -0
- package/src/templates/frontend/src/pages/dashboard/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/dashboard/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/pages/home/components/features-grid.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/getting-started.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/hero-section.tsx.hbs +47 -0
- package/src/templates/frontend/src/pages/home/components/resources-section.tsx.hbs +34 -0
- package/src/templates/frontend/src/pages/home/home.tsx.hbs +20 -0
- package/src/templates/frontend/src/pages/home/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/home/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/router/auth-guard.tsx.hbs +57 -0
- package/src/templates/frontend/src/router/error-boundary.tsx.hbs +61 -0
- package/src/templates/frontend/src/router/index.tsx.hbs +12 -0
- package/src/templates/frontend/src/router/layouts/auth-layout.tsx.hbs +68 -0
- package/src/templates/frontend/src/router/layouts/main-layout.tsx.hbs +137 -0
- package/src/templates/frontend/src/router/paths.ts.hbs +38 -0
- package/src/templates/frontend/src/router/routes.tsx.hbs +64 -0
- package/src/templates/frontend/src/shared/components/loading-spinner.tsx.hbs +20 -0
- package/src/templates/frontend/src/shared/components/not-found-page.tsx.hbs +31 -0
- package/src/templates/frontend/src/shared/hooks/api-error.ts.hbs +147 -0
- package/src/templates/frontend/src/shared/hooks/use-api-mutation.ts.hbs +88 -0
- package/src/templates/frontend/src/shared/hooks/use-api-query.ts.hbs +66 -0
- package/src/templates/frontend/src/shared/hooks/use-debounce.ts.hbs +10 -0
- package/src/templates/frontend/src/styles/globals.css.hbs +62 -0
- package/src/templates/frontend/src/vite-env.d.ts.hbs +1 -0
- package/src/templates/frontend/tailwind.config.js.hbs +73 -0
- package/src/templates/frontend/tsconfig.app.json.hbs +25 -0
- package/src/templates/frontend/tsconfig.json.hbs +7 -0
- package/src/templates/frontend/tsconfig.node.json.hbs +18 -0
- package/src/templates/frontend/vite.config.ts.hbs +21 -0
- package/src/templates/resource/backend/__init__.py.hbs +0 -0
- package/src/templates/resource/backend/admin.py.hbs +10 -0
- package/src/templates/resource/backend/models.py.hbs +24 -0
- package/src/templates/resource/backend/serializers.py.hbs +21 -0
- package/src/templates/resource/backend/tests.py.hbs +35 -0
- package/src/templates/resource/backend/urls.py.hbs +10 -0
- package/src/templates/resource/backend/views.py.hbs +32 -0
- package/src/templates/resource/frontend/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/frontend/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/frontend/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/frontend/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/frontend/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/frontend/index.ts.hbs +6 -0
- package/src/templates/resource/frontend/pages/{{kebabs}}-page.tsx.hbs +33 -0
- package/src/templates/resource/frontend/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
- package/src/templates/resource/frontend/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/pages/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/pages/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/pages/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/pages/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/pages/index.ts.hbs +6 -0
- package/src/templates/resource/pages/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/{{kebabs}}-page.tsx.hbs +33 -0
- 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
|
+
“Build faster. Ship with confidence.”
|
|
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
|
+
© {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>© {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'
|