From 7c666fee6a6c7de7033b6e6218db6d22ebe8707d Mon Sep 17 00:00:00 2001 From: HeiligerG Date: Sat, 4 Oct 2025 07:24:25 +0200 Subject: [PATCH] feat: add ProfileView, Components, Routes, Service, Store & FormSucess --- .husky/pre-commit | 1 - .../src/components/profile/DeleteAccount.tsx | 76 +++++++++++++ .../src/components/profile/ProfileForm.tsx | 107 ++++++++++++++++++ .../src/components/profile/ProfileHeader.tsx | 24 ++++ .../src/components/sidebar/ISidebar.ts | 5 +- .../src/components/sidebar/sidebar.tsx | 79 ++++++++----- .../src/components/ui/FormSuccess.tsx | 12 ++ apps/frontend/src/routeTree.gen.ts | 24 +++- apps/frontend/src/routes/_authenticated.tsx | 16 +-- .../src/routes/_authenticated/profile.tsx | 6 + apps/frontend/src/services/http.ts | 44 +++++++ apps/frontend/src/services/user.ts | 30 +++++ apps/frontend/src/stores/userStore.ts | 61 ++++++++++ apps/frontend/src/views/ProfileView.tsx | 54 +++++++++ 14 files changed, 491 insertions(+), 48 deletions(-) create mode 100644 apps/frontend/src/components/profile/DeleteAccount.tsx create mode 100644 apps/frontend/src/components/profile/ProfileForm.tsx create mode 100644 apps/frontend/src/components/profile/ProfileHeader.tsx create mode 100644 apps/frontend/src/components/ui/FormSuccess.tsx create mode 100644 apps/frontend/src/routes/_authenticated/profile.tsx create mode 100644 apps/frontend/src/services/user.ts create mode 100644 apps/frontend/src/stores/userStore.ts create mode 100644 apps/frontend/src/views/ProfileView.tsx diff --git a/.husky/pre-commit b/.husky/pre-commit index 72c4429..e69de29 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +0,0 @@ -npm test diff --git a/apps/frontend/src/components/profile/DeleteAccount.tsx b/apps/frontend/src/components/profile/DeleteAccount.tsx new file mode 100644 index 0000000..7744489 --- /dev/null +++ b/apps/frontend/src/components/profile/DeleteAccount.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { Trash2, AlertTriangle } from 'lucide-react'; +import { useAuthStore } from '../../stores/authStore'; + +interface DeleteAccountProps { + onDelete: () => Promise; +} + +export default function DeleteAccount({ onDelete }: DeleteAccountProps) { + const [showConfirm, setShowConfirm] = useState(false); + const [loading, setLoading] = useState(false); + const logout = useAuthStore((s) => s.logout); + + const handleDelete = async () => { + setLoading(true); + try { + await onDelete(); + await logout(); + } catch (error) { + console.error('Delete failed:', error); + setLoading(false); + } + }; + + if (!showConfirm) { + return ( +
+

Gefahrenzone

+

+ Account unwiderruflich löschen. Alle Daten gehen verloren. +

+ +
+ ); + } + + return ( +
+
+ +
+

Bist du sicher?

+

+ Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten gehen unwiderruflich + verloren. +

+
+
+
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/profile/ProfileForm.tsx b/apps/frontend/src/components/profile/ProfileForm.tsx new file mode 100644 index 0000000..3f1b3e2 --- /dev/null +++ b/apps/frontend/src/components/profile/ProfileForm.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { Save, X } from 'lucide-react'; +import TextField from '../ui/TextField'; +import FormError from '../ui/FormError'; +import FormSuccess from '../ui/FormSuccess'; +import { UpdateUserData } from '../../services/user'; + +interface ProfileFormProps { + initialData: UpdateUserData; + onSubmit: (data: UpdateUserData) => Promise; + onCancel?: () => void; +} + +export default function ProfileForm({ initialData, onSubmit, onCancel }: ProfileFormProps) { + const [formData, setFormData] = useState(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const hasChanges = + formData.first_name !== initialData.first_name || + formData.last_name !== initialData.last_name || + formData.email !== initialData.email; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(false); + setLoading(true); + + try { + await onSubmit(formData); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Speichern'); + } finally { + setLoading(false); + } + }; + + const handleReset = () => { + setFormData(initialData); + setError(''); + setSuccess(false); + onCancel?.(); + }; + + return ( +
+
+ setFormData({ ...formData, first_name: e.target.value })} + required + placeholder="Max" + autoComplete="given-name" + /> + setFormData({ ...formData, last_name: e.target.value })} + required + placeholder="Mustermann" + autoComplete="family-name" + /> +
+ + setFormData({ ...formData, email: e.target.value })} + required + placeholder="max@example.com" + autoComplete="email" + /> + + + + +
+ + + {hasChanges && ( + + )} +
+ + ); +} diff --git a/apps/frontend/src/components/profile/ProfileHeader.tsx b/apps/frontend/src/components/profile/ProfileHeader.tsx new file mode 100644 index 0000000..2f9ef28 --- /dev/null +++ b/apps/frontend/src/components/profile/ProfileHeader.tsx @@ -0,0 +1,24 @@ +import { User } from 'lucide-react'; + +interface ProfileHeaderProps { + firstName: string; + lastName: string; +} + +export default function ProfileHeader({ firstName, lastName }: ProfileHeaderProps) { + const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + + return ( +
+
+ {initials || } +
+
+

+ {firstName} {lastName} +

+

Profil bearbeiten

+
+
+ ); +} diff --git a/apps/frontend/src/components/sidebar/ISidebar.ts b/apps/frontend/src/components/sidebar/ISidebar.ts index 592b8bb..351e232 100644 --- a/apps/frontend/src/components/sidebar/ISidebar.ts +++ b/apps/frontend/src/components/sidebar/ISidebar.ts @@ -10,8 +10,5 @@ export type NavItem = { }; export type SidebarProps = { - active?: RouteKey; - onNavigate?: (key: RouteKey) => void; - logoUrl?: string; // default: /images/logo.png - user?: { name: string; role?: string }; + logoUrl?: string; }; diff --git a/apps/frontend/src/components/sidebar/sidebar.tsx b/apps/frontend/src/components/sidebar/sidebar.tsx index c99fcb1..364ef6d 100644 --- a/apps/frontend/src/components/sidebar/sidebar.tsx +++ b/apps/frontend/src/components/sidebar/sidebar.tsx @@ -1,12 +1,11 @@ -import { FC } from 'react'; +import { FC, useEffect } from 'react'; +import { Link, useRouterState } from '@tanstack/react-router'; import { RouteKey } from './SidebarEnum'; import type { NavItem, SidebarProps } from './ISidebar'; - +import { useUserStore } from '../../stores/userStore'; import { LayoutDashboard, Info, - Users, - Shield, ScanLine, Wrench, SlidersHorizontal, @@ -15,13 +14,11 @@ import { } from 'lucide-react'; const navItems: NavItem[] = [ - { key: RouteKey.Dashboard, label: 'Dashboard', icon: LayoutDashboard }, - { key: RouteKey.Daily, label: 'Daily', icon: Info }, - { key: RouteKey.Users, label: 'User', icon: Users }, - { key: RouteKey.Info, label: 'Information', icon: Shield }, - { key: RouteKey.Scan, label: 'Scan', icon: ScanLine }, - { key: RouteKey.Test, label: 'Test', icon: Wrench }, - { key: RouteKey.Sections, label: 'Edit Sections', icon: SlidersHorizontal }, + { key: RouteKey.Dashboard, label: 'Dashboard', icon: LayoutDashboard, href: '/' }, + { key: RouteKey.Daily, label: 'Daily', icon: Info, href: '/daily' }, + { key: RouteKey.Scan, label: 'Scan', icon: ScanLine, href: '/scan' }, + { key: RouteKey.Test, label: 'Test', icon: Wrench, href: '/test' }, + { key: RouteKey.Sections, label: 'Edit Sections', icon: SlidersHorizontal, href: '/sections' }, ]; const baseItem = @@ -31,16 +28,36 @@ const baseItem = const labelShow = 'pointer-events-none origin-left scale-0 opacity-0 transition-all duration-200 group-hover:scale-100 group-hover:opacity-100'; -const Sidebar: FC = ({ active, onNavigate, logoUrl = '/images/logo.png', user }) => { - const userName = user?.name ?? 'Gianluca Barbieri'; - const userRole = user?.role ?? 'Lernender'; +const Sidebar: FC = ({ logoUrl = '/images/logo.png' }) => { + const { user, fetchUser } = useUserStore(); + const router = useRouterState(); + const currentPath = router.location.pathname; + + useEffect(() => { + if (!user) { + fetchUser(); + } + }, [user, fetchUser]); + + const getActiveKey = (): RouteKey | null => { + if (currentPath === '/') return RouteKey.Dashboard; + if (currentPath === '/profile') return RouteKey.Profile; + if (currentPath === '/settings') return RouteKey.Settings; + if (currentPath.startsWith('/daily')) return RouteKey.Daily; + if (currentPath.startsWith('/scan')) return RouteKey.Scan; + if (currentPath.startsWith('/test')) return RouteKey.Test; + if (currentPath.startsWith('/sections')) return RouteKey.Sections; + return null; + }; - const renderItem = ({ key, label, icon: Icon, sublabel }: NavItem) => { + const active = getActiveKey(); + + const renderItem = ({ key, label, icon: Icon, sublabel, href }: NavItem & { href: string }) => { const isActive = active === key; return ( - + ); }; @@ -70,10 +87,10 @@ const Sidebar: FC = ({ active, onNavigate, logoUrl = '/images/logo >
{/* Brand */} - + {/* Main nav */} - + {/* Bottom actions */}
- {/* Settings as selectable item */} - {renderItem({ key: RouteKey.Settings, label: 'Settings', icon: Settings })} + {/* Settings */} + {renderItem({ + key: RouteKey.Settings, + label: 'Einstellungen', + icon: Settings, + href: '/settings', + })} - {/* Profile as normal item (kein Card) */} + {/* Profile */} {renderItem({ key: RouteKey.Profile, - label: userName, - sublabel: userRole, + label: user ? `${user.first_name} ${user.last_name}` : 'Profil', + sublabel: user?.email, icon: UserCircle2, + href: '/profile', })}
diff --git a/apps/frontend/src/components/ui/FormSuccess.tsx b/apps/frontend/src/components/ui/FormSuccess.tsx new file mode 100644 index 0000000..77c19b1 --- /dev/null +++ b/apps/frontend/src/components/ui/FormSuccess.tsx @@ -0,0 +1,12 @@ +import { CheckCircle } from 'lucide-react'; + +export default function FormSuccess({ message }: { message?: string }) { + if (!message) return null; + + return ( +
+ +

{message}

+
+ ); +} diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 1ce63fa..fe5aee6 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'; import { Route as LoginRouteImport } from './routes/login'; import { Route as AuthenticatedRouteImport } from './routes/_authenticated'; import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'; +import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'; const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -27,27 +28,35 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ path: '/', getParentRoute: () => AuthenticatedRoute, } as any); +const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any); export interface FileRoutesByFullPath { '/login': typeof LoginRoute; + '/profile': typeof AuthenticatedProfileRoute; '/': typeof AuthenticatedIndexRoute; } export interface FileRoutesByTo { '/login': typeof LoginRoute; + '/profile': typeof AuthenticatedProfileRoute; '/': typeof AuthenticatedIndexRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; '/_authenticated': typeof AuthenticatedRouteWithChildren; '/login': typeof LoginRoute; + '/_authenticated/profile': typeof AuthenticatedProfileRoute; '/_authenticated/': typeof AuthenticatedIndexRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: '/login' | '/'; + fullPaths: '/login' | '/profile' | '/'; fileRoutesByTo: FileRoutesByTo; - to: '/login' | '/'; - id: '__root__' | '/_authenticated' | '/login' | '/_authenticated/'; + to: '/login' | '/profile' | '/'; + id: '__root__' | '/_authenticated' | '/login' | '/_authenticated/profile' | '/_authenticated/'; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { @@ -78,14 +87,23 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedIndexRouteImport; parentRoute: typeof AuthenticatedRoute; }; + '/_authenticated/profile': { + id: '/_authenticated/profile'; + path: '/profile'; + fullPath: '/profile'; + preLoaderRoute: typeof AuthenticatedProfileRouteImport; + parentRoute: typeof AuthenticatedRoute; + }; } } interface AuthenticatedRouteChildren { + AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute; AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute; } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedProfileRoute: AuthenticatedProfileRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute, }; diff --git a/apps/frontend/src/routes/_authenticated.tsx b/apps/frontend/src/routes/_authenticated.tsx index 7128f02..5c39d08 100644 --- a/apps/frontend/src/routes/_authenticated.tsx +++ b/apps/frontend/src/routes/_authenticated.tsx @@ -1,25 +1,15 @@ -import { createFileRoute, Outlet, useRouterState } from '@tanstack/react-router'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; import Sidebar from '../components/sidebar/sidebar'; -import { RouteKey } from '../components/sidebar/SidebarEnum'; export const Route = createFileRoute('/_authenticated')({ component: AuthenticatedLayout, }); function AuthenticatedLayout() { - const router = useRouterState(); - - const getActiveRoute = (): RouteKey => { - const path = router.location.pathname; - if (path === '/') return RouteKey.Dashboard; - // ... weitere Routes - return RouteKey.Dashboard; - }; - return (
- {}} /> -
+ +
diff --git a/apps/frontend/src/routes/_authenticated/profile.tsx b/apps/frontend/src/routes/_authenticated/profile.tsx new file mode 100644 index 0000000..53a567c --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/profile.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import ProfileView from '../../views/ProfileView'; + +export const Route = createFileRoute('/_authenticated/profile')({ + component: ProfileView, +}); diff --git a/apps/frontend/src/services/http.ts b/apps/frontend/src/services/http.ts index 7db5677..0ad448c 100644 --- a/apps/frontend/src/services/http.ts +++ b/apps/frontend/src/services/http.ts @@ -45,6 +45,50 @@ export const http = { return (await res.json()) as T; }, + + async put(path: string, body?: unknown, init?: RequestInit): Promise { + const base = import.meta.env.VITE_API_URL ?? ''; + const token = localStorage.getItem('sesh_token'); + + const res = await fetch(base + path, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers || {}), + }, + body: JSON.stringify(body ?? {}), + ...init, + }); + + if (!res.ok) { + await handleErrorResponse(res); + } + + return (await res.json()) as T; + }, + + async delete(path: string, init?: RequestInit): Promise { + const base = import.meta.env.VITE_API_URL ?? ''; + const token = localStorage.getItem('sesh_token'); + + const res = await fetch(base + path, { + method: 'DELETE', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers || {}), + }, + ...init, + }); + + if (!res.ok) { + await handleErrorResponse(res); + } + + // DELETE kann leere Response haben + const text = await res.text(); + return (text ? JSON.parse(text) : {}) as T; + }, }; async function handleErrorResponse(res: Response): Promise { diff --git a/apps/frontend/src/services/user.ts b/apps/frontend/src/services/user.ts new file mode 100644 index 0000000..bbb0520 --- /dev/null +++ b/apps/frontend/src/services/user.ts @@ -0,0 +1,30 @@ +import { http } from './http'; + +export type User = { + id: string; + first_name: string; + last_name: string; + email: string; + created_at: string; + updated_at: string; +}; + +export type UpdateUserData = { + first_name: string; + last_name: string; + email: string; +}; + +export const userService = { + async getUser(): Promise { + return await http.get('/api/user'); + }, + + async updateUser(data: UpdateUserData): Promise { + return await http.put('/api/user', data); + }, + + async deleteUser(): Promise { + await http.delete('/api/user'); + }, +}; diff --git a/apps/frontend/src/stores/userStore.ts b/apps/frontend/src/stores/userStore.ts new file mode 100644 index 0000000..20f1629 --- /dev/null +++ b/apps/frontend/src/stores/userStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { userService, User, UpdateUserData } from '../services/user'; + +interface UserState { + user: User | null; + loading: boolean; + error: string | null; + fetchUser: () => Promise; + updateUser: (data: UpdateUserData) => Promise; + deleteUser: () => Promise; + clearError: () => void; +} + +export const useUserStore = create((set) => ({ + user: null, + loading: false, + error: null, + + fetchUser: async () => { + set({ loading: true, error: null }); + try { + const user = await userService.getUser(); + set({ user, loading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Fehler beim Laden', + loading: false, + }); + } + }, + + updateUser: async (data) => { + set({ loading: true, error: null }); + try { + const user = await userService.updateUser(data); + set({ user, loading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Fehler beim Speichern', + loading: false, + }); + throw error; + } + }, + + deleteUser: async () => { + set({ loading: true, error: null }); + try { + await userService.deleteUser(); + set({ user: null, loading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Fehler beim Löschen', + loading: false, + }); + throw error; + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/apps/frontend/src/views/ProfileView.tsx b/apps/frontend/src/views/ProfileView.tsx new file mode 100644 index 0000000..1ddf944 --- /dev/null +++ b/apps/frontend/src/views/ProfileView.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import { useUserStore } from '../stores/userStore'; +import ProfileHeader from '../components/profile/ProfileHeader'; +import ProfileForm from '../components/profile/ProfileForm'; +import DeleteAccount from '../components/profile/DeleteAccount'; + +export default function ProfileView() { + const { user, loading, fetchUser, updateUser, deleteUser } = useUserStore(); + + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + if (loading && !user) { + return ( +
+
Lädt Profil...
+
+ ); + } + + if (!user) { + return ( +
+
Profil nicht gefunden
+
+ ); + } + + return ( +
+ + +
+

Persönliche Informationen

+ +
+ + + +
+ Erstellt: {new Date(user.created_at).toLocaleDateString('de-DE')} • Aktualisiert:{' '} + {new Date(user.updated_at).toLocaleDateString('de-DE')} +
+
+ ); +}