feat: refactor frontend routing and authentication

- Changed root div ID from 'root' to 'app' in index.html.
- Updated package.json to include new dependencies for routing and state management.
- Removed App component and replaced it with a router setup in main.tsx.
- Added route definitions for login, about, and index pages.
- Implemented authentication logic using Zustand for state management.
- Created API client with Axios for handling requests and token management.
- Added CORS support in the backend API.
- Updated schema for login requests to use camelCase.
This commit is contained in:
2025-11-16 00:36:30 +01:00
parent f7a839b11a
commit 252491bd2f
19 changed files with 655 additions and 27 deletions

View File

@@ -1,11 +0,0 @@
import { Button } from "@/components/ui/button";
function App() {
return (
<div className="flex min-h-svh flex-col items-center justify-center">
<Button>Click me</Button>
</div>
);
}
export default App;

View File

@@ -0,0 +1,46 @@
export type PaginatedResponse<T> = {
data: T[]
page: number
limit: number
total_items: number
total_pages: number
has_next_page: boolean
has_prev_page: boolean
}
export type User = {
id: string
username: string
email: string
storage_used: number
storage_quota: number
}
export type Media = {
id: string
original_filename: string
mime_type: string
hash: string
file_url: string
thumbnail_url: string | null
}
export type Album = {
id: string
owner_id: string
name: string
description: string | null
is_public: boolean
created_at: string
updated_at: string
thumbnail_media_id: string | null
}
export type Person = {
id: string
owner_id: string
name: string
thumbnail_media_id: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,37 @@
import type { User } from "@/domain/types"
import { useAuthStorage } from "@/hooks/use-auth-storage"
import apiClient from "@/services/api-client"
import { useNavigate } from "@tanstack/react-router"
import { useMutation } from "@tanstack/react-query"
type LoginCredentials = {
usernameOrEmail: string
password: string
}
type LoginResponse = {
token: string
user: User
}
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
const { data } = await apiClient.post('/auth/login', credentials)
return data
}
export const useLogin = () => {
const navigate = useNavigate()
const { setToken } = useAuthStorage()
return useMutation({
mutationFn: login,
onSuccess: (data) => {
setToken(data.token, data.user)
navigate({ to: '/' })
},
onError: (error) => {
console.error('Login failed:', error)
// TODO: Add user-facing error toast
},
})
}

View File

@@ -0,0 +1,29 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import type { User } from "@/domain/types"
type AuthState = {
token: string | null
user: User | null
setToken: (token: string, user: User) => void
clearToken: () => void
}
/**
* Global store for authentication state (token and user).
* Persisted to localStorage.
*/
export const useAuthStorage = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setToken: (token, user) => set({ token, user }),
clearToken: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
},
),
)

View File

@@ -1,10 +1,36 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
import "./index.css";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: {
queryClient,
},
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
scrollRestoration: true,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,95 @@
/* 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 LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/login'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/login'
id: '__root__' | '/' | '/about' | '/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,55 @@
import {
Link,
Outlet,
createRootRouteWithContext,
useNavigate,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import type { QueryClient } from "@tanstack/react-query";
import { useAuthStorage } from "@/hooks/use-auth-storage";
import { useEffect } from "react";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
component: RootComponent,
notFoundComponent: () => {
return (
<div>
<p>This is the notFoundComponent configured on root route</p>
<Link to="/">Start Over</Link>
</div>
);
},
});
function RootComponent() {
const token = useAuthStorage((s) => s.token);
const navigate = useNavigate();
useEffect(() => {
if (!token) {
navigate({
to: "/login",
replace: true,
});
}
}, [token, navigate]);
return (
<>
<div className="flex h-screen bg-gray-100">
{/* <Sidebar /> */}
<div className="flex-1 flex flex-col">
{/* <Header /> */}
<main className="flex-1 p-6 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools position="bottom-right" />
</>
);
}

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import * as React from "react";
export const Route = createFileRoute("/about")({
component: AboutComponent,
});
function AboutComponent() {
return (
<div className="p-2">
<h3>About</h3>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: Dashboard,
});
function Dashboard() {
return (
<div className="p-4">
<h1 className="text-2xl font-bold">Welcome to Libertas</h1>
<p>This is your main dashboard.</p>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useLogin } from "@/features/auth/use-auth";
import { useAuthStorage } from "@/hooks/use-auth-storage";
import { useEffect } from "react";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const { mutate: login, isPending, error } = useLogin();
const token = useAuthStorage((s) => s.token);
useEffect(() => {
if (token) {
navigate({ to: "/", replace: true });
}
}, [token, navigate]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const usernameOrEmail = formData.get("email") as string;
const password = formData.get("password") as string;
login({ usernameOrEmail, password });
};
console.log("LoginPage render, isPending:", isPending, "error:", error);
return (
<div className="flex items-center justify-center h-screen bg-gray-100">
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-center">Login to Libertas</h1>
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email or Username
</label>
<input
id="email"
name="email"
type="text"
required
className="w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{error && (
<p className="text-sm text-red-600">
Login failed. Please check your credentials.
</p>
)}
<div>
<button
type="submit"
disabled={isPending}
className="w-full px-4 py-2 font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isPending ? "Logging in..." : "Login"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import axios from 'axios'
import { useAuthStorage } from '@/hooks/use-auth-storage'
const apiClient = axios.create({
baseURL: 'http://localhost:8080/api/v1',
})
apiClient.interceptors.request.use(
(config) => {
const token = useAuthStorage.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
},
)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
useAuthStorage.getState().clearToken()
window.location.reload()
}
return Promise.reject(error)
},
)
export default apiClient