refactor tags: create Tag component and update usages across ItemRow, TagAutocompleteInput, ItemsPage, TagsPage, and TripChecklist

This commit is contained in:
Felix Zett 2025-09-21 15:52:00 +02:00
parent b2099e9bbb
commit 16dbd44831
6 changed files with 109 additions and 91 deletions

View file

@ -1,11 +1,11 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef } from "react";
import { Item, Tag } from "../api"; import { Item, Tag as TagType } from "../api";
import { getTagColor } from "../utils/tagColors";
import TagAutocompleteInput from "./TagAutocompleteInput"; import TagAutocompleteInput from "./TagAutocompleteInput";
import Tag from "./Tag";
interface ItemRowProps { interface ItemRowProps {
item: Item; item: Item;
allTags: Tag[]; allTags: TagType[];
onUpdateName: (id: string, name: string) => void; onUpdateName: (id: string, name: string) => void;
onDeleteTag: (itemId: string, tagId: string) => void; onDeleteTag: (itemId: string, tagId: string) => void;
onAddTag: (itemId: string, tagId: string) => void; onAddTag: (itemId: string, tagId: string) => void;
@ -34,7 +34,6 @@ export default function ItemRow({
setIsEditing(false); setIsEditing(false);
} }
// --- Dim category part if "/" exists ---
function renderNameWithCategory(name: string) { function renderNameWithCategory(name: string) {
if (!name.includes("/")) return name; if (!name.includes("/")) return name;
const [cat, rest] = name.split("/", 2); const [cat, rest] = name.split("/", 2);
@ -46,7 +45,6 @@ export default function ItemRow({
); );
} }
// Trip-Namen lookup
const tripName = const tripName =
item.trip_id && trips item.trip_id && trips
? trips.find((t) => t.id === item.trip_id)?.name ? trips.find((t) => t.id === item.trip_id)?.name
@ -58,7 +56,6 @@ export default function ItemRow({
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => { onMouseLeave={() => {
setHover(false); setHover(false);
// Only close add tag input if not focused
if ( if (
!addingTag || !addingTag ||
(inputRef.current && document.activeElement !== inputRef.current) (inputRef.current && document.activeElement !== inputRef.current)
@ -92,29 +89,22 @@ export default function ItemRow({
)} )}
{item.tags.map((tag) => ( {item.tags.map((tag) => (
<span <Tag
key={tag.id} key={tag.id}
className={ tag={tag}
`${getTagColor(tag.id).bg} text-sm rounded px-1 py-0.5 flex items-center font-medium` isMarked={false}
} hoveredTag={null}
> onRemoveTag={() => onDeleteTag(item.id, tag.id)}
#{tag.name} showRemove={hover}
{tag.mandatory && <span className="text-red-500 font-bold ml-0.5">!</span>} />
{hover && (
<button
onClick={() => onDeleteTag(item.id, tag.id)}
className="ml-1 text-xs text-red-500 hover:text-red-700"
>
×
</button>
)}
</span>
))} ))}
{tripName && ( {tripName && (
<span className="ml-2 px-2 py-0.5 rounded bg-gray-100 text-gray-800 text-sm font-semibold"> <Tag
{tripName} tag={{ id: "trip", name: tripName }}
</span> className="bg-yellow-100 text-yellow-900"
// No remove, no mark, just display
/>
)} )}
{hover && !addingTag && ( {hover && !addingTag && (
@ -149,7 +139,6 @@ export default function ItemRow({
/> />
)} )}
{/* Löschen-Button am Ende der Zeile */}
<span className="ml-auto"> <span className="ml-auto">
{hover && ( {hover && (
<button <button

View file

@ -0,0 +1,52 @@
import React from "react";
interface TagProps {
tag: { id: string; name: string; mandatory?: boolean };
isMarked?: boolean;
hoveredTag?: string | null;
onToggleMark?: (id: string) => void;
onRemoveTag?: (id: string) => void;
onMouseEnter?: (id: string) => void;
onMouseLeave?: () => void;
showRemove?: boolean;
className?: string;
}
export default function Tag({
tag,
isMarked,
hoveredTag,
onToggleMark,
onRemoveTag,
onMouseEnter,
onMouseLeave,
showRemove,
className = "bg-purple-50 text-purple-900",
}: TagProps) {
return (
<span
key={tag.id}
className={
`relative px-2 py-0.5 rounded mr-1 text-xs shadow shadow-md cursor-pointer transition font-medium ` +
(isMarked ? "ring-2 ring-yellow-400 " : "") +
(tag.mandatory ? "bg-purple-200 text-purple-900" : className )
}
onClick={() => onToggleMark && onToggleMark(tag.id)}
onMouseEnter={() => onMouseEnter && onMouseEnter(tag.id)}
onMouseLeave={() => onMouseLeave && onMouseLeave()}
>
{tag.name}
{showRemove && onRemoveTag && (
<button
className="ml-1 text-xs text-red-500 hover:text-red-700"
onClick={e => {
e.stopPropagation();
onRemoveTag(tag.id);
}}
>
×
</button>
)}
</span>
);
}

View file

@ -1,9 +1,10 @@
import React, { useState, useImperativeHandle, forwardRef, useRef } from "react"; import React, { useState, useImperativeHandle, forwardRef, useRef } from "react";
import { Tag } from "../api"; import { Tag as TagType } from "../api";
import Tag from "./Tag";
interface TagAutocompleteInputProps { interface TagAutocompleteInputProps {
allTags: Tag[]; allTags: TagType[];
selectedTags: Tag[]; selectedTags: TagType[];
onAddTag: (tagId: string, viaTab?: boolean) => void; onAddTag: (tagId: string, viaTab?: boolean) => void;
onEscape?: () => void; onEscape?: () => void;
placeholder?: string; placeholder?: string;
@ -86,14 +87,19 @@ const TagAutocompleteInput = forwardRef<HTMLInputElement, TagAutocompleteInputPr
</div> </div>
)} )}
{filteredSuggestions.length > 0 && ( {filteredSuggestions.length > 0 && (
<ul className="absolute left-0 top-full mt-1 bg-white border rounded shadow z-10 w-full max-h-40 overflow-auto"> <ul className="absolute left-0 top-full mt-1 bg-white border rounded shadow z-10 w-full max-h-40 overflow-auto flex flex-wrap gap-2 p-2">
{filteredSuggestions.map((tag, idx) => ( {filteredSuggestions.map((tag, idx) => (
<li <li
key={tag.id} key={tag.id}
className={ className={
"px-3 py-1 cursor-pointer flex items-center " + "cursor-pointer " +
(idx === autocompleteIndex ? "bg-blue-100 font-bold" : "") (idx === autocompleteIndex ? "bg-blue-100 font-bold" : "")
} }
style={{
display: "inline-block",
margin: "2px 4px",
borderRadius: "0.5rem",
}}
onMouseDown={() => { onMouseDown={() => {
onAddTag(tag.id); onAddTag(tag.id);
setInput(""); setInput("");
@ -101,10 +107,12 @@ const TagAutocompleteInput = forwardRef<HTMLInputElement, TagAutocompleteInputPr
}} }}
onMouseEnter={() => setAutocompleteIndex(idx)} onMouseEnter={() => setAutocompleteIndex(idx)}
> >
#{tag.name} <Tag
{tag.mandatory && ( tag={tag}
<span className="text-red-500 font-bold ml-1">!</span> className={
)} (idx === autocompleteIndex ? "ring-2 ring-blue-400" : "")
}
/>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -12,7 +12,7 @@ import {
} from "../api"; } from "../api";
import ItemList from "../components/ItemList"; import ItemList from "../components/ItemList";
import TagFilter from "../components/TagFilter"; import TagFilter from "../components/TagFilter";
import { getTagColor } from "../utils/tagColors"; import Tag from "../components/Tag";
async function fetchTrips(): Promise<{ id: string; name: string }[]> { async function fetchTrips(): Promise<{ id: string; name: string }[]> {
const res = await fetch("http://localhost:8000/trips/"); const res = await fetch("http://localhost:8000/trips/");
@ -287,18 +287,12 @@ export default function ItemsPage() {
{!newItemTripId && ( {!newItemTripId && (
<div className="flex flex-wrap gap-2 bg-white rounded "> <div className="flex flex-wrap gap-2 bg-white rounded ">
{tags.map((tag) => ( {tags.map((tag) => (
<span <Tag
key={tag.id} key={tag.id}
className={ tag={tag}
`${getTagColor(tag.id).bg} text-sm rounded px-1 py-0.5 flex items-center cursor-pointer font-medium` + isMarked={newItemTags.includes(tag.id)}
(newItemTags.includes(tag.id) ? " ring-2 ring-blue-400 font-bold" : "") onToggleMark={toggleNewItemTag}
} />
onClick={() => toggleNewItemTag(tag.id)}
title={tag.mandatory ? "Pflicht-Tag (mandatory)" : ""}
>
#{tag.name}
{tag.mandatory && <span className="text-red-500 font-bold ml-0.5">!</span>}
</span>
))} ))}
</div> </div>
)} )}

View file

@ -1,12 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { getTags } from "../api"; import { getTags } from "../api";
import type { Tag } from "../api"; import Tag from "../components/Tag";
import { getTagColor } from "../utils/tagColors";
const API_BASE = "http://localhost:8000"; const API_BASE = "http://localhost:8000";
export default function TagsPage() { export default function TagsPage() {
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<any[]>([]);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editMandatory, setEditMandatory] = useState(false); const [editMandatory, setEditMandatory] = useState(false);
@ -15,7 +14,6 @@ export default function TagsPage() {
async function loadTags() { async function loadTags() {
const loaded = await getTags(); const loaded = await getTags();
// Alphabetisch sortieren
setTags(loaded.sort((a, b) => a.name.localeCompare(b.name))); setTags(loaded.sort((a, b) => a.name.localeCompare(b.name)));
} }
@ -128,20 +126,14 @@ export default function TagsPage() {
className="flex items-center group" className="flex items-center group"
style={{ marginBottom: "0.5rem" }} style={{ marginBottom: "0.5rem" }}
> >
<span <Tag
className={ tag={tag}
`${getTagColor(tag.id).bg} px-4 py-2 rounded-xl text-lg cursor-pointer font-semibold`
}
onClick={() => { onClick={() => {
setEditingId(tag.id); setEditingId(tag.id);
setEditName(tag.name); setEditName(tag.name);
setEditMandatory(tag.mandatory); setEditMandatory(tag.mandatory);
}} }}
title="Bearbeiten" />
>
#{tag.name}
{tag.mandatory && <span className="text-red-500 font-bold ml-1">!</span>}
</span>
</li> </li>
) )
)} )}

View file

@ -3,7 +3,8 @@ import React, { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api"; import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput"; import TagAutocompleteInput from "../components/TagAutocompleteInput";
import { getTagColor } from "../utils/tagColors"; import TripTag from "../components/Tag";
import Tag from "../components/Tag";
export default function TripChecklist({ trips }: { trips: any[] }) { export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams(); const { id } = useParams();
@ -282,36 +283,18 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
{selectedTags.length === 0 ? ( {selectedTags.length === 0 ? (
<span className="text-gray-400 text-sm">keine</span> <span className="text-gray-400 text-sm">keine</span>
) : ( ) : (
selectedTags.map((tag: any) => { selectedTags.map((tag: any) => (
const isMarked = markedTags.some((mt: any) => mt.id === tag.id); <Tag
const color = getTagColor(tag.id);
return (
<span
key={tag.id} key={tag.id}
className={ tag={tag}
`relative px-2 py-0.5 rounded mr-1 text-xs shadow shadow-md cursor-pointer transition font-medium ${color.bg} ` + isMarked={markedTags.some((mt: any) => mt.id === tag.id)}
(isMarked ? "ring-2 ring-yellow-400" : "") hoveredTag={hoveredTag}
} onToggleMark={handleToggleMark}
onClick={() => handleToggleMark(tag.id)} onRemoveTag={handleRemoveTag}
onMouseEnter={() => setHoveredTag(tag.id)} onMouseEnter={setHoveredTag}
onMouseLeave={() => setHoveredTag(null)} onMouseLeave={() => setHoveredTag(null)}
> />
#{tag.name} ))
{tag.mandatory && <span className="text-red-500 font-bold ml-0.5">!</span>}
{hoveredTag === tag.id && (
<button
className="absolute -top-2 -right-2 text-xs text-red-500 bg-white rounded-full px-1 shadow"
onClick={e => {
e.stopPropagation();
handleRemoveTag(tag.id);
}}
>
×
</button>
)}
</span>
);
})
)} )}
{/* "+Tag" link and dropdown */} {/* "+Tag" link and dropdown */}
{!addingTag ? ( {!addingTag ? (