mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1e05501b9d
Mechanical, and it ends the migration visibly.
45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.
forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:
NumericInput — a plain DOM ref, no imperative handle involved
JourneyMap — useImperativeHandle stays, only the wrapping goes
JourneyMapGL — same
JourneyMapAuto — same
Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.
The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.
The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.
TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.
Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useNavigate } from 'react-router'
|
|
import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
|
|
|
export default function InAppNotificationBell(): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const { settings } = useSettingsStore()
|
|
const darkMode = settings.dark_mode
|
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
|
|
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
|
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
fetchUnreadCount()
|
|
}
|
|
}, [isAuthenticated])
|
|
|
|
const handleOpen = () => {
|
|
if (!open) {
|
|
fetchNotifications(true)
|
|
}
|
|
setOpen(v => !v)
|
|
}
|
|
|
|
const handleShowAll = () => {
|
|
setOpen(false)
|
|
navigate('/notifications')
|
|
}
|
|
|
|
const displayCount = unreadCount > 99 ? '99+' : unreadCount
|
|
|
|
return (
|
|
<div className="relative flex-shrink-0">
|
|
<button
|
|
onClick={handleOpen}
|
|
title={t('notifications.title')}
|
|
className="relative p-2 rounded-lg transition-colors text-content-muted"
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<Bell className="w-4 h-4" />
|
|
{unreadCount > 0 && (
|
|
<span
|
|
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
|
style={{
|
|
background: '#ef4444',
|
|
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
|
minWidth: 14,
|
|
height: 14,
|
|
padding: '0 3px',
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{displayCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && createPortal(
|
|
<>
|
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
|
<div
|
|
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
|
style={{
|
|
position: 'fixed',
|
|
top: 'var(--nav-h)',
|
|
right: 8,
|
|
width: 360,
|
|
maxWidth: 'calc(100vw - 16px)',
|
|
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
|
zIndex: 9999,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
|
>
|
|
<span className="text-sm font-semibold text-content">
|
|
{t('notifications.title')}
|
|
{unreadCount > 0 && (
|
|
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
|
{unreadCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={markAllRead}
|
|
title={t('notifications.markAllRead')}
|
|
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<CheckCheck className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
{notifications.length > 0 && (
|
|
<button
|
|
onClick={deleteAll}
|
|
title={t('notifications.deleteAll')}
|
|
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notification list */}
|
|
<div className="overflow-y-auto flex-1">
|
|
{isLoading && notifications.length === 0 ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
|
<Bell className="w-8 h-8 text-content-faint" />
|
|
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
|
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
|
</div>
|
|
) : (
|
|
notifications.slice(0, 10).map(n => (
|
|
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<button
|
|
onClick={handleShowAll}
|
|
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content"
|
|
style={{
|
|
background: 'transparent',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
{t('notifications.showAll')}
|
|
</button>
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
)
|
|
}
|