This commit is contained in:
Kevin Sträßler 2025-12-06 20:33:10 +01:00
commit 81e2c273b1
53 changed files with 12052 additions and 0 deletions

21
.cta.json Normal file
View file

@ -0,0 +1,21 @@
{
"projectName": ".",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "npm",
"addOnOptions": {
"drizzle": {
"database": "postgresql"
}
},
"git": true,
"version": 1,
"framework": "react-cra",
"chosenAddOns": [
"eslint",
"start",
"drizzle",
"shadcn"
]
}

7
.cursorrules Normal file
View file

@ -0,0 +1,7 @@
# shadcn instructions
Use the latest version of Shadcn to install new components, like this command to add a button component:
```bash
pnpx shadcn@latest add button
```

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
todos.json

8
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/ReactTest.iml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ReactTest.iml" filepath="$PROJECT_DIR$/.idea/ReactTest.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
yarn.lock

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 WebDevSimplified
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

310
README.md Normal file
View file

@ -0,0 +1,310 @@
Welcome to your new TanStack app!
# Getting Started
To run this application:
```bash
npm install
npm run start
```
# Building For Production
To build this application for production:
```bash
npm run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
npm run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
## Linting & Formatting
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
```bash
npm run lint
npm run format
npm run check
```
## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
```bash
pnpx shadcn@latest add button
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add another a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
Here is an example layout that includes a header:
```tsx
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { Link } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
<Outlet />
<TanStackRouterDevtools />
</>
),
})
```
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
const peopleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/people",
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json() as Promise<{
results: {
name: string;
}[];
}>;
},
component: () => {
const data = peopleRoute.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
},
});
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
### React-Query
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
First add your dependencies:
```bash
npm install @tanstack/react-query @tanstack/react-query-devtools
```
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// ...
const queryClient = new QueryClient();
// ...
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
```
You can also add TanStack Query Devtools to the root route (optional).
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools />
</>
),
});
```
Now you can use `useQuery` to fetch your data.
```tsx
import { useQuery } from "@tanstack/react-query";
import "./App.css";
function App() {
const { data } = useQuery({
queryKey: ["people"],
queryFn: () =>
fetch("https://swapi.dev/api/people")
.then((res) => res.json())
.then((data) => data.results as { name: string }[]),
initialData: [],
});
return (
<div>
<ul>
{data.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
</div>
);
}
export default App;
```
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
## State Management
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
First you need to add TanStack Store as a dependency:
```bash
npm install @tanstack/store
```
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
function App() {
const count = useStore(countStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
</div>
);
}
export default App;
```
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
Let's check this out by doubling the count using derived state.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store, Derived } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
const doubledStore = new Derived({
fn: () => countStore.state * 2,
deps: [countStore],
});
doubledStore.mount();
function App() {
const count = useStore(countStore);
const doubledCount = useStore(doubledStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
<div>Doubled - {doubledCount}</div>
</div>
);
}
export default App;
```
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).

24
components.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json"
}
}

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
})

View file

@ -0,0 +1,7 @@
CREATE TABLE "todos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"isComplete" boolean NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,65 @@
{
"id": "e00eaee3-3cbf-4b37-8328-12a539eff67d",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.todos": {
"name": "todos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"isComplete": {
"name": "isComplete",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1765049424271,
"tag": "0000_foamy_ken_ellis",
"breakpoints": true
}
]
}

5
eslint.config.js Normal file
View file

@ -0,0 +1,5 @@
// @ts-check
import { tanstackConfig } from '@tanstack/eslint-config'
export default [...tanstackConfig]

9806
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": ".",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"serve": "vite preview",
"test": "vitest run",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.30.0",
"drizzle-orm": "^0.39.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"pg": "^8.11.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"vite-tsconfig-paths": "^5.1.4",
"zod": "^4.1.12"
},
"devDependencies": {
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/pg": "^8.10.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"tsx": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}

10
prettier.config.js Normal file
View file

@ -0,0 +1,10 @@
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};
export default config;

1
public/drizzle.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" fill="none" viewBox="0 0 160 160"><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 43.48 67.304)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 76.94 46.534)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 128.424 46.535)"/><rect width="9.631" height="40.852" fill="#C5F74F" rx="4.816" transform="matrix(.87303 .48767 -.49721 .86763 94.957 67.304)"/></svg>

After

Width:  |  Height:  |  Size: 608 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { Button } from './ui/button'
import { createClientOnlyFn } from '@tanstack/react-start'
import { ClientOnly } from '@tanstack/react-router'
export function LocalCountButton() {
return (
<ClientOnly fallback="sdfsdfsdfsdf">
<ClientSection />
</ClientOnly>
)
}
function ClientSection() {
const [count, setCount] = useState(loadCount)
useEffect(() => {
localStorage.setItem('count', count.toString())
}, [count])
return (
<Button variant="outline" size="sm" onClick={() => setCount((c) => c + 1)}>
{count}
</Button>
)
}
const loadCount = createClientOnlyFn(() => {
const storedCount = localStorage.getItem('count')
return storedCount ? parseInt(storedCount) : 0
})

View file

@ -0,0 +1,79 @@
import { FormEvent, useRef, useState } from 'react'
import { Input } from './ui/input'
import { Button } from './ui/button'
import { LoadingSwap } from './ui/loading-swap'
import { PlusIcon } from 'lucide-react'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import z from 'zod'
import { db } from '@/db'
import { todos } from '@/db/schema'
import { redirect } from '@tanstack/react-router'
import { eq } from 'drizzle-orm'
const addTodo = createServerFn({ method: 'POST' })
.inputValidator(z.object({ name: z.string().min(1) }))
.handler(async ({ data }) => {
await db.insert(todos).values({ ...data, isComplete: false })
throw redirect({ to: '/' })
})
const updateTodo = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string().min(1), name: z.string().min(1) }))
.handler(async ({ data }) => {
await db.update(todos).set(data).where(eq(todos.id, data.id))
throw redirect({ to: '/' })
})
export function TodoForm({
todo,
}: {
todo?: {
name: string
id: string
}
}) {
const nameRef = useRef<HTMLInputElement>(null)
const [isLoading, setIsLoading] = useState(false)
const addTodoFn = useServerFn(addTodo)
const updateTodoFn = useServerFn(updateTodo)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const name = nameRef.current?.value
if (!name) return
setIsLoading(true)
if (todo == null) {
await addTodoFn({ data: { name } })
} else {
await updateTodoFn({ data: { name, id: todo.id } })
}
setIsLoading(false)
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
autoFocus
ref={nameRef}
placeholder="Enter your todo..."
className="flex-1"
aria-label="Name"
defaultValue={todo?.name}
/>
<Button type="submit" disabled={isLoading}>
<LoadingSwap isLoading={isLoading} className="flex gap-2 items-center">
{todo == null ? (
<>
<PlusIcon /> Add
</>
) : (
'Update'
)}
</LoadingSwap>
</Button>
</form>
)
}

View file

@ -0,0 +1,77 @@
import { type ComponentProps, type ReactNode, useTransition } from "react"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { LoadingSwap } from "@/components/ui/loading-swap"
import {
AlertDialog,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
export function ActionButton({
action,
requireAreYouSure = false,
areYouSureDescription = "This action cannot be undone.",
...props
}: ComponentProps<typeof Button> & {
action: () => Promise<{ error: boolean; message?: string }>
requireAreYouSure?: boolean
areYouSureDescription?: ReactNode
}) {
const [isLoading, startTransition] = useTransition()
function performAction() {
startTransition(async () => {
const data = await action()
if (data.error) toast.error(data.message ?? "Error")
})
}
if (requireAreYouSure) {
return (
<AlertDialog open={isLoading ? true : undefined}>
<AlertDialogTrigger asChild>
<Button {...props} />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
{areYouSureDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={isLoading} onClick={performAction}>
<LoadingSwap isLoading={isLoading}>Yes</LoadingSwap>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
return (
<Button
{...props}
disabled={props.disabled ?? isLoading}
onClick={e => {
performAction()
props.onClick?.(e)
}}
>
<LoadingSwap
isLoading={isLoading}
className="inline-flex items-center gap-2"
>
{props.children}
</LoadingSwap>
</Button>
)
}

View file

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,62 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
destructiveGhost:
'text-destructive hover:bg-destructive/10 hover:text-destructive-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

104
src/components/ui/empty.tsx Normal file
View file

@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,36 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
import type { ReactNode } from "react"
export function LoadingSwap({
isLoading,
children,
className,
}: {
isLoading: boolean
children: ReactNode
className?: string
}) {
return (
<div className="grid grid-cols-1 items-center justify-items-center">
<div
className={cn(
"col-start-1 col-end-2 row-start-1 row-end-2 w-full",
isLoading ? "invisible" : "visible",
className,
)}
>
{children}
</div>
<div
className={cn(
"col-start-1 col-end-2 row-start-1 row-end-2",
isLoading ? "visible" : "invisible",
className,
)}
>
<Loader2Icon className="animate-spin" />
</div>
</div>
)
}

View file

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

114
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

9
src/db/index.ts Normal file
View file

@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema.ts'
const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
})
export const db = drizzle(pool, { schema })

12
src/db/schema.ts Normal file
View file

@ -0,0 +1,12 @@
import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core'
export const todos = pgTable('todos', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
isComplete: boolean().notNull(),
createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp({ withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
})

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

104
src/routeTree.gen.ts Normal file
View file

@ -0,0 +1,104 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as TodosNewIndexRouteImport } from './routes/todos/new/index'
import { Route as TodosIdEditIndexRouteImport } from './routes/todos/$id/edit/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const TodosNewIndexRoute = TodosNewIndexRouteImport.update({
id: '/todos/new/',
path: '/todos/new/',
getParentRoute: () => rootRouteImport,
} as any)
const TodosIdEditIndexRoute = TodosIdEditIndexRouteImport.update({
id: '/todos/$id/edit/',
path: '/todos/$id/edit/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/todos/new': typeof TodosNewIndexRoute
'/todos/$id/edit': typeof TodosIdEditIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/todos/new': typeof TodosNewIndexRoute
'/todos/$id/edit': typeof TodosIdEditIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/todos/new/': typeof TodosNewIndexRoute
'/todos/$id/edit/': typeof TodosIdEditIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/todos/new' | '/todos/$id/edit'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/todos/new' | '/todos/$id/edit'
id: '__root__' | '/' | '/todos/new/' | '/todos/$id/edit/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
TodosNewIndexRoute: typeof TodosNewIndexRoute
TodosIdEditIndexRoute: typeof TodosIdEditIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/todos/new/': {
id: '/todos/new/'
path: '/todos/new'
fullPath: '/todos/new'
preLoaderRoute: typeof TodosNewIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/todos/$id/edit/': {
id: '/todos/$id/edit/'
path: '/todos/$id/edit'
fullPath: '/todos/$id/edit'
preLoaderRoute: typeof TodosIdEditIndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
TodosNewIndexRoute: TodosNewIndexRoute,
TodosIdEditIndexRoute: TodosIdEditIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

13
src/router.tsx Normal file
View file

@ -0,0 +1,13 @@
import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
// Create a new router instance
export const getRouter = () => {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreloadStaleTime: 0,
})
}

55
src/routes/__root.tsx Normal file
View file

@ -0,0 +1,55 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
}

221
src/routes/index.tsx Normal file
View file

@ -0,0 +1,221 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import { db } from '@/db'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { EditIcon, ListTodoIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { ActionButton } from '@/components/ui/action-button'
import z from 'zod'
import { todos } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { startTransition, useState } from 'react'
import { LocalCountButton } from '@/components/local-count-button'
const serverLoader = createServerFn({ method: 'GET' }).handler(() => {
return db.query.todos.findMany()
})
export const Route = createFileRoute('/')({
component: App,
loader: () => {
return serverLoader()
},
})
function App() {
const todos = Route.useLoaderData()
const completedCount = todos.filter((t) => t.isComplete).length
const totalCount = todos.length
return (
<div className="min-h-screen container space-y-8">
<div className="flex justify-between items-center gap-4">
<div className="space-y-2">
<h1 className="text-4xl font-bold">Todo List</h1>
{totalCount > 0 && (
<Badge variant="outline">
{completedCount} of {totalCount} completed
</Badge>
)}
</div>
<div className="flex gap-2">
<LocalCountButton />
<Button size="sm" asChild>
<Link to="/todos/new">
<PlusIcon /> Add Todo
</Link>
</Button>
</div>
</div>
<TodoListTable todos={todos} />
</div>
)
}
function TodoListTable({
todos,
}: {
todos: Array<{
id: string
name: string
isComplete: boolean
createdAt: Date
}>
}) {
if (todos.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<ListTodoIcon />
</EmptyMedia>
<EmptyTitle>No Todos</EmptyTitle>
<EmptyDescription>Try adding a new todo</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<Link to="/todos/new">
<PlusIcon /> Add Todo
</Link>
</Button>
</EmptyContent>
</Empty>
)
}
return (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead></TableHead>
<TableHead>Task</TableHead>
<TableHead>Created On</TableHead>
<TableHead className="w-0"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todos.map((todo) => (
<TodoTableRow key={todo.id} {...todo} />
))}
</TableBody>
</Table>
)
}
const deleteFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string().min(1) }))
.handler(async ({ data }) => {
await db.delete(todos).where(eq(todos.id, data.id))
return { error: false }
})
const toggleFn = createServerFn({ method: 'POST' })
.inputValidator(
z.object({
id: z.string().min(1),
isComplete: z.boolean(),
}),
)
.handler(async ({ data }) => {
await new Promise((res) => setTimeout(res, 1000))
await db
.update(todos)
.set({ isComplete: data.isComplete })
.where(eq(todos.id, data.id))
})
function TodoTableRow({
createdAt,
id,
isComplete,
name,
}: {
id: string
name: string
isComplete: boolean
createdAt: Date
}) {
const deleteFnServer = useServerFn(deleteFn)
const toggleFnServer = useServerFn(toggleFn)
const [isCurrentComplete, setIsCurrentComplete] = useState(isComplete)
const router = useRouter()
return (
<TableRow
onClick={(e) => {
const target = e.target as HTMLElement
if (target.closest('[data-actions]')) return
setIsCurrentComplete((c) => !c)
startTransition(async () => {
await toggleFnServer({ data: { id, isComplete: !isCurrentComplete } })
router.invalidate()
})
}}
>
<TableCell>
<Checkbox checked={isCurrentComplete} />
</TableCell>
<TableCell
className={cn(
'font-medium',
isCurrentComplete && 'text-muted-foreground line-through',
)}
>
{name}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(createdAt)}
</TableCell>
<TableCell data-actions>
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon-sm" asChild>
<Link to="/todos/$id/edit" params={{ id }}>
<EditIcon />
</Link>
</Button>
<ActionButton
action={async () => {
const res = await deleteFnServer({ data: { id } })
router.invalidate()
return res
}}
variant="destructiveGhost"
size="icon-sm"
>
<Trash2Icon />
</ActionButton>
</div>
</TableCell>
</TableRow>
)
}
function formatDate(date: Date) {
const formatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'short',
})
return formatter.format(date)
}

View file

@ -0,0 +1,62 @@
import { TodoForm } from '@/components/todo-form'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { db } from '@/db'
import { todos } from '@/db/schema'
import { createFileRoute, Link, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { ArrowLeftIcon } from 'lucide-react'
const loaderFn = createServerFn({ method: 'GET' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const todo = await db.query.todos.findFirst({
where: eq(todos.id, data.id),
})
if (todo == null) throw notFound()
return todo
})
export const Route = createFileRoute('/todos/$id/edit/')({
component: RouteComponent,
loader: ({ params }) => loaderFn({ data: params }),
})
function RouteComponent() {
const todo = Route.useLoaderData()
return (
<div className="container space-y-2">
<Button
asChild
variant="ghost"
size="sm"
className="text-muted-foreground"
>
<Link to="/">
<ArrowLeftIcon /> Todo List
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Edit Todo</CardTitle>
<CardDescription>
Update the details of your todo item
</CardDescription>
</CardHeader>
<CardContent>
<TodoForm todo={todo} />
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,43 @@
import { TodoForm } from '@/components/todo-form'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { createFileRoute, Link } from '@tanstack/react-router'
import { ArrowLeftIcon } from 'lucide-react'
export const Route = createFileRoute('/todos/new/')({
component: RouteComponent,
})
function RouteComponent() {
return (
<div className="container space-y-2">
<Button
asChild
variant="ghost"
size="sm"
className="text-muted-foreground"
>
<Link to="/">
<ArrowLeftIcon /> Todo List
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Add New Todo</CardTitle>
<CardDescription>
Create a new task to add to your todo list
</CardDescription>
</CardHeader>
<CardContent>
<TodoForm />
</CardContent>
</Card>
</div>
)
}

144
src/styles.css Normal file
View file

@ -0,0 +1,144 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
body {
@apply m-0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.871 0.006 286.286);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.141 0.005 285.823);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@utility container {
@apply max-w-4xl;
margin-inline: auto;
padding: 1rem;
}

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
}
}
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import tailwindcss from '@tailwindcss/vite'
const config = defineConfig({
plugins: [
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config