feat(PROJ-9): implement labels frontend - LabelList, LabelPicker, search integration, admin UI

This commit is contained in:
sysops
2026-03-18 09:51:10 +01:00
parent 2e9f1f0471
commit cee75094ad
7 changed files with 923 additions and 1 deletions
+308
View File
@@ -0,0 +1,308 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getLabels,
createLabel,
updateLabel,
deleteLabel,
type MailLabel,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
const LABEL_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#6366f1",
"#a855f7",
"#ec4899",
];
interface LabelListProps {
selectedLabelId?: number | null;
onLabelSelect: (id: number | null) => void;
}
export function LabelList({ selectedLabelId, onLabelSelect }: LabelListProps) {
const [labels, setLabels] = useState<MailLabel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Create form
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(LABEL_COLORS[0]);
const [creating, setCreating] = useState(false);
// Edit state
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const [saving, setSaving] = useState(false);
// Hover state
const [hoveredId, setHoveredId] = useState<number | null>(null);
const loadLabels = useCallback(async () => {
try {
const data = await getLabels();
setLabels(data || []);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "Fehler beim Laden");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLabels();
}, [loadLabels]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
await createLabel(newName.trim(), newColor);
setNewName("");
setNewColor(LABEL_COLORS[0]);
setShowCreate(false);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Erstellen");
} finally {
setCreating(false);
}
}
async function handleUpdate(e: React.FormEvent) {
e.preventDefault();
if (!editId || !editName.trim()) return;
setSaving(true);
try {
await updateLabel(editId, editName.trim(), editColor);
setEditId(null);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Speichern");
} finally {
setSaving(false);
}
}
async function handleDelete(label: MailLabel) {
if (!window.confirm(`Label "${label.name}" wirklich loeschen?`)) return;
try {
await deleteLabel(label.id);
if (selectedLabelId === label.id) onLabelSelect(null);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Loeschen");
}
}
function startEdit(label: MailLabel) {
setEditId(label.id);
setEditName(label.name);
setEditColor(label.color);
}
if (loading) {
return (
<div className="space-y-2 p-2" aria-label="Labels laden">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
);
}
return (
<aside className="space-y-2" aria-label="Label-Filter">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Labels</h3>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Label list */}
<ul className="space-y-0.5">
{labels.map((label) => (
<li
key={label.id}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors ${
selectedLabelId === label.id
? "bg-muted font-medium"
: "hover:bg-muted/50"
}`}
onMouseEnter={() => setHoveredId(label.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() =>
onLabelSelect(selectedLabelId === label.id ? null : label.id)
}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onLabelSelect(selectedLabelId === label.id ? null : label.id);
}
}}
aria-pressed={selectedLabelId === label.id}
aria-label={`Label ${label.name}${label.is_global ? " (global)" : ""}`}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
<span className="truncate flex-1">{label.name}</span>
{label.is_global && (
<span className="text-xs text-muted-foreground" title="Globales Label" aria-label="Globales Label">
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</span>
)}
{!label.is_global && hoveredId === label.id && (
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
className="text-muted-foreground hover:text-foreground"
title="Bearbeiten"
aria-label={`Label ${label.name} bearbeiten`}
onClick={(e) => {
e.stopPropagation();
startEdit(label);
}}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
className="text-muted-foreground hover:text-destructive"
title="Loeschen"
aria-label={`Label ${label.name} loeschen`}
onClick={(e) => {
e.stopPropagation();
handleDelete(label);
}}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</span>
)}
</li>
))}
</ul>
{labels.length === 0 && !error && (
<p className="text-xs text-muted-foreground px-2">Keine Labels vorhanden.</p>
)}
{/* Edit inline form */}
{editId !== null && (
<form onSubmit={handleUpdate} className="space-y-2 rounded-md border p-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Label-Name"
className="h-7 text-sm"
autoFocus
aria-label="Label-Name bearbeiten"
/>
<div className="flex flex-wrap gap-1.5">
{LABEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-5 w-5 rounded-full border-2 transition-transform ${
editColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setEditColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
<div className="flex gap-1">
<Button type="submit" size="sm" className="h-7 text-xs" disabled={saving}>
{saving ? "..." : "Speichern"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setEditId(null)}
>
Abbrechen
</Button>
</div>
</form>
)}
{/* Create form */}
{showCreate ? (
<form onSubmit={handleCreate} className="space-y-2 rounded-md border p-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neues Label"
className="h-7 text-sm"
autoFocus
aria-label="Neuer Label-Name"
/>
<div className="flex flex-wrap gap-1.5">
{LABEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-5 w-5 rounded-full border-2 transition-transform ${
newColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setNewColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
<div className="flex gap-1">
<Button type="submit" size="sm" className="h-7 text-xs" disabled={creating || !newName.trim()}>
{creating ? "..." : "Erstellen"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setShowCreate(false)}
>
Abbrechen
</Button>
</div>
</form>
) : (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs text-muted-foreground h-7"
onClick={() => setShowCreate(true)}
>
+ Neues Label
</Button>
)}
</aside>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { assignLabel, removeLabelFromEmail, type MailLabel } from "@/lib/api";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
interface LabelPickerProps {
emailId: string;
assignedLabelIds: number[];
allLabels: MailLabel[];
onUpdate: () => void;
}
export function LabelPicker({
emailId,
assignedLabelIds,
allLabels,
onUpdate,
}: LabelPickerProps) {
const [open, setOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const assignedSet = new Set(assignedLabelIds);
async function toggleLabel(label: MailLabel) {
setActionLoading(label.id);
try {
if (assignedSet.has(label.id)) {
await removeLabelFromEmail(emailId, label.id);
} else {
await assignLabel(emailId, label.id);
}
onUpdate();
} catch (e) {
console.error("Label toggle failed:", e);
} finally {
setActionLoading(null);
}
}
const assignedLabels = allLabels.filter((l) => assignedSet.has(l.id));
return (
<div className="flex flex-wrap items-center gap-2" aria-label="E-Mail-Labels">
{assignedLabels.map((label) => (
<Badge
key={label.id}
variant="secondary"
className="gap-1.5 text-xs font-normal"
style={{
borderColor: label.color,
borderWidth: "1px",
borderStyle: "solid",
}}
>
<span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
{label.name}
</Badge>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
aria-label="Label hinzufuegen oder entfernen"
>
+ Label
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Labels zuweisen
</p>
{allLabels.length === 0 && (
<p className="text-xs text-muted-foreground py-2 text-center">
Keine Labels vorhanden.
</p>
)}
<ul className="space-y-0.5 max-h-60 overflow-y-auto">
{allLabels.map((label) => {
const isAssigned = assignedSet.has(label.id);
const isLoading = actionLoading === label.id;
return (
<li key={label.id}>
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors disabled:opacity-50"
onClick={() => toggleLabel(label)}
disabled={isLoading}
aria-pressed={isAssigned}
aria-label={`Label ${label.name} ${isAssigned ? "entfernen" : "zuweisen"}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
isAssigned
? "bg-primary border-primary text-primary-foreground"
: "border-muted-foreground/30"
}`}
>
{isAssigned && (
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</span>
<span
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
<span className="truncate">{label.name}</span>
{isLoading && (
<span className="ml-auto text-xs text-muted-foreground">...</span>
)}
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
</div>
);
}