├── .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 | You need to enable JavaScript to run this app.
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 |
14 | {props.children}
15 |
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 |
17 | Logout
18 |
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 |
28 | Login
29 |
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 |
16 |
17 | /home
18 |
19 |
20 | {auth.token ? (
21 |
22 | /auth
23 |
24 | ) : (
25 |
26 | /auth
27 |
28 | )}
29 |
30 |
31 | /routing
32 |
33 |
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 |
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 |
--------------------------------------------------------------------------------