feat: initialize frontend with React, Vite, and Tailwind CSS

- package.json with dependencies and scripts for development
- postcss.config.js for Tailwind CSS and autoprefixer
- main App component with routing for Trips and Items pages
- API functions for fetching trips and items
- components for item listing, item rows, search bar, and tag filtering
- ItemsPage to manage items with search and tag filtering
- TripsPage to display trips and associated items
- Tailwind CSS and TypeScript settings
This commit is contained in:
Felix Zett 2025-08-17 20:13:46 +02:00
parent 686d22871b
commit 56de7bb167
19 changed files with 3142 additions and 1 deletions

4
.gitignore vendored
View file

@ -1 +1,3 @@
**/__pycache__/
**/__pycache__/
/*.zip
node_modules/

View file

@ -23,5 +23,18 @@ services:
- ./:/app
environment:
- PYTHONUNBUFFERED=1
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules # anonymes Volume → trennt node_modules von Host
environment:
- CHOKIDAR_USEPOLLING=true # für Hot-Reload in Docker nötig
stdin_open: true
tty: true
volumes:
db_data:

16
frontend/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
# Nur package.json kopieren, um Cache zu nutzen
COPY package*.json ./
# Installation im Container
RUN npm install
# Code mounten wir später über compose
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Packlist</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2635
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
frontend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "packlist-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^7.8.1"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"autoprefixer": "^10.0.0",
"postcss": "^8.0.0",
"tailwindcss": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

21
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,21 @@
import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom";
import { ItemsPage } from "./pages/ItemsPage";
import { TripsPage } from "./pages/TripsPage"; // das ist dein bisheriger Inhalt
export default function App() {
return (
<Router>
<div className="p-4 max-w-2xl mx-auto">
<nav className="flex gap-4 mb-6">
<Link to="/" className="text-blue-500 underline">Trips</Link>
<Link to="/items" className="text-blue-500 underline">Items</Link>
</nav>
<Routes>
<Route path="/" element={<TripsPage />} />
<Route path="/items" element={<ItemsPage />} />
</Routes>
</div>
</Router>
);
}

65
frontend/src/api.ts Normal file
View file

@ -0,0 +1,65 @@
export const API_BASE = 'http://localhost:8000';
export async function getSeed() {
return fetch(`${API_BASE}/dev/seed`).then(res => res.json());
}
export async function getTrips() {
return fetch(`${API_BASE}/trips/`).then(res => res.json());
}
export async function getTripItems(tripId: string) {
return fetch(`${API_BASE}/trip-items/by-trip/${tripId}`).then(res => res.json());
}
export async function toggleTripItem(tripItemId: string) {
return fetch(`${API_BASE}/trip-items/${tripItemId}/toggle`, { method: 'POST' }).then(res => res.json());
}
export interface Tag {
id: string;
name: string;
}
export interface Item {
id: string;
name: string;
tags: Tag[];
}
export const api = {
async getItems(): Promise<Item[]> {
// später durch echten fetch ersetzen
return [
{ id: "1", name: "Ladekabel Mac", tags: [{ id: "t1", name: "kristin" }] },
{
id: "2",
name: "{nights} Unterhosen",
tags: [
{ id: "t2", name: "felix" },
{ id: "t1", name: "kristin" },
],
},
];
},
async getTags(): Promise<Tag[]> {
return [
{ id: "t1", name: "kristin" },
{ id: "t2", name: "felix" },
{ id: "t3", name: "sommer" },
];
},
async renameItem(id: string, newName: string) {
console.log("rename", id, newName);
},
async removeTag(itemId: string, tagId: string) {
console.log("removeTag", itemId, tagId);
},
async addTag(itemId: string, tagName: string) {
console.log("addTag", itemId, tagName);
},
async addItem(name: string) {
console.log("addItem", name);
},
};

View file

@ -0,0 +1,25 @@
import { Item } from "../api";
import { ItemRow } from "./ItemRow";
interface Props {
items: Item[];
onRename: (id: string, newName: string) => void;
onRemoveTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagName: string) => void;
}
export function ItemList({ items, onRename, onRemoveTag, onAddTag }: Props) {
return (
<div className="space-y-2">
{items.map(item => (
<ItemRow
key={item.id}
item={item}
onRename={onRename}
onRemoveTag={onRemoveTag}
onAddTag={onAddTag}
/>
))}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useState } from "react";
import { Item } from "../api";
interface Props {
item: Item;
onRename: (id: string, newName: string) => void;
onRemoveTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagName: string) => void;
}
export function ItemRow({ item, onRename, onRemoveTag, onAddTag }: Props) {
const [editing, setEditing] = useState(false);
const [newName, setNewName] = useState(item.name);
const [addingTag, setAddingTag] = useState(false);
const [newTag, setNewTag] = useState("");
return (
<div className="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
{/* Name */}
<div className="flex-1">
{editing ? (
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onBlur={() => {
setEditing(false);
if (newName !== item.name) onRename(item.id, newName);
}}
className="border-b border-gray-400 outline-none"
autoFocus
/>
) : (
<span onClick={() => setEditing(true)} className="cursor-pointer">
{item.name}
</span>
)}
</div>
{/* Tags */}
<div className="flex gap-2 items-center">
{item.tags.map(tag => (
<span
key={tag.id}
className="bg-gray-200 px-2 py-1 rounded-full text-sm relative group"
>
#{tag.name}
<button
onClick={() => onRemoveTag(item.id, tag.id)}
className="ml-1 text-xs text-red-600 opacity-0 group-hover:opacity-100"
>
</button>
</span>
))}
{addingTag ? (
<input
value={newTag}
onChange={e => setNewTag(e.target.value)}
onBlur={() => {
if (newTag.trim()) onAddTag(item.id, newTag.trim());
setAddingTag(false);
setNewTag("");
}}
className="border-b w-20 outline-none"
autoFocus
/>
) : (
<button
onClick={() => setAddingTag(true)}
className="text-gray-400 hover:text-gray-600"
>
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
interface Props {
value: string;
onChange: (v: string) => void;
}
export function SearchBar({ value, onChange }: Props) {
return (
<input
type="text"
placeholder="🔍 Suche..."
value={value}
onChange={e => onChange(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
/>
);
}

View file

@ -0,0 +1,27 @@
import { Tag } from "../api";
interface Props {
tags: Tag[];
selected: string[];
onToggle: (tag: string) => void;
}
export function TagFilter({ tags, selected, onToggle }: Props) {
return (
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<button
key={tag.id}
onClick={() => onToggle(tag.name)}
className={`px-2 py-1 rounded-full text-sm ${
selected.includes(tag.name)
? "bg-blue-500 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
>
#{tag.name}
</button>
))}
</div>
);
}

3
frontend/src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
frontend/src/main.tsx Normal file
View file

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

View file

@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { SearchBar } from "../components/SearchBar";
import { TagFilter } from "../components/TagFilter";
import { ItemList } from "../components/ItemList";
import { api, Item, Tag } from "../api";
export function ItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
useEffect(() => {
api.getItems().then(setItems);
api.getTags().then(setTags);
}, []);
const handleRename = (id: string, newName: string) => {
setItems(prev =>
prev.map(item => (item.id === id ? { ...item, name: newName } : item))
);
api.renameItem(id, newName);
};
const handleRemoveTag = (itemId: string, tagId: string) => {
setItems(prev =>
prev.map(item =>
item.id === itemId
? { ...item, tags: item.tags.filter(t => t.id !== tagId) }
: item
)
);
api.removeTag(itemId, tagId);
};
const handleAddTag = (itemId: string, tagName: string) => {
const newTag: Tag = { id: crypto.randomUUID(), name: tagName };
setItems(prev =>
prev.map(item =>
item.id === itemId ? { ...item, tags: [...item.tags, newTag] } : item
)
);
api.addTag(itemId, tagName);
};
const handleAddItem = (name: string) => {
const newItem: Item = { id: crypto.randomUUID(), name, tags: [] };
setItems(prev => [...prev, newItem]);
api.addItem(name);
};
// Filtering
const filtered = items.filter(item => {
const matchSearch = item.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchTags =
selectedTags.length === 0 ||
selectedTags.some(tag =>
item.tags.map(t => t.name).includes(tag)
);
return matchSearch && matchTags;
});
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Items</h1>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<TagFilter
tags={tags}
selected={selectedTags}
onToggle={tag =>
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
)
}
/>
<ItemList
items={filtered}
onRename={handleRename}
onRemoveTag={handleRemoveTag}
onAddTag={handleAddTag}
/>
<button
onClick={() => handleAddItem("Neues Item")}
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
+ Neues Item
</button>
</div>
);
}

View file

@ -0,0 +1,73 @@
import React, { useState, useEffect } from "react";
import { getSeed, getTrips, getTripItems, toggleTripItem } from "../api";
export function TripsPage() {
const [trips, setTrips] = useState<any[]>([]);
const [items, setItems] = useState<Record<string, any[]>>({});
async function loadTrips() {
const data = await getTrips();
setTrips(data);
}
async function loadItems(tripId: string) {
const data = await getTripItems(tripId);
setItems(prev => ({ ...prev, [tripId]: data }));
}
useEffect(() => {
loadTrips();
}, []);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Packlist</h1>
<button
className="bg-blue-500 text-white px-4 py-2 rounded mb-4"
onClick={async () => {
await getSeed();
await loadTrips();
}}
>
Seed-Daten erzeugen
</button>
{trips.map(trip => (
<div key={trip.id} className="border rounded p-2 mb-4">
<div className="flex justify-between items-center">
<div>
<h2 className="font-bold">{trip.name}</h2>
<p>
{trip.start_date} {trip.end_date}
</p>
</div>
<button
className="text-sm text-blue-500 underline"
onClick={() => loadItems(trip.id)}
>
Packliste anzeigen
</button>
</div>
{items[trip.id] && (
<ul className="mt-2">
{items[trip.id].map(item => (
<li key={item.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
onChange={async () => {
await toggleTripItem(item.id);
await loadItems(trip.id);
}}
/>
<span>{item.name_calculated}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,10 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {},
},
plugins: [],
}

12
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"jsx": "react-jsx",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}