Replace Next.js app with Vite React app (gerbil-manager-web)

- Remove gerbil-manager-nextjs (unused scaffold; recoverable from history)
- Scaffold gerbil-manager-web: Vite + React 19 + TypeScript
- German-only UI (lang=de, central strings in src/strings/de.ts)
- Mobile-first shell: bottom tab bar on phones, sidebar on >=768px
- react-router with skeleton routes (Start, Rennmaeuse, Wuerfe, Zuechter)
- API client (src/api/client.ts) pointing at GerbilManagerWebAPI,
  configurable via VITE_API_BASE_URL (default http://localhost:5179)
- nginx-based Dockerfile with SPA fallback; docker-compose frontend
  service now builds gerbil-manager-web

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:30:34 +02:00
parent 42a43ae24e
commit 6ee85bc909
41 changed files with 3436 additions and 4758 deletions

View File

@@ -1,3 +1,8 @@
# GerbilManager
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
# Requirements
Install ef:
@@ -15,6 +20,11 @@ dotnet ef migrations add <name>
### force compose rebuild
docker-compose build --no-cache
## Frontend (gerbil-manager-web)
cd gerbil-manager-web
npm install
npm run dev
TODO:
- Backup docker volume database

View File

@@ -5,10 +5,13 @@ version: '3.4'
services:
frontend:
image: gerbilmanagernextjs
image: gerbilmanagerweb
build:
context: .
dockerfile: gerbil-manager-nextjs/Dockerfile
dockerfile: gerbil-manager-web/Dockerfile
args:
# Der Browser erreicht die API über den am Host veröffentlichten Port
- VITE_API_BASE_URL=http://localhost:80
ports:
- 3000:3000
networks:

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,63 +0,0 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY gerbil-manager-nextjs/package.json gerbil-manager-nextjs/package-lock.json* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY gerbil-manager-nextjs/ .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
# RUN yarn build
# If using npm comment out above and use below instead
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,40 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
## Inspiration
[SideBar](https://tailwindcomponents.com/component/sub-menu-sidebar)

View File

@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = {
output: 'standalone'
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "gerbil-manager-nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.1"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.0.1"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,27 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -1,121 +0,0 @@
import Image from 'next/image'
export default function Home() {
return (
<aside className="flex">
<div className="flex flex-col items-center w-16 h-screen py-8 space-y-8 bg-white dark:bg-gray-900 dark:border-gray-700">
<a href="#">
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
</a>
<a href="#" className="p-1.5 text-blue-500 transition-colors duration-200 bg-blue-100 rounded-lg dark:text-blue-400 dark:bg-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 107.5 7.5h-7.5V6z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0013.5 3v7.5z" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</a>
<a href="#" className="p-1.5 text-gray-500 focus:outline-nones transition-colors duration-200 rounded-lg dark:text-gray-400 dark:hover:bg-gray-800 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
</div>
<div className="h-screen py-8 overflow-y-auto bg-white border-l border-r sm:w-64 w-60 dark:bg-gray-900 dark:border-gray-700">
<h2 className="px-5 text-lg font-medium text-gray-800 dark:text-white">Accounts</h2>
<div className="mt-8 space-y-4">
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Mia John</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">11.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">arthur melo</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">1.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 bg-gray-100 dark:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Jane Doe</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">15.6 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Amelia. Anderson</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">32.9 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 dark:hover:bg-gray-800 gap-x-2 hover:bg-gray-100 focus:outline-none">
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Joseph Gonzalez</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">100.2 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Olivia Wathan</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">8.6 Followers</p>
</div>
</button>
<button className="flex items-center w-full px-5 py-2 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 gap-x-2 focus:outline-none">
<div className="relative">
<span className="h-2 w-2 rounded-full bg-emerald-500 absolute right-0.5 ring-1 ring-white bottom-0"></span>
</div>
<div className="text-left rtl:text-right">
<h1 className="text-sm font-medium text-gray-700 capitalize dark:text-white">Junior REIS</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">56.6 Followers</p>
</div>
</button>
</div>
</div>
</aside>
)
}

View File

@@ -1,20 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
export default config

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

24
gerbil-manager-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,25 @@
# Build-Stufe: Vite-Produktionsbuild
FROM node:22-alpine AS build
WORKDIR /app
COPY gerbil-manager-web/package.json gerbil-manager-web/package-lock.json ./
RUN npm ci
COPY gerbil-manager-web/ .
# Basis-URL der API (zur Build-Zeit eingebettet; Browser erreicht die API über den Host)
ARG VITE_API_BASE_URL=http://localhost:80
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
# Laufzeit-Stufe: statische Auslieferung über nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# SPA-Fallback: alle Pfade auf index.html umleiten (react-router)
RUN printf 'server {\n\
listen 3000;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,33 @@
# gerbil-manager-web
Frontend des Rennmaus-Managers — React + TypeScript + Vite, mobile-first (Smartphone und Laptop).
## Entwicklung
```bash
npm install
npm run dev
```
Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
(Profil `http` in `GerbilManagerWebAPI/Properties/launchSettings.json`).
## Konfiguration
| Variable | Standard | Beschreibung |
|---|---|---|
| `VITE_API_BASE_URL` | `http://localhost:5179` | Basis-URL der GerbilManagerWebAPI |
## Konventionen
- **Alle für Benutzer sichtbaren Texte sind Deutsch** und liegen zentral in
`src/strings/de.ts` — keine Texte direkt in Komponenten hartkodieren.
- API-Zugriffe laufen über `src/api/client.ts`.
- Routen werden in `src/App.tsx` registriert, Seiten liegen in `src/pages/`.
## Build
```bash
npm run build
npm run preview
```

View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
<title>Rennmaus-Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2796
gerbil-manager-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "gerbil-manager-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,23 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import AppShell from './components/AppShell'
import BreedersPage from './pages/BreedersPage'
import GerbilsPage from './pages/GerbilsPage'
import HomePage from './pages/HomePage'
import LittersPage from './pages/LittersPage'
import NotFoundPage from './pages/NotFoundPage'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomePage />} />
<Route path="rennmaeuse" element={<GerbilsPage />} />
<Route path="wuerfe" element={<LittersPage />} />
<Route path="zuechter" element={<BreedersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,66 @@
import { de } from '../strings/de'
/**
* Basis-URL der GerbilManagerWebAPI.
* Per VITE_API_BASE_URL konfigurierbar (z. B. in Docker / Aspire);
* Standard ist das lokale Dev-Profil der API (launchSettings.json, Profil "http").
*/
export const API_BASE_URL: string =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5179'
export class ApiError extends Error {
readonly status: number | null
constructor(message: string, status: number | null) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function errorMessageFor(status: number): string {
if (status === 404) return de.api.errors.notFound
if (status >= 500) return de.api.errors.server
return de.api.errors.unknown
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(`${API_BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
} catch {
throw new ApiError(de.api.errors.network, null)
}
if (!response.ok) {
throw new ApiError(errorMessageFor(response.status), response.status)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
/**
* Ressourcen-Pfade der API (siehe GerbilManagerWebAPI/Controllers).
* Alle drei Ressourcen bieten volle CRUD-Endpunkte:
* GET /{resource}, GET /{resource}/{id}, POST, PUT /{id}, DELETE /{id}
*/
export const resources = {
gerbils: '/gerbils',
litters: '/litters',
breeders: '/breeders',
} as const

View File

@@ -0,0 +1,47 @@
import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de'
const navItems = [
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭', end: false },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼', end: false },
{ to: '/zuechter', label: de.nav.breeders, icon: '🧑‍🌾', end: false },
]
/**
* App-Grundgerüst, mobile-first:
* - Smartphone: Kopfzeile oben, Tab-Leiste unten
* - Laptop (ab 768px): Seitenleiste links, Inhalt rechts
*/
export default function AppShell() {
return (
<div className="app-shell">
<header className="app-header">
<span className="app-logo" aria-hidden="true">🐭</span>
<h1 className="app-title">{de.app.title}</h1>
</header>
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
isActive ? 'nav-link nav-link--active' : 'nav-link'
}
>
<span className="nav-icon" aria-hidden="true">
{item.icon}
</span>
<span className="nav-label">{item.label}</span>
</NavLink>
))}
</nav>
<main className="app-main">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,157 @@
/* Mobile-first Grundlayout: Tab-Leiste unten auf dem Smartphone,
Seitenleiste links ab 768px (Laptop). */
:root {
--color-bg: #faf7f2;
--color-surface: #ffffff;
--color-border: #e5ddd0;
--color-text: #3b3026;
--color-text-muted: #8a7d6b;
--color-accent: #b5651d;
--color-accent-soft: #f6e7d7;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100dvh;
}
h1,
h2,
h3 {
margin: 0 0 0.5rem;
}
a {
color: var(--color-accent);
}
/* --- Grundgerüst (Smartphone) --- */
.app-shell {
display: grid;
min-height: 100dvh;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'header'
'main'
'nav';
}
.app-header {
grid-area: header;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
}
.app-logo {
font-size: 1.5rem;
}
.app-title {
font-size: 1.15rem;
margin: 0;
}
.app-main {
grid-area: main;
padding: 1rem;
max-width: 60rem;
width: 100%;
}
/* Tab-Leiste unten */
.app-nav {
grid-area: nav;
display: flex;
justify-content: space-around;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
position: sticky;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.5rem 0.75rem;
flex: 1;
font-size: 0.7rem;
text-decoration: none;
color: var(--color-text-muted);
min-height: 44px; /* Touch-Ziel */
}
.nav-link--active {
color: var(--color-accent);
font-weight: 600;
}
.nav-icon {
font-size: 1.25rem;
}
/* --- Laptop / Tablet (ab 768px): Seitenleiste links --- */
@media (min-width: 768px) {
.app-shell {
grid-template-columns: 14rem 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'nav main';
}
.app-nav {
flex-direction: column;
justify-content: flex-start;
gap: 0.25rem;
border-top: none;
border-right: 1px solid var(--color-border);
padding: 1rem 0.5rem;
position: static;
}
.nav-link {
flex-direction: row;
flex: 0 0 auto;
gap: 0.6rem;
font-size: 0.95rem;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
}
.nav-link:hover {
background: var(--color-accent-soft);
}
.nav-link--active {
background: var(--color-accent-soft);
}
.app-main {
padding: 1.5rem 2rem;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,10 @@
import { de } from '../strings/de'
export default function BreedersPage() {
return (
<section>
<h2>{de.pages.breeders.title}</h2>
<p>{de.pages.breeders.comingSoon}</p>
</section>
)
}

View File

@@ -0,0 +1,10 @@
import { de } from '../strings/de'
export default function GerbilsPage() {
return (
<section>
<h2>{de.pages.gerbils.title}</h2>
<p>{de.pages.gerbils.comingSoon}</p>
</section>
)
}

View File

@@ -0,0 +1,10 @@
import { de } from '../strings/de'
export default function HomePage() {
return (
<section>
<h2>{de.pages.home.title}</h2>
<p>{de.pages.home.intro}</p>
</section>
)
}

View File

@@ -0,0 +1,10 @@
import { de } from '../strings/de'
export default function LittersPage() {
return (
<section>
<h2>{de.pages.litters.title}</h2>
<p>{de.pages.litters.comingSoon}</p>
</section>
)
}

View File

@@ -0,0 +1,12 @@
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
export default function NotFoundPage() {
return (
<section>
<h2>{de.pages.notFound.title}</h2>
<p>{de.pages.notFound.message}</p>
<Link to="/">{de.pages.notFound.backHome}</Link>
</section>
)
}

View File

@@ -0,0 +1,57 @@
/**
* Zentrale deutsche Texte der Anwendung.
* Alle für Benutzer sichtbaren Zeichenketten gehören hierher —
* niemals Texte direkt in Komponenten hartkodieren.
*/
export const de = {
app: {
title: 'Rennmaus-Manager',
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
},
nav: {
home: 'Start',
gerbils: 'Rennmäuse',
litters: 'Würfe',
breeders: 'Züchter',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
},
pages: {
home: {
title: 'Willkommen',
intro:
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
},
gerbils: {
title: 'Rennmäuse',
comingSoon: 'Die Rennmaus-Verwaltung entsteht gerade. Bald kannst du hier deine Tiere anlegen und pflegen.',
},
litters: {
title: 'Würfe',
comingSoon: 'Die Wurf-Verwaltung entsteht gerade. Bald kannst du hier Würfe dokumentieren.',
},
breeders: {
title: 'Züchter',
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
},
notFound: {
title: 'Seite nicht gefunden',
message: 'Die angeforderte Seite existiert nicht.',
backHome: 'Zur Startseite',
},
},
api: {
errors: {
network: 'Verbindung zum Server fehlgeschlagen. Bitte später erneut versuchen.',
notFound: 'Der Eintrag wurde nicht gefunden.',
server: 'Auf dem Server ist ein Fehler aufgetreten.',
unknown: 'Ein unbekannter Fehler ist aufgetreten.',
},
},
common: {
loading: 'Lädt …',
},
} as const
export type Strings = typeof de

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})