create-modern-react 1.0.0 → 2.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/README.md +270 -72
- package/bin/index.js +13 -13
- package/lib/install.js +103 -32
- package/lib/prompts.js +152 -179
- package/lib/setup.js +267 -159
- package/package.json +17 -8
- package/templates/base/.env.example +9 -0
- package/templates/base/.eslintrc.cjs +37 -0
- package/templates/base/.prettierrc +11 -0
- package/templates/base/components.json +17 -0
- package/templates/base/index.html +2 -1
- package/templates/base/package.json +33 -14
- package/templates/base/postcss.config.js +6 -0
- package/templates/base/src/App.tsx +5 -18
- package/templates/base/src/components/layout/error-boundary.tsx +60 -0
- package/templates/base/src/components/layout/index.ts +2 -0
- package/templates/base/src/components/layout/root-layout.tsx +36 -0
- package/templates/base/src/components/ui/button.tsx +55 -0
- package/templates/base/src/components/ui/card.tsx +85 -0
- package/templates/base/src/components/ui/index.ts +12 -0
- package/templates/base/src/components/ui/input.tsx +24 -0
- package/templates/base/src/components/ui/separator.tsx +29 -0
- package/templates/base/src/components/ui/skeleton.tsx +15 -0
- package/templates/base/src/hooks/index.ts +3 -0
- package/templates/base/src/hooks/use-cancel-token.ts +63 -0
- package/templates/base/src/hooks/use-debounce.ts +29 -0
- package/templates/base/src/hooks/use-loader.ts +39 -0
- package/templates/base/src/index.css +73 -60
- package/templates/base/src/lib/utils.ts +14 -0
- package/templates/base/src/main.tsx +6 -6
- package/templates/base/src/providers/index.tsx +27 -0
- package/templates/base/src/providers/theme-provider.tsx +92 -0
- package/templates/base/src/routes/index.tsx +40 -0
- package/templates/base/src/routes/routes.ts +36 -0
- package/templates/base/src/screens/home/index.tsx +132 -0
- package/templates/base/src/screens/not-found/index.tsx +29 -0
- package/templates/base/src/services/alertify-services.ts +133 -0
- package/templates/base/src/services/api/api-helpers.ts +130 -0
- package/templates/base/src/services/api/axios-instance.ts +77 -0
- package/templates/base/src/services/api/index.ts +9 -0
- package/templates/base/src/services/index.ts +2 -0
- package/templates/base/src/types/index.ts +55 -0
- package/templates/base/src/vite-env.d.ts +31 -0
- package/templates/base/tailwind.config.js +77 -0
- package/templates/base/tsconfig.json +4 -3
- package/templates/base/tsconfig.node.json +22 -0
- package/templates/base/vite.config.ts +65 -4
- package/templates/optional/antd/config-provider.tsx +33 -0
- package/templates/optional/antd/index.ts +2 -0
- package/templates/optional/antd/styles/antd-overrides.css +104 -0
- package/templates/optional/antd/theme.ts +75 -0
- package/templates/optional/husky/.husky/pre-commit +1 -0
- package/templates/optional/husky/.lintstagedrc.json +6 -0
- package/templates/optional/redux/hooks.ts +17 -0
- package/templates/optional/redux/index.ts +13 -0
- package/templates/optional/redux/provider.tsx +33 -0
- package/templates/optional/redux/store/index.ts +45 -0
- package/templates/optional/redux/store/slices/app-slice.ts +62 -0
- package/templates/base/src/App.css +0 -14
|
@@ -1,70 +1,83 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
font-weight: 400;
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
place-items: center;
|
|
30
|
-
min-width: 320px;
|
|
31
|
-
min-height: 100vh;
|
|
32
|
-
}
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
11
|
+
--popover: 0 0% 100%;
|
|
12
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
13
|
+
--primary: 222.2 47.4% 11.2%;
|
|
14
|
+
--primary-foreground: 210 40% 98%;
|
|
15
|
+
--secondary: 210 40% 96.1%;
|
|
16
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
17
|
+
--muted: 210 40% 96.1%;
|
|
18
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
19
|
+
--accent: 210 40% 96.1%;
|
|
20
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
21
|
+
--destructive: 0 84.2% 60.2%;
|
|
22
|
+
--destructive-foreground: 210 40% 98%;
|
|
23
|
+
--border: 214.3 31.8% 91.4%;
|
|
24
|
+
--input: 214.3 31.8% 91.4%;
|
|
25
|
+
--ring: 222.2 84% 4.9%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
.dark {
|
|
30
|
+
--background: 222.2 84% 4.9%;
|
|
31
|
+
--foreground: 210 40% 98%;
|
|
32
|
+
--card: 222.2 84% 4.9%;
|
|
33
|
+
--card-foreground: 210 40% 98%;
|
|
34
|
+
--popover: 222.2 84% 4.9%;
|
|
35
|
+
--popover-foreground: 210 40% 98%;
|
|
36
|
+
--primary: 210 40% 98%;
|
|
37
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
38
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
39
|
+
--secondary-foreground: 210 40% 98%;
|
|
40
|
+
--muted: 217.2 32.6% 17.5%;
|
|
41
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
42
|
+
--accent: 217.2 32.6% 17.5%;
|
|
43
|
+
--accent-foreground: 210 40% 98%;
|
|
44
|
+
--destructive: 0 62.8% 30.6%;
|
|
45
|
+
--destructive-foreground: 210 40% 98%;
|
|
46
|
+
--border: 217.2 32.6% 17.5%;
|
|
47
|
+
--input: 217.2 32.6% 17.5%;
|
|
48
|
+
--ring: 212.7 26.8% 83.9%;
|
|
49
|
+
}
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
transition: border-color 0.25s;
|
|
50
|
-
}
|
|
51
|
-
button:hover {
|
|
52
|
-
border-color: #646cff;
|
|
53
|
-
}
|
|
54
|
-
button:focus,
|
|
55
|
-
button:focus-visible {
|
|
56
|
-
outline: 4px auto -webkit-focus-ring-color;
|
|
52
|
+
@layer base {
|
|
53
|
+
* {
|
|
54
|
+
@apply border-border;
|
|
55
|
+
}
|
|
56
|
+
body {
|
|
57
|
+
@apply bg-background text-foreground;
|
|
58
|
+
font-feature-settings:
|
|
59
|
+
'rlig' 1,
|
|
60
|
+
'calt' 1;
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
/* Custom scrollbar */
|
|
65
|
+
@layer utilities {
|
|
66
|
+
.scrollbar-thin {
|
|
67
|
+
scrollbar-width: thin;
|
|
68
|
+
}
|
|
69
|
+
.scrollbar-thin::-webkit-scrollbar {
|
|
70
|
+
width: 6px;
|
|
71
|
+
height: 6px;
|
|
72
|
+
}
|
|
73
|
+
.scrollbar-thin::-webkit-scrollbar-track {
|
|
74
|
+
background: transparent;
|
|
63
75
|
}
|
|
64
|
-
|
|
65
|
-
color:
|
|
76
|
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
77
|
+
background-color: hsl(var(--muted-foreground) / 0.3);
|
|
78
|
+
border-radius: 3px;
|
|
66
79
|
}
|
|
67
|
-
|
|
68
|
-
background-color:
|
|
80
|
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
81
|
+
background-color: hsl(var(--muted-foreground) / 0.5);
|
|
69
82
|
}
|
|
70
83
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Combines class names using clsx and tailwind-merge
|
|
6
|
+
* This ensures Tailwind classes are properly merged without conflicts
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* cn('px-2 py-1', condition && 'bg-blue-500', 'px-4')
|
|
10
|
+
* // Returns 'py-1 bg-blue-500 px-4' (px-4 overrides px-2)
|
|
11
|
+
*/
|
|
12
|
+
export function cn(...inputs: ClassValue[]) {
|
|
13
|
+
return twMerge(clsx(inputs));
|
|
14
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import ReactDOM from
|
|
3
|
-
import App from
|
|
4
|
-
import
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import './index.css';
|
|
5
5
|
|
|
6
|
-
ReactDOM.createRoot(document.getElementById(
|
|
6
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
7
7
|
<React.StrictMode>
|
|
8
8
|
<App />
|
|
9
|
-
</React.StrictMode
|
|
9
|
+
</React.StrictMode>
|
|
10
10
|
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { ThemeProvider } from './theme-provider';
|
|
3
|
+
import { RootLayout } from '~/components/layout';
|
|
4
|
+
import { ErrorBoundary } from '~/components/layout';
|
|
5
|
+
|
|
6
|
+
interface ProvidersProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Application providers composition
|
|
12
|
+
* Wraps the app with all necessary context providers
|
|
13
|
+
*
|
|
14
|
+
* Order matters! Providers at the top are available to all children.
|
|
15
|
+
* Add new providers here when needed (e.g., Redux, Auth, etc.)
|
|
16
|
+
*/
|
|
17
|
+
export function Providers({ children }: ProvidersProps) {
|
|
18
|
+
return (
|
|
19
|
+
<ErrorBoundary>
|
|
20
|
+
<ThemeProvider defaultTheme="system" storageKey="app-theme">
|
|
21
|
+
<RootLayout>{children}</RootLayout>
|
|
22
|
+
</ThemeProvider>
|
|
23
|
+
</ErrorBoundary>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { ThemeProvider, useTheme } from './theme-provider';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
type Theme = 'dark' | 'light' | 'system';
|
|
10
|
+
|
|
11
|
+
interface ThemeContextType {
|
|
12
|
+
theme: Theme;
|
|
13
|
+
setTheme: (theme: Theme) => void;
|
|
14
|
+
resolvedTheme: 'dark' | 'light';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
interface ThemeProviderProps {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
defaultTheme?: Theme;
|
|
22
|
+
storageKey?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ThemeProvider({
|
|
26
|
+
children,
|
|
27
|
+
defaultTheme = 'system',
|
|
28
|
+
storageKey = 'app-theme',
|
|
29
|
+
}: ThemeProviderProps) {
|
|
30
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
31
|
+
if (typeof window !== 'undefined') {
|
|
32
|
+
return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
|
|
33
|
+
}
|
|
34
|
+
return defaultTheme;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light');
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const root = window.document.documentElement;
|
|
41
|
+
root.classList.remove('light', 'dark');
|
|
42
|
+
|
|
43
|
+
let effectiveTheme: 'dark' | 'light';
|
|
44
|
+
|
|
45
|
+
if (theme === 'system') {
|
|
46
|
+
effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
47
|
+
? 'dark'
|
|
48
|
+
: 'light';
|
|
49
|
+
} else {
|
|
50
|
+
effectiveTheme = theme;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
root.classList.add(effectiveTheme);
|
|
54
|
+
setResolvedTheme(effectiveTheme);
|
|
55
|
+
}, [theme]);
|
|
56
|
+
|
|
57
|
+
// Listen for system theme changes
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (theme !== 'system') return;
|
|
60
|
+
|
|
61
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
62
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
63
|
+
const root = window.document.documentElement;
|
|
64
|
+
root.classList.remove('light', 'dark');
|
|
65
|
+
const newTheme = e.matches ? 'dark' : 'light';
|
|
66
|
+
root.classList.add(newTheme);
|
|
67
|
+
setResolvedTheme(newTheme);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
71
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
72
|
+
}, [theme]);
|
|
73
|
+
|
|
74
|
+
const setTheme = (newTheme: Theme) => {
|
|
75
|
+
localStorage.setItem(storageKey, newTheme);
|
|
76
|
+
setThemeState(newTheme);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
|
81
|
+
{children}
|
|
82
|
+
</ThemeContext.Provider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useTheme() {
|
|
87
|
+
const context = useContext(ThemeContext);
|
|
88
|
+
if (context === undefined) {
|
|
89
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
90
|
+
}
|
|
91
|
+
return context;
|
|
92
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Suspense } from 'react';
|
|
2
|
+
import { Route, Switch } from 'wouter';
|
|
3
|
+
import { routes } from './routes';
|
|
4
|
+
import { Skeleton } from '~/components/ui';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loading fallback component
|
|
8
|
+
*/
|
|
9
|
+
function RouteLoading() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex min-h-screen items-center justify-center">
|
|
12
|
+
<div className="flex flex-col items-center gap-4">
|
|
13
|
+
<Skeleton className="h-12 w-12 rounded-full" />
|
|
14
|
+
<Skeleton className="h-4 w-32" />
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Application router using Wouter
|
|
22
|
+
* - Lightweight (2KB) alternative to React Router
|
|
23
|
+
* - Supports lazy loading with React.Suspense
|
|
24
|
+
* - Simple API with <Route> and <Switch>
|
|
25
|
+
*/
|
|
26
|
+
export function AppRouter() {
|
|
27
|
+
return (
|
|
28
|
+
<Suspense fallback={<RouteLoading />}>
|
|
29
|
+
<Switch>
|
|
30
|
+
{routes.map(({ path, component: Component }) => (
|
|
31
|
+
<Route key={path} path={path}>
|
|
32
|
+
<Component />
|
|
33
|
+
</Route>
|
|
34
|
+
))}
|
|
35
|
+
</Switch>
|
|
36
|
+
</Suspense>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { routes } from './routes';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { lazy } from 'react';
|
|
2
|
+
|
|
3
|
+
// Lazy-loaded screens
|
|
4
|
+
const Home = lazy(() => import('~/screens/home'));
|
|
5
|
+
const NotFound = lazy(() => import('~/screens/not-found'));
|
|
6
|
+
|
|
7
|
+
export interface RouteConfig {
|
|
8
|
+
path: string;
|
|
9
|
+
component: React.LazyExoticComponent<React.ComponentType>;
|
|
10
|
+
title: string;
|
|
11
|
+
isPrivate?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Application routes configuration
|
|
16
|
+
* Add new routes here - they'll automatically be registered in the router
|
|
17
|
+
*/
|
|
18
|
+
export const routes: RouteConfig[] = [
|
|
19
|
+
{
|
|
20
|
+
path: '/',
|
|
21
|
+
component: Home,
|
|
22
|
+
title: 'Home',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
path: '*',
|
|
26
|
+
component: NotFound,
|
|
27
|
+
title: 'Not Found',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get route by path
|
|
33
|
+
*/
|
|
34
|
+
export function getRouteByPath(path: string): RouteConfig | undefined {
|
|
35
|
+
return routes.find((route) => route.path === path);
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Moon, Sun, Github, Zap } from 'lucide-react';
|
|
3
|
+
import { Button, Card, CardContent, CardHeader, CardTitle } from '~/components/ui';
|
|
4
|
+
import { useTheme } from '~/providers';
|
|
5
|
+
|
|
6
|
+
export default function Home() {
|
|
7
|
+
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
8
|
+
const [count, setCount] = useState(0);
|
|
9
|
+
|
|
10
|
+
const toggleTheme = () => {
|
|
11
|
+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex min-h-screen flex-col items-center justify-center p-8">
|
|
16
|
+
<div className="w-full max-w-2xl space-y-8">
|
|
17
|
+
{/* Header */}
|
|
18
|
+
<div className="text-center">
|
|
19
|
+
<div className="mb-4 flex justify-center">
|
|
20
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
|
21
|
+
<Zap className="h-8 w-8" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<h1 className="text-4xl font-bold tracking-tight">
|
|
25
|
+
create-modern-react
|
|
26
|
+
</h1>
|
|
27
|
+
<p className="mt-2 text-muted-foreground">
|
|
28
|
+
Production-ready React + TypeScript + Tailwind in seconds
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{/* Counter Card */}
|
|
33
|
+
<Card>
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle className="flex items-center justify-between">
|
|
36
|
+
<span>Interactive Counter</span>
|
|
37
|
+
<Button
|
|
38
|
+
variant="ghost"
|
|
39
|
+
size="icon"
|
|
40
|
+
onClick={toggleTheme}
|
|
41
|
+
aria-label="Toggle theme"
|
|
42
|
+
>
|
|
43
|
+
{resolvedTheme === 'dark' ? (
|
|
44
|
+
<Sun className="h-5 w-5" />
|
|
45
|
+
) : (
|
|
46
|
+
<Moon className="h-5 w-5" />
|
|
47
|
+
)}
|
|
48
|
+
</Button>
|
|
49
|
+
</CardTitle>
|
|
50
|
+
</CardHeader>
|
|
51
|
+
<CardContent className="space-y-4">
|
|
52
|
+
<div className="flex items-center justify-center gap-4">
|
|
53
|
+
<Button
|
|
54
|
+
variant="outline"
|
|
55
|
+
size="lg"
|
|
56
|
+
onClick={() => setCount((c) => c - 1)}
|
|
57
|
+
>
|
|
58
|
+
-
|
|
59
|
+
</Button>
|
|
60
|
+
<span className="min-w-[4rem] text-center text-4xl font-bold tabular-nums">
|
|
61
|
+
{count}
|
|
62
|
+
</span>
|
|
63
|
+
<Button
|
|
64
|
+
variant="outline"
|
|
65
|
+
size="lg"
|
|
66
|
+
onClick={() => setCount((c) => c + 1)}
|
|
67
|
+
>
|
|
68
|
+
+
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
72
|
+
Click the buttons to update the count
|
|
73
|
+
</p>
|
|
74
|
+
</CardContent>
|
|
75
|
+
</Card>
|
|
76
|
+
|
|
77
|
+
{/* Features */}
|
|
78
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
79
|
+
<FeatureCard
|
|
80
|
+
title="Vite + SWC"
|
|
81
|
+
description="Lightning fast builds with Hot Module Replacement"
|
|
82
|
+
/>
|
|
83
|
+
<FeatureCard
|
|
84
|
+
title="TypeScript"
|
|
85
|
+
description="Full type safety with strict mode enabled"
|
|
86
|
+
/>
|
|
87
|
+
<FeatureCard
|
|
88
|
+
title="Tailwind CSS"
|
|
89
|
+
description="Utility-first CSS with dark mode support"
|
|
90
|
+
/>
|
|
91
|
+
<FeatureCard
|
|
92
|
+
title="Shadcn/ui"
|
|
93
|
+
description="Beautiful, accessible components"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Footer */}
|
|
98
|
+
<div className="flex justify-center">
|
|
99
|
+
<Button variant="outline" asChild>
|
|
100
|
+
<a
|
|
101
|
+
href="https://github.com"
|
|
102
|
+
target="_blank"
|
|
103
|
+
rel="noopener noreferrer"
|
|
104
|
+
>
|
|
105
|
+
<Github className="mr-2 h-4 w-4" />
|
|
106
|
+
View on GitHub
|
|
107
|
+
</a>
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
112
|
+
Current theme: {theme} (resolved: {resolvedTheme})
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function FeatureCard({
|
|
120
|
+
title,
|
|
121
|
+
description,
|
|
122
|
+
}: {
|
|
123
|
+
title: string;
|
|
124
|
+
description: string;
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="rounded-lg border bg-card p-4 text-card-foreground">
|
|
128
|
+
<h3 className="font-semibold">{title}</h3>
|
|
129
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Link } from 'wouter';
|
|
2
|
+
import { Home, ArrowLeft } from 'lucide-react';
|
|
3
|
+
import { Button } from '~/components/ui';
|
|
4
|
+
|
|
5
|
+
export default function NotFound() {
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex min-h-screen flex-col items-center justify-center p-8">
|
|
8
|
+
<div className="text-center">
|
|
9
|
+
<h1 className="text-9xl font-bold text-muted-foreground/20">404</h1>
|
|
10
|
+
<h2 className="mt-4 text-2xl font-semibold">Page not found</h2>
|
|
11
|
+
<p className="mt-2 text-muted-foreground">
|
|
12
|
+
Sorry, we couldn't find the page you're looking for.
|
|
13
|
+
</p>
|
|
14
|
+
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
|
15
|
+
<Button asChild>
|
|
16
|
+
<Link href="/">
|
|
17
|
+
<Home className="mr-2 h-4 w-4" />
|
|
18
|
+
Go home
|
|
19
|
+
</Link>
|
|
20
|
+
</Button>
|
|
21
|
+
<Button variant="outline" onClick={() => window.history.back()}>
|
|
22
|
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
23
|
+
Go back
|
|
24
|
+
</Button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import toast from 'react-hot-toast';
|
|
2
|
+
|
|
3
|
+
type ToastPosition =
|
|
4
|
+
| 'top-left'
|
|
5
|
+
| 'top-center'
|
|
6
|
+
| 'top-right'
|
|
7
|
+
| 'bottom-left'
|
|
8
|
+
| 'bottom-center'
|
|
9
|
+
| 'bottom-right';
|
|
10
|
+
|
|
11
|
+
// Track the current toast ID to dismiss before showing new one
|
|
12
|
+
let currentToastId: string | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Alertify Service - Wrapper around react-hot-toast
|
|
16
|
+
* Auto-dismisses previous toast before showing new one
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* Alertify.success('User created successfully');
|
|
20
|
+
* Alertify.error('Failed to save changes');
|
|
21
|
+
* Alertify.info('Please wait...');
|
|
22
|
+
*/
|
|
23
|
+
export const Alertify = {
|
|
24
|
+
/**
|
|
25
|
+
* Show a success toast
|
|
26
|
+
*/
|
|
27
|
+
success(message: string, position: ToastPosition = 'bottom-right') {
|
|
28
|
+
if (currentToastId) {
|
|
29
|
+
toast.dismiss(currentToastId);
|
|
30
|
+
}
|
|
31
|
+
currentToastId = toast.success(message, { position });
|
|
32
|
+
return currentToastId;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Show an error toast
|
|
37
|
+
*/
|
|
38
|
+
error(message: string, position: ToastPosition = 'bottom-right') {
|
|
39
|
+
if (currentToastId) {
|
|
40
|
+
toast.dismiss(currentToastId);
|
|
41
|
+
}
|
|
42
|
+
currentToastId = toast.error(message, { position });
|
|
43
|
+
return currentToastId;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Show an info toast (default style)
|
|
48
|
+
*/
|
|
49
|
+
info(message: string, position: ToastPosition = 'bottom-right') {
|
|
50
|
+
if (currentToastId) {
|
|
51
|
+
toast.dismiss(currentToastId);
|
|
52
|
+
}
|
|
53
|
+
currentToastId = toast(message, {
|
|
54
|
+
position,
|
|
55
|
+
icon: 'ℹ️',
|
|
56
|
+
});
|
|
57
|
+
return currentToastId;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Show a loading toast (returns a function to update/dismiss it)
|
|
62
|
+
*/
|
|
63
|
+
loading(message: string, position: ToastPosition = 'bottom-right') {
|
|
64
|
+
if (currentToastId) {
|
|
65
|
+
toast.dismiss(currentToastId);
|
|
66
|
+
}
|
|
67
|
+
currentToastId = toast.loading(message, { position });
|
|
68
|
+
return {
|
|
69
|
+
id: currentToastId,
|
|
70
|
+
success: (successMessage: string) => {
|
|
71
|
+
toast.success(successMessage, { id: currentToastId });
|
|
72
|
+
},
|
|
73
|
+
error: (errorMessage: string) => {
|
|
74
|
+
toast.error(errorMessage, { id: currentToastId });
|
|
75
|
+
},
|
|
76
|
+
dismiss: () => {
|
|
77
|
+
toast.dismiss(currentToastId);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Show a custom toast with action button
|
|
84
|
+
*/
|
|
85
|
+
withAction(
|
|
86
|
+
message: string,
|
|
87
|
+
actionText: string,
|
|
88
|
+
onAction: () => void,
|
|
89
|
+
position: ToastPosition = 'bottom-right'
|
|
90
|
+
) {
|
|
91
|
+
if (currentToastId) {
|
|
92
|
+
toast.dismiss(currentToastId);
|
|
93
|
+
}
|
|
94
|
+
currentToastId = toast(
|
|
95
|
+
(t) => (
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<span>{message}</span>
|
|
98
|
+
<button
|
|
99
|
+
onClick={() => {
|
|
100
|
+
onAction();
|
|
101
|
+
toast.dismiss(t.id);
|
|
102
|
+
}}
|
|
103
|
+
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
104
|
+
>
|
|
105
|
+
{actionText}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
{ position, duration: 5000 }
|
|
110
|
+
);
|
|
111
|
+
return currentToastId;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Dismiss all toasts
|
|
116
|
+
*/
|
|
117
|
+
dismissAll() {
|
|
118
|
+
toast.dismiss();
|
|
119
|
+
currentToastId = undefined;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Dismiss a specific toast
|
|
124
|
+
*/
|
|
125
|
+
dismiss(toastId?: string) {
|
|
126
|
+
toast.dismiss(toastId || currentToastId);
|
|
127
|
+
if (!toastId || toastId === currentToastId) {
|
|
128
|
+
currentToastId = undefined;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export default Alertify;
|