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,131 @@
|
||||
/**
|
||||
* NotificationDropdown — panel that lists notifications inside the bell dropdown.
|
||||
*
|
||||
* Features:
|
||||
* - Uses useNotifications() to list notification items
|
||||
* - "Alle als gelesen" button marks all unread notifications as read
|
||||
* - "Alle anzeigen" link navigates to /settings/notifications
|
||||
* - Loading skeleton, empty state
|
||||
* - Max height with scroll
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle } from 'lucide-react';
|
||||
import { useNotifications, useMarkNotificationRead } from '@/api/notifications';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
export function NotificationDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError } = useNotifications();
|
||||
const markReadMutation = useMarkNotificationRead();
|
||||
|
||||
const notifications = data?.items ?? [];
|
||||
const hasUnread = notifications.some((n) => n.read_at == null);
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.forEach((n) => {
|
||||
if (n.read_at == null) {
|
||||
markReadMutation.mutate(n.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigateAll = () => {
|
||||
navigate('/settings/notifications');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50"
|
||||
role="menu"
|
||||
aria-label={t('notifications.title', 'Benachrichtigungen')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-secondary-200">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">
|
||||
{t('notifications.title', 'Benachrichtigungen')}
|
||||
</h3>
|
||||
{hasUnread && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markReadMutation.isPending}
|
||||
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed min-h-touch px-1 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.markAllRead', 'Alle als gelesen')}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.markAllRead', 'Alle als gelesen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading && <NotificationSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<AlertCircle className="w-6 h-6 text-danger-400 mx-auto mb-2" strokeWidth={2} />
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('notifications.errorLoading', 'Fehler beim Laden der Benachrichtigungen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && notifications.length === 0 && (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<Bell className="w-8 h-8 text-secondary-300 mx-auto mb-2" strokeWidth={2} />
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('notifications.empty', 'Keine Benachrichtigungen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && notifications.length > 0 && (
|
||||
<div role="menu">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-secondary-200 px-3 py-2">
|
||||
<button
|
||||
onClick={handleNavigateAll}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.viewAll', 'Alle anzeigen')}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.viewAll', 'Alle anzeigen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── internal: loading skeleton ──
|
||||
|
||||
function NotificationSkeleton() {
|
||||
return (
|
||||
<div className="px-3 py-2" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-start gap-3 py-2.5 border-b border-secondary-100 last:border-b-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-secondary-100 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 bg-secondary-100 rounded animate-pulse w-3/4" />
|
||||
<div className="h-2.5 bg-secondary-100 rounded animate-pulse w-1/2" />
|
||||
<div className="h-2 bg-secondary-100 rounded animate-pulse w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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