├── .husky ├── pre-commit └── commit-msg ├── src ├── api │ ├── index.ts │ └── repo.ts ├── context │ ├── index.ts │ └── auth.tsx ├── hooks │ ├── index.ts │ └── query.ts ├── components │ ├── index.ts │ └── button.tsx ├── config │ ├── index.ts │ └── redirects.tsx ├── icons │ ├── index.ts │ ├── logo.tsx │ └── github.tsx ├── utils │ ├── index.ts │ ├── class-names.ts │ ├── class-names.test.ts │ └── storage.ts ├── pages │ ├── 404.tsx │ ├── nested │ │ ├── child.tsx │ │ ├── index.tsx │ │ └── sibling.tsx │ ├── catch │ │ └── [...all].tsx │ ├── dynamic │ │ └── [timestamp].tsx │ ├── logout.tsx │ ├── index.tsx │ ├── routing.tsx │ ├── nested.tsx │ ├── login.tsx │ └── _app.tsx ├── main.tsx ├── router.ts └── main.css ├── commitlint.config.ts ├── netlify.toml ├── .gitignore ├── tests ├── utils.ts └── load.test.ts ├── lint-staged.config.js ├── vite.config.ts ├── prettier.config.js ├── index.html ├── tsconfig.json ├── playwright.config.ts ├── public ├── logo.svg └── favicon.svg ├── LICENSE ├── eslint.config.js ├── package.json └── readme.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './repo' 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './query' 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button' 2 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './redirects' 2 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github' 2 | export * from './logo' 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './class-names' 2 | export * from './storage' 3 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | playwright/.cache 4 | tests/.output 5 | tests/.report 6 | 7 | .env 8 | .env.local 9 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | app: { url: 'http://localhost:3000' }, 3 | ci: Boolean(process.env.CI), 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/class-names.ts: -------------------------------------------------------------------------------- 1 | export const classNames = (...list: (false | null | undefined | string)[]): string => { 2 | return list.filter(Boolean).join(' ') 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/class-names.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | import { classNames } from './class-names' 4 | 5 | test('combine classes', () => { 6 | expect(classNames('text-black', 'bg-white')).toBe('text-black bg-white') 7 | }) 8 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.{ts,tsx}': () => 'tsc -p tsconfig.json', 3 | '*.{js,jsx,ts,tsx}': 'eslint --fix --cache --cache-location node_modules/.cache/eslint', 4 | '*.{css,html,json,md,mdx,js,jsx,ts,tsx}': 'prettier --write --cache', 5 | } 6 | -------------------------------------------------------------------------------- /tests/load.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test('page loads', async ({ page }) => { 4 | await page.goto('/') 5 | await expect(page).toHaveTitle(/Render/) 6 | 7 | await page.getByRole('link', { name: '/home' }).click() 8 | await expect(page.getByText('Opinionated React Template')).toBeVisible() 9 | }) 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import generouted from '@generouted/react-router/plugin' 3 | import tailwind from '@tailwindcss/vite' 4 | import react from '@vitejs/plugin-react' 5 | 6 | export default defineConfig({ 7 | plugins: [tailwind(), react(), generouted()], 8 | resolve: { alias: { '@': '/src' } }, 9 | }) 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | export default { 3 | arrowParens: 'always', 4 | printWidth: 120, 5 | semi: false, 6 | singleQuote: true, 7 | tabWidth: 2, 8 | trailingComma: 'all', 9 | useTabs: false, 10 | plugins: ['prettier-plugin-tailwindcss'], 11 | tailwindStylesheet: './src/main.css', 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/router' 2 | 3 | export default function NotFound() { 4 | return ( 5 | <> 6 |

/404

7 |

page not found

8 | 9 | go home 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/nested/child.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/router' 2 | 3 | export default function Child() { 4 | return ( 5 | <> 6 |

/nested/child

7 |

Using layout from `src/pages/nested.tsx`

8 | 9 | ⟵ 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/nested/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/router' 2 | 3 | export default function NestedIndex() { 4 | return ( 5 | <> 6 |

/nested

7 |

Using layout from `src/pages/nested.tsx`

8 | 9 | ⟵ 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/nested/sibling.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/router' 2 | 3 | export default function Sibling() { 4 | return ( 5 | <> 6 |

/nested/slibling

7 |

Using layout from `src/pages/nested.tsx`

8 | 9 | ⟵ 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { Routes } from '@generouted/react-router' 4 | 5 | import { AuthProvider } from '@/context' 6 | 7 | const App = () => ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | 15 | const app = document.getElementById('app')! 16 | createRoot(app).render() 17 | -------------------------------------------------------------------------------- /src/pages/catch/[...all].tsx: -------------------------------------------------------------------------------- 1 | import { Link, useParams } from '@/router' 2 | 3 | export default function CatchAll() { 4 | const { '*': all } = useParams('/catch/*') 5 | 6 | return ( 7 | <> 8 |

/catch/*

9 |

{all}

10 | 11 | ⟵ 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/dynamic/[timestamp].tsx: -------------------------------------------------------------------------------- 1 | import { Link, useParams } from '@/router' 2 | 3 | export default function DynamicTimestamp() { 4 | const params = useParams('/dynamic/:timestamp') 5 | 6 | return ( 7 | <> 8 |

/dynamic/:timestamp

9 |

{params.timestamp}

10 | 11 | ⟵ 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | const store = typeof window !== 'undefined' ? localStorage : undefined 2 | 3 | export const storage = { 4 | set: (key: string, value: Item): void => store?.setItem(key, JSON.stringify(value)), 5 | get: (key: string): Item => { 6 | return ( 7 | store?.getItem(key)?.startsWith('{') ? JSON.parse(store?.getItem(key) || '""') : store?.getItem(key) 8 | ) as Item 9 | }, 10 | remove: (key: string): void => store?.removeItem(key), 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Render 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react' 2 | 3 | import { classNames } from '@/utils' 4 | 5 | export const Button = (props: ComponentProps<'button'>) => { 6 | return ( 7 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/api/repo.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQuery } from '@/hooks' 2 | 3 | const url = 'https://api.github.com/repos/oedotme' 4 | const get = (url: string) => fetch(url).then((response) => response.json()) as Promise 5 | 6 | export type Repo = { 7 | id: string 8 | name: string 9 | description: string 10 | html_url: string 11 | } 12 | 13 | export const getRepo = (name = 'render') => get(`${url}/${name}`) 14 | 15 | export const useRepo = (name = 'render'): Query => { 16 | return useQuery(['repo', name], () => getRepo(name), { cacheTime: Infinity }) 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/logout.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components' 2 | import { useAuth } from '@/context' 3 | 4 | export default function Logout() { 5 | const auth = useAuth() 6 | 7 | const logout = () => auth.logout() 8 | 9 | return ( 10 | <> 11 |

/auth

12 | 13 |
14 |

{auth.user?.email}

15 | 16 | 19 |
20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "esnext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "paths": { "@/*": ["src/*"] }, 18 | "types": ["vite/client"] 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from 'react-router' 2 | 3 | import { getRepo, Repo } from '@/api' 4 | import { Github, Logo } from '@/icons' 5 | 6 | export const Loader = () => { 7 | return getRepo('render') 8 | } 9 | 10 | export default function Home() { 11 | const data = useLoaderData() 12 | 13 | return ( 14 | <> 15 | 16 | {data.description} 17 | 18 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | // Generouted, changes to this file will be overridden 2 | /* eslint-disable */ 3 | 4 | import { components, hooks, utils } from '@generouted/react-router/client' 5 | 6 | export type Path = 7 | | `/` 8 | | `/catch/*` 9 | | `/dynamic/:timestamp` 10 | | `/login` 11 | | `/logout` 12 | | `/nested` 13 | | `/nested/child` 14 | | `/nested/sibling` 15 | | `/routing` 16 | 17 | export type Params = { 18 | '/catch/*': { '*': string } 19 | '/dynamic/:timestamp': { timestamp: string } 20 | } 21 | 22 | export type ModalPath = never 23 | 24 | export const { Link, Navigate } = components() 25 | export const { useModals, useNavigate, useParams } = hooks() 26 | export const { redirect } = utils() 27 | -------------------------------------------------------------------------------- /src/config/redirects.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'react' 2 | import { useLocation } from 'react-router' 3 | 4 | import { useAuth } from '@/context' 5 | import { Navigate, Path } from '@/router' 6 | 7 | const PRIVATE: Path[] = ['/logout'] 8 | const PUBLIC: Path[] = ['/login'] 9 | 10 | export const Redirects = ({ children }: { children: JSX.Element }) => { 11 | const auth = useAuth() 12 | const location = useLocation() 13 | 14 | const authedOnPublicPath = auth.token && PUBLIC.includes(location.pathname as Path) 15 | const unAuthedOnPrivatePath = !auth.token && PRIVATE.includes(location.pathname as Path) 16 | 17 | if (authedOnPublicPath) return 18 | if (unAuthedOnPrivatePath) return 19 | 20 | return children 21 | } 22 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | import { env } from './tests/utils' 4 | 5 | export default defineConfig({ 6 | testDir: '.', 7 | outputDir: './tests/.output', 8 | snapshotDir: './tests/.snapshots', 9 | fullyParallel: true, 10 | forbidOnly: env.ci, 11 | retries: env.ci ? 2 : 0, 12 | workers: env.ci ? 1 : undefined, 13 | reporter: [[env.ci ? 'html' : 'list', { outputFolder: './tests/.report' }]], 14 | projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], 15 | use: { 16 | baseURL: env.app.url, 17 | trace: env.ci ? 'on-first-retry' : 'off', 18 | }, 19 | webServer: { 20 | command: env.ci ? 'pnpm preview' : 'pnpm dev', 21 | url: env.app.url, 22 | reuseExistingServer: !env.ci, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/pages/routing.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from 'react-router' 2 | 3 | import { Link } from '@/router' 4 | 5 | export const Loader = () => { 6 | return Promise.resolve({ name: '/routing' }) 7 | } 8 | 9 | const date = () => Date.now() 10 | 11 | export default function Routing() { 12 | const data = useLoaderData<{ name: string }>() 13 | 14 | return ( 15 | <> 16 |

{data.name}

17 | 18 | 19 | dynamic route 20 | 21 | 22 | 23 | catch all routes 24 | 25 | 26 | 27 | nested layouts 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/nested.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router' 2 | 3 | import { Link } from '@/router' 4 | 5 | export default function Nested() { 6 | return ( 7 | <> 8 |

/nested

9 |
10 |
    11 |
  • 12 | 13 | /index 14 | 15 |
  • 16 |
  • 17 | 18 | /child 19 | 20 |
  • 21 |
  • 22 | 23 | /sibling 24 | 25 |
  • 26 |
27 | 28 | 29 |
30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { Button } from '@/components' 4 | import { useAuth } from '@/context' 5 | 6 | export default function Login() { 7 | const auth = useAuth() 8 | 9 | const [email, setEmail] = useState('') 10 | 11 | const handleChange = (event: React.ChangeEvent) => setEmail(event.target.value) 12 | const login = () => auth.login(email) 13 | 14 | return ( 15 | <> 16 |

/auth

17 | 18 |
19 | 26 | 27 | 30 |
31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/icons/logo.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react' 2 | 3 | export const Logo = (props: ComponentProps<'svg'>) => { 4 | return ( 5 | 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Omar Elhawary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import a11y from 'eslint-plugin-jsx-a11y' 3 | import react from 'eslint-plugin-react' 4 | import hooks from 'eslint-plugin-react-hooks' 5 | import sort from 'eslint-plugin-simple-import-sort' 6 | import tseslint from 'typescript-eslint' 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | a11y.flatConfigs.recommended, 11 | react.configs.flat.recommended, 12 | react.configs.flat['jsx-runtime'], 13 | ...tseslint.configs.recommendedTypeChecked, 14 | { 15 | settings: { react: { version: 'detect' } }, 16 | languageOptions: { parserOptions: { project: true, tsconfigRootDir: import.meta.dirname } }, 17 | linterOptions: { reportUnusedDisableDirectives: 'off' }, 18 | plugins: { 'react-hooks': hooks, 'simple-import-sort': sort }, 19 | rules: { 20 | ...hooks.configs.recommended.rules, 21 | 'simple-import-sort/exports': 'warn', 22 | 'simple-import-sort/imports': [ 23 | 'warn', 24 | { groups: [['^\\u0000'], ['^node:'], ['^(react|solid|vite)', '^@?\\w'], ['^'], ['^\\.']] }, 25 | ], 26 | }, 27 | }, 28 | { files: ['!src/**/*.{ts,tsx}'], extends: [tseslint.configs.disableTypeChecked] }, 29 | ) 30 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/main.css' 2 | 3 | import { Outlet } from 'react-router' 4 | 5 | import { Redirects } from '@/config' 6 | import { useAuth } from '@/context' 7 | import { Link } from '@/router' 8 | 9 | export default function App() { 10 | const auth = useAuth() 11 | 12 | return ( 13 |
14 |
15 | 34 | 35 | {auth.token ? 🔒 : null} 36 |
37 | 38 |
39 | 40 | 41 | 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/hooks/query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | 3 | export type Query = { loading: boolean; error: boolean; data?: T } 4 | type Options = { enabled?: boolean; cacheTime?: number } 5 | 6 | const defaultOptions: Options = { enabled: true, cacheTime: 0 } 7 | 8 | const cache = new Map() 9 | 10 | export const useQuery = (key: string | string[], fetcher: () => Promise, options?: Options): Query => { 11 | const ref = useRef<() => Promise>(undefined) 12 | 13 | const [status, setStatus] = useState<'stale' | 'loading' | 'error' | 'done'>('stale') 14 | const [data, setData] = useState() 15 | 16 | const id = useMemo(() => (typeof key === 'string' ? key : key.join('/')), [key]) 17 | const { enabled, cacheTime } = { ...defaultOptions, ...options } 18 | 19 | // eslint-disable-next-line react-hooks/refs 20 | ref.current = fetcher 21 | 22 | useEffect(() => { 23 | if (enabled) { 24 | if (cacheTime === Infinity && cache.has(id)) { 25 | setData(cache.get(id) as T) 26 | } else { 27 | setStatus('loading') 28 | ref 29 | .current?.() 30 | .then((data) => (setData(data), setStatus('done'), cache.set(id, data))) 31 | .catch(() => setStatus('error')) 32 | } 33 | } 34 | }, [id, enabled, cacheTime]) 35 | 36 | return { loading: status === 'loading', error: status === 'error', data } 37 | } 38 | -------------------------------------------------------------------------------- /src/icons/github.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react' 2 | 3 | export const Github = (props: ComponentProps<'svg'>) => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/context/auth.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useMemo, useState } from 'react' 2 | 3 | import { storage } from '@/utils' 4 | 5 | type User = { email: string } 6 | 7 | type AuthContext = { 8 | token: string 9 | user?: User 10 | login: (email: string) => void 11 | logout: () => void 12 | } 13 | 14 | const AuthContext = createContext(null) 15 | 16 | export const AuthProvider = ({ children }: { children: React.ReactNode }) => { 17 | const [token, setToken] = useState(() => storage.get('token') || '') 18 | const [user, setUser] = useState(() => storage.get('user') || undefined) 19 | 20 | const value = useMemo( 21 | () => ({ 22 | token, 23 | user, 24 | login: (email: string) => { 25 | setToken('token') 26 | setUser({ email }) 27 | storage.set('token', 'a0731ae631bc01dea99f13b3f8ed48fc') 28 | storage.set('user', { email }) 29 | }, 30 | logout: () => { 31 | setToken('') 32 | setUser(undefined) 33 | storage.remove('token') 34 | storage.remove('user') 35 | }, 36 | }), 37 | [token, user], 38 | ) 39 | 40 | return {children} 41 | } 42 | 43 | export const useAuth = (): AuthContext => { 44 | const context = useContext(AuthContext) 45 | if (!context) throw Error('useAuth should be used within ') 46 | return context 47 | } 48 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @theme { 4 | --color-gray-50: #f8fafc; 5 | --color-gray-100: #f1f5f9; 6 | --color-gray-200: #e2e8f0; 7 | --color-gray-300: #cbd5e1; 8 | --color-gray-400: #94a3b8; 9 | --color-gray-500: #64748b; 10 | --color-gray-600: #475569; 11 | --color-gray-700: #334155; 12 | --color-gray-800: #1e293b; 13 | --color-gray-900: #0f172a; 14 | --color-gray-950: #020617; 15 | 16 | --color-green-50: #ecfdf5; 17 | --color-green-100: #d1fae5; 18 | --color-green-200: #a7f3d0; 19 | --color-green-300: #6ee7b7; 20 | --color-green-400: #34d399; 21 | --color-green-500: #10b981; 22 | --color-green-600: #059669; 23 | --color-green-700: #047857; 24 | --color-green-800: #065f46; 25 | --color-green-900: #064e3b; 26 | --color-green-950: #022c22; 27 | 28 | --color-red-50: #fff1f2; 29 | --color-red-100: #ffe4e6; 30 | --color-red-200: #fecdd3; 31 | --color-red-300: #fda4af; 32 | --color-red-400: #fb7185; 33 | --color-red-500: #f43f5e; 34 | --color-red-600: #e11d48; 35 | --color-red-700: #be123c; 36 | --color-red-800: #9f1239; 37 | --color-red-900: #881337; 38 | --color-red-950: #4c0519; 39 | 40 | --color-default: #121e46; 41 | } 42 | 43 | /* 44 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 45 | so we've added these compatibility styles to make sure everything still 46 | looks the same as it did with Tailwind CSS v3. 47 | 48 | If we ever want to remove these styles, we need to add an explicit border 49 | color utility to any element that depends on these defaults. 50 | */ 51 | @layer base { 52 | *, 53 | ::after, 54 | ::before, 55 | ::backdrop, 56 | ::file-selector-button { 57 | border-color: var(--color-gray-200, currentcolor); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "render", 3 | "version": "1.0.0", 4 | "description": "Opinionated React template", 5 | "author": "Omar Elhawary ", 6 | "license": "MIT", 7 | "type": "module", 8 | "keywords": [ 9 | "app", 10 | "boilerplate", 11 | "client", 12 | "context", 13 | "data-loaders", 14 | "file-based-routing", 15 | "frontend", 16 | "hooks", 17 | "nested-layouts", 18 | "react", 19 | "react-router", 20 | "routes", 21 | "structure", 22 | "tanstack", 23 | "template", 24 | "vite", 25 | "web" 26 | ], 27 | "scripts": { 28 | "dev": "vite dev --port 3000", 29 | "build": "vite build", 30 | "preview": "vite build; vite preview --port 5000", 31 | "type-check": "tsc --noEmit", 32 | "format": "prettier --write 'src/**/*.{css,html,json,md,mdx,js,jsx,ts,tsx}'", 33 | "lint": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'", 34 | "test": "playwright test", 35 | "test:ui": "playwright test --ui", 36 | "test:headed": "playwright test --headed", 37 | "test:record": "playwright codegen", 38 | "prepare": "husky" 39 | }, 40 | "dependencies": { 41 | "@generouted/react-router": "^1.20.0", 42 | "react": "^19.2.0", 43 | "react-dom": "^19.2.0", 44 | "react-router": "^7.9.4" 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "^20.1.0", 48 | "@commitlint/config-conventional": "^20.0.0", 49 | "@eslint/js": "^9.37.0", 50 | "@playwright/test": "^1.56.0", 51 | "@tailwindcss/vite": "^4.1.14", 52 | "@types/node": "^24.7.2", 53 | "@types/react": "^19.2.2", 54 | "@types/react-dom": "^19.2.1", 55 | "@vitejs/plugin-react": "^5.0.4", 56 | "eslint": "^9.37.0", 57 | "eslint-plugin-jsx-a11y": "^6.10.2", 58 | "eslint-plugin-react": "^7.37.5", 59 | "eslint-plugin-react-hooks": "^7.0.0", 60 | "eslint-plugin-simple-import-sort": "^12.1.1", 61 | "husky": "^9.1.7", 62 | "lint-staged": "^16.2.4", 63 | "prettier": "^3.6.2", 64 | "prettier-plugin-tailwindcss": "^0.6.14", 65 | "tailwindcss": "^4.1.14", 66 | "typescript": "^5.9.3", 67 | "typescript-eslint": "^8.46.0", 68 | "vite": "^7.1.9" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Render · Opinionated React Template 5 | 6 |

7 |

Opinionated React Template

8 |
9 | 10 | # Render 11 | 12 | Opinionated React template setup with modern tooling, here some [recommendations](#recommendations) if needed 13 | 14 | ## Technologies and tools 15 | 16 | - [React](https://reactjs.org) 17 | - [TypeScript](https://www.typescriptlang.org) 18 | - [React Router](https://reactrouter.com) 19 | - [Generouted](https://github.com/oedotme/generouted) 20 | - [TailwindCSS](https://tailwindcss.com) 21 | - [Playwright](https://playwright.dev) 22 | - [ESLint](https://eslint.org) 23 | - [Prettier](https://prettier.io) 24 | - [Vite](https://vitejs.dev) 25 | - [PNPM](https://pnpm.io) 26 | - [Husky](https://typicode.github.io/husky) 27 | - [Commitlint](https://commitlint.js.org) 28 | - [Lint-staged](https://github.com/okonet/lint-staged) 29 | - [Netlify](http://netlify.com) 30 | 31 | ## Highlights 32 | 33 | ### File based routing 34 | 35 | - Using [`generouted` with type-safe navigation](https://github.com/oedotme/generouted) 36 | - Check [`generouted` features](https://github.com/oedotme/generouted#features) and [conventions](https://github.com/oedotme/generouted#conventions) for more details 37 | 38 | ### Authentication example 39 | 40 | - [Authentication context](./src/context/auth.tsx) 41 | - [Routes guard](./src/config/redirects.tsx) 42 | 43 | ### Custom hooks 44 | 45 | - [`useQuery` hook](./src/hooks/query.ts) 46 | 47 | ## Usage 48 | 49 | By [generating](https://github.com/oedotme/render/generate) from this template then/or cloning locally 50 | 51 | ## Getting started 52 | 53 | #### Installation 54 | 55 | ```shell 56 | # install dependencies 57 | pnpm install 58 | 59 | # install playwright browsers 60 | pnpm playwright install --with-deps chromium 61 | ``` 62 | 63 | #### Development and build 64 | 65 | ```shell 66 | # start development server · http://localhost:3000 67 | pnpm dev 68 | 69 | # build client for production 70 | pnpm build 71 | 72 | # start production preview · http://localhost:5000 73 | pnpm preview 74 | ``` 75 | 76 | #### Testing — end-to-end and unit tests 77 | 78 | ```shell 79 | # run all tests from the command-line 80 | pnpm test 81 | 82 | # run end-to-end tests in interactive mode 83 | pnpm test:ui 84 | 85 | # run end-to-end tests in headed browsers 86 | pnpm test:headed 87 | 88 | # run test generator to record an end-to-end test 89 | pnpm test:record 90 | ``` 91 | 92 | ## Recommendations 93 | 94 | #### Frameworks 95 | 96 | - [Astro](https://astro.build) 97 | - [Next.js](https://nextjs.org) 98 | - [Remix](https://remix.run) 99 | - [Blitz](https://blitzjs.com) 100 | - [Redwood](https://redwoodjs.com) 101 | 102 | #### Non-React Frameworks 103 | 104 | - [Solid](https://www.solidjs.com) 105 | - [SolidStart](https://start.solidjs.com) 106 | - [Preact](https://preactjs.com) 107 | 108 | #### Languages 109 | 110 | - [ReScript](https://rescript-lang.org) 111 | 112 | #### Routing 113 | 114 | - [TanStack Router](https://tanstack.com/router) 115 | 116 | #### Components 117 | 118 | - [Radix UI](https://www.radix-ui.com) 119 | - [Headless UI](https://headlessui.dev) 120 | - [Chakra UI](https://chakra-ui.com) 121 | 122 | #### Build tools 123 | 124 | - [Esbuild](https://esbuild.github.io) 125 | - [Parcel](https://parceljs.org) 126 | 127 | #### Server state 128 | 129 | - [TanStack Query](https://tanstack.com/query) 130 | - [SWR](https://swr.vercel.app) 131 | 132 | #### Data fetching 133 | 134 | - [Ky](https://github.com/sindresorhus/ky) 135 | - [Redaxios](https://github.com/developit/redaxios) 136 | - [Axios](https://github.com/axios/axios) 137 | 138 | #### Global state 139 | 140 | - [Zustand](https://github.com/pmndrs/zustand) 141 | - [Jotai](https://jotai.org) 142 | - [Recoil](https://recoiljs.org) 143 | - [Redux Toolkit](https://redux-toolkit.js.org) 144 | 145 | #### Animation 146 | 147 | - [Framer Motion](https://www.framer.com/motion) 148 | 149 | ## License 150 | 151 | MIT 152 | --------------------------------------------------------------------------------