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/routes/_authenticated+/tasks+/import/route.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod/v4'
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+/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/_auth+/sign-up/route.tsx:
--------------------------------------------------------------------------------
1 | import { parseWithZod } from '@conform-to/zod/v4'
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.email({
13 | error: (issue) =>
14 | issue.input === undefined
15 | ? 'Please enter your email'
16 | : 'Invalid email address',
17 | }),
18 | password: z
19 | .string({
20 | error: 'Please enter your password',
21 | })
22 | .min(7, {
23 | message: 'Password must be at least 7 characters long',
24 | }),
25 | confirmPassword: z
26 | .string({
27 | error: 'Please enter your password',
28 | })
29 | .min(7, {
30 | message: 'Password must be at least 7 characters long',
31 | }),
32 | })
33 | .refine((data) => data.password === data.confirmPassword, {
34 | message: "Passwords don't match.",
35 | path: ['confirmPassword'],
36 | })
37 |
38 | export const action = async ({ request }: Route.ActionArgs) => {
39 | const submission = parseWithZod(await request.formData(), {
40 | schema: formSchema,
41 | })
42 | if (submission.status !== 'success') {
43 | return { lastResult: submission.reply() }
44 | }
45 |
46 | if (submission.value.email === 'name@example.com') {
47 | return {
48 | lastResult: submission.reply({
49 | formErrors: ['User already exists with this email address'],
50 | }),
51 | }
52 | }
53 | await setTimeout(1000)
54 |
55 | throw await redirectWithSuccess('/', {
56 | message: 'Account created successfully!',
57 | })
58 | }
59 |
60 | export default function SignUp() {
61 | return (
62 |
63 |
64 |
65 | Create an account
66 |
67 |
68 | Enter your email and password to create an account.
69 | Already have an account?{' '}
70 |
74 | Sign In
75 |
76 |
77 |
78 |
79 |
80 | By creating an account, you agree to our{' '}
81 |
85 | Terms of Service
86 | {' '}
87 | and{' '}
88 |
92 | Privacy Policy
93 |
94 | .
95 |
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/app/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronRight, MoreHorizontal } from 'lucide-react'
2 | import { Slot as SlotPrimitive } from 'radix-ui'
3 | import type * as React from 'react'
4 |
5 | import { cn } from '~/lib/utils'
6 |
7 | function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
8 | return
9 | }
10 |
11 | function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
25 | return (
26 |
31 | )
32 | }
33 |
34 | function BreadcrumbLink({
35 | asChild,
36 | className,
37 | ...props
38 | }: React.ComponentProps<'a'> & {
39 | asChild?: boolean
40 | }) {
41 | const Comp = asChild ? SlotPrimitive.Slot : 'a'
42 |
43 | return (
44 |
49 | )
50 | }
51 |
52 | function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
53 | return (
54 | // biome-ignore lint/a11y/useFocusableInteractive: breadcrumb page is not interactive
55 | // biome-ignore lint/a11y/useSemanticElements: using span with role=link for non-navigable current page
56 |
64 | )
65 | }
66 |
67 | function BreadcrumbSeparator({
68 | children,
69 | className,
70 | ...props
71 | }: React.ComponentProps<'li'>) {
72 | return (
73 | svg]:size-3.5', className)}
78 | {...props}
79 | >
80 | {children ?? }
81 |
82 | )
83 | }
84 |
85 | function BreadcrumbEllipsis({
86 | className,
87 | ...props
88 | }: React.ComponentProps<'span'>) {
89 | return (
90 |
97 |
98 | More
99 |
100 | )
101 | }
102 |
103 | export {
104 | Breadcrumb,
105 | BreadcrumbEllipsis,
106 | BreadcrumbItem,
107 | BreadcrumbLink,
108 | BreadcrumbList,
109 | BreadcrumbPage,
110 | BreadcrumbSeparator,
111 | }
112 |
--------------------------------------------------------------------------------
/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/components/layout/team-switcher.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronsUpDown, Plus } from 'lucide-react'
2 | import * as React from 'react'
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuShortcut,
10 | DropdownMenuTrigger,
11 | } from '~/components/ui/dropdown-menu'
12 | import {
13 | SidebarMenu,
14 | SidebarMenuButton,
15 | SidebarMenuItem,
16 | useSidebar,
17 | } from '~/components/ui/sidebar'
18 |
19 | export function TeamSwitcher({
20 | teams,
21 | }: {
22 | teams: {
23 | name: string
24 | logo: React.ElementType
25 | plan: string
26 | }[]
27 | }) {
28 | const { isMobile } = useSidebar()
29 | // biome-ignore lint/style/noNonNullAssertion: teams array is guaranteed to have at least one element
30 | const [activeTeam, setActiveTeam] = React.useState(teams[0]!)
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
41 |
44 |
45 |
46 | {activeTeam.name}
47 |
48 | {activeTeam.plan}
49 |
50 |
51 |
52 |
53 |
59 |
60 | Teams
61 |
62 | {teams.map((team, index) => (
63 | setActiveTeam(team)}
66 | className="gap-2 p-2"
67 | >
68 |
69 |
70 |
71 | {team.name}
72 | ⌘{index + 1}
73 |
74 | ))}
75 |
76 |
77 |
80 | Add team
81 |
82 |
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/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+/settings+/display/display-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod/v4'
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 | shouldRevalidate: 'onBlur',
52 | })
53 | const itemList = fields.items.getFieldList()
54 | const navigation = useNavigation()
55 |
56 | return (
57 |
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/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/components/command-menu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconArrowRightDashed,
3 | IconDeviceLaptop,
4 | IconMoon,
5 | IconSun,
6 | } from '@tabler/icons-react'
7 | import { useTheme } from 'next-themes'
8 | import React from 'react'
9 | import { useNavigate } from 'react-router'
10 | import {
11 | CommandDialog,
12 | CommandEmpty,
13 | CommandGroup,
14 | CommandInput,
15 | CommandItem,
16 | CommandList,
17 | CommandSeparator,
18 | } from '~/components/ui/command'
19 | import { useSearch } from '~/context/search-context'
20 | import { sidebarData } from '~/data/sidebar-data'
21 | import { ScrollArea } from './ui/scroll-area'
22 |
23 | export function CommandMenu() {
24 | const navigate = useNavigate()
25 | const { setTheme } = useTheme()
26 | const { open, setOpen } = useSearch()
27 |
28 | const runCommand = React.useCallback(
29 | (command: () => unknown) => {
30 | setOpen(false)
31 | command()
32 | },
33 | [setOpen],
34 | )
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | No results found.
42 | {sidebarData.navGroups.map((group) => (
43 |
44 | {group.items.map((navItem, i) => {
45 | if (navItem.url)
46 | return (
47 | {
51 | runCommand(() => navigate(navItem.url as string))
52 | }}
53 | >
54 |
55 |
56 |
57 | {navItem.title}
58 |
59 | )
60 |
61 | return navItem.items?.map((subItem, i) => (
62 | {
66 | runCommand(() => navigate(subItem.url as string))
67 | }}
68 | >
69 |
70 |
71 |
72 | {subItem.title}
73 |
74 | ))
75 | })}
76 |
77 | ))}
78 |
79 |
80 | runCommand(() => setTheme('light'))}>
81 | Light
82 |
83 | runCommand(() => setTheme('dark'))}>
84 |
85 | Dark
86 |
87 | runCommand(() => setTheme('system'))}>
88 |
89 | System
90 |
91 |
92 |
93 |
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/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/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+/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+/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/_auth+/sign-in/components/user-auth-form.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
2 | import { parseWithZod } from '@conform-to/zod/v4'
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 { type action, formSchema } 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 | shouldRevalidate: 'onBlur',
27 | })
28 | const navigation = useNavigation()
29 | const isLoading = navigation.state === 'submitting'
30 |
31 | return (
32 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------