Compare commits
10 Commits
1e33b87c96
...
d470eb2cd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d470eb2cd5 | ||
|
|
cbd0cdced4 | ||
|
|
e8269bb2fe | ||
|
|
ba8a46fcd3 | ||
|
|
0bea7bc3f4 | ||
|
|
10859a4e64 | ||
|
|
2b78d4e332 | ||
|
|
448721f0a0 | ||
|
|
eb7c7a7221 | ||
|
|
4c8a6e9fd3 |
16
.cta.json
Normal file
16
.cta.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"projectName": "dofus-manager2",
|
||||||
|
"mode": "file-router",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": false,
|
||||||
|
"packageManager": "pnpm",
|
||||||
|
"git": true,
|
||||||
|
"install": true,
|
||||||
|
"addOnOptions": {},
|
||||||
|
"version": 1,
|
||||||
|
"framework": "react-cra",
|
||||||
|
"chosenAddOns": [
|
||||||
|
"start",
|
||||||
|
"nitro"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dofus_manager?schema=public"
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_SECRET="change-me-to-a-random-string-min-32-chars"
|
||||||
135
.gitea/workflow/ci.yml
Normal file
135
.gitea/workflow/ci.yml
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Environment to deploy"
|
||||||
|
required: true
|
||||||
|
default: "staging"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- staging
|
||||||
|
- production
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: dofus_manager
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: pnpm prisma generate
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: pnpm prisma migrate deploy
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_URL }}/dofus-manager:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_URL }}/dofus-manager:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/develop'
|
||||||
|
environment: staging
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to staging
|
||||||
|
run: |
|
||||||
|
echo "Deploying to staging..."
|
||||||
|
# SSH ou webhook vers votre serveur staging
|
||||||
|
curl -X POST "${{ secrets.STAGING_WEBHOOK_URL }}" || true
|
||||||
|
|
||||||
|
deploy-production:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to production
|
||||||
|
run: |
|
||||||
|
echo "Deploying to production..."
|
||||||
|
curl -X POST "${{ secrets.PRODUCTION_WEBHOOK_URL }}" || true
|
||||||
|
|
||||||
|
deploy-manual:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
environment: ${{ github.event.inputs.environment }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to ${{ github.event.inputs.environment }}
|
||||||
|
run: |
|
||||||
|
echo "Manual deploy to ${{ github.event.inputs.environment }}"
|
||||||
|
# Votre logique de déploiement ici
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
count.txt
|
||||||
|
.env
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
todos.json
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"files.readonlyInclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
}
|
||||||
|
}
|
||||||
290
README.md
Normal file
290
README.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
Welcome to your new TanStack app!
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
To run this application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
# Building For Production
|
||||||
|
|
||||||
|
To build this application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This project uses CSS for styling.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
pnpm add @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
|
||||||
|
pnpm add @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).
|
||||||
37
biome.json
Normal file
37
biome.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "stone",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
45
docker/Dockerfile
Normal file
45
docker/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# docker/Dockerfile
|
||||||
|
|
||||||
|
# ============ Build stage ============
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm prisma generate
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ============ Production stage ============
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 app
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder --chown=app:nodejs /app/.output ./.output
|
||||||
|
COPY --from=builder --chown=app:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=app:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=app:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
20
docker/docker-compose.dev.yml
Normal file
20
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=dofus_manager
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
47
docker/docker-compose.yml
Normal file
47
docker/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/dofus_manager
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- NODE_ENV=production
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=dofus_manager
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
@@ -22,10 +22,10 @@ src/server/
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/server/functions/characters.ts
|
// src/server/functions/characters.ts
|
||||||
import { createServerFn } from '@tanstack/react-start/server';
|
import { createServerFn } from "@tanstack/react-start/server";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { db } from '@/lib/server/db';
|
import { db } from "@/lib/server/db";
|
||||||
import { requireAuth } from '@/server/middleware/auth';
|
import { requireAuth } from "@/server/middleware/auth";
|
||||||
|
|
||||||
// Schemas
|
// Schemas
|
||||||
const createCharacterSchema = z.object({
|
const createCharacterSchema = z.object({
|
||||||
@@ -48,7 +48,7 @@ const characterFiltersSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
export const getCharacters = createServerFn({ method: 'GET' })
|
export const getCharacters = createServerFn({ method: "GET" })
|
||||||
.validator((data: unknown) => characterFiltersSchema.parse(data))
|
.validator((data: unknown) => characterFiltersSchema.parse(data))
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -57,7 +57,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
|||||||
const where = {
|
const where = {
|
||||||
account: { userId: session.userId },
|
account: { userId: session.userId },
|
||||||
...(search && {
|
...(search && {
|
||||||
name: { contains: search, mode: 'insensitive' as const },
|
name: { contains: search, mode: "insensitive" as const },
|
||||||
}),
|
}),
|
||||||
...(classIds?.length && { classId: { in: classIds } }),
|
...(classIds?.length && { classId: { in: classIds } }),
|
||||||
...(serverIds?.length && { serverId: { in: serverIds } }),
|
...(serverIds?.length && { serverId: { in: serverIds } }),
|
||||||
@@ -70,7 +70,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
|||||||
include: { account: { select: { id: true, name: true } } },
|
include: { account: { select: { id: true, name: true } } },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
db.character.count({ where }),
|
db.character.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -86,7 +86,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createCharacter = createServerFn({ method: 'POST' })
|
export const createCharacter = createServerFn({ method: "POST" })
|
||||||
.validator((data: unknown) => createCharacterSchema.parse(data))
|
.validator((data: unknown) => createCharacterSchema.parse(data))
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -97,14 +97,16 @@ export const createCharacter = createServerFn({ method: 'POST' })
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error("Account not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.character.create({ data });
|
return db.character.create({ data });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
|
export const bulkDeleteCharacters = createServerFn({ method: "POST" })
|
||||||
.validator((data: unknown) => z.object({ ids: z.array(z.string().cuid()) }).parse(data))
|
.validator((data: unknown) =>
|
||||||
|
z.object({ ids: z.array(z.string().cuid()) }).parse(data),
|
||||||
|
)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
|
|
||||||
@@ -123,8 +125,8 @@ export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/server/middleware/auth.ts
|
// src/server/middleware/auth.ts
|
||||||
import { getWebRequest } from '@tanstack/react-start/server';
|
import { getWebRequest } from "@tanstack/react-start/server";
|
||||||
import { db } from '@/lib/server/db';
|
import { db } from "@/lib/server/db";
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -133,13 +135,14 @@ interface Session {
|
|||||||
|
|
||||||
export async function requireAuth(): Promise<Session> {
|
export async function requireAuth(): Promise<Session> {
|
||||||
const request = getWebRequest();
|
const request = getWebRequest();
|
||||||
const sessionId = request.headers.get('cookie')
|
const sessionId = request.headers
|
||||||
?.split(';')
|
.get("cookie")
|
||||||
.find(c => c.trim().startsWith('session='))
|
?.split(";")
|
||||||
?.split('=')[1];
|
.find((c) => c.trim().startsWith("session="))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
throw new Error('Unauthorized');
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await db.session.findUnique({
|
const session = await db.session.findUnique({
|
||||||
@@ -148,7 +151,7 @@ export async function requireAuth(): Promise<Session> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session || session.expiresAt < new Date()) {
|
if (!session || session.expiresAt < new Date()) {
|
||||||
throw new Error('Session expired');
|
throw new Error("Session expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,7 +173,7 @@ export async function getOptionalAuth(): Promise<Session | null> {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/lib/server/cache.ts
|
// src/lib/server/cache.ts
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from "node-cache";
|
||||||
|
|
||||||
// Different TTLs for different data types
|
// Different TTLs for different data types
|
||||||
const caches = {
|
const caches = {
|
||||||
@@ -196,7 +199,9 @@ export const userCache = {
|
|||||||
|
|
||||||
// Helper to invalidate all cache for a user
|
// Helper to invalidate all cache for a user
|
||||||
invalidateUser: (userId: string): void => {
|
invalidateUser: (userId: string): void => {
|
||||||
const keys = caches.user.keys().filter(k => k.startsWith(`user:${userId}:`));
|
const keys = caches.user
|
||||||
|
.keys()
|
||||||
|
.filter((k) => k.startsWith(`user:${userId}:`));
|
||||||
caches.user.del(keys);
|
caches.user.del(keys);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,56 +16,57 @@ Draft
|
|||||||
2. Docker Compose configuration with app service and PostgreSQL 16
|
2. Docker Compose configuration with app service and PostgreSQL 16
|
||||||
3. Prisma configured and connected to PostgreSQL
|
3. Prisma configured and connected to PostgreSQL
|
||||||
4. shadcn/ui installed with base components (Button, Input, Card, Table)
|
4. shadcn/ui installed with base components (Button, Input, Card, Table)
|
||||||
5. ESLint + Prettier configured with recommended rules
|
5. Biome configured for linting and formatting
|
||||||
6. GitLab CI pipeline: build, lint, test stages
|
6. Gitea Actions workflow: build, lint, test stages
|
||||||
7. Dockerfile multi-stage pour production build
|
7. Dockerfile multi-stage pour production build
|
||||||
8. README avec instructions de setup local
|
8. README avec instructions de setup local
|
||||||
9. Application démarre et affiche une page d'accueil "Dofus Manager"
|
9. Application démarre et affiche une page d'accueil "Dofus Manager"
|
||||||
|
10. Health check endpoint `/api/health` pour Docker healthcheck
|
||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] Task 1: Initialize TanStack Start project (AC: 1)
|
- [x] Task 1: Initialize TanStack Start project (AC: 1)
|
||||||
- [ ] Create new TanStack Start project with `pnpm create @tanstack/start`
|
- [x] Create new TanStack Start project with `pnpm create @tanstack/start`
|
||||||
- [ ] Configure `tsconfig.json` with strict mode enabled
|
- [x] Configure `tsconfig.json` with strict mode enabled
|
||||||
- [ ] Configure path aliases (`@/` pointing to `src/`)
|
- [x] Configure path aliases (`@/` pointing to `src/`)
|
||||||
- [ ] Verify TypeScript strict compilation works
|
- [x] Verify TypeScript strict compilation works
|
||||||
|
|
||||||
- [ ] Task 2: Setup Docker environment (AC: 2, 7)
|
- [x] Task 2: Setup Docker environment (AC: 2, 7)
|
||||||
- [ ] Create `docker/` directory
|
- [x] Create `docker/` directory
|
||||||
- [ ] Create `docker/Dockerfile` with multi-stage build (builder + runner)
|
- [x] Create `docker/Dockerfile` with multi-stage build (builder + runner)
|
||||||
- [ ] Create `docker/docker-compose.yml` with app and postgres services
|
- [x] Create `docker/docker-compose.yml` with app and postgres services
|
||||||
- [ ] Create `docker/docker-compose.dev.yml` for local development (postgres only)
|
- [x] Create `docker/docker-compose.dev.yml` for local development (postgres only)
|
||||||
- [ ] Configure PostgreSQL 16-alpine with healthcheck
|
- [x] Configure PostgreSQL 16-alpine with healthcheck
|
||||||
- [ ] Test database connectivity
|
- [x] Test database connectivity
|
||||||
|
|
||||||
- [ ] Task 3: Configure Prisma ORM (AC: 3)
|
- [x] Task 3: Configure Prisma ORM (AC: 3)
|
||||||
- [ ] Install Prisma dependencies (`prisma`, `@prisma/client`)
|
- [x] Install Prisma dependencies (`prisma`, `@prisma/client`)
|
||||||
- [ ] Initialize Prisma with `pnpm prisma init`
|
- [x] Initialize Prisma with `pnpm prisma init`
|
||||||
- [ ] Configure `prisma/schema.prisma` with PostgreSQL provider
|
- [x] Configure `prisma/schema.prisma` with PostgreSQL provider
|
||||||
- [ ] Create `src/lib/server/db.ts` for Prisma client singleton
|
- [x] Create `src/lib/server/db.ts` for Prisma client singleton
|
||||||
- [ ] Create `.env.example` with DATABASE_URL template
|
- [x] Create `.env.example` with DATABASE_URL template
|
||||||
- [ ] Verify Prisma connects to database
|
- [x] Verify Prisma connects to database
|
||||||
|
|
||||||
- [ ] Task 4: Install and configure shadcn/ui (AC: 4)
|
- [x] Task 4: Install and configure shadcn/ui (AC: 4)
|
||||||
- [ ] Install Tailwind CSS 4.x
|
- [x] Install Tailwind CSS 4.x
|
||||||
- [ ] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
|
- [x] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
|
||||||
- [ ] Configure `components.json` for path aliases
|
- [x] Configure `components.json` for path aliases
|
||||||
- [ ] Install base components: Button, Input, Card, Table
|
- [x] Install base components: Button, Input, Card, Table
|
||||||
- [ ] Create `src/lib/utils.ts` with `cn` utility function
|
- [x] Create `src/lib/utils.ts` with `cn` utility function
|
||||||
- [ ] Install Lucide React for icons
|
- [x] Install Lucide React for icons
|
||||||
|
|
||||||
- [ ] Task 5: Configure linting and formatting (AC: 5)
|
- [x] Task 5: Configure linting and formatting (AC: 5)
|
||||||
- [ ] Install Biome (as specified in tech stack, not ESLint+Prettier)
|
- [x] Install Biome (as specified in tech stack, not ESLint+Prettier)
|
||||||
- [ ] Create `biome.json` with recommended rules
|
- [x] Create `biome.json` with recommended rules
|
||||||
- [ ] Add lint and format scripts to `package.json`
|
- [x] Add lint and format scripts to `package.json`
|
||||||
- [ ] Verify linting works on project files
|
- [x] Verify linting works on project files
|
||||||
|
|
||||||
- [ ] Task 6: Setup GitLab CI/CD pipeline (AC: 6)
|
- [ ] Task 6: Setup Gitea Actions workflow (AC: 6)
|
||||||
- [ ] Create `.gitlab-ci.yml` with stages: test, build, deploy
|
- [x] Create `.gitea/workflows/ci.yml`
|
||||||
- [ ] Configure test stage: lint, typecheck, test
|
- [x] Configure test job: lint, typecheck, test
|
||||||
- [ ] Configure build stage: Docker image build and push
|
- [x] Configure build job: Docker image build and push
|
||||||
- [ ] Configure deploy stages (staging/production) with manual triggers
|
- [x] Configure deploy jobs (staging/production) with manual triggers
|
||||||
- [ ] Add caching for node_modules
|
- [ ] Add caching for pnpm store
|
||||||
|
|
||||||
- [ ] Task 7: Create README documentation (AC: 8)
|
- [ ] Task 7: Create README documentation (AC: 8)
|
||||||
- [ ] Document project overview
|
- [ ] Document project overview
|
||||||
@@ -81,7 +82,13 @@ Draft
|
|||||||
- [ ] Create `src/styles/globals.css` with Tailwind imports
|
- [ ] Create `src/styles/globals.css` with Tailwind imports
|
||||||
- [ ] Verify application starts and renders correctly
|
- [ ] Verify application starts and renders correctly
|
||||||
|
|
||||||
- [ ] Task 9: Final verification
|
- [ ] Task 9: Create health check endpoint (AC: 10)
|
||||||
|
- [ ] Create `src/routes/api/health.ts` server function
|
||||||
|
- [ ] Return JSON `{ status: "ok", timestamp: Date }`
|
||||||
|
- [ ] Optionally check database connectivity
|
||||||
|
- [ ] Verify endpoint responds at `GET /api/health`
|
||||||
|
|
||||||
|
- [ ] Task 10: Final verification
|
||||||
- [ ] Run `pnpm dev` and verify app starts
|
- [ ] Run `pnpm dev` and verify app starts
|
||||||
- [ ] Run `pnpm lint` and verify no errors
|
- [ ] Run `pnpm lint` and verify no errors
|
||||||
- [ ] Run `pnpm typecheck` and verify no errors
|
- [ ] Run `pnpm typecheck` and verify no errors
|
||||||
@@ -118,7 +125,7 @@ Draft
|
|||||||
|
|
||||||
- Docker
|
- Docker
|
||||||
- Docker Compose
|
- Docker Compose
|
||||||
- GitLab CI
|
- Gitea Actions
|
||||||
|
|
||||||
**Dev Tools:**
|
**Dev Tools:**
|
||||||
|
|
||||||
@@ -131,9 +138,13 @@ Draft
|
|||||||
|
|
||||||
```
|
```
|
||||||
dofus-manager/
|
dofus-manager/
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── ci.yml
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── docker-compose.yml
|
│ ├── docker-compose.yml
|
||||||
|
│ └── docker-compose.dev.yml
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma
|
│ ├── schema.prisma
|
||||||
│ └── migrations/
|
│ └── migrations/
|
||||||
@@ -156,7 +167,9 @@ dofus-manager/
|
|||||||
│ │ └── logger.ts
|
│ │ └── logger.ts
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── __root.tsx
|
│ │ ├── __root.tsx
|
||||||
│ │ └── index.tsx
|
│ │ ├── index.tsx
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ └── health.ts
|
||||||
│ ├── styles/
|
│ ├── styles/
|
||||||
│ │ └── globals.css
|
│ │ └── globals.css
|
||||||
│ └── app.tsx
|
│ └── app.tsx
|
||||||
@@ -187,22 +200,66 @@ dofus-manager/
|
|||||||
- PostgreSQL 16-alpine with healthcheck
|
- PostgreSQL 16-alpine with healthcheck
|
||||||
- Traefik labels for reverse proxy (production)
|
- Traefik labels for reverse proxy (production)
|
||||||
|
|
||||||
### GitLab CI/CD [Source: architecture/14-deployment-architecture.md#gitlab-cicd-pipeline]
|
### Gitea Actions [Adapted from architecture]
|
||||||
|
|
||||||
**Stages:** test, build, deploy
|
**Workflow file:** `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
**Test stage:**
|
**Jobs:** test, build, deploy
|
||||||
|
|
||||||
- image: node:20-alpine
|
**Test job:**
|
||||||
- Commands: pnpm lint, pnpm typecheck, pnpm test
|
|
||||||
- Cache node_modules
|
|
||||||
|
|
||||||
**Build stage:**
|
- runs-on: ubuntu-latest
|
||||||
|
- Steps: checkout, setup pnpm, setup node, install, lint, typecheck, test
|
||||||
|
- Cache pnpm store
|
||||||
|
|
||||||
- image: docker:24
|
**Build job:**
|
||||||
- Build and push Docker image to registry
|
|
||||||
|
- needs: test
|
||||||
|
- Build and push Docker image
|
||||||
- Only on main/develop branches
|
- Only on main/develop branches
|
||||||
|
|
||||||
|
**Exemple de workflow:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "pnpm"
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
- run: pnpm typecheck
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: registry.example.com/dofus-manager:${{ github.sha }}
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
|
### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -234,9 +291,45 @@ SESSION_SECRET="your-secret-key-min-32-chars"
|
|||||||
- Types/Interfaces: PascalCase
|
- Types/Interfaces: PascalCase
|
||||||
- Constants: SCREAMING_SNAKE_CASE
|
- Constants: SCREAMING_SNAKE_CASE
|
||||||
|
|
||||||
### Important Discrepancy Note
|
### Health Check Endpoint [Source: architecture/19-monitoring-observability.md]
|
||||||
|
|
||||||
AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology-stack.md) specify **Biome** for linting and formatting. Recommend following the architecture document and using Biome instead, as it's the project standard.
|
**Endpoint:** `GET /api/health`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2026-01-19T10:00:00.000Z",
|
||||||
|
"database": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation avec TanStack Start:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/routes/api/health.ts
|
||||||
|
import { createAPIFileRoute } from "@tanstack/start/api";
|
||||||
|
import { prisma } from "@/lib/server/db";
|
||||||
|
|
||||||
|
export const Route = createAPIFileRoute("/api/health")({
|
||||||
|
GET: async () => {
|
||||||
|
let dbStatus = "disconnected";
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
dbStatus = "connected";
|
||||||
|
} catch {
|
||||||
|
dbStatus = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: dbStatus,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -265,8 +358,9 @@ AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology
|
|||||||
## Change Log
|
## Change Log
|
||||||
|
|
||||||
| Date | Version | Description | Author |
|
| Date | Version | Description | Author |
|
||||||
| ---------- | ------- | ---------------------- | -------- |
|
| ---------- | ------- | --------------------------------------------- | -------- |
|
||||||
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
|
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
|
||||||
|
| 2026-01-19 | 1.1 | Gitea Actions, Biome, Health endpoint ajoutés | SM Agent |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "dofus-manager2",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 3000",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@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",
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vite-tsconfig-paths": "^6.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.3.11",
|
||||||
|
"@tanstack/devtools-vite": "^0.3.11",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
|
"prisma": "^7.2.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^3.0.5",
|
||||||
|
"web-vitals": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5035
pnpm-lock.yaml
generated
Normal file
5035
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@prisma/engines'
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
187
prisma/migrations/20260119121104_init/migration.sql
Normal file
187
prisma/migrations/20260119121104_init/migration.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TeamType" AS ENUM ('MAIN', 'SECONDARY', 'CUSTOM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProgressionType" AS ENUM ('QUEST', 'DUNGEON', 'ACHIEVEMENT', 'DOFUS');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "characters" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"class_id" INTEGER NOT NULL,
|
||||||
|
"class_name" TEXT NOT NULL,
|
||||||
|
"server_id" INTEGER NOT NULL,
|
||||||
|
"server_name" TEXT NOT NULL,
|
||||||
|
"account_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "characters_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "teams" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" "TeamType" NOT NULL DEFAULT 'CUSTOM',
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "team_members" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"team_id" TEXT NOT NULL,
|
||||||
|
"character_id" TEXT NOT NULL,
|
||||||
|
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "team_members_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "progressions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" "ProgressionType" NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"dofusdb_id" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "progressions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "character_progressions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"character_id" TEXT NOT NULL,
|
||||||
|
"progression_id" TEXT NOT NULL,
|
||||||
|
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "character_progressions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "accounts_user_id_name_key" ON "accounts"("user_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "characters_account_id_idx" ON "characters"("account_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "characters_class_id_idx" ON "characters"("class_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "characters_server_id_idx" ON "characters"("server_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "characters_level_idx" ON "characters"("level");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "characters_account_id_name_key" ON "characters"("account_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "teams_user_id_idx" ON "teams"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "teams_user_id_name_key" ON "teams"("user_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "team_members_team_id_idx" ON "team_members"("team_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "team_members_character_id_idx" ON "team_members"("character_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "team_members_team_id_character_id_key" ON "team_members"("team_id", "character_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "progressions_dofusdb_id_key" ON "progressions"("dofusdb_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "progressions_type_idx" ON "progressions"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "progressions_category_idx" ON "progressions"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "character_progressions_character_id_idx" ON "character_progressions"("character_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "character_progressions_progression_id_idx" ON "character_progressions"("progression_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "character_progressions_completed_idx" ON "character_progressions"("completed");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "character_progressions_character_id_progression_id_key" ON "character_progressions"("character_id", "progression_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "characters" ADD CONSTRAINT "characters_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "teams" ADD CONSTRAINT "teams_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_progression_id_fkey" FOREIGN KEY ("progression_id") REFERENCES "progressions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
140
prisma/schema.prisma
Normal file
140
prisma/schema.prisma
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
teams Team[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
characters Character[]
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Character {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
level Int @default(1)
|
||||||
|
classId Int @map("class_id")
|
||||||
|
className String @map("class_name")
|
||||||
|
serverId Int @map("server_id")
|
||||||
|
serverName String @map("server_name")
|
||||||
|
accountId String @map("account_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
progressions CharacterProgression[]
|
||||||
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
|
teamMembers TeamMember[]
|
||||||
|
|
||||||
|
@@unique([accountId, name])
|
||||||
|
@@index([accountId])
|
||||||
|
@@index([classId])
|
||||||
|
@@index([serverId])
|
||||||
|
@@index([level])
|
||||||
|
@@map("characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
type TeamType @default(CUSTOM)
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
members TeamMember[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TeamMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String @map("team_id")
|
||||||
|
characterId String @map("character_id")
|
||||||
|
joinedAt DateTime @default(now()) @map("joined_at")
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, characterId])
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([characterId])
|
||||||
|
@@map("team_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Progression {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
type ProgressionType
|
||||||
|
category String
|
||||||
|
dofusDbId Int? @unique @map("dofusdb_id")
|
||||||
|
characterProgressions CharacterProgression[]
|
||||||
|
|
||||||
|
@@index([type])
|
||||||
|
@@index([category])
|
||||||
|
@@map("progressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterProgression {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
characterId String @map("character_id")
|
||||||
|
progressionId String @map("progression_id")
|
||||||
|
completed Boolean @default(false)
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
progression Progression @relation(fields: [progressionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([characterId, progressionId])
|
||||||
|
@@index([characterId])
|
||||||
|
@@index([progressionId])
|
||||||
|
@@index([completed])
|
||||||
|
@@map("character_progressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TeamType {
|
||||||
|
MAIN
|
||||||
|
SECONDARY
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProgressionType {
|
||||||
|
QUEST
|
||||||
|
DUNGEON
|
||||||
|
ACHIEVEMENT
|
||||||
|
DOFUS
|
||||||
|
}
|
||||||
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 |
38
src/App.css
Normal file
38
src/App.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
height: 40vmin;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #282c34;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(10px + 2vmin);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-link {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes App-logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,75 +5,75 @@
|
|||||||
|
|
||||||
/* Updated to Dofus Manager dark gaming theme */
|
/* Updated to Dofus Manager dark gaming theme */
|
||||||
:root {
|
:root {
|
||||||
--background: #0f172a;
|
--background: oklch(1 0 0);
|
||||||
--foreground: #f8fafc;
|
--foreground: oklch(0.147 0.004 49.25);
|
||||||
--card: #1e293b;
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: #f8fafc;
|
--card-foreground: oklch(0.147 0.004 49.25);
|
||||||
--popover: #1e293b;
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: #f8fafc;
|
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||||
--primary: #60a5fa;
|
--primary: oklch(0.216 0.006 56.043);
|
||||||
--primary-foreground: #0f172a;
|
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
--secondary: #334155;
|
--secondary: oklch(0.97 0.001 106.424);
|
||||||
--secondary-foreground: #f8fafc;
|
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||||
--muted: #334155;
|
--muted: oklch(0.97 0.001 106.424);
|
||||||
--muted-foreground: #94a3b8;
|
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||||
--accent: #334155;
|
--accent: oklch(0.97 0.001 106.424);
|
||||||
--accent-foreground: #f8fafc;
|
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
--destructive: #ef4444;
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: #f8fafc;
|
--destructive-foreground: #f8fafc;
|
||||||
--border: #334155;
|
--border: oklch(0.923 0.003 48.717);
|
||||||
--input: #334155;
|
--input: oklch(0.923 0.003 48.717);
|
||||||
--ring: #60a5fa;
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
--chart-1: #60a5fa;
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: #4ade80;
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: #f87171;
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: #fbbf24;
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: #a78bfa;
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--radius: 0.5rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: #1e293b;
|
--sidebar: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-foreground: #f8fafc;
|
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||||
--sidebar-primary: #60a5fa;
|
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||||
--sidebar-primary-foreground: #0f172a;
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-accent: #334155;
|
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||||
--sidebar-accent-foreground: #f8fafc;
|
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
--sidebar-border: #334155;
|
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||||
--sidebar-ring: #60a5fa;
|
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep dark class same as root for dark-first approach */
|
/* Keep dark class same as root for dark-first approach */
|
||||||
.dark {
|
.dark {
|
||||||
--background: #0f172a;
|
--background: oklch(0.147 0.004 49.25);
|
||||||
--foreground: #f8fafc;
|
--foreground: oklch(0.985 0.001 106.423);
|
||||||
--card: #1e293b;
|
--card: oklch(0.216 0.006 56.043);
|
||||||
--card-foreground: #f8fafc;
|
--card-foreground: oklch(0.985 0.001 106.423);
|
||||||
--popover: #1e293b;
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
--popover-foreground: #f8fafc;
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
--primary: #60a5fa;
|
--primary: oklch(0.923 0.003 48.717);
|
||||||
--primary-foreground: #0f172a;
|
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||||
--secondary: #334155;
|
--secondary: oklch(0.268 0.007 34.298);
|
||||||
--secondary-foreground: #f8fafc;
|
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||||
--muted: #334155;
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
--muted-foreground: #94a3b8;
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
--accent: #334155;
|
--accent: oklch(0.268 0.007 34.298);
|
||||||
--accent-foreground: #f8fafc;
|
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
--destructive: #ef4444;
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: #f8fafc;
|
--destructive-foreground: #f8fafc;
|
||||||
--border: #334155;
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: #334155;
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: #60a5fa;
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
--chart-1: #60a5fa;
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: #4ade80;
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: #f87171;
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: #fbbf24;
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: #a78bfa;
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: #1e293b;
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
--sidebar-foreground: #f8fafc;
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-primary: #60a5fa;
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: #0f172a;
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-accent: #334155;
|
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||||
--sidebar-accent-foreground: #f8fafc;
|
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
--sidebar-border: #334155;
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: #60a5fa;
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -115,6 +115,9 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
18
src/components/Header.css
Normal file
18
src/components/Header.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.header {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
27
src/components/Header.tsx
Normal file
27
src/components/Header.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import './Header.css';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<nav className="nav">
|
||||||
|
<div className="nav-item">
|
||||||
|
<Link to="/">Home</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 font-bold">
|
||||||
|
<Link to="/demo/start/server-funcs">Start - Server Functions</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 font-bold">
|
||||||
|
<Link to="/demo/start/api-request">Start - API Request</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 font-bold">
|
||||||
|
<Link to="/demo/start/ssr">Start - SSR Demos</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,33 @@
|
|||||||
import * as React from "react";
|
|
||||||
import {
|
import {
|
||||||
Search,
|
ArrowUpDown,
|
||||||
Plus,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ArrowUpDown,
|
Plus,
|
||||||
} from "lucide-react";
|
Search,
|
||||||
import { Input } from "@/components/ui/input";
|
} from 'lucide-react';
|
||||||
import { Button } from "@/components/ui/button";
|
import * as React from 'react';
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,106 +35,87 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from '@/components/ui/table';
|
||||||
import {
|
import { cn } from '@/lib/utils';
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
// Sample data
|
// Sample data
|
||||||
const characters = [
|
const characters = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
nom: "Krosmaster",
|
nom: 'Krosmaster',
|
||||||
classe: "Cra",
|
classe: 'Cra',
|
||||||
niveau: 200,
|
niveau: 200,
|
||||||
serveur: "Imagiro",
|
serveur: 'Imagiro',
|
||||||
compte: "Compte1",
|
compte: 'Compte1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
nom: "TankMaster",
|
nom: 'TankMaster',
|
||||||
classe: "Iop",
|
classe: 'Iop',
|
||||||
niveau: 200,
|
niveau: 200,
|
||||||
serveur: "Imagiro",
|
serveur: 'Imagiro',
|
||||||
compte: "Compte1",
|
compte: 'Compte1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
nom: "MoneyMaker",
|
nom: 'MoneyMaker',
|
||||||
classe: "Enu",
|
classe: 'Enu',
|
||||||
niveau: 200,
|
niveau: 200,
|
||||||
serveur: "Imagiro",
|
serveur: 'Imagiro',
|
||||||
compte: "Compte2",
|
compte: 'Compte2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
nom: "HealBot",
|
nom: 'HealBot',
|
||||||
classe: "Eni",
|
classe: 'Eni',
|
||||||
niveau: 195,
|
niveau: 195,
|
||||||
serveur: "Tylezia",
|
serveur: 'Tylezia',
|
||||||
compte: "Compte2",
|
compte: 'Compte2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
nom: "ShadowKill",
|
nom: 'ShadowKill',
|
||||||
classe: "Sram",
|
classe: 'Sram',
|
||||||
niveau: 200,
|
niveau: 200,
|
||||||
serveur: "Draconiros",
|
serveur: 'Draconiros',
|
||||||
compte: "Compte3",
|
compte: 'Compte3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
nom: "TimeWarp",
|
nom: 'TimeWarp',
|
||||||
classe: "Elio",
|
classe: 'Elio',
|
||||||
niveau: 180,
|
niveau: 180,
|
||||||
serveur: "Imagiro",
|
serveur: 'Imagiro',
|
||||||
compte: "Compte1",
|
compte: 'Compte1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
nom: "ArrowStorm",
|
nom: 'ArrowStorm',
|
||||||
classe: "Cra",
|
classe: 'Cra',
|
||||||
niveau: 200,
|
niveau: 200,
|
||||||
serveur: "Tylezia",
|
serveur: 'Tylezia',
|
||||||
compte: "Compte4",
|
compte: 'Compte4',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
nom: "BerserkerX",
|
nom: 'BerserkerX',
|
||||||
classe: "Iop",
|
classe: 'Iop',
|
||||||
niveau: 175,
|
niveau: 175,
|
||||||
serveur: "Draconiros",
|
serveur: 'Draconiros',
|
||||||
compte: "Compte3",
|
compte: 'Compte3',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
{ name: "Cra", count: 12 },
|
{ name: 'Cra', count: 12 },
|
||||||
{ name: "Iop", count: 8 },
|
{ name: 'Iop', count: 8 },
|
||||||
{ name: "Enu", count: 6 },
|
{ name: 'Enu', count: 6 },
|
||||||
{ name: "Eni", count: 5 },
|
{ name: 'Eni', count: 5 },
|
||||||
{ name: "Elio", count: 4 },
|
{ name: 'Elio', count: 4 },
|
||||||
{ name: "Sram", count: 6 },
|
{ name: 'Sram', count: 6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const serveurs = ["Imagiro", "Tylezia", "Draconiros"];
|
const serveurs = ['Imagiro', 'Tylezia', 'Draconiros'];
|
||||||
|
|
||||||
export function CharacterList() {
|
export function CharacterList() {
|
||||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||||
@@ -286,7 +286,7 @@ export function CharacterList() {
|
|||||||
<>
|
<>
|
||||||
<span className="text-sm text-[#94A3B8]">
|
<span className="text-sm text-[#94A3B8]">
|
||||||
{selectedIds.length} sélectionné
|
{selectedIds.length} sélectionné
|
||||||
{selectedIds.length > 1 ? "s" : ""}
|
{selectedIds.length > 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -355,10 +355,10 @@ export function CharacterList() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={character.id}
|
key={character.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-[#334155] h-12 transition-colors",
|
'border-[#334155] h-12 transition-colors',
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[#1E293B] border-l-2 border-l-[#60A5FA]"
|
? 'bg-[#1E293B] border-l-2 border-l-[#60A5FA]'
|
||||||
: "hover:bg-[#1E293B]/50",
|
: 'hover:bg-[#1E293B]/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableCell className="w-[40px]">
|
<TableCell className="w-[40px]">
|
||||||
@@ -399,7 +399,7 @@ export function CharacterList() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||||
>
|
>
|
||||||
{"<"}
|
{'<'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -427,7 +427,7 @@ export function CharacterList() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||||
>
|
>
|
||||||
{">"}
|
{'>'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -5,8 +6,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface ConfirmationModalProps {
|
interface ConfirmationModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +32,7 @@ export function ConfirmationModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Confirmer la mise à jour</DialogTitle>
|
<DialogTitle>Confirmer la mise à jour</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Marquer {progressionName} comme fait pour {incompleteCount}{" "}
|
Marquer {progressionName} comme fait pour {incompleteCount}{' '}
|
||||||
personnages ?
|
personnages ?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useLocation } from "@tanstack/react-router";
|
import { useLocation } from '@tanstack/react-router';
|
||||||
import { Button } from "@/components/ui/button";
|
import { ChevronRight, Moon, Sun } from 'lucide-react';
|
||||||
import { Moon, Sun, ChevronRight } from "lucide-react";
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const routeLabels: Record<string, string> = {
|
const routeLabels: Record<string, string> = {
|
||||||
"/": "Dashboard",
|
'/': 'Dashboard',
|
||||||
"/characters": "Personnages",
|
'/characters': 'Personnages',
|
||||||
"/accounts": "Comptes",
|
'/accounts': 'Comptes',
|
||||||
"/teams": "Teams",
|
'/teams': 'Teams',
|
||||||
"/settings": "Paramètres",
|
'/settings': 'Paramètres',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AppHeaderProps {
|
interface AppHeaderProps {
|
||||||
theme: "dark" | "light";
|
theme: 'dark' | 'light';
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
// Generate breadcrumb from pathname
|
// Generate breadcrumb from pathname
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
const breadcrumbs =
|
const breadcrumbs =
|
||||||
segments.length === 0
|
segments.length === 0
|
||||||
? [{ label: "Dashboard", href: "/" }]
|
? [{ label: 'Dashboard', href: '/' }]
|
||||||
: segments.map((segment, index) => {
|
: segments.map((segment, index) => {
|
||||||
const href = "/" + segments.slice(0, index + 1).join("/");
|
const href = '/' + segments.slice(0, index + 1).join('/');
|
||||||
const label =
|
const label =
|
||||||
routeLabels[href] ||
|
routeLabels[href] ||
|
||||||
segment.charAt(0).toUpperCase() + segment.slice(1);
|
segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||||
@@ -43,8 +43,8 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
index === breadcrumbs.length - 1
|
index === breadcrumbs.length - 1
|
||||||
? "font-medium text-foreground"
|
? 'font-medium text-foreground'
|
||||||
: "text-muted-foreground"
|
: 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
@@ -60,7 +60,7 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
|
|||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className="h-9 w-9"
|
className="h-9 w-9"
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === 'dark' ? (
|
||||||
<Sun className="h-5 w-5" />
|
<Sun className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<Moon className="h-5 w-5" />
|
<Moon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type React from "react";
|
import type React from 'react';
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from '@/components/layout/app-sidebar';
|
||||||
import { AppHeader } from "@/components/layout/app-header";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -11,20 +11,20 @@ interface AppShellProps {
|
|||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
|
|
||||||
// Apply theme class to html element
|
// Apply theme class to html element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (theme === "light") {
|
if (theme === 'light') {
|
||||||
root.classList.add("light");
|
root.classList.add('light');
|
||||||
} else {
|
} else {
|
||||||
root.classList.remove("light");
|
root.classList.remove('light');
|
||||||
}
|
}
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,8 +37,8 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-screen flex-col transition-all duration-200",
|
'flex min-h-screen flex-col transition-all duration-200',
|
||||||
sidebarCollapsed ? "pl-16" : "pl-60",
|
sidebarCollapsed ? 'pl-16' : 'pl-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AppHeader theme={theme} onToggleTheme={toggleTheme} />
|
<AppHeader theme={theme} onToggleTheme={toggleTheme} />
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { Link, useLocation } from "@tanstack/react-router";
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
import { Button } from "@/components/ui/button";
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
|
Home,
|
||||||
|
Settings,
|
||||||
|
Swords,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from '@/components/ui/tooltip';
|
||||||
import {
|
import { cn } from '@/lib/utils';
|
||||||
Home,
|
|
||||||
Users,
|
|
||||||
Folder,
|
|
||||||
Swords,
|
|
||||||
Settings,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: Home, label: "Dashboard", href: "/" },
|
{ icon: Home, label: 'Dashboard', href: '/' },
|
||||||
{ icon: Users, label: "Personnages", href: "/characters" },
|
{ icon: Users, label: 'Personnages', href: '/characters' },
|
||||||
{ icon: Folder, label: "Comptes", href: "/accounts" },
|
{ icon: Folder, label: 'Comptes', href: '/accounts' },
|
||||||
{ icon: Swords, label: "Teams", href: "/teams" },
|
{ icon: Swords, label: 'Teams', href: '/teams' },
|
||||||
{ icon: Settings, label: "Paramètres", href: "/settings" },
|
{ icon: Settings, label: 'Paramètres', href: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
@@ -37,8 +37,8 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200",
|
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200',
|
||||||
collapsed ? "w-16" : "w-60",
|
collapsed ? 'w-16' : 'w-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header with logo and toggle */}
|
{/* Header with logo and toggle */}
|
||||||
@@ -53,8 +53,8 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent",
|
'h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent',
|
||||||
collapsed && "mx-auto",
|
collapsed && 'mx-auto',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -76,10 +76,10 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
|
|||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
? 'bg-sidebar-primary text-sidebar-primary-foreground'
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" />
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import type React from "react";
|
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
|
||||||
Users,
|
|
||||||
Swords,
|
|
||||||
Coins,
|
|
||||||
BarChart3,
|
|
||||||
Plus,
|
|
||||||
ChevronDown,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
ChevronDown,
|
||||||
|
Coins,
|
||||||
|
FolderOpen,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
Swords,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from '@/components/ui/card';
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
// Custom progress bar with color support
|
// Custom progress bar with color support
|
||||||
function ColoredProgress({
|
function ColoredProgress({
|
||||||
@@ -31,12 +31,12 @@ function ColoredProgress({
|
|||||||
color,
|
color,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
color: "success" | "warning" | "info";
|
color: 'success' | 'warning' | 'info';
|
||||||
}) {
|
}) {
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
success: "bg-[#4ADE80]",
|
success: 'bg-[#4ADE80]',
|
||||||
warning: "bg-[#FBBF24]",
|
warning: 'bg-[#FBBF24]',
|
||||||
info: "bg-[#60A5FA]",
|
info: 'bg-[#60A5FA]',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +57,7 @@ function StatCard({
|
|||||||
secondary,
|
secondary,
|
||||||
linkText,
|
linkText,
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = '',
|
||||||
}: {
|
}: {
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -101,16 +101,16 @@ function StatCard({
|
|||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
// Mock data
|
// Mock data
|
||||||
const currencies = [
|
const currencies = [
|
||||||
{ name: "Doplons", amount: "12,450" },
|
{ name: 'Doplons', amount: '12,450' },
|
||||||
{ name: "Orichor", amount: "3,200" },
|
{ name: 'Orichor', amount: '3,200' },
|
||||||
{ name: "Kamas glace", amount: "8,100" },
|
{ name: 'Kamas glace', amount: '8,100' },
|
||||||
{ name: "Nuggets", amount: "2,340" },
|
{ name: 'Nuggets', amount: '2,340' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const progressions = [
|
const progressions = [
|
||||||
{ label: "Dofus", value: 72, color: "success" as const },
|
{ label: 'Dofus', value: 72, color: 'success' as const },
|
||||||
{ label: "Donjons", value: 45, color: "warning" as const },
|
{ label: 'Donjons', value: 45, color: 'warning' as const },
|
||||||
{ label: "Recherchés", value: 61, color: "info" as const },
|
{ label: 'Recherchés', value: 61, color: 'info' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { ChevronDown, ChevronRight, Check } from "lucide-react";
|
import { useState } from 'react';
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from '@/components/ui/collapsible';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ProgressionItem {
|
interface ProgressionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +18,7 @@ interface ProgressionItem {
|
|||||||
interface ProgressionSectionProps {
|
interface ProgressionSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
items: ProgressionItem[];
|
items: ProgressionItem[];
|
||||||
filter: "all" | "done" | "not-done";
|
filter: 'all' | 'done' | 'not-done';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressionSection({
|
export function ProgressionSection({
|
||||||
@@ -30,8 +30,8 @@ export function ProgressionSection({
|
|||||||
const [localItems, setLocalItems] = useState(items);
|
const [localItems, setLocalItems] = useState(items);
|
||||||
|
|
||||||
const filteredItems = localItems.filter((item) => {
|
const filteredItems = localItems.filter((item) => {
|
||||||
if (filter === "done") return item.completed;
|
if (filter === 'done') return item.completed;
|
||||||
if (filter === "not-done") return !item.completed;
|
if (filter === 'not-done') return !item.completed;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export function ProgressionSection({
|
|||||||
...item,
|
...item,
|
||||||
completed: !item.completed,
|
completed: !item.completed,
|
||||||
completedDate: !item.completed
|
completedDate: !item.completed
|
||||||
? new Date().toISOString().split("T")[0]
|
? new Date().toISOString().split('T')[0]
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
@@ -84,16 +84,16 @@ export function ProgressionSection({
|
|||||||
<label
|
<label
|
||||||
htmlFor={item.id}
|
htmlFor={item.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 text-sm cursor-pointer",
|
'flex-1 text-sm cursor-pointer',
|
||||||
item.completed ? "text-foreground" : "text-muted-foreground",
|
item.completed ? 'text-foreground' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</label>
|
</label>
|
||||||
{item.completed ? (
|
{item.completed ? (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<Check className="h-3.5 w-3.5" style={{ color: "#4ADE80" }} />
|
<Check className="h-3.5 w-3.5" style={{ color: '#4ADE80' }} />
|
||||||
<span style={{ color: "#4ADE80" }}>{item.completedDate}</span>
|
<span style={{ color: '#4ADE80' }}>{item.completedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useState } from 'react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { ConfirmationModal } from '@/components/confirmation-modal';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from '@/components/ui/select';
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -24,32 +25,31 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from '@/components/ui/table';
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ConfirmationModal } from "@/components/confirmation-modal";
|
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const teamMembers = [
|
const teamMembers = [
|
||||||
{ id: 1, name: "Krosmaster", completed: true, date: "2026-01-10" },
|
{ id: 1, name: 'Krosmaster', completed: true, date: '2026-01-10' },
|
||||||
{ id: 2, name: "TankMaster", completed: true, date: "2026-01-10" },
|
{ id: 2, name: 'TankMaster', completed: true, date: '2026-01-10' },
|
||||||
{ id: 3, name: "HealBot", completed: false, date: null },
|
{ id: 3, name: 'HealBot', completed: false, date: null },
|
||||||
{ id: 4, name: "SramKiller", completed: false, date: null },
|
{ id: 4, name: 'SramKiller', completed: false, date: null },
|
||||||
{ id: 5, name: "Eniripsa", completed: true, date: "2026-01-09" },
|
{ id: 5, name: 'Eniripsa', completed: true, date: '2026-01-09' },
|
||||||
{ id: 6, name: "Sacrieur", completed: true, date: "2026-01-08" },
|
{ id: 6, name: 'Sacrieur', completed: true, date: '2026-01-08' },
|
||||||
{ id: 7, name: "Pandawa", completed: true, date: "2026-01-10" },
|
{ id: 7, name: 'Pandawa', completed: true, date: '2026-01-10' },
|
||||||
{ id: 8, name: "Eliotrope", completed: true, date: "2026-01-07" },
|
{ id: 8, name: 'Eliotrope', completed: true, date: '2026-01-07' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const progressions = [
|
const progressions = [
|
||||||
{ id: "dofus-turquoise", name: "Dofus Turquoise" },
|
{ id: 'dofus-turquoise', name: 'Dofus Turquoise' },
|
||||||
{ id: "donjon-bethel", name: "Donjon Bethel" },
|
{ id: 'donjon-bethel', name: 'Donjon Bethel' },
|
||||||
{ id: "quete-ebene", name: "Quête Ébène" },
|
{ id: 'quete-ebene', name: 'Quête Ébène' },
|
||||||
{ id: "succes-dimension", name: "Succès Dimension" },
|
{ id: 'succes-dimension', name: 'Succès Dimension' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TeamDetailPage() {
|
export default function TeamDetailPage() {
|
||||||
const [selectedProgression, setSelectedProgression] =
|
const [selectedProgression, setSelectedProgression] =
|
||||||
useState("dofus-turquoise");
|
useState('dofus-turquoise');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
const completedCount = teamMembers.filter((m) => m.completed).length;
|
const completedCount = teamMembers.filter((m) => m.completed).length;
|
||||||
@@ -58,7 +58,7 @@ export default function TeamDetailPage() {
|
|||||||
const progressPercentage = Math.round((completedCount / totalCount) * 100);
|
const progressPercentage = Math.round((completedCount / totalCount) * 100);
|
||||||
|
|
||||||
const selectedProgressionName =
|
const selectedProgressionName =
|
||||||
progressions.find((p) => p.id === selectedProgression)?.name || "";
|
progressions.find((p) => p.id === selectedProgression)?.name || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background p-6">
|
<div className="min-h-screen bg-background p-6">
|
||||||
@@ -201,7 +201,7 @@ export default function TeamDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-2 text-muted-foreground">
|
<TableCell className="py-2 text-muted-foreground">
|
||||||
{member.date || "—"}
|
{member.date || '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
62
src/components/ui/button.tsx
Normal file
62
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
|
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',
|
||||||
|
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 = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
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 type * 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,
|
||||||
|
};
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type * 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 };
|
||||||
114
src/components/ui/table.tsx
Normal file
114
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type * 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,
|
||||||
|
};
|
||||||
13
src/data/demo.punk-songs.ts
Normal file
13
src/data/demo.punk-songs.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
|
|
||||||
|
export const getPunkSongs = createServerFn({
|
||||||
|
method: 'GET',
|
||||||
|
}).handler(async () => [
|
||||||
|
{ id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' },
|
||||||
|
{ id: 2, name: 'Smells Like Teen Spirit', artist: 'Nirvana' },
|
||||||
|
{ id: 3, name: 'The Middle', artist: 'Jimmy Eat World' },
|
||||||
|
{ id: 4, name: 'My Own Worst Enemy', artist: 'Lit' },
|
||||||
|
{ id: 5, name: 'Fat Lip', artist: 'Sum 41' },
|
||||||
|
{ id: 6, name: 'All the Small Things', artist: 'blink-182' },
|
||||||
|
{ id: 7, name: 'Beverly Hills', artist: 'Weezer' },
|
||||||
|
]);
|
||||||
18
src/lib/server/db.ts
Normal file
18
src/lib/server/db.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log:
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? ['query', 'error', 'warn']
|
||||||
|
: ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
12
src/logo.svg
Normal file
12
src/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
240
src/routeTree.gen.ts
Normal file
240
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/* 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 ApiHealthRouteImport } from './routes/api/health'
|
||||||
|
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
|
||||||
|
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
|
||||||
|
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
|
||||||
|
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
|
||||||
|
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
|
||||||
|
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
|
||||||
|
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||||
|
id: '/api/health',
|
||||||
|
path: '/api/health',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
||||||
|
id: '/demo/start/server-funcs',
|
||||||
|
path: '/demo/start/server-funcs',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
|
||||||
|
id: '/demo/start/api-request',
|
||||||
|
path: '/demo/start/api-request',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
|
||||||
|
id: '/demo/api/names',
|
||||||
|
path: '/demo/api/names',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
|
||||||
|
id: '/demo/start/ssr/',
|
||||||
|
path: '/demo/start/ssr/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
|
||||||
|
id: '/demo/start/ssr/spa-mode',
|
||||||
|
path: '/demo/start/ssr/spa-mode',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
|
||||||
|
id: '/demo/start/ssr/full-ssr',
|
||||||
|
path: '/demo/start/ssr/full-ssr',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
||||||
|
id: '/demo/start/ssr/data-only',
|
||||||
|
path: '/demo/start/ssr/data-only',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/demo/api/names': typeof DemoApiNamesRoute
|
||||||
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||||
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||||
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||||
|
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/demo/api/names': typeof DemoApiNamesRoute
|
||||||
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||||
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||||
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||||
|
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/demo/api/names': typeof DemoApiNamesRoute
|
||||||
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||||
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||||
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||||
|
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/api/health'
|
||||||
|
| '/demo/api/names'
|
||||||
|
| '/demo/start/api-request'
|
||||||
|
| '/demo/start/server-funcs'
|
||||||
|
| '/demo/start/ssr/data-only'
|
||||||
|
| '/demo/start/ssr/full-ssr'
|
||||||
|
| '/demo/start/ssr/spa-mode'
|
||||||
|
| '/demo/start/ssr/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/api/health'
|
||||||
|
| '/demo/api/names'
|
||||||
|
| '/demo/start/api-request'
|
||||||
|
| '/demo/start/server-funcs'
|
||||||
|
| '/demo/start/ssr/data-only'
|
||||||
|
| '/demo/start/ssr/full-ssr'
|
||||||
|
| '/demo/start/ssr/spa-mode'
|
||||||
|
| '/demo/start/ssr'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/api/health'
|
||||||
|
| '/demo/api/names'
|
||||||
|
| '/demo/start/api-request'
|
||||||
|
| '/demo/start/server-funcs'
|
||||||
|
| '/demo/start/ssr/data-only'
|
||||||
|
| '/demo/start/ssr/full-ssr'
|
||||||
|
| '/demo/start/ssr/spa-mode'
|
||||||
|
| '/demo/start/ssr/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
|
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
||||||
|
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
||||||
|
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute
|
||||||
|
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute
|
||||||
|
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute
|
||||||
|
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute
|
||||||
|
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/health': {
|
||||||
|
id: '/api/health'
|
||||||
|
path: '/api/health'
|
||||||
|
fullPath: '/api/health'
|
||||||
|
preLoaderRoute: typeof ApiHealthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/server-funcs': {
|
||||||
|
id: '/demo/start/server-funcs'
|
||||||
|
path: '/demo/start/server-funcs'
|
||||||
|
fullPath: '/demo/start/server-funcs'
|
||||||
|
preLoaderRoute: typeof DemoStartServerFuncsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/api-request': {
|
||||||
|
id: '/demo/start/api-request'
|
||||||
|
path: '/demo/start/api-request'
|
||||||
|
fullPath: '/demo/start/api-request'
|
||||||
|
preLoaderRoute: typeof DemoStartApiRequestRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/api/names': {
|
||||||
|
id: '/demo/api/names'
|
||||||
|
path: '/demo/api/names'
|
||||||
|
fullPath: '/demo/api/names'
|
||||||
|
preLoaderRoute: typeof DemoApiNamesRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/ssr/': {
|
||||||
|
id: '/demo/start/ssr/'
|
||||||
|
path: '/demo/start/ssr'
|
||||||
|
fullPath: '/demo/start/ssr/'
|
||||||
|
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/ssr/spa-mode': {
|
||||||
|
id: '/demo/start/ssr/spa-mode'
|
||||||
|
path: '/demo/start/ssr/spa-mode'
|
||||||
|
fullPath: '/demo/start/ssr/spa-mode'
|
||||||
|
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/ssr/full-ssr': {
|
||||||
|
id: '/demo/start/ssr/full-ssr'
|
||||||
|
path: '/demo/start/ssr/full-ssr'
|
||||||
|
fullPath: '/demo/start/ssr/full-ssr'
|
||||||
|
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo/start/ssr/data-only': {
|
||||||
|
id: '/demo/start/ssr/data-only'
|
||||||
|
path: '/demo/start/ssr/data-only'
|
||||||
|
fullPath: '/demo/start/ssr/data-only'
|
||||||
|
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
|
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||||
|
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
||||||
|
DemoStartServerFuncsRoute: DemoStartServerFuncsRoute,
|
||||||
|
DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute,
|
||||||
|
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
|
||||||
|
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
|
||||||
|
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
|
||||||
|
}
|
||||||
|
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>>
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/router.tsx
Normal file
17
src/router.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { createRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
// Import the generated route tree
|
||||||
|
import { routeTree } from './routeTree.gen';
|
||||||
|
|
||||||
|
// Create a new router instance
|
||||||
|
export const getRouter = () => {
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: {},
|
||||||
|
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -1,5 +1,58 @@
|
|||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { TanStackDevtools } from '@tanstack/react-devtools';
|
||||||
|
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
|
||||||
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
import Header from '../components/Header';
|
||||||
return <AppShell>{children}</AppShell>;
|
|
||||||
|
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">
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<TanStackDevtools
|
||||||
|
config={{
|
||||||
|
position: 'bottom-right',
|
||||||
|
}}
|
||||||
|
plugins={[
|
||||||
|
{
|
||||||
|
name: 'Tanstack Router',
|
||||||
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/routes/api/health.ts
Normal file
21
src/routes/api/health.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { createAPIFileRoute } from '@tanstack/start/api';
|
||||||
|
import { prisma } from '@/lib/server/db';
|
||||||
|
|
||||||
|
export const Route = createAPIFileRoute('/api/health')({
|
||||||
|
GET: async () => {
|
||||||
|
let dbStatus = 'disconnected';
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
dbStatus = 'connected';
|
||||||
|
} catch {
|
||||||
|
dbStatus = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: dbStatus,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
});
|
||||||
10
src/routes/demo/api.names.ts
Normal file
10
src/routes/demo/api.names.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { json } from '@tanstack/react-start';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/api/names')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: () => json(['Alice', 'Bob', 'Charlie']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
34
src/routes/demo/start.api-request.tsx
Normal file
34
src/routes/demo/start.api-request.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import './start.css';
|
||||||
|
|
||||||
|
function getNames() {
|
||||||
|
return fetch('/demo/api/names').then(
|
||||||
|
(res) => res.json() as Promise<string[]>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/api-request')({
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const [names, setNames] = useState<Array<string>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getNames().then(setNames);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="api-page">
|
||||||
|
<div className="content">
|
||||||
|
<h1>Start API Request Demo - Names List</h1>
|
||||||
|
<ul>
|
||||||
|
{names.map((name) => (
|
||||||
|
<li key={name}>{name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/routes/demo/start.css
Normal file
43
src/routes/demo/start.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.api-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-page .content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 2xl;
|
||||||
|
padding: 8rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
backdrop-filter: blur(1rem);
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.1);
|
||||||
|
border: 0.5rem solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-page .content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-page .content ul {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-page .content li {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(0.5rem);
|
||||||
|
box-shadow: 0 0 0.5rem 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-page .content li span {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
92
src/routes/demo/start.server-funcs.tsx
Normal file
92
src/routes/demo/start.server-funcs.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
||||||
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import './start.css';
|
||||||
|
|
||||||
|
/*
|
||||||
|
const loggingMiddleware = createMiddleware().server(
|
||||||
|
async ({ next, request }) => {
|
||||||
|
console.log("Request:", request.url);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const loggedServerFunction = createServerFn({ method: "GET" }).middleware([
|
||||||
|
loggingMiddleware,
|
||||||
|
]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TODOS_FILE = 'todos.json';
|
||||||
|
|
||||||
|
async function readTodos() {
|
||||||
|
return JSON.parse(
|
||||||
|
await fs.promises.readFile(TODOS_FILE, 'utf-8').catch(() =>
|
||||||
|
JSON.stringify(
|
||||||
|
[
|
||||||
|
{ id: 1, name: 'Get groceries' },
|
||||||
|
{ id: 2, name: 'Buy a new phone' },
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTodos = createServerFn({
|
||||||
|
method: 'GET',
|
||||||
|
}).handler(async () => await readTodos());
|
||||||
|
|
||||||
|
const addTodo = createServerFn({ method: 'POST' })
|
||||||
|
.inputValidator((d: string) => d)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const todos = await readTodos();
|
||||||
|
todos.push({ id: todos.length + 1, name: data });
|
||||||
|
await fs.promises.writeFile(TODOS_FILE, JSON.stringify(todos, null, 2));
|
||||||
|
return todos;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/server-funcs')({
|
||||||
|
component: Home,
|
||||||
|
loader: async () => await getTodos(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
let todos = Route.useLoaderData();
|
||||||
|
|
||||||
|
const [todo, setTodo] = useState('');
|
||||||
|
|
||||||
|
const submitTodo = useCallback(async () => {
|
||||||
|
todos = await addTodo({ data: todo });
|
||||||
|
setTodo('');
|
||||||
|
router.invalidate();
|
||||||
|
}, [addTodo, todo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Start Server Functions - Todo Example</h1>
|
||||||
|
<ul>
|
||||||
|
{todos?.map((t) => (
|
||||||
|
<li key={t.id}>{t.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={todo}
|
||||||
|
onChange={(e) => setTodo(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
submitTodo();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter a new todo..."
|
||||||
|
/>
|
||||||
|
<button disabled={todo.trim().length === 0} onClick={submitTodo}>
|
||||||
|
Add todo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/routes/demo/start.ssr.data-only.tsx
Normal file
25
src/routes/demo/start.ssr.data-only.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { getPunkSongs } from '@/data/demo.punk-songs';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/ssr/data-only')({
|
||||||
|
ssr: 'data-only',
|
||||||
|
component: RouteComponent,
|
||||||
|
loader: async () => await getPunkSongs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const punkSongs = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Data Only SSR - Punk Songs</h1>
|
||||||
|
<ul>
|
||||||
|
{punkSongs.map((song) => (
|
||||||
|
<li key={song.id}>
|
||||||
|
{song.name} - {song.artist}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/routes/demo/start.ssr.full-ssr.tsx
Normal file
24
src/routes/demo/start.ssr.full-ssr.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { getPunkSongs } from '@/data/demo.punk-songs';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/ssr/full-ssr')({
|
||||||
|
component: RouteComponent,
|
||||||
|
loader: async () => await getPunkSongs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const punkSongs = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Full SSR - Punk Songs</h1>
|
||||||
|
<ul>
|
||||||
|
{punkSongs.map((song) => (
|
||||||
|
<li key={song.id}>
|
||||||
|
{song.name} - {song.artist}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/routes/demo/start.ssr.index.tsx
Normal file
24
src/routes/demo/start.ssr.index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/ssr/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>SSR Demos</h1>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<Link to="/demo/start/ssr/spa-mode">SPA Mode</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/demo/start/ssr/full-ssr">Full SSR</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/demo/start/ssr/data-only">Data Only</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/routes/demo/start.ssr.spa-mode.tsx
Normal file
31
src/routes/demo/start.ssr.spa-mode.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getPunkSongs } from '@/data/demo.punk-songs';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/demo/start/ssr/spa-mode')({
|
||||||
|
ssr: false,
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const [punkSongs, setPunkSongs] = useState<
|
||||||
|
Awaited<ReturnType<typeof getPunkSongs>>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPunkSongs().then(setPunkSongs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>SPA Mode - Punk Songs</h1>
|
||||||
|
<ul>
|
||||||
|
{punkSongs.map((song) => (
|
||||||
|
<li key={song.id}>
|
||||||
|
{song.name} - {song.artist}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/routes/index.tsx
Normal file
37
src/routes/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import '../App.css';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({ component: App });
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<header className="App-header">
|
||||||
|
<img
|
||||||
|
src="/tanstack-circle-logo.png"
|
||||||
|
className="App-logo"
|
||||||
|
alt="TanStack Logo"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
Edit <code>src/routes/index.tsx</code> and save to reload.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
className="App-link"
|
||||||
|
href="https://reactjs.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Learn React
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="App-link"
|
||||||
|
href="https://tanstack.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Learn TanStack
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/styles.css
Normal file
14
src/styles.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
body {
|
||||||
|
margin: 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;
|
||||||
|
}
|
||||||
7
tests/unit/example.test.ts
Normal file
7
tests/unit/example.test.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('Example', () => {
|
||||||
|
it('should pass', () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { devtools } from '@tanstack/devtools-vite'
|
||||||
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
|
import viteReact from '@vitejs/plugin-react'
|
||||||
|
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||||
|
import { fileURLToPath, URL } from 'url'
|
||||||
|
import { nitro } from 'nitro/vite'
|
||||||
|
|
||||||
|
const config = defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
devtools(),
|
||||||
|
nitro(),
|
||||||
|
// this is the plugin that enables path aliases
|
||||||
|
viteTsConfigPaths({
|
||||||
|
projects: ['./tsconfig.json'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
tanstackStart(),
|
||||||
|
viteReact(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default config
|
||||||
Reference in New Issue
Block a user