test
This commit is contained in:
commit
81e2c273b1
53 changed files with 12052 additions and 0 deletions
21
.cta.json
Normal file
21
.cta.json
Normal 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
7
.cursorrules
Normal 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
13
.gitignore
vendored
Normal 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
8
.idea/.gitignore
vendored
Normal 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
12
.idea/ReactTest.iml
Normal 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
8
.idea/modules.xml
Normal 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
6
.idea/prettier.xml
Normal 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
6
.idea/vcs.xml
Normal 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
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
310
README.md
Normal 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
24
components.json
Normal 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
10
drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
})
|
||||
7
drizzle/0000_foamy_ken_ellis.sql
Normal file
7
drizzle/0000_foamy_ken_ellis.sql
Normal 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
|
||||
);
|
||||
65
drizzle/meta/0000_snapshot.json
Normal file
65
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
5
eslint.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// @ts-check
|
||||
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
|
||||
export default [...tanstackConfig]
|
||||
9806
package-lock.json
generated
Normal file
9806
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
63
package.json
Normal file
63
package.json
Normal 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
10
prettier.config.js
Normal 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
1
public/drizzle.svg
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
BIN
public/tanstack-circle-logo.png
Normal file
BIN
public/tanstack-circle-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
1
public/tanstack-word-logo-white.svg
Normal file
1
public/tanstack-word-logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
31
src/components/local-count-button.tsx
Normal file
31
src/components/local-count-button.tsx
Normal 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
|
||||
})
|
||||
79
src/components/todo-form.tsx
Normal file
79
src/components/todo-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/ui/action-button.tsx
Normal file
77
src/components/ui/action-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
62
src/components/ui/button.tsx
Normal file
62
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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
104
src/components/ui/empty.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
36
src/components/ui/loading-swap.tsx
Normal file
36
src/components/ui/loading-swap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/ui/sonner.tsx
Normal file
38
src/components/ui/sonner.tsx
Normal 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
114
src/components/ui/table.tsx
Normal 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
9
src/db/index.ts
Normal 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
12
src/db/schema.ts
Normal 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
6
src/lib/utils.ts
Normal 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
104
src/routeTree.gen.ts
Normal 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
13
src/router.tsx
Normal 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
55
src/routes/__root.tsx
Normal 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
221
src/routes/index.tsx
Normal 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)
|
||||
}
|
||||
62
src/routes/todos/$id/edit/index.tsx
Normal file
62
src/routes/todos/$id/edit/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/routes/todos/new/index.tsx
Normal file
43
src/routes/todos/new/index.tsx
Normal 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
144
src/styles.css
Normal 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
29
tsconfig.json
Normal 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
19
vite.config.ts
Normal 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
|
||||
Loading…
Reference in a new issue