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:
Agent Zero
2026-07-26 03:02:25 +02:00
parent 444c7fdb88
commit a7e3890634
22 changed files with 2684 additions and 8 deletions
@@ -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>
);
}