refactor tags: create Tag component and update usages across ItemRow, TagAutocompleteInput, ItemsPage, TagsPage, and TripChecklist
This commit is contained in:
parent
b2099e9bbb
commit
16dbd44831
6 changed files with 109 additions and 91 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
52
frontend/src/components/Tag.tsx
Normal file
52
frontend/src/components/Tag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue