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,74 @@
/**
* NotificationBell — bell icon with unread badge + dropdown.
*
* - Uses useUnreadNotificationCount() with 30 s polling
* - Bell icon (lucide-react Bell) with red badge count if > 0
* - Click toggles dropdown
* - Click outside closes dropdown
*/
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Bell } from 'lucide-react';
import { useUnreadNotificationCount } from '@/api/notifications';
import { NotificationDropdown } from '@/components/notifications/NotificationDropdown';
export function NotificationBell() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { data: unreadCount } = useUnreadNotificationCount({
refetchInterval: 30_000,
});
// Close on click outside
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open]);
const count = unreadCount ?? 0;
const displayCount = count > 99 ? '99+' : String(count);
return (
<div ref={containerRef} className="relative">
<button
onClick={() => setOpen(!open)}
className="relative p-2 rounded-md hover:bg-secondary-100 text-secondary-600 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('notifications.title', 'Benachrichtigungen')}
aria-expanded={open}
aria-haspopup="menu"
>
<Bell className="w-5 h-5" strokeWidth={2} aria-hidden="true" />
{count > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-danger-500 text-white text-[10px] font-bold leading-none"
aria-label={`${count} ungelesene Benachrichtigungen`}
>
{displayCount}
</span>
)}
</button>
{open && <NotificationDropdown />}
</div>
);
}
@@ -9,6 +9,7 @@ import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { useWindowStore } from '@/store/windowStore';
export function TopBar() {
@@ -80,6 +81,7 @@ export function TopBar() {
</div>
<div className="flex items-center gap-2">
<NotificationBell />
{/* Minimized windows */}
{minimizedWindows.length > 0 && (
<div className="flex items-center gap-1.5">