Compare commits
21 Commits
1b74650aa2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e05dc4a6 | |||
| 7b60f4a074 | |||
| 9645a2e5eb | |||
| b99fc6d348 | |||
| ffb484b117 | |||
| 5bd851d380 | |||
| 16c488c3c4 | |||
| e80989aecd | |||
| 23798e301b | |||
| f9bd9292ed | |||
| 03201cf133 | |||
| 47cef8e5a5 | |||
| 39c1958128 | |||
| 1c1f22afe5 | |||
| 6ed583f256 | |||
| e2c4178028 | |||
| 0d15a5bc18 | |||
| ac2491f3c5 | |||
| fa613adde5 | |||
| 13161fb56c | |||
| 7317db2637 |
16
.docker/nginx.conf
Normal file
16
.docker/nginx.conf
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# gzip för statiska assets
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
# Single Page App fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
.drone.yml
25
.drone.yml
@ -9,7 +9,7 @@ trigger:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install+lint+typecheck+test
|
- name: checks
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
environment:
|
environment:
|
||||||
PNPM_HOME: /root/.local/share/pnpm
|
PNPM_HOME: /root/.local/share/pnpm
|
||||||
@ -21,8 +21,10 @@ steps:
|
|||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
- pnpm test -- --run
|
- pnpm test -- --run
|
||||||
|
|
||||||
- name: docker-push
|
- name: docker_build_push
|
||||||
image: plugins/docker:latest
|
image: plugins/docker:latest
|
||||||
|
depends_on:
|
||||||
|
- checks
|
||||||
settings:
|
settings:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@ -37,23 +39,24 @@ steps:
|
|||||||
password:
|
password:
|
||||||
from_secret: REGISTRY_PASS
|
from_secret: REGISTRY_PASS
|
||||||
|
|
||||||
# Bygg-args till Dockerfile (Vite läser dem vid build)
|
# Build args → styr Vite och proxybas
|
||||||
build_args:
|
build_args:
|
||||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
- VITE_API_BASE_URL=https://rubble.se/hemhub/api/
|
||||||
- VITE_BASE_PATH=/hemhub/
|
- VITE_BASE_PATH=/hemhub/app/
|
||||||
|
- VITE_OIDC_AUTHORITY=https://rubble.se/hemhub/auth/realms/hemhub
|
||||||
|
- VITE_OIDC_CLIENT_ID=hemhub-public
|
||||||
|
- VITE_OIDC_REDIRECT_URI=https://rubble.se/hemhub/app/auth/callback
|
||||||
|
- VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://rubble.se/hemhub/app/
|
||||||
|
- VITE_OIDC_SILENT_REDIRECT_URI=https://rubble.se/hemhub/app/auth/silent-renew
|
||||||
|
|
||||||
# Taggar
|
# Taggar (latest + kort SHA)
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- ${DRONE_COMMIT_SHA:0:7}
|
- ${DRONE_COMMIT_SHA:0:7}
|
||||||
|
|
||||||
# Sätt till false/ta bort om ditt registry har TLS
|
# Sätt till false/ta bort om registret har TLS
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Drone-secret för backend-url, sätt i UI under repo -> Secrets
|
|
||||||
VITE_API_BASE_URL:
|
VITE_API_BASE_URL:
|
||||||
from_secret: VITE_API_BASE_URL
|
from_secret: VITE_API_BASE_URL
|
||||||
|
|
||||||
depends_on:
|
|
||||||
- install+lint+typecheck+test
|
|
||||||
|
|||||||
6
.env.local
Normal file
6
.env.local
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/
|
||||||
|
VITE_OIDC_AUTHORITY=http://localhost:8081/realms/hemhub
|
||||||
|
VITE_OIDC_CLIENT_ID=hemhub-public
|
||||||
|
VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
|
||||||
|
VITE_OIDC_SILENT_REDIRECT_URI=http://localhost:5173/auth/silent-renew
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,7 +11,7 @@ coverage/
|
|||||||
|
|
||||||
# env
|
# env
|
||||||
.env
|
.env
|
||||||
.env.*
|
#.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
60
Dockerfile
60
Dockerfile
@ -1,65 +1,41 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
############################
|
|
||||||
# Build stage
|
|
||||||
############################
|
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# pnpm via Corepack
|
|
||||||
ENV PNPM_HOME="/root/.local/share/pnpm"
|
ENV PNPM_HOME="/root/.local/share/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
# Installera beroenden
|
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# App-källor
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# --- Build args (styr Vite) ---
|
# --- Build args (med BRA defaults) ---
|
||||||
# Backend-url (ex sätts i Drone secrets)
|
|
||||||
ARG VITE_API_BASE_URL=http://localhost:8080
|
ARG VITE_API_BASE_URL=http://localhost:8080
|
||||||
# Base path för proxy under /hemhub/
|
|
||||||
ARG VITE_BASE_PATH=/
|
ARG VITE_BASE_PATH=/
|
||||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|
||||||
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
|
|
||||||
|
|
||||||
# Bygg (Vite läser env vid build)
|
# Gör ARG:arna synliga för Vite config (vite.config.ts läser process.env.VITE_*)
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
ENV VITE_BASE_PATH=$VITE_BASE_PATH
|
||||||
|
|
||||||
|
ARG VITE_OIDC_AUTHORITY=
|
||||||
|
ARG VITE_OIDC_CLIENT_ID=
|
||||||
|
ARG VITE_OIDC_REDIRECT_URI=
|
||||||
|
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||||
|
ARG VITE_OIDC_SILENT_REDIRECT_URI=
|
||||||
|
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY \
|
||||||
|
VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \
|
||||||
|
VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI \
|
||||||
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=$VITE_OIDC_POST_LOGOUT_REDIRECT_URI \
|
||||||
|
VITE_OIDC_SILENT_REDIRECT_URI=$VITE_OIDC_SILENT_REDIRECT_URI
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
|
||||||
############################
|
|
||||||
# Runtime stage (Nginx)
|
|
||||||
############################
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY ./.docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
# Minimal nginx-konfig med SPA-fallback
|
|
||||||
RUN <<'NGINX' sh -lc 'cat >/etc/nginx/conf.d/default.conf'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# gzip statiska assets
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
|
||||||
|
|
||||||
# Single Page App fallback
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINX
|
|
||||||
|
|
||||||
# Statiska filer från builden
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Hälsokoll (valfritt)
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1/ || exit 1
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1/ || exit 1
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
18
package.json
18
package.json
@ -6,10 +6,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"preview": "vite preview",
|
||||||
"preview": "vite preview"
|
"lint": "eslint --ext .ts,.tsx src",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest --run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -21,6 +27,7 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
@ -28,6 +35,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
@ -37,12 +47,14 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
940
pnpm-lock.yaml
generated
940
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
|||||||
// src/app/providers.tsx
|
// src/app/providers.tsx
|
||||||
import { type PropsWithChildren, useState } from 'react'
|
import { type PropsWithChildren, useState } from 'react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import AuthProvider from '@/auth/AuthProvider'
|
||||||
|
|
||||||
export function AppProviders({ children }: PropsWithChildren) {
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
const [client] = useState(
|
const [client] = useState(
|
||||||
@ -9,5 +10,9 @@ export function AppProviders({ children }: PropsWithChildren) {
|
|||||||
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,81 @@
|
|||||||
// src/app/router.tsx
|
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||||
|
import { Suspense, lazy } from 'react'
|
||||||
import { RootLayout } from '@/components/layout/RootLayout'
|
import { RootLayout } from '@/components/layout/RootLayout'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
import { RequireAuth } from '@/auth/RequireAuth'
|
||||||
import { HouseholdBoardPage } from '@/pages/HouseholdBoardPage'
|
|
||||||
import { ProjectBoardPage } from '@/pages/ProjectBoardPage'
|
// Hjälpare: mappa namngiven export → default för React.lazy
|
||||||
import { DueTomorrowPage } from '@/pages/DueTomorrowPage'
|
const load = <T, K extends keyof T>(importer: () => Promise<T>, key: K) =>
|
||||||
|
lazy(() => importer().then((m) => ({ default: m[key] as unknown as React.ComponentType })))
|
||||||
|
|
||||||
|
// Dina sidkomponenter (named exports i respektive fil)
|
||||||
|
const DashboardPage = load(() => import('@/pages/DashboardPage'), 'DashboardPage')
|
||||||
|
const HouseholdBoardPage = load(() => import('@/pages/HouseholdBoardPage'), 'HouseholdBoardPage')
|
||||||
|
const ProjectBoardPage = load(() => import('@/pages/ProjectBoardPage'), 'ProjectBoardPage')
|
||||||
|
const DueTomorrowPage = load(() => import('@/pages/DueTomorrowPage'), 'DueTomorrowPage')
|
||||||
|
|
||||||
|
// OIDC-sidor (named eller default – anpassa 'key' om din fil exporterar annat)
|
||||||
|
const AuthCallbackPage = load(() => import('@/pages/AuthCallbackPage'), 'default') // export default i vår version
|
||||||
|
const SilentRenewPage = load(() => import('@/pages/SilentRenewPage'), 'default') // om du har en sådan
|
||||||
|
|
||||||
|
function Fallback() {
|
||||||
|
return <div className="p-4 text-sm opacity-70">Laddar…</div>
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
|
// 🔓 Publika OIDC-rutter – måste vara o-skyddade
|
||||||
|
{ path: '/auth/callback', element: (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<AuthCallbackPage />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ path: '/auth/silent-renew', element: (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<SilentRenewPage />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// 🔒 Allt annat kräver inloggning
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <RootLayout />,
|
element: (
|
||||||
|
<RequireAuth>
|
||||||
|
<RootLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{
|
||||||
{ path: 'households/:householdId/board', element: <HouseholdBoardPage /> },
|
index: true,
|
||||||
{ path: 'projects/:projectId/board', element: <ProjectBoardPage /> },
|
element: (
|
||||||
{ path: 'tasks/due-tomorrow', element: <DueTomorrowPage /> },
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<DashboardPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'households/:householdId/board',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<HouseholdBoardPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/:projectId/board',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<ProjectBoardPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks/due-tomorrow',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<DueTomorrowPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@ -22,4 +83,3 @@ const router = createBrowserRouter([
|
|||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
return <RouterProvider router={router} />
|
return <RouterProvider router={router} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/auth/AuthProvider.tsx
Normal file
47
src/auth/AuthProvider.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useMemo, useState, type PropsWithChildren } from 'react'
|
||||||
|
import type { User } from 'oidc-client-ts'
|
||||||
|
import { userManager } from '@/auth/oidc'
|
||||||
|
import { Ctx, type AuthCtx } from './context'
|
||||||
|
|
||||||
|
export default function AuthProvider({ children }: PropsWithChildren) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userManager.getUser().then(u => setUser(u))
|
||||||
|
|
||||||
|
const onLoaded = (u: User) => setUser(u)
|
||||||
|
const onUnloaded = () => setUser(null)
|
||||||
|
const onExpired = async () => {
|
||||||
|
try { await userManager.signinSilent() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
userManager.events.addUserLoaded(onLoaded)
|
||||||
|
userManager.events.addUserUnloaded(onUnloaded)
|
||||||
|
userManager.events.addAccessTokenExpired(onExpired)
|
||||||
|
return () => {
|
||||||
|
userManager.events.removeUserLoaded(onLoaded)
|
||||||
|
userManager.events.removeUserUnloaded(onUnloaded)
|
||||||
|
userManager.events.removeAccessTokenExpired(onExpired)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = user?.access_token ?? null
|
||||||
|
if (token) sessionStorage.setItem('access_token', token)
|
||||||
|
else sessionStorage.removeItem('access_token')
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const api: AuthCtx = useMemo(() => ({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user && !user.expired,
|
||||||
|
signIn: async (returnTo?: string) => {
|
||||||
|
await userManager.signinRedirect({ state: { returnTo } })
|
||||||
|
},
|
||||||
|
signOut: async () => {
|
||||||
|
await userManager.signoutRedirect()
|
||||||
|
},
|
||||||
|
getAccessToken: () => user?.access_token ?? null,
|
||||||
|
}), [user])
|
||||||
|
|
||||||
|
return <Ctx.Provider value={api}>{children}</Ctx.Provider>
|
||||||
|
}
|
||||||
21
src/auth/RequireAuth.tsx
Normal file
21
src/auth/RequireAuth.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { type PropsWithChildren, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/auth/useAuth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hårdare skydd: om ej inloggad → trigga signIn och rendera inget.
|
||||||
|
* När auth är klar kommer användaren tillbaka via /auth/callback.
|
||||||
|
*/
|
||||||
|
export function RequireAuth({ children }: PropsWithChildren) {
|
||||||
|
const { isAuthenticated, signIn } = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Spara nuvarande URL så vi kan komma tillbaka efter login
|
||||||
|
const returnTo = window.location.pathname + window.location.search
|
||||||
|
void signIn(returnTo)
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, signIn])
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
12
src/auth/context.ts
Normal file
12
src/auth/context.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createContext } from 'react'
|
||||||
|
import type { User } from 'oidc-client-ts'
|
||||||
|
|
||||||
|
export interface AuthCtx {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
signIn: (returnTo?: string) => Promise<void>
|
||||||
|
signOut: () => Promise<void>
|
||||||
|
getAccessToken: () => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ctx = createContext<AuthCtx | null>(null)
|
||||||
19
src/auth/oidc.ts
Normal file
19
src/auth/oidc.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { UserManager, WebStorageStateStore, Log, type UserManagerSettings } from 'oidc-client-ts'
|
||||||
|
|
||||||
|
console.log('AUTHORITY:', import.meta.env.VITE_OIDC_AUTHORITY)
|
||||||
|
const settings: UserManagerSettings = {
|
||||||
|
authority: import.meta.env.VITE_OIDC_AUTHORITY!,
|
||||||
|
client_id: import.meta.env.VITE_OIDC_CLIENT_ID!,
|
||||||
|
redirect_uri: import.meta.env.VITE_OIDC_REDIRECT_URI!,
|
||||||
|
post_logout_redirect_uri: import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI!,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
loadUserInfo: true,
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
silent_redirect_uri: import.meta.env.VITE_OIDC_SILENT_REDIRECT_URI!,
|
||||||
|
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) Log.setLogger(console)
|
||||||
|
export const userManager = new UserManager(settings)
|
||||||
8
src/auth/useAuth.ts
Normal file
8
src/auth/useAuth.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { Ctx } from './context'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(Ctx)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
// src/components/layout/RootLayout.tsx
|
// src/components/layout/RootLayout.tsx
|
||||||
import { Outlet, NavLink } from 'react-router-dom'
|
import { Outlet, NavLink } from 'react-router-dom'
|
||||||
|
import { MeBadge } from '../../features/me/MeBadge'
|
||||||
|
import { useAuth } from '@/auth/useAuth'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
|
||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
|
const { isAuthenticated, signIn } = useAuth()
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-zinc-50 text-zinc-900">
|
<div className="min-h-dvh bg-zinc-50 text-zinc-900">
|
||||||
<header className="sticky top-0 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/40">
|
<header className="sticky top-0 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/40">
|
||||||
@ -11,10 +15,17 @@ export function RootLayout() {
|
|||||||
<NavLink to="/" className={({isActive})=>isActive?'font-semibold underline':'opacity-80 hover:opacity-100'}>Dashboard</NavLink>
|
<NavLink to="/" className={({isActive})=>isActive?'font-semibold underline':'opacity-80 hover:opacity-100'}>Dashboard</NavLink>
|
||||||
<NavLink to="/tasks/due-tomorrow" className={({isActive})=>isActive?'font-semibold underline':'opacity-80 hover:opacity-100'}>Due tomorrow</NavLink>
|
<NavLink to="/tasks/due-tomorrow" className={({isActive})=>isActive?'font-semibold underline':'opacity-80 hover:opacity-100'}>Due tomorrow</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<MeBadge />
|
||||||
|
{isAuthenticated
|
||||||
|
? <NavLink to="/logout" className="text-sm underline">Logga ut</NavLink>
|
||||||
|
: <button onClick={()=>signIn(window.location.pathname+window.location.search)} className="text-sm underline">Logga in</button>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster richColors closeButton />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
17
src/features/me/MeBadge.tsx
Normal file
17
src/features/me/MeBadge.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchMe } from './api'
|
||||||
|
|
||||||
|
export function MeBadge() {
|
||||||
|
const hasToken = !!sessionStorage.getItem('access_token')
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasToken, // 👈 vänta tills token finns
|
||||||
|
retry: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasToken) return <span className="opacity-60">ej inloggad</span>
|
||||||
|
if (isLoading) return <span className="opacity-60">…</span>
|
||||||
|
if (isError) return <span className="opacity-60">fel</span>
|
||||||
|
return <span className="opacity-80 text-sm">{data?.name || data?.preferred_username || 'me'}</span>
|
||||||
|
}
|
||||||
3
src/features/me/api.ts
Normal file
3
src/features/me/api.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { api } from '@/lib/http'
|
||||||
|
export type Me = { name?: string; preferred_username?: string; email?: string }
|
||||||
|
export const fetchMe = () => api.get('me').json<Me>()
|
||||||
36
src/features/tasks/api.ts
Normal file
36
src/features/tasks/api.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { api } from '@/lib/http'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
|
||||||
|
export function projectTasksKey(
|
||||||
|
projectId: string,
|
||||||
|
params?: Record<string, string | number | undefined>
|
||||||
|
) {
|
||||||
|
return ['projectTasks', projectId, params] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page<T> = { content: T[] }
|
||||||
|
|
||||||
|
export async function fetchProjectTasks(
|
||||||
|
projectId: string,
|
||||||
|
params?: { status?: TaskStatus }
|
||||||
|
): Promise<Task[]> {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params?.status) search.set('status', params.status)
|
||||||
|
const url = `api/v1/projects/${projectId}/tasks${search.size ? `?${search.toString()}` : ''}`
|
||||||
|
|
||||||
|
const raw = await api.get(url).json<unknown>()
|
||||||
|
if (Array.isArray(raw)) return raw as Task[]
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as Page<Task>).content)) {
|
||||||
|
return (raw as Page<Task>).content
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectTasksQuery(projectId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: projectTasksKey(projectId),
|
||||||
|
queryFn: () => fetchProjectTasks(projectId),
|
||||||
|
staleTime: 10_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
18
src/features/tasks/components/DraggableTaskCard.tsx
Normal file
18
src/features/tasks/components/DraggableTaskCard.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
import { TaskCard } from './TaskCard'
|
||||||
|
|
||||||
|
export function DraggableTaskCard({
|
||||||
|
task,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
task: Task
|
||||||
|
onClick?: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef } = useDraggable({ id: task.id })
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||||
|
<TaskCard task={task} onClick={onClick} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/features/tasks/components/DroppableColumn.tsx
Normal file
52
src/features/tasks/components/DroppableColumn.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useDroppable } from '@dnd-kit/core'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import { DraggableTaskCard } from './DraggableTaskCard'
|
||||||
|
|
||||||
|
export function DroppableColumn({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
items,
|
||||||
|
onCardClick,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
status: TaskStatus
|
||||||
|
items: Task[]
|
||||||
|
onCardClick?: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({ id: status })
|
||||||
|
const empty = items.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`col-${status}`} className="flex-1 min-w-[260px]">
|
||||||
|
<header className="mb-2 flex items-center justify-between">
|
||||||
|
<h3
|
||||||
|
id={`col-${status}`}
|
||||||
|
className="text-sm font-semibold uppercase tracking-wide opacity-70"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs opacity-60">{items.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
role="list"
|
||||||
|
aria-describedby={`col-${status}`}
|
||||||
|
className={[
|
||||||
|
'space-y-2 rounded-lg p-2 transition',
|
||||||
|
'min-h-[240px] border-2 border-dashed',
|
||||||
|
empty ? 'bg-gray-50 dark:bg-zinc-900/40' : 'bg-white/50 dark:bg-zinc-900/50',
|
||||||
|
isOver ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950/30' : 'border-gray-200',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{empty ? (
|
||||||
|
<div className="text-xs text-gray-500 italic">
|
||||||
|
Släpp här för att flytta till {title}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((t) => <DraggableTaskCard key={t.id} task={t} onClick={onCardClick} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/features/tasks/components/KanbanColumn.tsx
Normal file
20
src/features/tasks/components/KanbanColumn.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import { TaskCard } from './TaskCard'
|
||||||
|
|
||||||
|
export function KanbanColumn({ title, status, items }: { title: string; status: TaskStatus; items: Task[] }) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`col-${status}`} className="flex-1 min-w-[260px]">
|
||||||
|
<header className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 id={`col-${status}`} className="text-sm font-semibold uppercase tracking-wide opacity-70">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs opacity-60">{items.length}</span>
|
||||||
|
</header>
|
||||||
|
<div role="list" aria-describedby={`col-${status}`} className="space-y-2">
|
||||||
|
{items.map((t) => (
|
||||||
|
<TaskCard key={t.id} task={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/features/tasks/components/TaskCard.tsx
Normal file
33
src/features/tasks/components/TaskCard.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
task: Task
|
||||||
|
onClick?: (taskId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCard({ task, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
onClick={() => onClick?.(task.id)}
|
||||||
|
className="w-full text-left rounded-xl border bg-white dark:bg-zinc-800 px-3 py-2 shadow-sm
|
||||||
|
hover:shadow-md transition outline-offset-2 focus:outline focus:outline-2 focus:outline-indigo-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h4 className="text-sm font-medium leading-5">{task.title}</h4>
|
||||||
|
{task.priority && (
|
||||||
|
<span className="rounded-full border px-2 text-xs opacity-80">{task.priority}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<div className="mt-1 text-xs opacity-70">
|
||||||
|
Due: {new Date(task.dueDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.assigneeName && (
|
||||||
|
<div className="mt-1 text-xs opacity-70">Assignee: {task.assigneeName}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
src/features/tasks/components/TaskFormModal.tsx
Normal file
237
src/features/tasks/components/TaskFormModal.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import {
|
||||||
|
TaskCreateSchema,
|
||||||
|
TaskUpdateSchema,
|
||||||
|
TaskPriorityEnum,
|
||||||
|
type TaskCreateInput,
|
||||||
|
type TaskUpdateInput,
|
||||||
|
} from '@/features/tasks/schemas'
|
||||||
|
|
||||||
|
/** Skapa-variant */
|
||||||
|
export function CreateTaskFormModal(props: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
defaultValues: Omit<TaskCreateInput, 'projectId'> & { projectId: string }
|
||||||
|
onSubmit: (values: TaskCreateInput) => void
|
||||||
|
}) {
|
||||||
|
const { open, onOpenChange, defaultValues, onSubmit } = props
|
||||||
|
|
||||||
|
const form = useForm<TaskCreateInput>({
|
||||||
|
resolver: zodResolver(TaskCreateSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(defaultValues)
|
||||||
|
}, [defaultValues, form])
|
||||||
|
|
||||||
|
const submit = form.handleSubmit((values) => {
|
||||||
|
onSubmit({ ...values, projectId: defaultValues.projectId })
|
||||||
|
onOpenChange(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title="Ny task" open={open} onOpenChange={onOpenChange}>
|
||||||
|
<form onSubmit={submit} className="px-4 py-3 space-y-3">
|
||||||
|
<FieldText
|
||||||
|
label="Titel"
|
||||||
|
error={form.formState.errors.title?.message}
|
||||||
|
{...form.register('title')}
|
||||||
|
/>
|
||||||
|
<FieldTextarea
|
||||||
|
label="Beskrivning"
|
||||||
|
error={form.formState.errors.description?.message}
|
||||||
|
{...form.register('description', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<FieldDate
|
||||||
|
label="Förfallodatum"
|
||||||
|
error={form.formState.errors.dueDate?.message}
|
||||||
|
{...form.register('dueDate', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
/>
|
||||||
|
<FieldSelect
|
||||||
|
label="Prioritet"
|
||||||
|
error={form.formState.errors.priority?.message}
|
||||||
|
{...form.register('priority', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '—' },
|
||||||
|
...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FooterButtons onCancel={() => onOpenChange(false)} submitDisabled={!form.formState.isValid} />
|
||||||
|
</form>
|
||||||
|
</DialogShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redigera-variant */
|
||||||
|
export function EditTaskFormModal(props: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
taskId: string
|
||||||
|
defaultValues: TaskUpdateInput
|
||||||
|
onSubmit: (values: TaskUpdateInput) => void
|
||||||
|
}) {
|
||||||
|
const { open, onOpenChange, defaultValues, onSubmit } = props
|
||||||
|
|
||||||
|
const form = useForm<TaskUpdateInput>({
|
||||||
|
resolver: zodResolver(TaskUpdateSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(defaultValues)
|
||||||
|
}, [defaultValues, form])
|
||||||
|
|
||||||
|
const submit = form.handleSubmit((values) => {
|
||||||
|
onSubmit(values)
|
||||||
|
onOpenChange(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title="Redigera task" open={open} onOpenChange={onOpenChange}>
|
||||||
|
<form onSubmit={submit} className="px-4 py-3 space-y-3">
|
||||||
|
<FieldText
|
||||||
|
label="Titel"
|
||||||
|
error={form.formState.errors.title?.message}
|
||||||
|
{...form.register('title')}
|
||||||
|
/>
|
||||||
|
<FieldTextarea
|
||||||
|
label="Beskrivning"
|
||||||
|
error={form.formState.errors.description?.message}
|
||||||
|
{...form.register('description', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<FieldDate
|
||||||
|
label="Förfallodatum"
|
||||||
|
error={form.formState.errors.dueDate?.message}
|
||||||
|
{...form.register('dueDate', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
/>
|
||||||
|
<FieldSelect
|
||||||
|
label="Prioritet"
|
||||||
|
error={form.formState.errors.priority?.message}
|
||||||
|
{...form.register('priority', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '—' },
|
||||||
|
...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FieldSelect
|
||||||
|
label="Status"
|
||||||
|
error={form.formState.errors.status?.message}
|
||||||
|
{...form.register('status')}
|
||||||
|
options={[
|
||||||
|
{ value: 'OPEN', label: 'OPEN' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'IN_PROGRESS' },
|
||||||
|
{ value: 'DONE', label: 'DONE' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FooterButtons onCancel={() => onOpenChange(false)} submitDisabled={!form.formState.isValid} />
|
||||||
|
</form>
|
||||||
|
</DialogShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------- UI-helpers ----------------- */
|
||||||
|
|
||||||
|
function DialogShell({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" aria-labelledby="task-modal-title" className={`fixed inset-0 z-50 ${open ? '' : 'hidden'}`}>
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={() => onOpenChange(false)} />
|
||||||
|
<div className="absolute inset-0 grid place-items-center p-4">
|
||||||
|
<div className="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border shadow-xl">
|
||||||
|
<header className="border-b px-4 py-3">
|
||||||
|
<h2 id="task-modal-title" className="text-lg font-semibold">{title}</h2>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
} & React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
function FieldText(props: FieldProps) {
|
||||||
|
const { label, error, ...rest } = props
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||||
|
<input {...rest} className="w-full rounded-md border px-3 py-2" />
|
||||||
|
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextareaProps = {
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
|
function FieldTextarea(props: TextareaProps) {
|
||||||
|
const { label, error, ...rest } = props
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||||
|
<textarea {...rest} className="w-full rounded-md border px-3 py-2" />
|
||||||
|
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDate(props: FieldProps) {
|
||||||
|
return <FieldText {...props} type="date" />
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectProps = {
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
} & React.SelectHTMLAttributes<HTMLSelectElement>
|
||||||
|
|
||||||
|
function FieldSelect(props: SelectProps) {
|
||||||
|
const { label, error, options, ...rest } = props
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||||
|
<select {...rest} className="w-full rounded-md border px-3 py-2">
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FooterButtons({ onCancel, submitDisabled }: { onCancel: () => void; submitDisabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="rounded-md border px-3 py-2" onClick={onCancel}>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="rounded-md bg-indigo-600 text-white px-3 py-2 disabled:opacity-50" disabled={submitDisabled}>
|
||||||
|
Spara
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/features/tasks/mutations.ts
Normal file
142
src/features/tasks/mutations.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '@/lib/http'
|
||||||
|
import { projectTasksKey } from './api'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import type { TaskCreateInput, TaskUpdateInput } from './schemas'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
/* ---------- PATCH STATUS (DnD) ---------- */
|
||||||
|
|
||||||
|
async function patchTaskStatus(taskId: string, status: TaskStatus) {
|
||||||
|
await api.patch(`api/v1/tasks/${taskId}`, { json: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateStatusVars = { taskId: string; status: TaskStatus }
|
||||||
|
type UpdateStatusCtx = { prev?: Task[]; key: readonly unknown[]; taskId: string }
|
||||||
|
|
||||||
|
export function useUpdateTaskStatusMutation(projectId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
|
||||||
|
return useMutation<void, unknown, UpdateStatusVars, UpdateStatusCtx>({
|
||||||
|
mutationFn: ({ taskId, status }) => patchTaskStatus(taskId, status),
|
||||||
|
|
||||||
|
onMutate: async ({ taskId, status }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: key })
|
||||||
|
const prev = qc.getQueryData<Task[]>(key)
|
||||||
|
|
||||||
|
if (Array.isArray(prev)) {
|
||||||
|
const next: Task[] = prev.map((t) => (t.id === taskId ? { ...t, status } : t))
|
||||||
|
qc.setQueryData<Task[]>(key, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading('Uppdaterar task…', { id: `task-${taskId}` })
|
||||||
|
return { prev, key, taskId }
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||||
|
if (ctx?.taskId) toast.error('Kunde inte uppdatera – ändring ångrad', { id: `task-${ctx.taskId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (_d, vars) => {
|
||||||
|
toast.success('Task uppdaterad', { id: `task-${vars.taskId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: key })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- CREATE (projektbunden) ---------- */
|
||||||
|
|
||||||
|
async function createProjectTask(input: TaskCreateInput): Promise<Task> {
|
||||||
|
return api.post('api/v1/tasks', { json: input }).json<Task>()
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCtx = { prev?: Task[]; key: readonly unknown[]; tempId: string }
|
||||||
|
|
||||||
|
export function useCreateTaskMutation(projectId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
|
||||||
|
return useMutation<Task, unknown, TaskCreateInput, CreateCtx>({
|
||||||
|
mutationFn: (input) => createProjectTask(input),
|
||||||
|
|
||||||
|
onMutate: async (input) => {
|
||||||
|
await qc.cancelQueries({ queryKey: key })
|
||||||
|
const prev = qc.getQueryData<Task[]>(key)
|
||||||
|
const tempId = `temp-${Date.now()}`
|
||||||
|
if (Array.isArray(prev)) {
|
||||||
|
const temp: Task = {
|
||||||
|
id: tempId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: input.priority ?? null,
|
||||||
|
dueDate: input.dueDate ?? null,
|
||||||
|
assigneeName: null,
|
||||||
|
}
|
||||||
|
qc.setQueryData<Task[]>(key, [temp, ...prev])
|
||||||
|
}
|
||||||
|
toast.loading('Skapar task…', { id: `create-${tempId}` })
|
||||||
|
return { prev, key, tempId }
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||||
|
toast.error('Kunde inte skapa task', { id: `create-error` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (created, _vars, ctx) => {
|
||||||
|
// Byt ev. ut temp om du vill; vi invaliderar ändå nedan.
|
||||||
|
toast.success('Task skapad', { id: `create-${ctx?.tempId ?? created.id}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: key })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- EDIT (titel/beskriv/prio/due/status) ---------- */
|
||||||
|
|
||||||
|
async function updateTask(taskId: string, patch: TaskUpdateInput): Promise<void> {
|
||||||
|
await api.patch(`api/v1/tasks/${taskId}`, { json: patch })
|
||||||
|
}
|
||||||
|
type EditVars = { taskId: string; patch: TaskUpdateInput }
|
||||||
|
type EditCtx = { prev?: Task[]; key: readonly unknown[]; taskId: string }
|
||||||
|
|
||||||
|
export function useEditTaskMutation(projectId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
|
||||||
|
return useMutation<void, unknown, EditVars, EditCtx>({
|
||||||
|
mutationFn: ({ taskId, patch }) => updateTask(taskId, patch),
|
||||||
|
|
||||||
|
onMutate: async ({ taskId, patch }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: key })
|
||||||
|
const prev = qc.getQueryData<Task[]>(key)
|
||||||
|
if (Array.isArray(prev)) {
|
||||||
|
const next = prev.map((t) => (t.id === taskId ? { ...t, ...patch } : t))
|
||||||
|
qc.setQueryData<Task[]>(key, next)
|
||||||
|
}
|
||||||
|
toast.loading('Sparar ändringar…', { id: `edit-${taskId}` })
|
||||||
|
return { prev, key, taskId }
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||||
|
toast.error('Kunde inte spara ändringar', { id: `edit-${ctx?.taskId ?? ''}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (_d, { taskId }) => {
|
||||||
|
toast.success('Ändringar sparade', { id: `edit-${taskId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: key })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
26
src/features/tasks/schemas.ts
Normal file
26
src/features/tasks/schemas.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// Dela samma enum som i types/task.ts
|
||||||
|
export const TaskStatusEnum = z.enum(['OPEN', 'IN_PROGRESS', 'DONE'])
|
||||||
|
export const TaskPriorityEnum = z.enum(['LOW', 'MEDIUM', 'HIGH'])
|
||||||
|
|
||||||
|
export const TaskCreateSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Titel krävs').max(200, 'Max 200 tecken'),
|
||||||
|
description: z.string().max(5000, 'Max 5000 tecken').optional().nullable(),
|
||||||
|
dueDate: z.string().date('Ogiltigt datum').optional().or(z.literal('')).nullable(),
|
||||||
|
priority: TaskPriorityEnum.optional().nullable(),
|
||||||
|
// projektbunden task:
|
||||||
|
projectId: z.string().min(1, 'projectId krävs'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TaskCreateInput = z.infer<typeof TaskCreateSchema>
|
||||||
|
|
||||||
|
export const TaskUpdateSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(5000).optional().nullable(),
|
||||||
|
dueDate: z.string().date().optional().or(z.literal('')).nullable(),
|
||||||
|
priority: TaskPriorityEnum.optional().nullable(),
|
||||||
|
status: TaskStatusEnum.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TaskUpdateInput = z.infer<typeof TaskUpdateSchema>
|
||||||
23
src/lib/http.ts
Normal file
23
src/lib/http.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// src/lib/http.ts
|
||||||
|
import ky from 'ky'
|
||||||
|
|
||||||
|
export const api = ky.create({
|
||||||
|
// pekar på ditt API, t.ex. https://rubble.se/hemhub/api/ i prod (sätts via VITE_API_BASE_URL)
|
||||||
|
prefixUrl: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/',
|
||||||
|
hooks: {
|
||||||
|
beforeRequest: [
|
||||||
|
async (req) => {
|
||||||
|
const token = sessionStorage.getItem('access_token')
|
||||||
|
if (token) req.headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterResponse: [
|
||||||
|
async (_req, _opts, res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
// I Iteration 1, mjuk hantering — vi låter RequireAuth sköta redirecten
|
||||||
|
console.warn('401 from API – probably not logged in yet')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
4
src/lib/queryKeys.ts
Normal file
4
src/lib/queryKeys.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// src/lib/queryKeys.ts
|
||||||
|
export const qk = {
|
||||||
|
me: ['me'] as const,
|
||||||
|
}
|
||||||
38
src/pages/AuthCallbackPage.tsx
Normal file
38
src/pages/AuthCallbackPage.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// src/pages/AuthCallbackPage.tsx
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { userManager } from '@/auth/oidc' // behåll alias/relativt enligt ditt projekt
|
||||||
|
|
||||||
|
// Hjälpare: plocka ut returnTo från okänt state
|
||||||
|
function pickReturnTo(state: unknown): string | undefined {
|
||||||
|
if (state && typeof state === 'object' && 'returnTo' in state) {
|
||||||
|
const v = (state as Record<string, unknown>).returnTo
|
||||||
|
return typeof v === 'string' ? v : undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const handled = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handled.current) return
|
||||||
|
handled.current = true
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await userManager.signinRedirectCallback()
|
||||||
|
const target = pickReturnTo(res?.state) ?? '/'
|
||||||
|
// Städa bort ?code&state ur URL:en:
|
||||||
|
window.history.replaceState({}, '', target)
|
||||||
|
navigate(target, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('signinRedirectCallback failed:', err)
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
return <p>Completing sign-in…</p>
|
||||||
|
}
|
||||||
7
src/pages/LogoutPage.tsx
Normal file
7
src/pages/LogoutPage.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useAuth } from '@/auth/useAuth'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { signOut } = useAuth()
|
||||||
|
useEffect(() => { void signOut() }, [signOut])
|
||||||
|
return <p>Signing out…</p>
|
||||||
|
}
|
||||||
@ -1,2 +1,131 @@
|
|||||||
// src/pages/ProjectBoardPage.tsx
|
import { useParams } from 'react-router-dom'
|
||||||
export function ProjectBoardPage() { return <div>Project Kanban</div> }
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useProjectTasksQuery } from '@/features/tasks/api'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCorners,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { DroppableColumn } from '@/features/tasks/components/DroppableColumn'
|
||||||
|
import {
|
||||||
|
useUpdateTaskStatusMutation,
|
||||||
|
useCreateTaskMutation,
|
||||||
|
useEditTaskMutation,
|
||||||
|
} from '@/features/tasks/mutations'
|
||||||
|
import { CreateTaskFormModal, EditTaskFormModal } from '@/features/tasks/components/TaskFormModal'
|
||||||
|
|
||||||
|
function splitByStatus(items: Task[]) {
|
||||||
|
return {
|
||||||
|
OPEN: items.filter((t) => t.status === 'OPEN'),
|
||||||
|
IN_PROGRESS: items.filter((t) => t.status === 'IN_PROGRESS'),
|
||||||
|
DONE: items.filter((t) => t.status === 'DONE'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskStatus(x: unknown): x is TaskStatus {
|
||||||
|
return x === 'OPEN' || x === 'IN_PROGRESS' || x === 'DONE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectBoardPage() {
|
||||||
|
const { projectId = '' } = useParams()
|
||||||
|
const { data, isLoading, isError } = useProjectTasksQuery(projectId)
|
||||||
|
const { mutate: mutateStatus } = useUpdateTaskStatusMutation(projectId)
|
||||||
|
const createTask = useCreateTaskMutation(projectId)
|
||||||
|
const editTask = useEditTaskMutation(projectId)
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor))
|
||||||
|
|
||||||
|
// ✅ Memoisera tasks utifrån data
|
||||||
|
const tasks = useMemo(() => data ?? [], [data])
|
||||||
|
// ✅ Låt kolumner bero på tasks (inte data direkt)
|
||||||
|
const cols = useMemo(() => splitByStatus(tasks), [tasks])
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editTaskId, setEditTaskId] = useState<string | null>(null)
|
||||||
|
const currentTask = useMemo(
|
||||||
|
() => tasks.find((t) => t.id === editTaskId) ?? null,
|
||||||
|
[tasks, editTaskId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) return <p>Laddar tasks…</p>
|
||||||
|
if (isError) return <p>Något gick fel när tasks skulle hämtas.</p>
|
||||||
|
|
||||||
|
const onDragEnd = (e: DragEndEvent) => {
|
||||||
|
const taskId = String(e.active.id)
|
||||||
|
const dest = e.over?.id
|
||||||
|
if (!dest || typeof dest !== 'string') return
|
||||||
|
const toStatus = dest.toUpperCase()
|
||||||
|
if (!isTaskStatus(toStatus)) return
|
||||||
|
mutateStatus({ taskId, status: toStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => setCreateOpen(true)
|
||||||
|
const openEdit = (taskId: string) => {
|
||||||
|
setEditTaskId(taskId)
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Projektboard</h1>
|
||||||
|
<button onClick={openCreate} className="rounded-md bg-indigo-600 text-white px-3 py-2">
|
||||||
|
Ny task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<DroppableColumn title="Open" status="OPEN" items={cols.OPEN} onCardClick={openEdit} />
|
||||||
|
<DroppableColumn
|
||||||
|
title="In progress"
|
||||||
|
status="IN_PROGRESS"
|
||||||
|
items={cols.IN_PROGRESS}
|
||||||
|
onCardClick={openEdit}
|
||||||
|
/>
|
||||||
|
<DroppableColumn title="Done" status="DONE" items={cols.DONE} onCardClick={openEdit} />
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Create */}
|
||||||
|
<CreateTaskFormModal
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
defaultValues={{
|
||||||
|
title: '',
|
||||||
|
description: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: null, // ✅ inte tom sträng
|
||||||
|
projectId,
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => createTask.mutate(values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit */}
|
||||||
|
{currentTask && (
|
||||||
|
<EditTaskFormModal
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) setEditTaskId(null)
|
||||||
|
setEditOpen(v)
|
||||||
|
}}
|
||||||
|
taskId={currentTask.id}
|
||||||
|
defaultValues={{
|
||||||
|
title: currentTask.title,
|
||||||
|
description: currentTask.description ?? null,
|
||||||
|
dueDate: currentTask.dueDate ?? null,
|
||||||
|
priority: currentTask.priority ?? null, // ✅ inte tom sträng
|
||||||
|
status: currentTask.status,
|
||||||
|
}}
|
||||||
|
onSubmit={(patch) => editTask.mutate({ taskId: currentTask.id, patch })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
8
src/pages/SilentRenewPage.tsx
Normal file
8
src/pages/SilentRenewPage.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { userManager } from '@/auth/oidc'
|
||||||
|
|
||||||
|
|
||||||
|
export default function SilentRenewPage() {
|
||||||
|
useEffect(() => { userManager.signinSilentCallback() }, [])
|
||||||
|
return <p>Silent renew…</p>
|
||||||
|
}
|
||||||
8
src/pages/__tests__/DashboardPage.test.tsx
Normal file
8
src/pages/__tests__/DashboardPage.test.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { DashboardPage } from '../DashboardPage'
|
||||||
|
import { expect, test } from 'vitest'
|
||||||
|
|
||||||
|
test('renders dashboard headline', () => {
|
||||||
|
render(<DashboardPage />)
|
||||||
|
expect(screen.getByText(/Välkommen till HemHub/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
12
src/types/task.ts
Normal file
12
src/types/task.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export type TaskStatus = 'OPEN' | 'IN_PROGRESS' | 'DONE'
|
||||||
|
export type TaskPriority = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
status: TaskStatus
|
||||||
|
priority?: TaskPriority | null
|
||||||
|
dueDate?: string | null // ISO
|
||||||
|
assigneeName?: string | null
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
import type { Config } from 'tailwindcss'
|
||||||
|
import animate from 'tailwindcss-animate'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: ['class'],
|
darkMode: 'class',
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
theme: { extend: {} },
|
theme: { extend: {} },
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [animate],
|
||||||
}
|
} satisfies Config
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
@ -27,5 +28,12 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["src/*"] }
|
"paths": { "@/*": ["src/*"] }
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/__tests__/**"
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,36 @@
|
|||||||
// vite.config.ts
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tsconfigPaths()],
|
plugins: [react(), tsconfigPaths()],
|
||||||
resolve: {
|
build: {
|
||||||
alias: {
|
// Valfritt: höj varningsgränsen lite (ex. 1024 kB)
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
chunkSizeWarningLimit: 1024,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
// bryt ut större bibliotek i separata återanvändbara chunks
|
||||||
|
react: ['react', 'react-dom'],
|
||||||
|
router: ['react-router-dom'],
|
||||||
|
query: ['@tanstack/react-query'],
|
||||||
|
dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/accessibility'],
|
||||||
|
oidc: ['oidc-client-ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Snabbare dev-optimering
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react-router-dom',
|
||||||
|
'@tanstack/react-query',
|
||||||
|
'@dnd-kit/core',
|
||||||
|
'@dnd-kit/sortable',
|
||||||
|
'@dnd-kit/accessibility',
|
||||||
|
'oidc-client-ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()], // 👈 viktigt
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
2
vitest.setup.ts
Normal file
2
vitest.setup.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// vitest.setup.ts
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
Reference in New Issue
Block a user