geraete/components/ui/Feed.tsx
2025-11-18 14:44:36 +01:00

352 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/ui/Feed.tsx
'use client';
import * as React from 'react';
import {
TagIcon,
PlusIcon,
TrashIcon,
PencilIcon
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
/* ───────── Types ───────── */
export type FeedPerson = {
name: string;
href?: string;
imageUrl?: string; // optional: Avatar-Bild
};
export type FeedTag = {
name: string;
href?: string;
/** z.B. 'fill-red-500' */
color?: string;
};
export type FeedChange = {
field: string;
/** Anzeigename, z.B. "Standort" statt "location" */
label?: string;
from: string | null;
to: string | null;
};
export type FeedItem =
| {
id: string | number;
type: 'comment';
person: FeedPerson;
imageUrl?: string;
comment: string;
date: string;
/** Art des Kommentars steuert Icon/Farbe */
commentKind?: 'created' | 'deleted' | 'generic';
}
| {
id: string | number;
type: 'assignment';
person: FeedPerson;
assigned: FeedPerson;
date: string;
}
| {
id: string | number;
type: 'tags';
person: FeedPerson;
tags: FeedTag[];
date: string;
}
| {
id: string | number;
type: 'change';
person: FeedPerson;
changes: FeedChange[];
date: string;
};
export interface FeedProps {
items: FeedItem[];
className?: string;
}
/* ───────── Helper ───────── */
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
// deterministische Farbe aus dem Namen
function colorFromName(name: string): string {
const palette = [
'bg-sky-500',
'bg-emerald-500',
'bg-violet-500',
'bg-amber-500',
'bg-rose-500',
'bg-indigo-500',
'bg-teal-500',
'bg-fuchsia-500',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
const idx = Math.abs(hash) % palette.length;
return palette[idx];
}
// sprechende Zusammenfassung für "change"
function getChangeSummary(
item: Extract<FeedItem, { type: 'change' }>,
): React.ReactNode {
const { changes } = item;
if (!changes.length) return 'hat Änderungen vorgenommen';
// Sonderfall: nur Tags
if (changes.length === 1 && changes[0].field === 'tags') {
const c = changes[0];
const beforeList = (c.from ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const afterList = (c.to ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const beforeLower = beforeList.map((x) => x.toLowerCase());
const afterLower = afterList.map((x) => x.toLowerCase());
const added = afterList.filter(
(t) => !beforeLower.includes(t.toLowerCase()),
);
const removed = beforeList.filter(
(t) => !afterLower.includes(t.toLowerCase()),
);
const parts: string[] = [];
if (added.length) parts.push(`hinzugefügt: ${added.join(', ')}`);
if (removed.length) parts.push(`entfernt: ${removed.join(', ')}`);
if (parts.length > 0) {
return (
<>
hat Tags <em>{parts.join(' · ')}</em>
</>
);
}
return 'hat Tags angepasst';
}
if (changes.length === 1) {
const c = changes[0];
const label = c.label ?? c.field;
return (
<>
hat <em>{label}</em> geändert
</>
);
}
const labels = changes.map((c) => c.label ?? c.field);
const uniqueLabels = Array.from(new Set(labels));
const maxShow = 3;
if (uniqueLabels.length <= maxShow) {
return (
<>
hat{' '}
{uniqueLabels.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < uniqueLabels.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
geändert
</>
);
}
const first = uniqueLabels.slice(0, maxShow);
return (
<>
hat{' '}
{first.map((label, index) => (
<React.Fragment key={label}>
<em>{label}</em>
{index < first.length - 1 ? ', ' : ' '}
</React.Fragment>
))}
und weitere geändert
</>
);
}
/* ───────── Component ───────── */
export default function Feed({ items, className }: FeedProps) {
if (!items.length) {
return (
<p
className={clsx(
'text-sm text-gray-500 dark:text-gray-400',
className,
)}
>
Keine Aktivitäten vorhanden.
</p>
);
}
return (
<ul
role="list"
className={clsx('pb-4', className)}
>
{items.map((item, idx) => {
// Icon + Hintergrund
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
PencilIcon;
let iconBg = 'bg-gray-400 dark:bg-gray-600';
if (item.type === 'tags') {
Icon = TagIcon;
iconBg = 'bg-amber-500';
} else if (item.type === 'change') {
const isTagsOnly =
item.changes.length === 1 && item.changes[0].field === 'tags';
Icon = isTagsOnly ? TagIcon : PencilIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-cyan-500';
} else if (item.type === 'comment') {
if (item.commentKind === 'created') {
Icon = PlusIcon;
iconBg = 'bg-emerald-500';
} else if (item.commentKind === 'deleted') {
Icon = TrashIcon;
iconBg = 'bg-rose-500';
} else {
iconBg = colorFromName(item.person.name);
}
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
// Textinhalt (content) dein bisheriger Code unverändert:
let content: React.ReactNode = null;
if (item.type === 'comment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
<span className="text-gray-300 dark:text-gray-200">
{item.comment}
</span>
</p>
);
} else if (item.type === 'assignment') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat{' '}
<span className="font-medium text-gray-900 dark:text-white">
{item.assigned.name}
</span>{' '}
zugewiesen.
</p>
);
} else if (item.type === 'tags') {
content = (
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
hat Tags hinzugefügt:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{item.tags.map((t) => t.name).join(', ')}
</span>
</p>
);
} else if (item.type === 'change') {
const summary = getChangeSummary(item);
content = (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{item.person.name}
</span>{' '}
{summary}
</p>
{item.changes.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-gray-500">
{item.changes.slice(0, 2).map((c, i) => (
<span
key={`${c.field}-${i}`}
className="flex flex-wrap items-baseline gap-x-1"
>
<span className="line-through text-red-500/80 dark:text-red-400/90">
{c.from ?? '—'}
</span>
<span className="text-gray-400"></span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{c.to ?? '—'}
</span>
{i < Math.min(2, item.changes.length) - 1 && (
<span className="mx-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</span>
))}
{item.changes.length > 2 && (
<span className="ml-1 text-gray-500 dark:text-gray-600">
·
</span>
)}
</p>
)}
</div>
);
}
return (
<li key={item.id}>
<div className="relative pb-6">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
/>
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
iconBg,
'flex size-8 items-center justify-center rounded-full',
)}
>
<Icon aria-hidden="true" className="size-4 text-white" />
</span>
</div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>{content}</div>
<div className="whitespace-nowrap text-right text-[11px] text-gray-500 dark:text-gray-400">
{item.date}
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
);
}