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:
parent
686d22871b
commit
56de7bb167
19 changed files with 3142 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
**/__pycache__/
|
||||
**/__pycache__/
|
||||
/*.zip
|
||||
node_modules/
|
||||
|
|
@ -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
16
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
2635
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
21
frontend/src/App.tsx
Normal file
21
frontend/src/App.tsx
Normal 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
65
frontend/src/api.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
25
frontend/src/components/ItemList.tsx
Normal file
25
frontend/src/components/ItemList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/ItemRow.tsx
Normal file
79
frontend/src/components/ItemRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/SearchBar.tsx
Normal file
16
frontend/src/components/SearchBar.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/TagFilter.tsx
Normal file
27
frontend/src/components/TagFilter.tsx
Normal 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
3
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
92
frontend/src/pages/ItemsPage.tsx
Normal file
92
frontend/src/pages/ItemsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/pages/TripsPage.tsx
Normal file
73
frontend/src/pages/TripsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
12
frontend/tsconfig.json
Normal file
12
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue