feat: enhance ItemsPage and components with improved item management and tagging functionality

This commit is contained in:
Felix Zett 2025-08-17 21:36:02 +02:00
parent 56de7bb167
commit 4e451d751b
6 changed files with 401 additions and 217 deletions

View file

@ -1,21 +1,102 @@
import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom"; import React, { useState, useEffect } from "react";
import { ItemsPage } from "./pages/ItemsPage"; import { getSeed, getTrips, getTripItems, toggleTripItem } from "./api";
import { TripsPage } from "./pages/TripsPage"; // das ist dein bisheriger Inhalt import ItemsPage from "./pages/ItemsPage";
export default function App() { export default function App() {
return ( const [view, setView] = useState<"trips" | "items">("trips");
<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> const [trips, setTrips] = useState<any[]>([]);
<Route path="/" element={<TripsPage />} /> const [items, setItems] = useState<Record<string, any[]>>({});
<Route path="/items" element={<ItemsPage />} />
</Routes> 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(() => {
if (view === "trips") {
loadTrips();
}
}, [view]);
if (view === "items") {
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="mb-4">
<button
onClick={() => setView("trips")}
className="bg-gray-500 text-white px-3 py-1 rounded"
>
Zurück zu Trips
</button>
</div>
<ItemsPage />
</div> </div>
</Router> );
}
return (
<div className="p-4 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-4">Packlist</h1>
<div className="flex gap-2 mb-4">
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
onClick={async () => {
await getSeed();
await loadTrips();
}}
>
Seed-Daten erzeugen
</button>
<button
className="bg-green-500 text-white px-4 py-2 rounded"
onClick={() => setView("items")}
>
Alle Items
</button>
</div>
{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

@ -1,4 +1,15 @@
export const API_BASE = 'http://localhost:8000'; export interface Tag {
id: string;
name: string;
}
export interface Item {
id: string;
name: string;
tags: Tag[];
}
const API_BASE = "http://localhost:8000"; // ggf. anpassen
export async function getSeed() { export async function getSeed() {
return fetch(`${API_BASE}/dev/seed`).then(res => res.json()); return fetch(`${API_BASE}/dev/seed`).then(res => res.json());
@ -17,49 +28,52 @@ export async function toggleTripItem(tripItemId: string) {
} }
export interface Tag { export async function getItems(): Promise<Item[]> {
id: string; const res = await fetch(`${API_BASE}/items/`);
name: string; if (!res.ok) throw new Error("Failed to fetch items");
return res.json();
} }
export interface Item { export async function getTags(): Promise<Tag[]> {
id: string; const res = await fetch(`${API_BASE}/tags/`);
name: string; if (!res.ok) throw new Error("Failed to fetch tags");
tags: Tag[]; return res.json();
} }
export const api = { export async function updateItemName(itemId: string, name: string): Promise<Item> {
async getItems(): Promise<Item[]> { const res = await fetch(`${API_BASE}/items/${itemId}`, {
// später durch echten fetch ersetzen method: "PUT",
return [ headers: { "Content-Type": "application/json" },
{ id: "1", name: "Ladekabel Mac", tags: [{ id: "t1", name: "kristin" }] }, body: JSON.stringify({ name }),
{ });
id: "2", if (!res.ok) throw new Error("Failed to update item");
name: "{nights} Unterhosen", return res.json();
tags: [ }
{ id: "t2", name: "felix" },
{ id: "t1", name: "kristin" }, export async function deleteItemTag(itemId: string, tagId: string): Promise<Item> {
], const res = await fetch(`${API_BASE}/items/${itemId}/tags/${tagId}`, {
}, method: "DELETE",
]; });
}, if (!res.ok) throw new Error("Failed to delete tag from item");
async getTags(): Promise<Tag[]> { return res.json();
return [ }
{ id: "t1", name: "kristin" },
{ id: "t2", name: "felix" }, export async function addItemTag(itemId: string, tagName: string): Promise<Item> {
{ id: "t3", name: "sommer" }, const res = await fetch(`${API_BASE}/items/${itemId}/tags`, {
]; method: "POST",
}, headers: { "Content-Type": "application/json" },
async renameItem(id: string, newName: string) { body: JSON.stringify({ name: tagName }),
console.log("rename", id, newName); });
}, if (!res.ok) throw new Error("Failed to add tag to item");
async removeTag(itemId: string, tagId: string) { return res.json();
console.log("removeTag", itemId, tagId); }
},
async addTag(itemId: string, tagName: string) { export async function createItem(name: string, tags: string[]): Promise<Item> {
console.log("addTag", itemId, tagName); const res = await fetch(`${API_BASE}/items/`, {
}, method: "POST",
async addItem(name: string) { headers: { "Content-Type": "application/json" },
console.log("addItem", name); body: JSON.stringify({ name, tags }),
}, });
}; if (!res.ok) throw new Error("Failed to create item");
return res.json();
}

View file

@ -1,25 +1,38 @@
import { Item } from "../api"; import React from "react";
import { ItemRow } from "./ItemRow"; import { Item, Tag } from "../api";
import ItemRow from "./ItemRow";
interface Props { interface ItemListProps {
items: Item[]; items: Item[];
onRename: (id: string, newName: string) => void; allTags: Tag[];
onRemoveTag: (itemId: string, tagId: string) => void; onUpdateName: (id: string, name: string) => void;
onAddTag: (itemId: string, tagName: string) => void; onDeleteTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagId: string) => void;
} }
export function ItemList({ items, onRename, onRemoveTag, onAddTag }: Props) { export default function ItemList({
items,
allTags,
onUpdateName,
onDeleteTag,
onAddTag,
}: ItemListProps) {
if (items.length === 0) {
return <p className="text-gray-500 italic">Keine Items gefunden.</p>;
}
return ( return (
<div className="space-y-2"> <ul className="divide-y">
{items.map(item => ( {items.map((item) => (
<ItemRow <ItemRow
key={item.id} key={item.id}
item={item} item={item}
onRename={onRename} allTags={allTags}
onRemoveTag={onRemoveTag} onUpdateName={onUpdateName}
onDeleteTag={onDeleteTag}
onAddTag={onAddTag} onAddTag={onAddTag}
/> />
))} ))}
</div> </ul>
); );
} }

View file

@ -1,79 +1,134 @@
import { useState } from "react"; import React, { useState } from "react";
import { Item } from "../api"; import { Item, Tag } from "../api";
interface Props { interface ItemRowProps {
item: Item; item: Item;
onRename: (id: string, newName: string) => void; allTags: Tag[];
onRemoveTag: (itemId: string, tagId: string) => void; onUpdateName: (id: string, name: string) => void;
onAddTag: (itemId: string, tagName: string) => void; onDeleteTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagId: string) => void;
} }
export function ItemRow({ item, onRename, onRemoveTag, onAddTag }: Props) { export default function ItemRow({
const [editing, setEditing] = useState(false); item,
const [newName, setNewName] = useState(item.name); allTags,
onUpdateName,
onDeleteTag,
onAddTag,
}: ItemRowProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(item.name);
const [hover, setHover] = useState(false);
const [addingTag, setAddingTag] = useState(false); const [addingTag, setAddingTag] = useState(false);
const [newTag, setNewTag] = useState(""); const [newTagInput, setNewTagInput] = useState("");
const filteredSuggestions = allTags.filter(
(t) =>
t.name.toLowerCase().includes(newTagInput.toLowerCase()) &&
!item.tags.some((it) => it.id === t.id)
);
function handleSave() {
if (editName.trim() && editName !== item.name) {
onUpdateName(item.id, editName.trim());
}
setIsEditing(false);
}
return ( return (
<div className="flex items-center justify-between p-2 hover:bg-gray-50 rounded"> <li
{/* Name */} className="flex flex-wrap items-center gap-2 py-1 border-b"
<div className="flex-1"> onMouseEnter={() => setHover(true)}
{editing ? ( onMouseLeave={() => {
<input setHover(false);
value={newName} setAddingTag(false);
onChange={e => setNewName(e.target.value)} setNewTagInput("");
onBlur={() => { }}
setEditing(false); >
if (newName !== item.name) onRename(item.id, newName); {isEditing ? (
}} <input
className="border-b border-gray-400 outline-none" className="border rounded px-1 py-0.5"
autoFocus value={editName}
/> onChange={(e) => setEditName(e.target.value)}
) : ( onBlur={handleSave}
<span onClick={() => setEditing(true)} className="cursor-pointer"> onKeyDown={(e) => {
{item.name} if (e.key === "Enter") handleSave();
</span> if (e.key === "Escape") {
)} setEditName(item.name);
</div> setIsEditing(false);
}
}}
autoFocus
/>
) : (
<span
className="cursor-pointer"
onClick={() => setIsEditing(true)}
>
{item.name}
</span>
)}
{/* Tags */} {item.tags.map((tag) => (
<div className="flex gap-2 items-center"> <span
{item.tags.map(tag => ( key={tag.id}
<span className="bg-gray-200 text-sm rounded px-1 py-0.5 flex items-center"
key={tag.id} >
className="bg-gray-200 px-2 py-1 rounded-full text-sm relative group" #{tag.name}
> {hover && (
#{tag.name}
<button <button
onClick={() => onRemoveTag(item.id, tag.id)} onClick={() => onDeleteTag(item.id, tag.id)}
className="ml-1 text-xs text-red-600 opacity-0 group-hover:opacity-100" className="ml-1 text-xs text-red-500 hover:text-red-700"
> >
×
</button> </button>
</span> )}
))} </span>
))}
{addingTag ? ( {hover && !addingTag && (
<button
className="text-xs text-blue-500 hover:underline"
onClick={() => setAddingTag(true)}
>
+ Tag
</button>
)}
{addingTag && (
<div className="relative">
<input <input
value={newTag} className="border rounded px-1 py-0.5"
onChange={e => setNewTag(e.target.value)} placeholder="Tag..."
onBlur={() => { value={newTagInput}
if (newTag.trim()) onAddTag(item.id, newTag.trim()); onChange={(e) => setNewTagInput(e.target.value)}
setAddingTag(false);
setNewTag("");
}}
className="border-b w-20 outline-none"
autoFocus autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
setAddingTag(false);
setNewTagInput("");
}
}}
/> />
) : ( {newTagInput && filteredSuggestions.length > 0 && (
<button <ul className="absolute bg-white border rounded mt-1 shadow-md z-10">
onClick={() => setAddingTag(true)} {filteredSuggestions.map((tag) => (
className="text-gray-400 hover:text-gray-600" <li
> key={tag.id}
className="px-2 py-1 hover:bg-gray-100 cursor-pointer"
</button> onMouseDown={() => {
)} onAddTag(item.id, tag.id);
</div> setAddingTag(false);
</div> setNewTagInput("");
}}
>
#{tag.name}
</li>
))}
</ul>
)}
</div>
)}
</li>
); );
} }

View file

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

View file

@ -1,92 +1,109 @@
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { SearchBar } from "../components/SearchBar"; import {
import { TagFilter } from "../components/TagFilter"; getItems,
import { ItemList } from "../components/ItemList"; getTags,
import { api, Item, Tag } from "../api"; createItem,
updateItemName,
deleteItemTag,
addItemTag,
Item,
Tag,
} from "../api";
import ItemList from "../components/ItemList";
import TagFilter from "../components/TagFilter";
export function ItemsPage() { export default function ItemsPage() {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [filterText, setFilterText] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
async function loadData() {
setLoading(true);
try {
const [itemsData, tagsData] = await Promise.all([getItems(), getTags()]);
setItems(itemsData);
setTags(tagsData);
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
api.getItems().then(setItems); loadData();
api.getTags().then(setTags);
}, []); }, []);
const handleRename = (id: string, newName: string) => { function handleTagToggle(tagId: string) {
setItems(prev => setSelectedTags((prev) =>
prev.map(item => (item.id === id ? { ...item, name: newName } : item)) prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
); );
api.renameItem(id, newName); }
};
const handleRemoveTag = (itemId: string, tagId: string) => { async function handleAddItem(name: string, tagNames: string[]) {
setItems(prev => const newItem = await createItem(name, tagNames);
prev.map(item => setItems((prev) => [...prev, newItem]);
item.id === itemId }
? { ...item, tags: item.tags.filter(t => t.id !== tagId) }
: item
)
);
api.removeTag(itemId, tagId);
};
const handleAddTag = (itemId: string, tagName: string) => { async function handleRenameItem(itemId: string, name: string) {
const newTag: Tag = { id: crypto.randomUUID(), name: tagName }; const updated = await updateItemName(itemId, name);
setItems(prev => setItems((prev) => prev.map((it) => (it.id === itemId ? updated : it)));
prev.map(item => }
item.id === itemId ? { ...item, tags: [...item.tags, newTag] } : item
)
);
api.addTag(itemId, tagName);
};
const handleAddItem = (name: string) => { async function handleDeleteTag(itemId: string, tagId: string) {
const newItem: Item = { id: crypto.randomUUID(), name, tags: [] }; const updated = await deleteItemTag(itemId, tagId);
setItems(prev => [...prev, newItem]); setItems((prev) => prev.map((it) => (it.id === itemId ? updated : it)));
api.addItem(name); }
};
// Filtering async function handleAddTag(itemId: string, tagName: string) {
const filtered = items.filter(item => { const updated = await addItemTag(itemId, tagName);
const matchSearch = item.name setItems((prev) => prev.map((it) => (it.id === itemId ? updated : it)));
.toLowerCase() }
.includes(searchQuery.toLowerCase());
const matchTags = // Filtern
const filteredItems = items.filter((item) => {
const matchesText =
filterText === "" ||
item.name.toLowerCase().includes(filterText.toLowerCase());
const matchesTags =
selectedTags.length === 0 || selectedTags.length === 0 ||
selectedTags.some(tag => item.tags.some((t) => selectedTags.includes(t.id));
item.tags.map(t => t.name).includes(tag)
); return matchesText && matchesTags;
return matchSearch && matchTags;
}); });
return ( return (
<div className="p-6 space-y-6"> <div className="p-4 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold">Items</h1> <h1 className="text-2xl font-bold mb-4">Items</h1>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<div className="mb-4 flex items-center gap-2">
<input
type="text"
placeholder="🔍 Suchen..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
className="border rounded px-2 py-1 w-full"
/>
</div>
<TagFilter <TagFilter
tags={tags} tags={tags}
selected={selectedTags} selected={selectedTags}
onToggle={tag => onToggle={handleTagToggle}
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
)
}
/> />
<ItemList
items={filtered} {loading ? (
onRename={handleRename} <p>Lade...</p>
onRemoveTag={handleRemoveTag} ) : (
onAddTag={handleAddTag} <ItemList
/> items={filteredItems}
<button allTags={tags}
onClick={() => handleAddItem("Neues Item")} onUpdateName={handleRenameItem}
className="px-4 py-2 bg-blue-500 text-white rounded-lg" onDeleteTag={handleDeleteTag}
> onAddTag={handleAddTag}
+ Neues Item />
</button> )}
</div> </div>
); );
} }