geraete/components/ui/Feed.tsx
2025-11-17 15:26:43 +01:00

310 lines
9.4 KiB
TypeScript

// components/ui/Feed.tsx
'use client';
import * as React from 'react';
import {
ChatBubbleLeftEllipsisIcon,
TagIcon,
} 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;
}
| {
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' }>): string {
const { changes } = item;
if (!changes.length) return 'hat Änderungen vorgenommen';
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 ${parts.join(' · ')}`;
}
return 'hat Tags angepasst';
}
if (changes.length === 1) {
const c = changes[0];
const label = c.label ?? c.field;
return `hat ${label} 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.join(', ')} geändert`;
}
const first = uniqueLabels.slice(0, maxShow).join(', ');
return `hat ${first} 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 (
<div className={clsx('h-full overflow-y-auto pr-2', className)}>
<ul role="list" className="pb-4">
{items.map((item, idx) => {
// Icon + Hintergrund ähnlich wie im Beispiel
let Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> =
ChatBubbleLeftEllipsisIcon;
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 : ChatBubbleLeftEllipsisIcon;
iconBg = isTagsOnly ? 'bg-amber-500' : 'bg-emerald-500';
} else if (item.type === 'comment') {
iconBg = colorFromName(item.person.name);
} else if (item.type === 'assignment') {
iconBg = 'bg-indigo-500';
}
// Textinhalt ähnlich wie "content + target"
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>{' '}
hat kommentiert:{' '}
<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">
{/* Icon-Kreis wie im Beispiel */}
<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>
{/* Text + Datum rechts */}
<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>
</div>
);
}