352 lines
9.9 KiB
TypeScript
352 lines
9.9 KiB
TypeScript
// 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>
|
||
);
|
||
} |