310 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|