Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation) - Custom Fields Backend: model, schema, service, routes, migration 0041 - Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering) - Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions - Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem - NotificationBell integrated into TopBar - Routes: /tags, /settings/custom-fields registered - Settings nav: Custom Fields entry added - Menu items: Tags added to automation plugin manifest
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* NotificationItem — single notification row inside the dropdown.
|
||||
*
|
||||
* Props:
|
||||
* notification: NotificationItem
|
||||
* onMarkRead: (id: string) => void
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
Info,
|
||||
Mail,
|
||||
CheckSquare,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
User,
|
||||
FileText,
|
||||
Bell,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/**
|
||||
* Returns a German relative-time string like "vor 5 Min" or "vor 2 Stunden".
|
||||
* Falls back to "gerade eben" for < 1 min and an absolute date for > 7 days.
|
||||
*/
|
||||
function relativeTime(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return '';
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return 'gerade eben';
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `vor ${diffMin} Min`;
|
||||
const diffHrs = Math.floor(diffMin / 60);
|
||||
if (diffHrs < 24) return `vor ${diffHrs} Std`;
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
if (diffDays < 7) return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
|
||||
// > 7 days: show absolute date
|
||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Map notification type → lucide icon. */
|
||||
const typeIconMap: Record<string, LucideIcon> = {
|
||||
info: Info,
|
||||
email: Mail,
|
||||
mail: Mail,
|
||||
task: CheckSquare,
|
||||
calendar: Calendar,
|
||||
event: Calendar,
|
||||
alert: AlertCircle,
|
||||
warning: AlertCircle,
|
||||
error: AlertCircle,
|
||||
contact: User,
|
||||
user: User,
|
||||
document: FileText,
|
||||
file: FileText,
|
||||
};
|
||||
|
||||
function getIconForType(type: string): LucideIcon {
|
||||
return typeIconMap[type] ?? Bell;
|
||||
}
|
||||
|
||||
// ── component ──
|
||||
|
||||
export interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||
const isUnread = notification.read_at == null;
|
||||
const Icon = getIconForType(notification.type);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnread) {
|
||||
onMarkRead(notification.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'w-full text-left flex items-start gap-3 px-3 py-2.5 hover:bg-secondary-50 transition-colors cursor-pointer',
|
||||
'border-b border-secondary-100 last:border-b-0 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isUnread && 'bg-primary-50/40',
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-label={notification.title}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-shrink-0 mt-0.5 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUnread ? 'bg-primary-100 text-primary-600' : 'bg-secondary-100 text-secondary-500',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={2} />
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm truncate',
|
||||
isUnread ? 'font-semibold text-secondary-900' : 'font-medium text-secondary-700',
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{isUnread && (
|
||||
<span
|
||||
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
|
||||
aria-label="ungelesen"
|
||||
title="ungelesen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-xs text-secondary-500 truncate mt-0.5">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
{notification.created_at && (
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
{relativeTime(notification.created_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user