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 { ItemsPage } from "./pages/ItemsPage";
import { TripsPage } from "./pages/TripsPage"; // das ist dein bisheriger Inhalt
import React, { useState, useEffect } from "react";
import { getSeed, getTrips, getTripItems, toggleTripItem } from "./api";
import ItemsPage from "./pages/ItemsPage";
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>
const [view, setView] = useState<"trips" | "items">("trips");
<Routes>
<Route path="/" element={<TripsPage />} />
<Route path="/items" element={<ItemsPage />} />
</Routes>
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(() => {
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>
);
}
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>
</Router>
);
}

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() {
return fetch(`${API_BASE}/dev/seed`).then(res => res.json());
@ -17,49 +28,52 @@ export async function toggleTripItem(tripItemId: string) {
}
export interface Tag {
id: string;
name: string;
export async function getItems(): Promise<Item[]> {
const res = await fetch(`${API_BASE}/items/`);
if (!res.ok) throw new Error("Failed to fetch items");
return res.json();
}
export interface Item {
id: string;
name: string;
tags: Tag[];
export async function getTags(): Promise<Tag[]> {
const res = await fetch(`${API_BASE}/tags/`);
if (!res.ok) throw new Error("Failed to fetch tags");
return res.json();
}
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);
},
};
export async function updateItemName(itemId: string, name: string): Promise<Item> {
const res = await fetch(`${API_BASE}/items/${itemId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error("Failed to update item");
return res.json();
}
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");
return res.json();
}
export async function addItemTag(itemId: string, tagName: string): Promise<Item> {
const res = await fetch(`${API_BASE}/items/${itemId}/tags`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: tagName }),
});
if (!res.ok) throw new Error("Failed to add tag to item");
return res.json();
}
export async function createItem(name: string, tags: string[]): Promise<Item> {
const res = await fetch(`${API_BASE}/items/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
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 { ItemRow } from "./ItemRow";
import React from "react";
import { Item, Tag } from "../api";
import ItemRow from "./ItemRow";
interface Props {
interface ItemListProps {
items: Item[];
onRename: (id: string, newName: string) => void;
onRemoveTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagName: string) => void;
allTags: Tag[];
onUpdateName: (id: string, name: string) => void;
onDeleteTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagId: string) => void;
}
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>;
}
export function ItemList({ items, onRename, onRemoveTag, onAddTag }: Props) {
return (
<div className="space-y-2">
{items.map(item => (
<ul className="divide-y">
{items.map((item) => (
<ItemRow
key={item.id}
item={item}
onRename={onRename}
onRemoveTag={onRemoveTag}
allTags={allTags}
onUpdateName={onUpdateName}
onDeleteTag={onDeleteTag}
onAddTag={onAddTag}
/>
))}
</div>
</ul>
);
}

View file

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

View file

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

View file

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