tr]:last:border-b-0',
46 | className,
47 | )}
48 | {...props}
49 | />
50 | )
51 | }
52 |
53 | function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
54 | return (
55 |
63 | )
64 | }
65 |
66 | function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
67 | return (
68 | [role=checkbox]]:translate-y-[2px]',
72 | className,
73 | )}
74 | {...props}
75 | />
76 | )
77 | }
78 |
79 | function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
80 | return (
81 | [role=checkbox]]:translate-y-[2px]',
85 | className,
86 | )}
87 | {...props}
88 | />
89 | )
90 | }
91 |
92 | function TableCaption({
93 | className,
94 | ...props
95 | }: React.ComponentProps<'caption'>) {
96 | return (
97 |
102 | )
103 | }
104 |
105 | export {
106 | Table,
107 | TableBody,
108 | TableCaption,
109 | TableCell,
110 | TableFooter,
111 | TableHead,
112 | TableHeader,
113 | TableRow,
114 | }
115 |
--------------------------------------------------------------------------------
/app/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as TabsPrimitive from '@radix-ui/react-tabs'
2 | import type * as React from 'react'
3 |
4 | import { cn } from '~/lib/utils'
5 |
6 | function Tabs({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | )
17 | }
18 |
19 | function TabsList({
20 | className,
21 | ...props
22 | }: React.ComponentProps) {
23 | return (
24 |
32 | )
33 | }
34 |
35 | function TabsTrigger({
36 | className,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
48 | )
49 | }
50 |
51 | function TabsContent({
52 | className,
53 | ...props
54 | }: React.ComponentProps) {
55 | return (
56 |
61 | )
62 | }
63 |
64 | export { Tabs, TabsContent, TabsList, TabsTrigger }
65 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from 'react'
2 |
3 | import { cn } from '~/lib/utils'
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
2 | import type * as React from 'react'
3 |
4 | import { cn } from '~/lib/utils'
5 |
6 | function TooltipProvider({
7 | delayDuration = 0,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | )
17 | }
18 |
19 | function Tooltip({
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | function TooltipTrigger({
30 | ...props
31 | }: React.ComponentProps) {
32 | return
33 | }
34 |
35 | function TooltipContent({
36 | className,
37 | sideOffset = 0,
38 | children,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
43 |
52 | {children}
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
60 |
--------------------------------------------------------------------------------
/app/context/search-context.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { CommandMenu } from '~/components/command-menu'
3 |
4 | interface SearchContextType {
5 | open: boolean
6 | setOpen: React.Dispatch>
7 | }
8 |
9 | const SearchContext = React.createContext(null)
10 |
11 | interface Props {
12 | children: React.ReactNode
13 | }
14 |
15 | export function SearchProvider({ children }: Props) {
16 | const [open, setOpen] = React.useState(false)
17 |
18 | React.useEffect(() => {
19 | const down = (e: KeyboardEvent) => {
20 | if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
21 | e.preventDefault()
22 | setOpen((open) => !open)
23 | }
24 | }
25 | document.addEventListener('keydown', down)
26 | return () => document.removeEventListener('keydown', down)
27 | }, [])
28 |
29 | return (
30 |
31 | {children}
32 |
33 |
34 | )
35 | }
36 |
37 | // eslint-disable-next-line react-refresh/only-export-components
38 | export const useSearch = () => {
39 | const searchContext = React.useContext(SearchContext)
40 |
41 | if (!searchContext) {
42 | throw new Error('useSearch has to be used within ')
43 | }
44 |
45 | return searchContext
46 | }
47 |
--------------------------------------------------------------------------------
/app/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react'
2 |
3 | type Debounce = (fn: () => void) => void
4 |
5 | export const useDebounce = (timeout = 500): Debounce => {
6 | const timer = useRef | null>(null)
7 | const debounce: Debounce = useCallback(
8 | (fn) => {
9 | if (timer.current) {
10 | clearTimeout(timer.current)
11 | }
12 | timer.current = setTimeout(() => {
13 | fn()
14 | }, timeout)
15 | },
16 | [timeout],
17 | )
18 | return debounce
19 | }
20 |
--------------------------------------------------------------------------------
/app/hooks/use-dialog-state.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | /**
4 | * Custom hook for confirm dialog
5 | * @param initialState string | null
6 | * @returns A stateful value, and a function to update it.
7 | * @example const [open, setOpen] = useDialogState<"approve" | "reject">()
8 | */
9 | export default function useDialogState(
10 | initialState: T | null = null,
11 | ) {
12 | const [open, _setOpen] = useState(initialState)
13 |
14 | const setOpen = (str: T | null) =>
15 | _setOpen((prev) => (prev === str ? null : str))
16 |
17 | return [open, setOpen] as const
18 | }
19 |
--------------------------------------------------------------------------------
/app/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener('change', onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener('change', onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/app/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener('change', onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener('change', onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/app/hooks/use-smart-navigation.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useLocation, useNavigate } from 'react-router'
3 |
4 | /**
5 | * A smart navigation hook that can automatically save the current location and provide a goBack function.
6 | *
7 | * @param options - Configuration options for the smart navigation
8 | * @param options.autoSave - Whether to automatically save the current location to sessionStorage. Defaults to false.
9 | * @param options.baseUrl - The base URL of the nested route. Used as the storage key and fallback URL. Defaults to '/'.
10 | *
11 | * @returns An object containing the goBack function and backUrl
12 | * @returns goBack - A function that navigates back to the saved location or fallback URL
13 | * @returns backUrl - The saved URL or base URL that goBack will navigate to
14 | *
15 | * @example
16 | * ```typescript
17 | * // Usage on a list page - automatically save current location with filters/pagination
18 | * const { goBack } = useSmartNavigation({
19 | * autoSave: true,
20 | * baseUrl: '/tasks'
21 | * })
22 | *
23 | * // Usage on a detail page - provide goBack function to return to saved list location
24 | * const { goBack, backUrl } = useSmartNavigation({
25 | * autoSave: false,
26 | * baseUrl: '/tasks'
27 | * })
28 | *
29 | * // Navigate back to saved list location (e.g., on save/cancel button)
30 | * goBack()
31 | *
32 | * // Or use backUrl for Link component
33 | * Back to List
34 | * ```
35 | */
36 | export function useSmartNavigation(options?: {
37 | autoSave?: boolean
38 | baseUrl?: string
39 | }) {
40 | const location = useLocation()
41 | const navigate = useNavigate()
42 |
43 | const { autoSave = false, baseUrl = '/' } = options || {}
44 |
45 | // 自動保存
46 | useEffect(() => {
47 | if (autoSave && baseUrl === location.pathname) {
48 | sessionStorage.setItem(
49 | `nav_${baseUrl}`,
50 | location.pathname + location.search,
51 | )
52 | }
53 | }, [location, baseUrl, autoSave])
54 |
55 | // SSR-safe fallback
56 | const getBackUrl = () => {
57 | if (typeof window === 'undefined') return baseUrl // SSR
58 | const savedUrl = sessionStorage.getItem(`nav_${baseUrl}`)
59 | return savedUrl || baseUrl
60 | }
61 |
62 | const goBack = () => {
63 | navigate(getBackUrl())
64 | }
65 |
66 | return {
67 | goBack,
68 | backUrl: getBackUrl(),
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import {
3 | Links,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | data,
9 | isRouteErrorResponse,
10 | useRouteError,
11 | } from 'react-router'
12 | import { getToast } from 'remix-toast'
13 | import { toast } from 'sonner'
14 | import { Toaster } from '~/components/ui/sonner'
15 | import type { Route } from './+types/root'
16 | import { ThemeProvider } from './components/theme-provider'
17 | import './index.css'
18 |
19 | export const meta: Route.MetaFunction = () => {
20 | return [{ title: 'Shadcn Admin React Router v7' }]
21 | }
22 |
23 | export const loader = async ({ request }: Route.LoaderArgs) => {
24 | const { toast, headers } = await getToast(request)
25 | return data({ toastData: toast }, { headers })
26 | }
27 |
28 | export const Layout = ({ children }: { children: React.ReactNode }) => {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {children}
40 |
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | export default function App({
48 | loaderData: { toastData },
49 | }: Route.ComponentProps) {
50 | useEffect(() => {
51 | if (!toastData) {
52 | return
53 | }
54 | let toastFn = toast.info
55 | if (toastData.type === 'error') {
56 | toastFn = toast.error
57 | } else if (toastData.type === 'success') {
58 | toastFn = toast.success
59 | }
60 | toastFn(toastData.message, {
61 | description: toastData.description,
62 | position: 'top-right',
63 | })
64 | }, [toastData])
65 |
66 | return
67 | }
68 |
69 | export function ErrorBoundary() {
70 | const error = useRouteError()
71 |
72 | let status = 0
73 | let statusText = ''
74 | let message = ''
75 | if (isRouteErrorResponse(error)) {
76 | status = error.status
77 | statusText = error.statusText
78 | message = error.data
79 | } else if (error instanceof Error) {
80 | status = 500
81 | statusText = 'Internal Server Error'
82 | message = error.message
83 | }
84 |
85 | return (
86 |
87 |
88 | {status} {statusText}
89 |
90 |
{message}
91 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/app/routes.ts:
--------------------------------------------------------------------------------
1 | import { remixRoutesOptionAdapter } from '@react-router/remix-routes-option-adapter'
2 | import { flatRoutes } from 'remix-flat-routes'
3 |
4 | export default remixRoutesOptionAdapter((defineRotue) =>
5 | flatRoutes('routes', defineRotue, {
6 | ignoredRouteFiles: ['**/_shared/**', '**/index.ts'],
7 | }),
8 | )
9 |
--------------------------------------------------------------------------------
/app/routes/_auth+/_layout/route.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, useLocation } from 'react-router'
2 |
3 | export default function AuthLayout() {
4 | const pathname = useLocation().pathname
5 | if (pathname === '/sign-in-2') {
6 | return
7 | }
8 |
9 | return (
10 |
11 |
12 |
13 |
23 |
24 |
25 |
Shadcn Admin
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/app/routes/_auth+/forgot-password/components/forgot-password-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod'
3 | import type { HTMLAttributes } from 'react'
4 | import { Form, useActionData, useNavigation } from 'react-router'
5 | import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
6 | import { Button } from '~/components/ui/button'
7 | import { Input } from '~/components/ui/input'
8 | import { Label } from '~/components/ui/label'
9 | import { cn } from '~/lib/utils'
10 | import { formSchema, type action } from '../route'
11 |
12 | type ForgotFormProps = HTMLAttributes
13 |
14 | export function ForgotForm({ className, ...props }: ForgotFormProps) {
15 | const actionData = useActionData()
16 | const [form, { email }] = useForm({
17 | lastResult: actionData?.lastResult,
18 | defaultValue: { email: '' },
19 | onValidate: ({ formData }) =>
20 | parseWithZod(formData, { schema: formSchema }),
21 | })
22 | const navigation = useNavigation()
23 |
24 | return (
25 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/routes/_auth+/forgot-password/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { Link } from 'react-router'
4 | import { dataWithSuccess } from 'remix-toast'
5 | import { z } from 'zod'
6 | import { Card } from '~/components/ui/card'
7 | import type { Route } from './+types/route'
8 | import { ForgotForm } from './components/forgot-password-form'
9 |
10 | export const formSchema = z.object({
11 | email: z
12 | .string({ required_error: 'Please enter your email' })
13 | .email({ message: 'Invalid email address' }),
14 | })
15 |
16 | export const action = async ({ request }: Route.ActionArgs) => {
17 | const submission = parseWithZod(await request.formData(), {
18 | schema: formSchema,
19 | })
20 | if (submission.status !== 'success') {
21 | return { lastResult: submission.reply() }
22 | }
23 | if (submission.value.email !== 'name@example.com') {
24 | return {
25 | lastResult: submission.reply({
26 | formErrors: ['Email not found in our records. Please try again.'],
27 | }),
28 | }
29 | }
30 | await setTimeout(1000)
31 | return dataWithSuccess(
32 | { lastResult: submission.reply({ resetForm: true }) },
33 | {
34 | message: 'Password reset link sent to your email',
35 | },
36 | )
37 | }
38 |
39 | export default function ForgotPassword() {
40 | return (
41 |
42 |
43 |
44 | Forgot Password
45 |
46 |
47 | Enter your registered email and we will send you a link to
48 | reset your password.
49 |
50 |
51 |
52 |
53 | Don't have an account?{' '}
54 |
58 | Sign up
59 |
60 | .
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/routes/_auth+/otp/components/otp-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod'
3 | import { useState, type HTMLAttributes } from 'react'
4 | import { Form, useActionData, useNavigation } from 'react-router'
5 | import { PinInput, PinInputField } from '~/components/pin-input'
6 | import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
7 | import { Button } from '~/components/ui/button'
8 | import { Input } from '~/components/ui/input'
9 | import { Separator } from '~/components/ui/separator'
10 | import { cn } from '~/lib/utils'
11 | import { formSchema, type action } from '../route'
12 |
13 | type OtpFormProps = HTMLAttributes
14 |
15 | export function OtpForm({ className, ...props }: OtpFormProps) {
16 | const actionData = useActionData()
17 | const [form, { otp }] = useForm({
18 | lastResult: actionData?.lastResult,
19 | defaultValue: { otp: '' },
20 | onValidate: ({ formData }) =>
21 | parseWithZod(formData, { schema: formSchema }),
22 | })
23 | const navigation = useNavigation()
24 | const [disabledBtn, setDisabledBtn] = useState(true)
25 |
26 | return (
27 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/app/routes/_auth+/otp/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { Link } from 'react-router'
4 | import { redirectWithSuccess } from 'remix-toast'
5 | import { z } from 'zod'
6 | import { Card } from '~/components/ui/card'
7 | import type { Route } from './+types/route'
8 | import { OtpForm } from './components/otp-form'
9 |
10 | export const formSchema = z.object({
11 | otp: z.string({ required_error: 'Please enter your otp code.' }),
12 | })
13 |
14 | export const action = async ({ request }: Route.ActionArgs) => {
15 | const submission = parseWithZod(await request.formData(), {
16 | schema: formSchema,
17 | })
18 | if (submission.status !== 'success') {
19 | return { lastResult: submission.reply() }
20 | }
21 |
22 | if (submission.value.otp !== '123456') {
23 | return {
24 | lastResult: submission.reply({ formErrors: ['Invalid OTP code'] }),
25 | }
26 | }
27 | await setTimeout(1000)
28 |
29 | throw await redirectWithSuccess('/', {
30 | message: 'You have successfully logged in!',
31 | })
32 | }
33 |
34 | export default function Otp() {
35 | return (
36 |
37 |
38 |
39 | Two-factor Authentication
40 |
41 |
42 | Please enter the authentication code. We have sent the
43 | authentication code to your email.
44 |
45 |
46 |
47 |
48 | Haven't received it?{' '}
49 |
53 | Resend a new code.
54 |
55 | .
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/app/routes/_auth+/sign-in-2/components/user-auth-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod'
3 | import { IconBrandFacebook, IconBrandGithub } from '@tabler/icons-react'
4 | import type { HTMLAttributes } from 'react'
5 | import { Form, Link, useActionData, useNavigation } from 'react-router'
6 | import { PasswordInput } from '~/components/password-input'
7 | import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
8 | import { Button } from '~/components/ui/button'
9 | import { Input } from '~/components/ui/input'
10 | import { Label } from '~/components/ui/label'
11 | import { cn } from '~/lib/utils'
12 | import { formSchema, type action } from '../route'
13 |
14 | type UserAuthFormProps = HTMLAttributes
15 |
16 | export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
17 | const actionData = useActionData()
18 | const [form, { email, password }] = useForm({
19 | lastResult: actionData?.lastResult,
20 | defaultValue: {
21 | email: '',
22 | password: '',
23 | },
24 | onValidate: ({ formData }) =>
25 | parseWithZod(formData, { schema: formSchema }),
26 | })
27 | const navigation = useNavigation()
28 | const isLoading = navigation.state === 'submitting'
29 |
30 | return (
31 |
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/app/routes/_auth+/sign-in/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { redirectWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import { Card } from '~/components/ui/card'
6 | import type { Route } from './+types/route'
7 | import { UserAuthForm } from './components/user-auth-form'
8 |
9 | export const formSchema = z.object({
10 | email: z
11 | .string({ required_error: 'Please enter your email' })
12 | .email({ message: 'Invalid email address' }),
13 | password: z.string({ required_error: 'Please enter your password' }).min(7, {
14 | message: 'Password must be at least 7 characters long',
15 | }),
16 | })
17 |
18 | export const action = async ({ request }: Route.ActionArgs) => {
19 | const submission = parseWithZod(await request.formData(), {
20 | schema: formSchema,
21 | })
22 |
23 | if (submission.status !== 'success') {
24 | return { lastResult: submission.reply() }
25 | }
26 |
27 | if (submission.value.email !== 'name@example.com') {
28 | return {
29 | lastResult: submission.reply({
30 | formErrors: ['Invalid email or password'],
31 | }),
32 | }
33 | }
34 | await setTimeout(1000)
35 |
36 | throw await redirectWithSuccess('/', {
37 | message: 'You have successfully logged in!',
38 | })
39 | }
40 |
41 | export default function SignIn() {
42 | return (
43 |
44 |
45 |
Login
46 |
47 | Enter your email and password below
48 | to log into your account
49 |
50 |
51 |
52 |
53 | By clicking login, you agree to our{' '}
54 |
58 | Terms of Service
59 | {' '}
60 | and{' '}
61 |
65 | Privacy Policy
66 |
67 | .
68 |
69 |
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/app/routes/_auth+/sign-up/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { Link } from 'react-router'
4 | import { redirectWithSuccess } from 'remix-toast'
5 | import { z } from 'zod'
6 | import { Card } from '~/components/ui/card'
7 | import type { Route } from './+types/route'
8 | import { SignUpForm } from './components/sign-up-form'
9 |
10 | export const formSchema = z
11 | .object({
12 | email: z
13 | .string()
14 | .min(1, { message: 'Please enter your email' })
15 | .email({ message: 'Invalid email address' }),
16 | password: z
17 | .string()
18 | .min(1, {
19 | message: 'Please enter your password',
20 | })
21 | .min(7, {
22 | message: 'Password must be at least 7 characters long',
23 | }),
24 | confirmPassword: z.string(),
25 | })
26 | .refine((data) => data.password === data.confirmPassword, {
27 | message: "Passwords don't match.",
28 | path: ['confirmPassword'],
29 | })
30 |
31 | export const action = async ({ request }: Route.ActionArgs) => {
32 | const submission = parseWithZod(await request.formData(), {
33 | schema: formSchema,
34 | })
35 | if (submission.status !== 'success') {
36 | return { lastResult: submission.reply() }
37 | }
38 |
39 | if (submission.value.email !== 'name@example.com') {
40 | return {
41 | lastResult: submission.reply({
42 | formErrors: ['Invalid email or password'],
43 | }),
44 | }
45 | }
46 | await setTimeout(1000)
47 |
48 | throw await redirectWithSuccess('/', {
49 | message: 'Account created successfully!',
50 | })
51 | }
52 |
53 | export default function SignUp() {
54 | return (
55 |
56 |
57 |
58 | Create an account
59 |
60 |
61 | Enter your email and password to create an account.
62 | Already have an account?{' '}
63 |
67 | Sign In
68 |
69 |
70 |
71 |
72 |
73 | By creating an account, you agree to our{' '}
74 |
78 | Terms of Service
79 | {' '}
80 | and{' '}
81 |
85 | Privacy Policy
86 |
87 | .
88 |
89 |
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/_index/components/overview.tsx:
--------------------------------------------------------------------------------
1 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'
2 |
3 | const data = [
4 | {
5 | name: 'Jan',
6 | total: Math.floor(Math.random() * 5000) + 1000,
7 | },
8 | {
9 | name: 'Feb',
10 | total: Math.floor(Math.random() * 5000) + 1000,
11 | },
12 | {
13 | name: 'Mar',
14 | total: Math.floor(Math.random() * 5000) + 1000,
15 | },
16 | {
17 | name: 'Apr',
18 | total: Math.floor(Math.random() * 5000) + 1000,
19 | },
20 | {
21 | name: 'May',
22 | total: Math.floor(Math.random() * 5000) + 1000,
23 | },
24 | {
25 | name: 'Jun',
26 | total: Math.floor(Math.random() * 5000) + 1000,
27 | },
28 | {
29 | name: 'Jul',
30 | total: Math.floor(Math.random() * 5000) + 1000,
31 | },
32 | {
33 | name: 'Aug',
34 | total: Math.floor(Math.random() * 5000) + 1000,
35 | },
36 | {
37 | name: 'Sep',
38 | total: Math.floor(Math.random() * 5000) + 1000,
39 | },
40 | {
41 | name: 'Oct',
42 | total: Math.floor(Math.random() * 5000) + 1000,
43 | },
44 | {
45 | name: 'Nov',
46 | total: Math.floor(Math.random() * 5000) + 1000,
47 | },
48 | {
49 | name: 'Dec',
50 | total: Math.floor(Math.random() * 5000) + 1000,
51 | },
52 | ]
53 |
54 | export function Overview() {
55 | return (
56 |
57 |
58 |
65 | `$${value}`}
71 | />
72 |
78 |
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/_index/components/recent-sales.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'
2 |
3 | export function RecentSales() {
4 | return (
5 |
6 |
7 |
8 |
9 | OM
10 |
11 |
12 |
13 |
Olivia Martin
14 |
15 | olivia.martin@email.com
16 |
17 |
18 |
+$1,999.00
19 |
20 |
21 |
22 |
23 |
24 | JL
25 |
26 |
27 |
28 |
Jackson Lee
29 |
30 | jackson.lee@email.com
31 |
32 |
33 |
+$39.00
34 |
35 |
36 |
37 |
38 |
39 | IN
40 |
41 |
42 |
43 |
Isabella Nguyen
44 |
45 | isabella.nguyen@email.com
46 |
47 |
48 |
+$299.00
49 |
50 |
51 |
52 |
53 |
54 |
55 | WK
56 |
57 |
58 |
59 |
William Kim
60 |
will@email.com
61 |
62 |
+$99.00
63 |
64 |
65 |
66 |
67 |
68 |
69 | SD
70 |
71 |
72 |
73 |
Sofia Davis
74 |
75 | sofia.davis@email.com
76 |
77 |
78 |
+$39.00
79 |
80 |
81 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/_layout/route.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router'
2 | import { AppSidebar } from '~/components/layout/app-sidebar'
3 | import { SidebarProvider } from '~/components/ui/sidebar'
4 | import { SearchProvider } from '~/context/search-context'
5 | import { cn } from '~/lib/utils'
6 |
7 | export default function DashboardLayout() {
8 | return (
9 |
10 |
11 |
12 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/apps/data/apps.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconBrandDiscord,
3 | IconBrandDocker,
4 | IconBrandFigma,
5 | IconBrandGithub,
6 | IconBrandGitlab,
7 | IconBrandGmail,
8 | IconBrandMedium,
9 | IconBrandNotion,
10 | IconBrandSkype,
11 | IconBrandSlack,
12 | IconBrandStripe,
13 | IconBrandTelegram,
14 | IconBrandTrello,
15 | IconBrandWhatsapp,
16 | IconBrandZoom,
17 | } from '@tabler/icons-react'
18 |
19 | export const apps = [
20 | {
21 | name: 'Telegram',
22 | logo: ,
23 | connected: false,
24 | desc: 'Connect with Telegram for real-time communication.',
25 | },
26 | {
27 | name: 'Notion',
28 | logo: ,
29 | connected: true,
30 | desc: 'Effortlessly sync Notion pages for seamless collaboration.',
31 | },
32 | {
33 | name: 'Figma',
34 | logo: ,
35 | connected: true,
36 | desc: 'View and collaborate on Figma designs in one place.',
37 | },
38 | {
39 | name: 'Trello',
40 | logo: ,
41 | connected: false,
42 | desc: 'Sync Trello cards for streamlined project management.',
43 | },
44 | {
45 | name: 'Slack',
46 | logo: ,
47 | connected: false,
48 | desc: 'Integrate Slack for efficient team communication',
49 | },
50 | {
51 | name: 'Zoom',
52 | logo: ,
53 | connected: true,
54 | desc: 'Host Zoom meetings directly from the dashboard.',
55 | },
56 | {
57 | name: 'Stripe',
58 | logo: ,
59 | connected: false,
60 | desc: 'Easily manage Stripe transactions and payments.',
61 | },
62 | {
63 | name: 'Gmail',
64 | logo: ,
65 | connected: true,
66 | desc: 'Access and manage Gmail messages effortlessly.',
67 | },
68 | {
69 | name: 'Medium',
70 | logo: ,
71 | connected: false,
72 | desc: 'Explore and share Medium stories on your dashboard.',
73 | },
74 | {
75 | name: 'Skype',
76 | logo: ,
77 | connected: false,
78 | desc: 'Connect with Skype contacts seamlessly.',
79 | },
80 | {
81 | name: 'Docker',
82 | logo: ,
83 | connected: false,
84 | desc: 'Effortlessly manage Docker containers on your dashboard.',
85 | },
86 | {
87 | name: 'GitHub',
88 | logo: ,
89 | connected: false,
90 | desc: 'Streamline code management with GitHub integration.',
91 | },
92 | {
93 | name: 'GitLab',
94 | logo: ,
95 | connected: false,
96 | desc: 'Efficiently manage code projects with GitLab integration.',
97 | },
98 | {
99 | name: 'Discord',
100 | logo: ,
101 | connected: false,
102 | desc: 'Connect with Discord for seamless team communication.',
103 | },
104 | {
105 | name: 'WhatsApp',
106 | logo: ,
107 | connected: false,
108 | desc: 'Easily integrate WhatsApp for direct messaging.',
109 | },
110 | ]
111 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/help-center/route.tsx:
--------------------------------------------------------------------------------
1 | import ComingSoon from '~/components/coming-soon'
2 |
3 | export default ComingSoon
4 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/_index/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import ContentSection from '../_layout/components/content-section'
6 | import type { Route } from './+types/route'
7 | import ProfileForm from './profile-form'
8 |
9 | export const profileFormSchema = z.object({
10 | username: z
11 | .string()
12 | .min(2, {
13 | message: 'Username must be at least 2 characters.',
14 | })
15 | .max(30, {
16 | message: 'Username must not be longer than 30 characters.',
17 | }),
18 | email: z
19 | .string({
20 | required_error: 'Please select an email to display.',
21 | })
22 | .email(),
23 | bio: z.string().max(160).min(4),
24 | urls: z
25 | .array(z.string().url({ message: 'Please enter a valid URL.' }))
26 | .optional(),
27 | })
28 |
29 | export const action = async ({ request }: Route.ActionArgs) => {
30 | const submission = parseWithZod(await request.formData(), {
31 | schema: profileFormSchema,
32 | })
33 | if (submission.status !== 'success') {
34 | return { lastResult: submission.reply() }
35 | }
36 | if (submission.value.username !== 'shadcn') {
37 | return {
38 | lastResult: submission.reply({
39 | formErrors: ['Username must be shadcn'],
40 | }),
41 | }
42 | }
43 |
44 | // Save the form data to the database or API.
45 | await setTimeout(1000)
46 |
47 | return dataWithSuccess(
48 | {
49 | lastResult: submission.reply({ resetForm: true }),
50 | },
51 | {
52 | message: 'Profile updated!',
53 | description: JSON.stringify(submission.value, null, 2),
54 | },
55 | )
56 | }
57 |
58 | export default function SettingsProfile() {
59 | return (
60 |
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/_layout/components/content-section.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollArea } from '~/components/ui/scroll-area'
2 | import { Separator } from '~/components/ui/separator'
3 |
4 | interface ContentSectionProps {
5 | title: string
6 | desc: string
7 | children: React.JSX.Element
8 | }
9 |
10 | export default function ContentSection({
11 | title,
12 | desc,
13 | children,
14 | }: ContentSectionProps) {
15 | return (
16 |
17 |
18 |
{title}
19 |
{desc}
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/_layout/components/sidebar-nav.tsx:
--------------------------------------------------------------------------------
1 | import { useState, type JSX } from 'react'
2 | import { Link, useLocation, useNavigate } from 'react-router'
3 | import { buttonVariants } from '~/components/ui/button'
4 | import { ScrollArea } from '~/components/ui/scroll-area'
5 | import {
6 | Select,
7 | SelectContent,
8 | SelectItem,
9 | SelectTrigger,
10 | SelectValue,
11 | } from '~/components/ui/select'
12 | import { cn } from '~/lib/utils'
13 |
14 | interface SidebarNavProps extends React.HTMLAttributes {
15 | items: {
16 | href: string
17 | title: string
18 | icon: JSX.Element
19 | }[]
20 | }
21 |
22 | export default function SidebarNav({
23 | className,
24 | items,
25 | ...props
26 | }: SidebarNavProps) {
27 | const { pathname } = useLocation()
28 | const navigate = useNavigate()
29 | const [val, setVal] = useState(pathname ?? '/settings')
30 |
31 | const handleSelect = (e: string) => {
32 | setVal(e)
33 | navigate(e)
34 | }
35 |
36 | return (
37 | <>
38 |
39 |
40 |
41 |
42 |
43 |
44 | {items.map((item) => (
45 |
46 |
47 | {item.icon}
48 | {item.title}
49 |
50 |
51 | ))}
52 |
53 |
54 |
55 |
56 |
61 |
68 | {items.map((item) => (
69 |
80 | {item.icon}
81 | {item.title}
82 |
83 | ))}
84 |
85 |
86 | >
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/_layout/route.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconBrowserCheck,
3 | IconNotification,
4 | IconPalette,
5 | IconTool,
6 | IconUser,
7 | } from '@tabler/icons-react'
8 | import { Outlet } from 'react-router'
9 | import { Header } from '~/components/layout/header'
10 | import { Main } from '~/components/layout/main'
11 | import { ProfileDropdown } from '~/components/profile-dropdown'
12 | import { Search } from '~/components/search'
13 | import { ThemeSwitch } from '~/components/theme-switch'
14 | import { Separator } from '~/components/ui/separator'
15 | import SidebarNav from './components/sidebar-nav'
16 |
17 | export default function Settings() {
18 | return (
19 | <>
20 | {/* ===== Top Heading ===== */}
21 |
28 |
29 |
30 |
31 |
32 | Settings
33 |
34 |
35 | Manage your account settings and set e-mail preferences.
36 |
37 |
38 |
39 |
47 |
48 | >
49 | )
50 | }
51 |
52 | const sidebarNavItems = [
53 | {
54 | title: 'Profile',
55 | icon: ,
56 | href: '/settings',
57 | },
58 | {
59 | title: 'Account',
60 | icon: ,
61 | href: '/settings/account',
62 | },
63 | {
64 | title: 'Appearance',
65 | icon: ,
66 | href: '/settings/appearance',
67 | },
68 | {
69 | title: 'Notifications',
70 | icon: ,
71 | href: '/settings/notifications',
72 | },
73 | {
74 | title: 'Display',
75 | icon: ,
76 | href: '/settings/display',
77 | },
78 | ]
79 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/account/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import ContentSection from '../_layout/components/content-section'
6 | import type { Route } from './+types/route'
7 | import { AccountForm } from './account-form'
8 |
9 | export const accountFormSchema = z.object({
10 | name: z
11 | .string({ required_error: 'Name must be at least 2 characters.' })
12 | .min(2, {
13 | message: 'Name must be at least 2 characters.',
14 | })
15 | .max(30, {
16 | message: 'Name must not be longer than 30 characters.',
17 | }),
18 | dob: z.date({
19 | required_error: 'A date of birth is required.',
20 | }),
21 | language: z.string({
22 | required_error: 'Please select a language.',
23 | }),
24 | })
25 |
26 | export const action = async ({ request }: Route.ActionArgs) => {
27 | const submission = parseWithZod(await request.formData(), {
28 | schema: accountFormSchema,
29 | })
30 | if (submission.status !== 'success') {
31 | return { lastResult: submission.reply() }
32 | }
33 |
34 | // Save the form data to the database or API.
35 | await setTimeout(1000)
36 |
37 | return dataWithSuccess(
38 | {
39 | lastResult: submission.reply({ resetForm: true }),
40 | },
41 | {
42 | message: 'Account settings updated.',
43 | description: JSON.stringify(submission.value, null, 2),
44 | },
45 | )
46 | }
47 |
48 | export default function SettingsAccount() {
49 | return (
50 |
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/appearance/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import ContentSection from '../_layout/components/content-section'
6 | import type { Route } from './+types/route'
7 | import { AppearanceForm } from './appearance-form'
8 |
9 | export const appearanceFormSchema = z.object({
10 | theme: z.enum(['light', 'dark'], {
11 | required_error: 'Please select a theme.',
12 | }),
13 | font: z.enum(['inter', 'manrope', 'system'], {
14 | invalid_type_error: 'Select a font',
15 | required_error: 'Please select a font.',
16 | }),
17 | })
18 |
19 | export const action = async ({ request }: Route.ActionArgs) => {
20 | const submission = parseWithZod(await request.formData(), {
21 | schema: appearanceFormSchema,
22 | })
23 | if (submission.status !== 'success') {
24 | return { lastResult: submission.reply() }
25 | }
26 |
27 | // Save the form data to the database or API.
28 | await setTimeout(1000)
29 |
30 | return dataWithSuccess(
31 | {
32 | lastResult: submission.reply({ resetForm: true }),
33 | },
34 | {
35 | message: 'Appearance settings updated.',
36 | description: JSON.stringify(submission.value, null, 2),
37 | },
38 | )
39 | }
40 |
41 | export default function SettingsAppearance() {
42 | return (
43 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/display/display-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod'
3 | import { Form, useActionData, useNavigation } from 'react-router'
4 | import type { z } from 'zod'
5 | import { Button } from '~/components/ui/button'
6 | import { Checkbox } from '~/components/ui/checkbox'
7 | import { Label } from '~/components/ui/label'
8 | import { displayFormSchema, type action } from './route'
9 |
10 | const items = [
11 | {
12 | id: 'recents',
13 | label: 'Recents',
14 | },
15 | {
16 | id: 'home',
17 | label: 'Home',
18 | },
19 | {
20 | id: 'applications',
21 | label: 'Applications',
22 | },
23 | {
24 | id: 'desktop',
25 | label: 'Desktop',
26 | },
27 | {
28 | id: 'downloads',
29 | label: 'Downloads',
30 | },
31 | {
32 | id: 'documents',
33 | label: 'Documents',
34 | },
35 | ] as const
36 |
37 | type DisplayFormValues = z.infer
38 |
39 | // This can come from your database or API.
40 | const defaultValue: Partial = {
41 | items: ['recents', 'home'],
42 | }
43 |
44 | export function DisplayForm() {
45 | const actionData = useActionData()
46 | const [form, fields] = useForm({
47 | lastResult: actionData?.lastResult,
48 | defaultValue,
49 | onValidate: ({ formData }) =>
50 | parseWithZod(formData, { schema: displayFormSchema }),
51 | })
52 | const itemList = fields.items.getFieldList()
53 | const navigation = useNavigation()
54 |
55 | return (
56 |
106 | )
107 | }
108 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/display/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import ContentSection from '../_layout/components/content-section'
6 | import type { Route } from './+types/route'
7 | import { DisplayForm } from './display-form'
8 |
9 | export const displayFormSchema = z.object({
10 | items: z.array(z.string()).refine((value) => value.some((item) => item), {
11 | message: 'You have to select at least one item.',
12 | }),
13 | })
14 |
15 | export const action = async ({ request }: Route.ActionArgs) => {
16 | const submission = parseWithZod(await request.formData(), {
17 | schema: displayFormSchema,
18 | })
19 | if (submission.status !== 'success') {
20 | return { lastResult: submission.reply() }
21 | }
22 |
23 | // Save the form data to the database or API.
24 | await setTimeout(1000)
25 |
26 | return dataWithSuccess(
27 | {
28 | lastResult: submission.reply({ resetForm: true }),
29 | },
30 | {
31 | message: 'Display settings updated.',
32 | description: JSON.stringify(submission.value, null, 2),
33 | },
34 | )
35 | }
36 |
37 | export default function SettingsDisplay() {
38 | return (
39 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/settings+/notifications/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import ContentSection from '../_layout/components/content-section'
6 | import type { Route } from './+types/route'
7 | import { NotificationsForm } from './notifications-form'
8 |
9 | export const notificationsFormSchema = z.object({
10 | type: z.enum(['all', 'mentions', 'none'], {
11 | required_error: 'You need to select a notification type.',
12 | }),
13 | mobile: z.boolean().default(false).optional(),
14 | communication_emails: z.boolean().default(false).optional(),
15 | social_emails: z.boolean().default(false).optional(),
16 | marketing_emails: z.boolean().default(false).optional(),
17 | security_emails: z.boolean(),
18 | })
19 |
20 | export const action = async ({ request }: Route.ActionArgs) => {
21 | const submission = parseWithZod(await request.formData(), {
22 | schema: notificationsFormSchema,
23 | })
24 | if (submission.status !== 'success') {
25 | return { lastResult: submission.reply() }
26 | }
27 |
28 | // Save the form data to the database or API.
29 | await setTimeout(1000)
30 |
31 | return dataWithSuccess(
32 | {
33 | lastResult: submission.reply({ resetForm: true }),
34 | },
35 | {
36 | message: 'Notification settings updated.',
37 | description: JSON.stringify(submission.value, null, 2),
38 | },
39 | )
40 | }
41 |
42 | export default function SettingsNotifications() {
43 | return (
44 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/$task._index/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout as sleep } from 'node:timers/promises'
3 | import { data } from 'react-router'
4 | import { redirectWithSuccess } from 'remix-toast'
5 | import { Separator } from '~/components/ui/separator'
6 | import {
7 | TasksMutateForm,
8 | updateSchema,
9 | } from '../_shared/components/tasks-mutate-form'
10 | import { tasks } from '../_shared/data/tasks'
11 | import type { Route } from './+types/route'
12 |
13 | export const handle = {
14 | breadcrumb: () => ({ label: 'Edit' }),
15 | }
16 |
17 | export const loader = async ({ params }: Route.LoaderArgs) => {
18 | const task = tasks.find((t) => t.id === params.task)
19 | if (!task) {
20 | throw data(null, { status: 404 })
21 | }
22 | return { task }
23 | }
24 |
25 | export const action = async ({ request }: Route.ActionArgs) => {
26 | const url = new URL(request.url)
27 | const submission = parseWithZod(await request.formData(), {
28 | schema: updateSchema,
29 | })
30 | if (submission.status !== 'success') {
31 | return { lastResult: submission.reply() }
32 | }
33 |
34 | // Update the task
35 | await sleep(1000)
36 | const taskIndex = tasks.findIndex((t) => t.id === submission.value.id)
37 | if (taskIndex === -1) {
38 | throw data(null, { status: 404, statusText: 'Task not found' })
39 | }
40 | tasks.splice(taskIndex, 1, submission.value)
41 |
42 | return redirectWithSuccess(`/tasks?${url.searchParams.toString()}`, {
43 | message: 'Task updated successfully',
44 | description: `The task ${submission.value.id} has been updated.`,
45 | })
46 | }
47 |
48 | export default function TaskEdit({
49 | loaderData: { task },
50 | }: Route.ComponentProps) {
51 | return (
52 |
53 |
54 |
Edit Task
55 |
56 | Edit the task by providing necessary info. Click save when you're
57 | done.
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/$task.delete/route.tsx:
--------------------------------------------------------------------------------
1 | import { setTimeout as sleep } from 'node:timers/promises'
2 | import { useEffect } from 'react'
3 | import { data, href, useFetcher } from 'react-router'
4 | import { dataWithSuccess } from 'remix-toast'
5 | import { ConfirmDialog } from '~/components/confirm-dialog'
6 | import type { Task } from '../_shared/data/schema'
7 | import { tasks } from '../_shared/data/tasks'
8 | import type { Route } from './+types/route'
9 |
10 | export const action = async ({ params }: Route.ActionArgs) => {
11 | const taskIndex = tasks.findIndex((t) => t.id === params.task)
12 | if (taskIndex === -1) {
13 | throw data(null, { status: 404, statusText: 'Task not found' })
14 | }
15 |
16 | // Delete the task
17 | await sleep(1000)
18 | tasks.splice(taskIndex, 1)
19 |
20 | return dataWithSuccess(
21 | {
22 | done: true,
23 | },
24 | {
25 | message: 'Task deleted successfully',
26 | description: `Task with ID ${params.task} has been deleted.`,
27 | },
28 | )
29 | }
30 |
31 | export function TaskDeleteConfirmDialog({
32 | task,
33 | open,
34 | onOpenChange,
35 | }: {
36 | task: Task
37 | open: boolean
38 | onOpenChange: (v: boolean) => void
39 | }) {
40 | const fetcher = useFetcher({ key: `task-delete-${task.id}` })
41 |
42 | useEffect(() => {
43 | if (fetcher.data?.done) onOpenChange(false)
44 | }, [fetcher.data, onOpenChange])
45 |
46 | return (
47 |
58 | You are about to delete a task with the ID {task.id} .{' '}
59 |
60 | This action cannot be undone.
61 | >
62 | }
63 | confirmText="Delete"
64 | />
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/$task.label/route.ts:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout } from 'node:timers/promises'
3 | import { dataWithError, dataWithSuccess } from 'remix-toast'
4 | import { z } from 'zod'
5 | import { tasks } from '../_shared/data/tasks'
6 | import type { Route } from './+types/route'
7 |
8 | export const action = async ({ request, params }: Route.ActionArgs) => {
9 | const taskIndex = tasks.findIndex((t) => t.id === params.task)
10 | if (taskIndex === -1) {
11 | throw dataWithError(null, { message: 'Task not found' })
12 | }
13 |
14 | const submission = parseWithZod(await request.formData(), {
15 | schema: z.object({ label: z.string() }),
16 | })
17 | if (submission.status !== 'success') {
18 | throw dataWithError(submission.error, { message: 'Invalid submission' })
19 | }
20 |
21 | // update task label
22 | await setTimeout(1000)
23 | tasks[taskIndex].label = submission.value.label
24 |
25 | return dataWithSuccess(null, {
26 | message: 'Task label updated successfully',
27 | description: `Task ${params.task} label has been updated to ${submission.value.label}`,
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/data-table-column-header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowDownIcon,
3 | ArrowUpIcon,
4 | CaretSortIcon,
5 | EyeNoneIcon,
6 | } from '@radix-ui/react-icons'
7 | import type { Column } from '@tanstack/react-table'
8 | import { Button } from '~/components/ui/button'
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuSeparator,
14 | DropdownMenuTrigger,
15 | } from '~/components/ui/dropdown-menu'
16 | import { cn } from '~/lib/utils'
17 | import { useDataTableState } from '../hooks/use-data-table-state'
18 |
19 | interface DataTableColumnHeaderProps
20 | extends React.HTMLAttributes {
21 | column: Column
22 | title: string
23 | }
24 |
25 | export function DataTableColumnHeader({
26 | column,
27 | title,
28 | className,
29 | }: DataTableColumnHeaderProps) {
30 | const { sort, updateSort } = useDataTableState()
31 |
32 | column.id
33 | if (!column.getCanSort()) {
34 | return {title}
35 | }
36 |
37 | return (
38 |
39 |
40 |
41 |
46 | {title}
47 | {column.id === sort.sort_by && sort.sort_order === 'desc' ? (
48 |
49 | ) : (column.id === sort.sort_by && sort.sort_order) === 'asc' ? (
50 |
51 | ) : (
52 |
53 | )}
54 |
55 |
56 |
57 |
59 | updateSort({
60 | sort_by: column.id,
61 | sort_order: 'asc',
62 | })
63 | }
64 | >
65 |
66 | Asc
67 |
68 |
70 | updateSort({
71 | sort_by: column.id,
72 | sort_order: 'desc',
73 | })
74 | }
75 | >
76 |
77 | Desc
78 |
79 |
80 | column.toggleVisibility(false)}>
81 |
82 | Hide
83 |
84 |
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/data-table-row-actions.tsx:
--------------------------------------------------------------------------------
1 | import { DotsHorizontalIcon } from '@radix-ui/react-icons'
2 | import { IconTrash } from '@tabler/icons-react'
3 | import type { Row } from '@tanstack/react-table'
4 | import { useState } from 'react'
5 | import { href, Link, useFetcher } from 'react-router'
6 | import { Button } from '~/components/ui/button'
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuRadioGroup,
12 | DropdownMenuRadioItem,
13 | DropdownMenuSeparator,
14 | DropdownMenuShortcut,
15 | DropdownMenuSub,
16 | DropdownMenuSubContent,
17 | DropdownMenuSubTrigger,
18 | DropdownMenuTrigger,
19 | } from '~/components/ui/dropdown-menu'
20 | import { TaskDeleteConfirmDialog } from '../../$task.delete/route'
21 | import { labels } from '../../_shared/data/data'
22 | import { taskSchema } from '../../_shared/data/schema'
23 |
24 | interface DataTableRowActionsProps {
25 | row: Row
26 | }
27 |
28 | export function DataTableRowActions({
29 | row,
30 | }: DataTableRowActionsProps) {
31 | const task = taskSchema.parse(row.original)
32 | const fetcher = useFetcher({ key: `task-label-${task.id}` })
33 | const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
34 |
35 | return (
36 | <>
37 |
38 |
39 |
43 |
44 | Open menu
45 |
46 |
47 |
48 |
49 | Edit
50 |
51 | Make a copy
52 | Favorite
53 |
54 |
55 | Labels
56 |
57 | {
60 | fetcher.submit(
61 | { id: task.id, label: value },
62 | {
63 | action: href('/tasks/:task/label', { task: task.id }),
64 | method: 'POST',
65 | },
66 | )
67 | }}
68 | >
69 | {labels.map((label) => (
70 |
71 | {label.label}
72 |
73 | ))}
74 |
75 |
76 |
77 |
78 | setDeleteDialogOpen(true)}
81 | >
82 | Delete
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | {
94 | setDeleteDialogOpen(v)
95 | }}
96 | />
97 | >
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/data-table-toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { Cross2Icon } from '@radix-ui/react-icons'
2 | import type { Table } from '@tanstack/react-table'
3 | import { Button } from '~/components/ui/button'
4 | import { FILTER_FIELD_LABELS, FILTER_FIELDS } from '../config'
5 | import { useDataTableState } from '../hooks/use-data-table-state'
6 | import { DataTableFacetedFilter } from './data-table-faceted-filter'
7 | import { DataTableViewOptions } from './data-table-view-options'
8 | import { SearchInput } from './search-input'
9 |
10 | export type FacetedCountProps = Record>
11 |
12 | interface DataTableToolbarProps {
13 | table: Table
14 | facetedCounts?: FacetedCountProps
15 | }
16 |
17 | export function DataTableToolbar({
18 | table,
19 | facetedCounts,
20 | }: DataTableToolbarProps) {
21 | const { search, isFiltered, resetFilters } = useDataTableState()
22 |
23 | return (
24 |
25 |
26 |
31 |
32 | {FILTER_FIELDS.filter((filterKey) => table.getColumn(filterKey)).map(
33 | (filterKey) => {
34 | const options = FILTER_FIELD_LABELS[filterKey]
35 | return (
36 | ({
41 | ...option,
42 | count: facetedCounts?.[filterKey][option.value],
43 | }))}
44 | />
45 | )
46 | },
47 | )}
48 |
49 | {isFiltered && (
50 |
resetFilters()}
54 | className="h-8 px-2 lg:px-3"
55 | >
56 | Reset
57 |
58 |
59 | )}
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/data-table-view-options.tsx:
--------------------------------------------------------------------------------
1 | import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
2 | import { MixerHorizontalIcon } from '@radix-ui/react-icons'
3 | import type { Table } from '@tanstack/react-table'
4 | import { Button } from '~/components/ui/button'
5 | import {
6 | DropdownMenu,
7 | DropdownMenuCheckboxItem,
8 | DropdownMenuContent,
9 | DropdownMenuLabel,
10 | DropdownMenuSeparator,
11 | } from '~/components/ui/dropdown-menu'
12 |
13 | interface DataTableViewOptionsProps {
14 | table: Table
15 | }
16 |
17 | export function DataTableViewOptions({
18 | table,
19 | }: DataTableViewOptionsProps) {
20 | return (
21 |
22 |
23 |
28 |
29 | View
30 |
31 |
32 |
33 | Toggle columns
34 |
35 | {table
36 | .getAllColumns()
37 | .filter(
38 | (column) =>
39 | typeof column.accessorFn !== 'undefined' && column.getCanHide(),
40 | )
41 | .map((column) => {
42 | return (
43 | column.toggleVisibility(!!value)}
48 | >
49 | {column.id}
50 |
51 | )
52 | })}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/data-table.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type ColumnDef,
3 | type VisibilityState,
4 | flexRender,
5 | getCoreRowModel,
6 | getSortedRowModel,
7 | useReactTable,
8 | } from '@tanstack/react-table'
9 | import * as React from 'react'
10 | import {
11 | Table,
12 | TableBody,
13 | TableCell,
14 | TableHead,
15 | TableHeader,
16 | TableRow,
17 | } from '~/components/ui/table'
18 | import {
19 | DataTablePagination,
20 | type PaginationProps,
21 | } from './data-table-pagination'
22 | import { DataTableToolbar, type FacetedCountProps } from './data-table-toolbar'
23 |
24 | interface DataTableProps {
25 | columns: ColumnDef[]
26 | data: TData[]
27 | pagination: PaginationProps
28 | facetedCounts?: FacetedCountProps
29 | }
30 |
31 | export function DataTable({
32 | columns,
33 | data,
34 | pagination,
35 | facetedCounts,
36 | }: DataTableProps) {
37 | const [rowSelection, setRowSelection] = React.useState({})
38 | const [columnVisibility, setColumnVisibility] =
39 | React.useState({})
40 |
41 | const table = useReactTable({
42 | data,
43 | columns,
44 | state: {
45 | columnVisibility,
46 | rowSelection,
47 | },
48 | enableRowSelection: true,
49 | onRowSelectionChange: setRowSelection,
50 | onColumnVisibilityChange: setColumnVisibility,
51 | getCoreRowModel: getCoreRowModel(),
52 | getSortedRowModel: getSortedRowModel(),
53 | })
54 |
55 | return (
56 |
57 |
58 |
59 |
60 |
61 | {table.getHeaderGroups().map((headerGroup) => (
62 |
63 | {headerGroup.headers.map((header) => {
64 | return (
65 |
66 | {header.isPlaceholder
67 | ? null
68 | : flexRender(
69 | header.column.columnDef.header,
70 | header.getContext(),
71 | )}
72 |
73 | )
74 | })}
75 |
76 | ))}
77 |
78 |
79 | {table.getRowModel().rows?.length ? (
80 | table.getRowModel().rows.map((row) => (
81 |
85 | {row.getVisibleCells().map((cell) => (
86 |
87 | {flexRender(
88 | cell.column.columnDef.cell,
89 | cell.getContext(),
90 | )}
91 |
92 | ))}
93 |
94 | ))
95 | ) : (
96 |
97 |
101 | No results.
102 |
103 |
104 | )}
105 |
106 |
107 |
108 |
109 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/components/search-input.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from 'lucide-react'
2 | import { useSearchParams } from 'react-router'
3 | import { Button } from '~/components/ui/button'
4 | import { Input } from '~/components/ui/input'
5 | import { cn } from '~/lib/utils'
6 | import { SEARCH_FIELD } from '../config'
7 |
8 | interface SearchInputProps extends React.ComponentPropsWithRef {}
9 | export const SearchInput = ({ className, ...rest }: SearchInputProps) => {
10 | const [searchParams] = useSearchParams()
11 |
12 | return (
13 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/config/constants.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IconArrowDown,
3 | IconArrowRight,
4 | IconArrowUp,
5 | IconCircle,
6 | IconCircleCheck,
7 | IconCircleX,
8 | IconExclamationCircle,
9 | IconStopwatch,
10 | } from '@tabler/icons-react'
11 |
12 | // Search fields
13 | export const SEARCH_FIELD = 'title' as const
14 |
15 | // Faceted filter options
16 | export const FILTER_FIELD_LABELS = {
17 | status: [
18 | {
19 | value: 'backlog',
20 | label: 'Backlog',
21 | icon: IconExclamationCircle,
22 | },
23 | {
24 | value: 'todo',
25 | label: 'Todo',
26 | icon: IconCircle,
27 | },
28 | {
29 | value: 'in progress',
30 | label: 'In Progress',
31 | icon: IconStopwatch,
32 | },
33 | {
34 | value: 'done',
35 | label: 'Done',
36 | icon: IconCircleCheck,
37 | },
38 | {
39 | value: 'canceled',
40 | label: 'Canceled',
41 | icon: IconCircleX,
42 | },
43 | ],
44 | priority: [
45 | {
46 | label: 'High',
47 | value: 'high',
48 | icon: IconArrowUp,
49 | },
50 | {
51 | label: 'Medium',
52 | value: 'medium',
53 | icon: IconArrowRight,
54 | },
55 | {
56 | label: 'Low',
57 | value: 'low',
58 | icon: IconArrowDown,
59 | },
60 | ],
61 | } as const
62 | export const FILTER_FIELDS = Object.keys(
63 | FILTER_FIELD_LABELS,
64 | ) as (keyof typeof FILTER_FIELD_LABELS)[]
65 |
66 | // Pagination items per page options
67 | export const PAGINATION_PER_PAGE_ITEMS = ['10', '20', '30', '40', '50'] as const
68 | export const PAGINATION_PER_PAGE_DEFAULT = PAGINATION_PER_PAGE_ITEMS[0]
69 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './columns'
2 | export * from './constants'
3 | export * from './schema'
4 | export * from './types'
5 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/config/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import {
3 | FILTER_FIELDS,
4 | PAGINATION_PER_PAGE_DEFAULT,
5 | PAGINATION_PER_PAGE_ITEMS,
6 | SEARCH_FIELD,
7 | } from '../config'
8 |
9 | // Define the schema for the search query
10 | export const SearchSchema = z.object({
11 | [SEARCH_FIELD]: z.preprocess(
12 | (val) => (val === null ? undefined : val),
13 | z.string().optional().default(''),
14 | ),
15 | })
16 |
17 | // Define the schema for filters
18 | export const FilterSchema = z.object(
19 | FILTER_FIELDS.reduce(
20 | (acc, field) => {
21 | acc[field] = z.array(z.string()).optional().default([])
22 | return acc
23 | },
24 | {} as Record<
25 | (typeof FILTER_FIELDS)[number],
26 | z.ZodDefault>>
27 | >,
28 | ),
29 | )
30 |
31 | // Define the schema for sorting
32 | export const SortSchema = z.object({
33 | sort_by: z.preprocess(
34 | (val) => (val === null ? undefined : val),
35 | z.string().optional(),
36 | ),
37 | sort_order: z.preprocess(
38 | (val) => (val === null ? undefined : val),
39 | z
40 | .union([z.literal('asc'), z.literal('desc')])
41 | .optional()
42 | .default('asc'),
43 | ),
44 | })
45 |
46 | // Define the schema for pagination
47 | export const PaginationSchema = z.object({
48 | page: z.preprocess(
49 | (val) => (val === null ? undefined : val),
50 | z.string().optional().default('1').transform(Number),
51 | ),
52 | per_page: z.preprocess(
53 | (val) => (val === null ? undefined : val),
54 | z
55 | .enum(PAGINATION_PER_PAGE_ITEMS)
56 | .optional()
57 | .default(PAGINATION_PER_PAGE_DEFAULT)
58 | .transform(Number),
59 | ),
60 | })
61 |
62 | // Parse query parameters from the request
63 | export const parseQueryParams = (request: Request) => {
64 | const searchParams = new URL(request.url).searchParams
65 | const search = SearchSchema.parse({
66 | [SEARCH_FIELD]: searchParams.get(SEARCH_FIELD),
67 | })
68 | const filters = FilterSchema.parse(
69 | Object.fromEntries(
70 | FILTER_FIELDS.map((field) => [field, searchParams.getAll(field)]),
71 | ),
72 | )
73 | const { sort_by: sortBy, sort_order: sortOrder } = SortSchema.parse({
74 | sort_by: searchParams.get('sort_by'),
75 | sort_order: searchParams.get('sort_order'),
76 | })
77 |
78 | const { page, per_page: perPage } = PaginationSchema.parse({
79 | page: searchParams.get('page'),
80 | per_page: searchParams.get('per_page'),
81 | })
82 |
83 | return { search, filters, sortBy, sortOrder, page, perPage }
84 | }
85 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/config/types.ts:
--------------------------------------------------------------------------------
1 | import type { z } from 'zod'
2 | import type {
3 | FilterSchema,
4 | PaginationSchema,
5 | SearchSchema,
6 | SortSchema,
7 | } from './schema'
8 |
9 | export type Search = z.infer
10 | export type Filters = z.infer
11 | export type Sort = z.infer
12 | export type Pagination = z.infer
13 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/queries.server.ts:
--------------------------------------------------------------------------------
1 | import type { Task } from '../_shared/data/schema'
2 | import { tasks as initialTasks } from '../_shared/data/tasks'
3 | import type { FILTER_FIELDS, Search } from './config'
4 |
5 | const matchesSearch = (task: Task, search: Search) => {
6 | const searchTerms = Object.values(search)
7 | .filter(Boolean)
8 | .map((value) => value.toLowerCase())
9 | if (searchTerms.length === 0) return true
10 | const taskString = Object.values(task)
11 | .map((value) => String(value).toLowerCase())
12 | .join(' ')
13 |
14 | return searchTerms.every((term) => taskString.includes(term))
15 | }
16 |
17 | interface ListFilteredTasksArgs {
18 | search: Search
19 | filters: Record
20 | page: number
21 | perPage: number
22 | sortBy?: string
23 | sortOrder?: 'asc' | 'desc'
24 | }
25 |
26 | export const listFilteredTasks = ({
27 | search,
28 | filters,
29 | page,
30 | perPage,
31 | sortBy,
32 | sortOrder,
33 | }: ListFilteredTasksArgs) => {
34 | const tasks = initialTasks
35 | .filter((task) => matchesSearch(task, search))
36 | .filter((task) => {
37 | // Filter by other filters
38 | return Object.entries(filters).every(([key, value]) => {
39 | if (value.length === 0) return true
40 | return value.includes((task as unknown as Record)[key])
41 | })
42 | })
43 | .sort((a, b) => {
44 | if (!sortBy) return 0
45 |
46 | const aValue = (a as Record)[sortBy]
47 | const bValue = (b as Record)[sortBy]
48 |
49 | // Validate field existence
50 | if (aValue === undefined || bValue === undefined) return 0
51 |
52 | // Handle different types appropriately
53 | if (typeof aValue === 'number' && typeof bValue === 'number') {
54 | return sortOrder === 'asc' ? aValue - bValue : bValue - aValue
55 | }
56 |
57 | // Convert to string for string comparison
58 | const aStr = String(aValue)
59 | const bStr = String(bValue)
60 |
61 | if (sortOrder === 'asc') {
62 | return aStr.localeCompare(bStr)
63 | }
64 | return bStr.localeCompare(aStr)
65 | })
66 |
67 | const totalPages = Math.ceil(tasks.length / perPage)
68 | const totalItems = tasks.length
69 | const newCurrentPage = Math.min(page, totalPages)
70 |
71 | return {
72 | data: tasks.slice((newCurrentPage - 1) * perPage, newCurrentPage * perPage),
73 | pagination: {
74 | page,
75 | perPage,
76 | totalPages,
77 | totalItems,
78 | },
79 | }
80 | }
81 |
82 | interface GetFacetedCountsArgs {
83 | facets: typeof FILTER_FIELDS
84 | search: Search
85 | filters: Record
86 | }
87 | export const getFacetedCounts = ({
88 | facets,
89 | search,
90 | filters,
91 | }: GetFacetedCountsArgs) => {
92 | const facetedCounts: Record> = {}
93 |
94 | // For each facet, filter the tasks based on the filters and count the occurrences
95 | for (const facet of facets) {
96 | // Filter the tasks based on the filters
97 | const filteredTasks = initialTasks
98 | .filter((task) => matchesSearch(task, search))
99 | // Filter by other filters
100 | .filter((task) => {
101 | return Object.entries(filters).every(([key, value]) => {
102 | if (key === facet || value.length === 0) return true
103 | return value.includes((task as Record)[key])
104 | })
105 | })
106 |
107 | // Count the occurrences of each facet value
108 | facetedCounts[facet] = filteredTasks.reduce(
109 | (acc, task) => {
110 | acc[(task as Record)[facet]] =
111 | (acc[(task as Record)[facet]] ?? 0) + 1
112 | return acc
113 | },
114 | {} as Record,
115 | )
116 | }
117 |
118 | return facetedCounts
119 | }
120 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_index/route.tsx:
--------------------------------------------------------------------------------
1 | import { IconDownload, IconPlus } from '@tabler/icons-react'
2 | import { href, Link } from 'react-router'
3 | import { Button } from '~/components/ui/button'
4 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
5 | import type { Route } from './+types/route'
6 | import { DataTable } from './components/data-table'
7 | import { columns, parseQueryParams } from './config'
8 | import { getFacetedCounts, listFilteredTasks } from './queries.server'
9 |
10 | export const loader = ({ request }: Route.LoaderArgs) => {
11 | const { search, filters, page, perPage, sortBy, sortOrder } =
12 | parseQueryParams(request)
13 |
14 | // listFilteredTasks is a server-side function that fetch tasks from the database
15 | const { data: tasks, pagination } = listFilteredTasks({
16 | search,
17 | filters,
18 | page,
19 | perPage,
20 | sortBy,
21 | sortOrder,
22 | })
23 |
24 | // getFacetedCounts is a server-side function that fetches the counts of each filter
25 | const facetedCounts = getFacetedCounts({
26 | facets: ['status', 'priority'],
27 | search,
28 | filters,
29 | })
30 |
31 | return {
32 | tasks,
33 | pagination,
34 | facetedCounts,
35 | }
36 | }
37 |
38 | export default function Tasks({
39 | loaderData: { tasks, pagination, facetedCounts },
40 | }: Route.ComponentProps) {
41 | useSmartNavigation({ autoSave: true, baseUrl: href('/tasks') })
42 |
43 | return (
44 |
45 |
46 |
47 |
Tasks
48 |
49 | Here's a list of your tasks for this month!
50 |
51 |
52 |
53 |
54 |
55 | Import
56 |
57 |
58 |
59 |
60 | Create
61 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_layout/hooks/use-breadcrumbs.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { href, Link, useMatches } from 'react-router'
3 | import {
4 | Breadcrumb,
5 | BreadcrumbItem,
6 | BreadcrumbLink,
7 | BreadcrumbList,
8 | BreadcrumbPage,
9 | BreadcrumbSeparator,
10 | } from '~/components/ui/breadcrumb'
11 |
12 | interface MatchedBreadcrumbItem {
13 | label: string
14 | to?: string
15 | isCurrentPage?: boolean
16 | }
17 |
18 | function isBreadcrumbHandle(
19 | handle: unknown,
20 | ): handle is { breadcrumb: (params: unknown) => object } {
21 | return (
22 | typeof handle === 'object' &&
23 | !!handle &&
24 | 'breadcrumb' in handle &&
25 | typeof handle.breadcrumb === 'function'
26 | )
27 | }
28 |
29 | export const useBreadcrumbs = () => {
30 | const matches = useMatches()
31 | const breadcrumbMatches = matches.filter((match) =>
32 | isBreadcrumbHandle(match.handle),
33 | )
34 |
35 | const breadcrumbItems = breadcrumbMatches.map((match, idx) => {
36 | if (!isBreadcrumbHandle(match.handle)) {
37 | return null
38 | }
39 | return {
40 | ...match.handle.breadcrumb(match.data),
41 | isCurrentPage: idx === breadcrumbMatches.length - 1,
42 | }
43 | }) as MatchedBreadcrumbItem[]
44 |
45 | const Breadcrumbs = () => {
46 | return (
47 | <>
48 |
49 |
50 |
51 |
52 | Home
53 |
54 |
55 |
56 | {breadcrumbItems.map((item, idx) => {
57 | return (
58 |
59 |
60 |
61 | {item.to ? (
62 |
63 | {item.label}
64 |
65 | ) : (
66 | {item.label}
67 | )}
68 |
69 |
70 | )
71 | })}
72 |
73 |
74 | >
75 | )
76 | }
77 |
78 | return { Breadcrumbs }
79 | }
80 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_layout/route.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router'
2 | import { Header } from '~/components/layout/header'
3 | import { Main } from '~/components/layout/main'
4 | import { ProfileDropdown } from '~/components/profile-dropdown'
5 | import { Search } from '~/components/search'
6 | import { ThemeSwitch } from '~/components/theme-switch'
7 | import { useBreadcrumbs } from './hooks/use-breadcrumbs'
8 |
9 | export const handle = {
10 | breadcrumb: () => ({ label: 'Tasks', to: '/tasks' }),
11 | }
12 |
13 | export default function Tasks() {
14 | const { Breadcrumbs } = useBreadcrumbs()
15 |
16 | return (
17 | <>
18 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_shared/data/data.tsx:
--------------------------------------------------------------------------------
1 | export const labels = [
2 | {
3 | value: 'bug',
4 | label: 'Bug',
5 | },
6 | {
7 | value: 'feature',
8 | label: 'Feature',
9 | },
10 | {
11 | value: 'documentation',
12 | label: 'Documentation',
13 | },
14 | ]
15 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/_shared/data/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | // We're keeping a simple non-relational schema here.
4 | // IRL, you will have a schema for your data models.
5 | export const taskSchema = z.object({
6 | id: z.string(),
7 | title: z.string(),
8 | status: z.string(),
9 | label: z.string(),
10 | priority: z.string(),
11 | })
12 |
13 | export type Task = z.infer
14 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/create/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout as sleep } from 'node:timers/promises'
3 | import { href } from 'react-router'
4 | import { redirectWithSuccess } from 'remix-toast'
5 | import { Separator } from '~/components/ui/separator'
6 | import {
7 | TasksMutateForm,
8 | createSchema,
9 | } from '../_shared/components/tasks-mutate-form'
10 | import { tasks } from '../_shared/data/tasks'
11 | import type { Route } from './+types/route'
12 |
13 | export const handle = {
14 | breadcrumb: () => ({ label: 'Create' }),
15 | }
16 |
17 | export const action = async ({ request }: Route.ActionArgs) => {
18 | const submission = parseWithZod(await request.formData(), {
19 | schema: createSchema,
20 | })
21 | if (submission.status !== 'success') {
22 | return { lastResult: submission.reply() }
23 | }
24 |
25 | // Create a new task
26 | await sleep(1000)
27 | const { intent, ...task } = submission.value
28 | const maxIdNumber = tasks.reduce((max, t) => {
29 | const idNumber = Number.parseInt(t.id.split('-')[1])
30 | return idNumber > max ? idNumber : max
31 | }, 0)
32 | const id = `TASK-${String(maxIdNumber + 1).padStart(4, '0')}`
33 | tasks.unshift({ id, ...task })
34 |
35 | return redirectWithSuccess(href('/tasks'), {
36 | message: 'Task created successfully',
37 | description: JSON.stringify(submission.value),
38 | })
39 | }
40 |
41 | export default function TaskCreate() {
42 | return (
43 |
44 |
45 |
Create Task
46 |
47 | Add a new task by providing necessary info. Click save when
48 | you're done.
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/tasks+/import/route.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod'
3 | import { setTimeout as sleep } from 'node:timers/promises'
4 | import { Form, href, Link } from 'react-router'
5 | import { redirectWithSuccess } from 'remix-toast'
6 | import { z } from 'zod'
7 | import { Button } from '~/components/ui/button'
8 | import { Input } from '~/components/ui/input'
9 | import { Label } from '~/components/ui/label'
10 | import { Separator } from '~/components/ui/separator'
11 | import { HStack } from '~/components/ui/stack'
12 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
13 | import type { Route } from './+types/route'
14 |
15 | export const formSchema = z.object({
16 | file: z
17 | .instanceof(File, { message: 'Please upload a file.' })
18 | .refine(
19 | (file) => ['text/csv'].includes(file.type),
20 | 'Please upload csv format.',
21 | ),
22 | })
23 |
24 | export const handle = {
25 | breadcrumb: () => ({ label: 'Import' }),
26 | }
27 |
28 | export const action = async ({ request }: Route.ActionArgs) => {
29 | const submission = parseWithZod(await request.formData(), {
30 | schema: formSchema,
31 | })
32 | if (submission.status !== 'success') {
33 | return { lastResult: submission.reply() }
34 | }
35 |
36 | await sleep(1000)
37 |
38 | // Create a new task
39 | return redirectWithSuccess('tasks', {
40 | message: 'Tasks imported successfully.',
41 | description: JSON.stringify(submission.value),
42 | })
43 | }
44 |
45 | export default function TaskImport() {
46 | const [form, { file }] = useForm>({
47 | defaultValue: { file: undefined },
48 | onValidate: ({ formData }) =>
49 | parseWithZod(formData, { schema: formSchema }),
50 | })
51 | const { backUrl } = useSmartNavigation({ baseUrl: href('/tasks') })
52 |
53 | return (
54 |
55 |
56 |
Import Task
57 |
58 | Import tasks quickly from a CSV file.
59 |
60 |
61 |
62 |
63 |
64 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/$user.delete/components/users-delete-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { IconAlertTriangle } from '@tabler/icons-react'
2 | import { useState } from 'react'
3 | import { ConfirmDialog } from '~/components/confirm-dialog'
4 | import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
5 | import { Input } from '~/components/ui/input'
6 | import { Label } from '~/components/ui/label'
7 | import type { User } from '../../_shared/data/schema'
8 |
9 | interface Props {
10 | open: boolean
11 | onOpenChange: (open: boolean) => void
12 | user: User
13 | }
14 |
15 | export function UsersDeleteDialog({ open, onOpenChange, user }: Props) {
16 | const [value, setValue] = useState('')
17 |
18 | return (
19 |
25 | {' '}
29 | Delete User
30 |
31 | }
32 | desc={
33 |
34 |
35 | Are you sure you want to delete{' '}
36 | {user.username} ?
37 |
38 | This action will permanently remove the user with the role of{' '}
39 | {user.role.toUpperCase()} from
40 | the system. This cannot be undone.
41 |
42 |
43 |
44 | Username:
45 | setValue(e.target.value)}
48 | placeholder="Enter username to confirm deletion."
49 | />
50 |
51 |
52 |
53 | Warning!
54 |
55 | Please be carefull, this operation can not be rolled back.
56 |
57 |
58 |
59 | }
60 | confirmText="Delete"
61 | destructive
62 | />
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/$user.delete/route.tsx:
--------------------------------------------------------------------------------
1 | import { setTimeout as sleep } from 'node:timers/promises'
2 | import { useState } from 'react'
3 | import { data, href } from 'react-router'
4 | import { redirectWithSuccess } from 'remix-toast'
5 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
6 | import { users } from '../_shared/data/users'
7 | import type { Route } from './+types/route'
8 | import { UsersDeleteDialog } from './components/users-delete-dialog'
9 |
10 | export const loader = ({ params }: Route.LoaderArgs) => {
11 | const user = users.find((user) => user.id === params.user)
12 | if (!user) {
13 | throw data(null, { status: 404 })
14 | }
15 | return { user }
16 | }
17 |
18 | export const action = async ({ request, params }: Route.ActionArgs) => {
19 | const url = new URL(request.url)
20 | const user = users.find((user) => user.id === params.user)
21 | if (!user) {
22 | throw data(null, { status: 404 })
23 | }
24 |
25 | await sleep(1000)
26 | // remove the user from the list
27 | const updatedUsers = users.filter((u) => u.id !== user.id)
28 | users.length = 0
29 | users.push(...updatedUsers)
30 |
31 | return redirectWithSuccess(`/users?${url.searchParams.toString()}`, {
32 | message: 'User deleted successfully!',
33 | description: `The user ${user.email} has been removed.`,
34 | })
35 | }
36 |
37 | export default function UserDelete({
38 | loaderData: { user },
39 | }: Route.ComponentProps) {
40 | const [open, setOpen] = useState(true)
41 | const { goBack } = useSmartNavigation({ baseUrl: href('/users') })
42 |
43 | return (
44 | {
49 | if (!v) {
50 | setOpen(false)
51 | // wait for the drawer to close
52 | setTimeout(() => {
53 | goBack()
54 | }, 300) // the duration of the modal close animation
55 | }
56 | }}
57 | />
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/$user.update/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout as sleep } from 'node:timers/promises'
3 | import { useState } from 'react'
4 | import { data, href } from 'react-router'
5 | import { redirectWithSuccess } from 'remix-toast'
6 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
7 | import {
8 | UsersActionDialog,
9 | editSchema as formSchema,
10 | } from '../_shared/components/users-action-dialog'
11 | import { users } from '../_shared/data/users'
12 | import type { Route } from './+types/route'
13 |
14 | export const loader = ({ params }: Route.LoaderArgs) => {
15 | const user = users.find((u) => u.id === params.user)
16 | if (!user) {
17 | throw data(null, { status: 404 })
18 | }
19 | return { user }
20 | }
21 |
22 | export const action = async ({ request, params }: Route.ActionArgs) => {
23 | const url = new URL(request.url)
24 | const user = users.find((u) => u.id === params.user)
25 | if (!user) {
26 | throw data(null, { status: 404 })
27 | }
28 |
29 | const submission = parseWithZod(await request.formData(), {
30 | schema: formSchema,
31 | })
32 | if (submission.status !== 'success') {
33 | return { lastResult: submission.reply() }
34 | }
35 |
36 | // Update the user
37 | await sleep(1000)
38 | const updatedUser = {
39 | ...submission.value,
40 | id: user.id,
41 | createdAt: user.createdAt,
42 | status: user.status,
43 | updatedAt: new Date(),
44 | }
45 | const updatedUsers = users.map((u) => (u.id === user.id ? updatedUser : u))
46 | users.length = 0
47 | users.push(...updatedUsers)
48 |
49 | return redirectWithSuccess(`/users?${url.searchParams.toString()}`, {
50 | message: 'User updated successfully',
51 | description: JSON.stringify(updatedUser),
52 | })
53 | }
54 |
55 | export default function UserUpdate({
56 | loaderData: { user },
57 | }: Route.ComponentProps) {
58 | const [open, setOpen] = useState(true)
59 | const { goBack } = useSmartNavigation({ baseUrl: href('/users') })
60 | return (
61 | {
66 | if (!v) {
67 | setOpen(false)
68 | // wait for the modal to close
69 | setTimeout(() => {
70 | goBack()
71 | }, 300) // the duration of the modal close animation
72 | }
73 | }}
74 | />
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/components/data-table-column-header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowDownIcon,
3 | ArrowUpIcon,
4 | CaretSortIcon,
5 | EyeNoneIcon,
6 | } from '@radix-ui/react-icons'
7 | import type { Column } from '@tanstack/react-table'
8 | import { Button } from '~/components/ui/button'
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuSeparator,
14 | DropdownMenuTrigger,
15 | } from '~/components/ui/dropdown-menu'
16 | import { cn } from '~/lib/utils'
17 | import { useDataTableState } from '../hooks/use-data-table-state'
18 |
19 | interface DataTableColumnHeaderProps
20 | extends React.HTMLAttributes {
21 | column: Column
22 | title: string
23 | }
24 |
25 | export function DataTableColumnHeader({
26 | column,
27 | title,
28 | className,
29 | }: DataTableColumnHeaderProps) {
30 | const { sort, updateSort } = useDataTableState()
31 |
32 | column.id
33 | if (!column.getCanSort()) {
34 | return {title}
35 | }
36 |
37 | return (
38 |
39 |
40 |
41 |
46 | {title}
47 | {column.id === sort.sort_by && sort.sort_order === 'desc' ? (
48 |
49 | ) : column.id === sort.sort_by && sort.sort_order === 'asc' ? (
50 |
51 | ) : (
52 |
53 | )}
54 |
55 |
56 |
57 |
59 | updateSort({
60 | sort_by: column.id,
61 | sort_order: 'asc',
62 | })
63 | }
64 | >
65 |
66 | Asc
67 |
68 |
70 | updateSort({
71 | sort_by: column.id,
72 | sort_order: 'desc',
73 | })
74 | }
75 | >
76 |
77 | Desc
78 |
79 |
80 | column.toggleVisibility(false)}>
81 |
82 | Hide
83 |
84 |
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/components/data-table-row-actions.tsx:
--------------------------------------------------------------------------------
1 | import { DotsHorizontalIcon } from '@radix-ui/react-icons'
2 | import { IconEdit, IconTrash } from '@tabler/icons-react'
3 | import type { Row } from '@tanstack/react-table'
4 | import { href, Link } from 'react-router'
5 | import { Button } from '~/components/ui/button'
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuSeparator,
11 | DropdownMenuShortcut,
12 | DropdownMenuTrigger,
13 | } from '~/components/ui/dropdown-menu'
14 | import type { User } from '../../_shared/data/schema'
15 |
16 | interface DataTableRowActionsProps {
17 | row: Row
18 | }
19 |
20 | export function DataTableRowActions({ row }: DataTableRowActionsProps) {
21 | return (
22 | <>
23 |
24 |
25 |
29 |
30 | Open menu
31 |
32 |
33 |
34 |
35 |
36 | Edit
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | Delete
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | >
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/components/data-table-toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { Cross2Icon } from '@radix-ui/react-icons'
2 | import type { Table } from '@tanstack/react-table'
3 | import { Button } from '~/components/ui/button'
4 | import { userTypes } from '../../_shared/data/data'
5 | import { useDataTableState } from '../hooks/use-data-table-state'
6 | import { DataTableFacetedFilter } from './data-table-faceted-filter'
7 | import { DataTableViewOptions } from './data-table-view-options'
8 | import { SearchInput } from './search-input'
9 |
10 | export type FacetedCountProps = Record>
11 |
12 | interface DataTableToolbarProps {
13 | table: Table
14 | facetedCounts?: FacetedCountProps
15 | }
16 |
17 | export function DataTableToolbar({
18 | table,
19 | facetedCounts,
20 | }: DataTableToolbarProps) {
21 | const { queries, updateQueries, isFiltered, resetFilters } =
22 | useDataTableState()
23 |
24 | return (
25 |
26 |
27 |
{
33 | updateQueries({
34 | username: value,
35 | })
36 | }}
37 | />
38 |
39 | {table.getColumn('status') && (
40 |
66 | )}
67 | {table.getColumn('role') && (
68 | ({
72 | ...t,
73 | count: facetedCounts?.role[t.value],
74 | }))}
75 | />
76 | )}
77 |
78 | {isFiltered && (
79 | resetFilters()}
82 | className="h-8 px-2 lg:px-3"
83 | >
84 | Reset
85 |
86 |
87 | )}
88 |
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/components/data-table-view-options.tsx:
--------------------------------------------------------------------------------
1 | import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
2 | import { MixerHorizontalIcon } from '@radix-ui/react-icons'
3 | import type { Table } from '@tanstack/react-table'
4 | import { Button } from '~/components/ui/button'
5 | import {
6 | DropdownMenu,
7 | DropdownMenuCheckboxItem,
8 | DropdownMenuContent,
9 | DropdownMenuLabel,
10 | DropdownMenuSeparator,
11 | } from '~/components/ui/dropdown-menu'
12 |
13 | interface DataTableViewOptionsProps {
14 | table: Table
15 | }
16 |
17 | export function DataTableViewOptions({
18 | table,
19 | }: DataTableViewOptionsProps) {
20 | return (
21 |
22 |
23 |
28 |
29 | View
30 |
31 |
32 |
33 | Toggle columns
34 |
35 | {table
36 | .getAllColumns()
37 | .filter(
38 | (column) =>
39 | typeof column.accessorFn !== 'undefined' && column.getCanHide(),
40 | )
41 | .map((column) => {
42 | return (
43 | column.toggleVisibility(!!value)}
48 | >
49 | {column.id}
50 |
51 | )
52 | })}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/components/search-input.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react'
2 | import { Input } from '~/components/ui/input'
3 | import { cn } from '~/lib/utils'
4 |
5 | interface SearchInputProps extends React.ComponentPropsWithRef {
6 | onSearch: (text: string) => void
7 | }
8 | export const SearchInput = ({
9 | className,
10 | onSearch,
11 | ...rest
12 | }: SearchInputProps) => {
13 | const isImeOn = useRef(false) // IME = Input Method Editor, e.g. Japanese keyboard
14 | const [prevText, setPrevText] = useState('')
15 |
16 | const handleChange = (text: string) => {
17 | if (prevText === text) return
18 | if (text === '') {
19 | // onCompositionEnd may not be called in Chrome when clearing text
20 | isImeOn.current = false
21 | } else if (isImeOn.current) {
22 | return
23 | }
24 | setPrevText(text)
25 | onSearch(text)
26 | }
27 |
28 | return (
29 | {
33 | const value = event.currentTarget.value
34 | handleChange(value)
35 | }}
36 | onCompositionStart={() => {
37 | isImeOn.current = true
38 | }}
39 | onCompositionEnd={(e) => {
40 | isImeOn.current = false
41 | handleChange((e.target as HTMLInputElement).value)
42 | }}
43 | className={cn('h-8 w-[150px] lg:w-[250px]')}
44 | {...rest}
45 | />
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/queries.server.ts:
--------------------------------------------------------------------------------
1 | import { users as initialUsers } from '../_shared/data/users'
2 |
3 | interface ListFilteredUsersArgs {
4 | username: string
5 | filters: Record
6 | currentPage: number
7 | pageSize: number
8 | sortBy?: string
9 | sortOrder: 'asc' | 'desc'
10 | }
11 |
12 | export const listFilteredUsers = ({
13 | username,
14 | filters,
15 | currentPage,
16 | pageSize,
17 | sortBy,
18 | sortOrder,
19 | }: ListFilteredUsersArgs) => {
20 | const users = initialUsers
21 | .filter((user) => {
22 | // Filter by title
23 | return user.username.toLowerCase().includes(username.toLowerCase())
24 | })
25 | .filter((user) => {
26 | // Filter by other filters
27 | return Object.entries(filters).every(([key, value]) => {
28 | if (value.length === 0) return true
29 | return value.includes((user as unknown as Record)[key])
30 | })
31 | })
32 | .sort((a, b) => {
33 | if (!sortBy) return 0
34 |
35 | const aValue = a[sortBy as keyof typeof a]
36 | const bValue = b[sortBy as keyof typeof b]
37 |
38 | if (typeof aValue !== 'string' || typeof bValue !== 'string') {
39 | console.warn(`Invalid sort field type for ${sortBy}`)
40 | return 0
41 | }
42 |
43 | return sortOrder === 'asc'
44 | ? aValue.localeCompare(bValue)
45 | : bValue.localeCompare(aValue)
46 | })
47 |
48 | const totalPages = Math.ceil(users.length / pageSize)
49 | const totalItems = users.length
50 | const newCurrentPage = Math.min(currentPage, totalPages)
51 |
52 | return {
53 | data: users.slice(
54 | (newCurrentPage - 1) * pageSize,
55 | newCurrentPage * pageSize,
56 | ),
57 | pagination: {
58 | currentPage: newCurrentPage,
59 | pageSize,
60 | totalPages,
61 | totalItems,
62 | },
63 | }
64 | }
65 |
66 | interface GetFacetedCountsArgs {
67 | facets: string[]
68 | username: string
69 | filters: Record
70 | }
71 | export const getFacetedCounts = ({
72 | facets,
73 | username,
74 | filters,
75 | }: GetFacetedCountsArgs) => {
76 | const facetedCounts: Record> = {}
77 |
78 | // For each facet, filter the tasks based on the filters and count the occurrences
79 | for (const facet of facets) {
80 | // Filter the users based on the filters
81 | const filteredUsers = initialUsers
82 | .filter((user) => {
83 | // Filter by title
84 | return user.username.toLowerCase().includes(username.toLowerCase())
85 | })
86 | // Filter by other filters
87 | .filter((user) => {
88 | return Object.entries(filters).every(([key, value]) => {
89 | if (key === facet || value.length === 0) return true
90 | return value.includes(
91 | (user as unknown as Record)[key],
92 | )
93 | })
94 | })
95 |
96 | // Count the occurrences of each facet value
97 | facetedCounts[facet] = filteredUsers.reduce(
98 | (acc, user) => {
99 | acc[(user as unknown as Record)[facet]] =
100 | (acc[(user as unknown as Record)[facet]] ?? 0) + 1
101 | return acc
102 | },
103 | {} as Record,
104 | )
105 | }
106 |
107 | return facetedCounts
108 | }
109 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_layout/route.tsx:
--------------------------------------------------------------------------------
1 | import { IconMailPlus, IconUserPlus } from '@tabler/icons-react'
2 | import { href, Link, Outlet } from 'react-router'
3 | import { Header } from '~/components/layout/header'
4 | import { Main } from '~/components/layout/main'
5 | import { ProfileDropdown } from '~/components/profile-dropdown'
6 | import { Search } from '~/components/search'
7 | import { ThemeSwitch } from '~/components/theme-switch'
8 | import { Button } from '~/components/ui/button'
9 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
10 | import type { Route } from './+types/route'
11 | import { columns } from './components/users-columns'
12 | import { UsersTable } from './components/users-table'
13 | import {
14 | FilterSchema,
15 | PaginationSchema,
16 | QuerySchema,
17 | SortSchema,
18 | } from './hooks/use-data-table-state'
19 | import { getFacetedCounts, listFilteredUsers } from './queries.server'
20 |
21 | export const loader = ({ request }: Route.LoaderArgs) => {
22 | const searchParams = new URLSearchParams(new URL(request.url).searchParams)
23 |
24 | const { username } = QuerySchema.parse({
25 | username: searchParams.get('username'),
26 | })
27 |
28 | const { ...filters } = FilterSchema.parse({
29 | status: searchParams.getAll('status'),
30 | priority: searchParams.getAll('priority'),
31 | })
32 |
33 | const { sort_by: sortBy, sort_order: sortOrder } = SortSchema.parse({
34 | sort_by: searchParams.get('sort_by'),
35 | sort_order: searchParams.get('sort_order'),
36 | })
37 |
38 | const { page: currentPage, per_page: pageSize } = PaginationSchema.parse({
39 | page: searchParams.get('page'),
40 | per_page: searchParams.get('per_page'),
41 | })
42 |
43 | const { pagination, data: users } = listFilteredUsers({
44 | username,
45 | filters,
46 | currentPage,
47 | pageSize,
48 | sortBy,
49 | sortOrder,
50 | })
51 |
52 | const facetedCounts = getFacetedCounts({
53 | facets: ['status', 'role'],
54 | username,
55 | filters,
56 | })
57 |
58 | return { users, pagination, facetedCounts }
59 | }
60 |
61 | export default function Users({
62 | loaderData: { users, pagination, facetedCounts },
63 | }: Route.ComponentProps) {
64 | useSmartNavigation({ autoSave: true, baseUrl: href('/users') })
65 |
66 | return (
67 | <>
68 |
75 |
76 |
77 |
78 |
79 |
User List
80 |
81 | Manage your users and their roles here.
82 |
83 |
84 |
85 |
86 |
87 | Invite User
88 |
89 |
90 |
91 |
92 | Add User
93 |
94 |
95 |
96 |
97 |
98 |
104 |
105 |
106 |
107 |
108 | >
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_shared/data/data.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IconCash,
3 | IconShield,
4 | IconUsersGroup,
5 | IconUserShield,
6 | } from '@tabler/icons-react'
7 | import type { UserStatus } from './schema'
8 |
9 | export const callTypes = new Map([
10 | ['active', 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200'],
11 | ['inactive', 'bg-neutral-300/40 border-neutral-300'],
12 | ['invited', 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300'],
13 | [
14 | 'suspended',
15 | 'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
16 | ],
17 | ])
18 |
19 | export const userTypes = [
20 | {
21 | label: 'Superadmin',
22 | value: 'superadmin',
23 | icon: IconShield,
24 | },
25 | {
26 | label: 'Admin',
27 | value: 'admin',
28 | icon: IconUserShield,
29 | },
30 | {
31 | label: 'Manager',
32 | value: 'manager',
33 | icon: IconUsersGroup,
34 | },
35 | {
36 | label: 'Cashier',
37 | value: 'cashier',
38 | icon: IconCash,
39 | },
40 | ] as const
41 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_shared/data/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | const userStatusSchema = z.union([
4 | z.literal('active'),
5 | z.literal('inactive'),
6 | z.literal('invited'),
7 | z.literal('suspended'),
8 | ])
9 | export type UserStatus = z.infer
10 |
11 | const userRoleSchema = z.union([
12 | z.literal('superadmin'),
13 | z.literal('admin'),
14 | z.literal('cashier'),
15 | z.literal('manager'),
16 | ])
17 | export type UserRole = z.infer
18 |
19 | const userSchema = z.object({
20 | id: z.string(),
21 | firstName: z.string(),
22 | lastName: z.string(),
23 | username: z.string(),
24 | email: z.string(),
25 | phoneNumber: z.string(),
26 | status: userStatusSchema,
27 | role: userRoleSchema,
28 | createdAt: z.coerce.date(),
29 | updatedAt: z.coerce.date(),
30 | })
31 | export type User = z.infer
32 |
33 | export const userListSchema = z.array(userSchema)
34 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/_shared/data/users.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 |
3 | export const users = Array.from({ length: 20 }, () => {
4 | const firstName = faker.person.firstName()
5 | const lastName = faker.person.lastName()
6 | return {
7 | id: faker.string.uuid(),
8 | firstName,
9 | lastName,
10 | username: faker.internet
11 | .username({ firstName, lastName })
12 | .toLocaleLowerCase(),
13 | email: faker.internet.email({ firstName }).toLocaleLowerCase(),
14 | phoneNumber: faker.phone.number({ style: 'international' }),
15 | status: faker.helpers.arrayElement([
16 | 'active',
17 | 'inactive',
18 | 'invited',
19 | 'suspended',
20 | ]),
21 | role: faker.helpers.arrayElement([
22 | 'superadmin',
23 | 'admin',
24 | 'cashier',
25 | 'manager',
26 | ]),
27 | createdAt: faker.date.past(),
28 | updatedAt: faker.date.recent(),
29 | }
30 | })
31 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/add/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout as sleep } from 'node:timers/promises'
3 | import { useState } from 'react'
4 | import { href } from 'react-router'
5 | import { redirectWithSuccess } from 'remix-toast'
6 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
7 | import {
8 | UsersActionDialog,
9 | createSchema as formSchema,
10 | } from '../_shared/components/users-action-dialog'
11 | import { users } from '../_shared/data/users'
12 | import type { Route } from './+types/route'
13 |
14 | export const action = async ({ request }: Route.ActionArgs) => {
15 | const url = new URL(request.url)
16 | const submission = parseWithZod(await request.formData(), {
17 | schema: formSchema,
18 | })
19 | if (submission.status !== 'success') {
20 | return { lastResult: submission.reply() }
21 | }
22 |
23 | // Create a new task
24 | await sleep(1000)
25 | const newUser = {
26 | ...submission.value,
27 | createdAt: new Date(),
28 | updatedAt: new Date(),
29 | id: crypto.randomUUID(),
30 | status: 'active',
31 | } as const
32 | users.unshift(newUser)
33 |
34 | return redirectWithSuccess(`/users?${url.searchParams.toString()}`, {
35 | message: 'User added successfully',
36 | description: JSON.stringify(newUser),
37 | })
38 | }
39 |
40 | export default function UserAdd() {
41 | const [open, setOpen] = useState(true)
42 | const { goBack } = useSmartNavigation({
43 | baseUrl: href('/users'),
44 | })
45 |
46 | return (
47 | {
51 | if (!v) {
52 | setOpen(false)
53 | console.log('UserAdd dialog closed')
54 | // wait for the modal to close
55 | setTimeout(() => {
56 | console.log('Navigating back to users list')
57 | goBack()
58 | }, 300) // the duration of the modal close animation
59 | }
60 | }}
61 | />
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/app/routes/_authenticated+/users+/invite/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod'
2 | import { setTimeout as sleep } from 'node:timers/promises'
3 | import { useState } from 'react'
4 | import { href } from 'react-router'
5 | import { redirectWithSuccess } from 'remix-toast'
6 | import { z } from 'zod'
7 | import { useSmartNavigation } from '~/hooks/use-smart-navigation'
8 | import type { Route } from './+types/route'
9 | import { UsersInviteDialog } from './components/users-invite-dialog'
10 |
11 | export const formSchema = z.object({
12 | email: z
13 | .string({ required_error: 'Email is required.' })
14 | .email({ message: 'Email is invalid.' }),
15 | role: z.string({ required_error: 'Role is required.' }),
16 | desc: z.string().optional(),
17 | })
18 |
19 | export const action = async ({ request }: Route.ActionArgs) => {
20 | const url = new URL(request.url)
21 | const submission = parseWithZod(await request.formData(), {
22 | schema: formSchema,
23 | })
24 | if (submission.status !== 'success') {
25 | return { lastResult: submission.reply() }
26 | }
27 |
28 | await sleep(1000)
29 |
30 | return redirectWithSuccess(`/users?${url.searchParams.toString()}`, {
31 | message: 'User invited successfully!',
32 | description: JSON.stringify(submission.value),
33 | })
34 | }
35 |
36 | export default function UserInvite() {
37 | const [open, setOpen] = useState(true)
38 | const { goBack } = useSmartNavigation({ baseUrl: href('/users') })
39 |
40 | return (
41 | {
45 | if (!v) {
46 | setOpen(false)
47 | // wait for the drawer to close
48 | setTimeout(() => {
49 | goBack()
50 | }, 300) // the duration of the modal close animation
51 | }
52 | }}
53 | />
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/app/routes/_errors+/401.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useNavigate } from 'react-router'
2 | import { Button } from '~/components/ui/button'
3 |
4 | export default function UnauthorisedError() {
5 | const navigate = useNavigate()
6 | return (
7 |
8 |
9 |
401
10 |
Unauthorized Access
11 |
12 | Please log in with the appropriate credentials to access this
13 | resource.
14 |
15 |
16 | navigate(-1)}>
17 | Go Back
18 |
19 |
20 | Back to Home
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/routes/_errors+/403.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useNavigate } from 'react-router'
2 | import { Button } from '~/components/ui/button'
3 |
4 | export default function ForbiddenError() {
5 | const navigate = useNavigate()
6 |
7 | return (
8 |
9 |
10 |
403
11 |
Access Forbidden
12 |
13 | You don't have necessary permission
14 | to view this resource.
15 |
16 |
17 | navigate(-1)}>
18 | Go Back
19 |
20 |
21 | Back to Home
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/app/routes/_errors+/404.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useNavigate } from 'react-router'
2 | import { Button } from '~/components/ui/button'
3 |
4 | export default function NotFoundError() {
5 | const navigate = useNavigate()
6 | return (
7 |
8 |
9 |
404
10 |
Oops! Page Not Found!
11 |
12 | It seems like the page you're looking for
13 | does not exist or might have been removed.
14 |
15 |
16 | navigate(-1)}>
17 | Go Back
18 |
19 |
20 | Back to Home
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/routes/_errors+/500.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useNavigate } from 'react-router'
2 | import { Button } from '~/components/ui/button'
3 | import { cn } from '~/lib/utils'
4 |
5 | interface GeneralErrorProps extends React.HTMLAttributes {
6 | minimal?: boolean
7 | }
8 |
9 | export default function GeneralError({
10 | className,
11 | minimal = false,
12 | }: GeneralErrorProps) {
13 | const navigate = useNavigate()
14 | return (
15 |
16 |
17 | {!minimal && (
18 |
500
19 | )}
20 |
Oops! Something went wrong {`:')`}
21 |
22 | We apologize for the inconvenience. Please try again later.
23 |
24 | {!minimal && (
25 |
26 | navigate(-1)}
30 | >
31 | Go Back
32 |
33 |
34 | Back to Home
35 |
36 |
37 | )}
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/app/routes/_errors+/503.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '~/components/ui/button'
2 |
3 | export default function MaintenanceError() {
4 | return (
5 |
6 |
7 |
503
8 |
Website is under maintenance!
9 |
10 | The site is not available at the moment.
11 | We'll be back online shortly.
12 |
13 |
14 | Learn more
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "organizeImports": {
4 | "enabled": false
5 | },
6 | "vcs": {
7 | "enabled": true,
8 | "clientKind": "git",
9 | "useIgnoreFile": true
10 | },
11 | "linter": {
12 | "enabled": true,
13 | "rules": {
14 | "recommended": true,
15 | "suspicious": {
16 | "useAwait": "error",
17 | "noExplicitAny": "off",
18 | "noArrayIndexKey": "off"
19 | },
20 | "a11y": {
21 | "noSvgWithoutTitle": "off"
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils",
16 | "ui": "~/components/ui",
17 | "lib": "~/lib",
18 | "hooks": "~/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shadcn-admin-react-router",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "build": "react-router build",
8 | "dev": "react-router dev",
9 | "lint": "biome lint .",
10 | "format:check": "prettier --check .",
11 | "format": "prettier --write .",
12 | "typecheck": "react-router typegen && tsc"
13 | },
14 | "dependencies": {
15 | "@conform-to/react": "^1.6.0",
16 | "@conform-to/zod": "^1.6.0",
17 | "@radix-ui/react-alert-dialog": "^1.1.14",
18 | "@radix-ui/react-avatar": "^1.1.10",
19 | "@radix-ui/react-checkbox": "^1.3.2",
20 | "@radix-ui/react-collapsible": "^1.1.11",
21 | "@radix-ui/react-dialog": "^1.1.14",
22 | "@radix-ui/react-dropdown-menu": "^2.1.15",
23 | "@radix-ui/react-icons": "^1.3.2",
24 | "@radix-ui/react-label": "^2.1.7",
25 | "@radix-ui/react-popover": "^1.1.14",
26 | "@radix-ui/react-radio-group": "^1.3.7",
27 | "@radix-ui/react-scroll-area": "^1.2.9",
28 | "@radix-ui/react-select": "^2.2.5",
29 | "@radix-ui/react-separator": "^1.1.7",
30 | "@radix-ui/react-slot": "^1.2.3",
31 | "@radix-ui/react-switch": "^1.2.5",
32 | "@radix-ui/react-tabs": "^1.1.12",
33 | "@radix-ui/react-tooltip": "^1.2.7",
34 | "@radix-ui/react-visually-hidden": "^1.2.3",
35 | "@react-router/node": "^7.6.0",
36 | "@tabler/icons-react": "^3.33.0",
37 | "@tanstack/react-table": "^8.21.3",
38 | "class-variance-authority": "^0.7.1",
39 | "clsx": "^2.1.1",
40 | "cmdk": "1.1.1",
41 | "date-fns": "^4.1.0",
42 | "isbot": "^5.1.28",
43 | "lucide-react": "^0.511.0",
44 | "next-themes": "^0.4.6",
45 | "react": "^19.1.0",
46 | "react-day-picker": "8.10.1",
47 | "react-dom": "^19.1.0",
48 | "react-router": "^7.6.0",
49 | "react-twc": "^1.4.2",
50 | "recharts": "^2.15.3",
51 | "remix-toast": "^3.1.0",
52 | "sonner": "^2.0.3",
53 | "tailwind-merge": "^3.3.0",
54 | "zod": "^3.25.28"
55 | },
56 | "devDependencies": {
57 | "@biomejs/biome": "^1.9.4",
58 | "@faker-js/faker": "^9.8.0",
59 | "@react-router/dev": "^7.6.0",
60 | "@react-router/remix-routes-option-adapter": "^7.6.0",
61 | "@tailwindcss/vite": "^4.1.7",
62 | "@types/node": "^22.15.21",
63 | "@types/react": "^19.1.5",
64 | "@types/react-dom": "^19.1.5",
65 | "@vercel/react-router": "1.1.0",
66 | "prettier": "^3.5.3",
67 | "prettier-plugin-organize-imports": "^4.1.0",
68 | "prettier-plugin-tailwindcss": "^0.6.11",
69 | "remix-flat-routes": "^0.8.5",
70 | "tailwindcss": "^4.1.7",
71 | "tailwindcss-animate": "^1.0.7",
72 | "typescript": "~5.8.3",
73 | "vite": "6.3.5",
74 | "vite-tsconfig-paths": "^5.1.4"
75 | },
76 | "pnpm": {
77 | "onlyBuiltDependencies": [
78 | "@biomejs/biome",
79 | "@tailwindcss/oxide",
80 | "esbuild",
81 | "workerd"
82 | ]
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | semi: false,
3 | singleQuote: true,
4 | trailingComma: 'all',
5 | plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
6 | tailwindFunctions: ['twMerge'],
7 | }
8 |
--------------------------------------------------------------------------------
/public/avatars/01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/01.png
--------------------------------------------------------------------------------
/public/avatars/02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/02.png
--------------------------------------------------------------------------------
/public/avatars/03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/03.png
--------------------------------------------------------------------------------
/public/avatars/04.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/04.png
--------------------------------------------------------------------------------
/public/avatars/05.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/05.png
--------------------------------------------------------------------------------
/public/avatars/shadcn.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/avatars/shadcn.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/images/favicon.png
--------------------------------------------------------------------------------
/public/images/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/images/shadcn-admin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coji/shadcn-admin-react-router/986cf066e823e633aee1d2464cd3e65795e24b48/public/images/shadcn-admin.png
--------------------------------------------------------------------------------
/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@react-router/dev/config'
2 | import { vercelPreset } from '@vercel/react-router/vite'
3 |
4 | export default {
5 | ssr: true,
6 | presets: [vercelPreset()],
7 | } satisfies Config
8 |
--------------------------------------------------------------------------------
/server/app.ts:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from '@react-router/express'
2 | import express from 'express'
3 | import 'react-router'
4 |
5 | declare module 'react-router' {
6 | export interface AppLoadContext {
7 | VALUE_FROM_VERCEL: string
8 | }
9 | }
10 |
11 | const app = express()
12 |
13 | app.use(
14 | createRequestHandler({
15 | // @ts-expect-error - virtual module provided by React Router at build time
16 | build: () => import('virtual:react-router/server-build'),
17 | getLoadContext() {
18 | return {
19 | VALUE_FROM_VERCEL: 'Hello from Vercel',
20 | }
21 | },
22 | }),
23 | )
24 |
25 | export default app
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*.ts",
4 | "**/*.tsx",
5 | "**/.server/**/*.ts",
6 | "**/.server/**/*.tsx",
7 | "**/.client/**/*.ts",
8 | "**/.client/**/*.tsx",
9 | ".react-router/types/**/*"
10 | ],
11 | "exclude": ["src"],
12 | "compilerOptions": {
13 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
14 | "types": ["@react-router/node", "vite/client"],
15 | "isolatedModules": true,
16 | "esModuleInterop": true,
17 | "jsx": "react-jsx",
18 | "moduleResolution": "Bundler",
19 | "resolveJsonModule": true,
20 | "target": "ES2022",
21 | "strict": true,
22 | "allowJs": true,
23 | "skipLibCheck": true,
24 | "forceConsistentCasingInFileNames": true,
25 | "paths": {
26 | "~/*": ["./app/*"]
27 | },
28 |
29 | "noUnusedLocals": true,
30 | "noUnusedParameters": true,
31 | "noFallthroughCasesInSwitch": true,
32 | "noUncheckedSideEffectImports": true,
33 |
34 | // Remix takes care of building everything in `remix build`.
35 | "noEmit": true,
36 | "rootDirs": [".", "./.react-router/types"],
37 | "verbatimModuleSyntax": true
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from '@react-router/dev/vite'
2 | import tailwindcss from '@tailwindcss/vite'
3 | import { defineConfig } from 'vite'
4 | import tsconfigPaths from 'vite-tsconfig-paths'
5 |
6 | export default defineConfig({
7 | plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
8 | })
9 |
--------------------------------------------------------------------------------