diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a68d7f3..a64a732 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - -
- + const [view, setView] = useState<"trips" | "items">("trips"); - - } /> - } /> - + const [trips, setTrips] = useState([]); + const [items, setItems] = useState>({}); + + 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 ( +
+
+ +
+
- + ); + } + + return ( +
+

Packlist

+ +
+ + +
+ + {trips.map((trip) => ( +
+
+
+

{trip.name}

+

+ {trip.start_date} – {trip.end_date} +

+
+ +
+ {items[trip.id] && ( +
    + {items[trip.id].map((item) => ( +
  • + { + await toggleTripItem(item.id); + await loadItems(trip.id); + }} + /> + {item.name_calculated} +
  • + ))} +
+ )} +
+ ))} +
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ed7a5bf..18cea36 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + 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 { + 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 { - // 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 { - 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/components/ItemList.tsx b/frontend/src/components/ItemList.tsx index 3fa02e9..acc2f27 100644 --- a/frontend/src/components/ItemList.tsx +++ b/frontend/src/components/ItemList.tsx @@ -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 function ItemList({ items, onRename, onRemoveTag, onAddTag }: Props) { +export default function ItemList({ + items, + allTags, + onUpdateName, + onDeleteTag, + onAddTag, +}: ItemListProps) { + if (items.length === 0) { + return

Keine Items gefunden.

; + } + return ( -
- {items.map(item => ( +
    + {items.map((item) => ( ))} -
+ ); } diff --git a/frontend/src/components/ItemRow.tsx b/frontend/src/components/ItemRow.tsx index de05bd9..6fce0dd 100644 --- a/frontend/src/components/ItemRow.tsx +++ b/frontend/src/components/ItemRow.tsx @@ -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 ( -
- {/* Name */} -
- {editing ? ( - setNewName(e.target.value)} - onBlur={() => { - setEditing(false); - if (newName !== item.name) onRename(item.id, newName); - }} - className="border-b border-gray-400 outline-none" - autoFocus - /> - ) : ( - setEditing(true)} className="cursor-pointer"> - {item.name} - - )} -
+
  • setHover(true)} + onMouseLeave={() => { + setHover(false); + setAddingTag(false); + setNewTagInput(""); + }} + > + {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditName(item.name); + setIsEditing(false); + } + }} + autoFocus + /> + ) : ( + setIsEditing(true)} + > + {item.name} + + )} - {/* Tags */} -
    - {item.tags.map(tag => ( - - #{tag.name} + {item.tags.map((tag) => ( + + #{tag.name} + {hover && ( - - ))} + )} + + ))} - {addingTag ? ( + {hover && !addingTag && ( + + )} + + {addingTag && ( +
    setNewTag(e.target.value)} - onBlur={() => { - if (newTag.trim()) onAddTag(item.id, newTag.trim()); - setAddingTag(false); - setNewTag(""); - }} - className="border-b w-20 outline-none" + 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 && ( +
      + {filteredSuggestions.map((tag) => ( +
    • { + onAddTag(item.id, tag.id); + setAddingTag(false); + setNewTagInput(""); + }} + > + #{tag.name} +
    • + ))} +
    + )} +
  • + )} + ); } diff --git a/frontend/src/components/TagFilter.tsx b/frontend/src/components/TagFilter.tsx index b5ad139..e151bc3 100644 --- a/frontend/src/components/TagFilter.tsx +++ b/frontend/src/components/TagFilter.tsx @@ -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 ( -
    - {tags.map(tag => ( - - ))} +
    + {tags.map((tag) => { + const isSelected = selected.includes(tag.id); + return ( + + ); + })}
    ); } diff --git a/frontend/src/pages/ItemsPage.tsx b/frontend/src/pages/ItemsPage.tsx index 85dad4e..e66a3a1 100644 --- a/frontend/src/pages/ItemsPage.tsx +++ b/frontend/src/pages/ItemsPage.tsx @@ -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([]); const [tags, setTags] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); + const [filterText, setFilterText] = useState(""); const [selectedTags, setSelectedTags] = useState([]); + 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 ( -
    -

    Items

    - +
    +

    Items

    + +
    + setFilterText(e.target.value)} + className="border rounded px-2 py-1 w-full" + /> +
    + - setSelectedTags(prev => - prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag] - ) - } + onToggle={handleTagToggle} /> - - + + {loading ? ( +

    Lade...

    + ) : ( + + )}
    ); }