mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ce1fa68c84
Adds a Special Themes row to Settings > Appearance > Theme. Halloween is a scheme on the existing data-scheme axis that is always dark: the applier and the boot script force .dark while it is on, and the color mode control is dimmed with a hint. - Palette for the whole app (surfaces, dashboard, Vacay glass, Journey, mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css. - Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on: fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop on a thread and flee when poked, a pumpkin patch and an ember running along the navbar. Figures are pre-rendered 3D sprite sheets. - Nothing is drawn over a map: the Atlas gets no decor, the trip planner only dresses its side panels. Marker clusters turn into lanterns. - The 3D mascot plays its own Halloween clips in every scene (witch hat, a spider on the brim, a lit pumpkin beside it); the SVG fallback wears the same costume. - Reduced motion stills everything, transparency off drops the fog, shared pages never show it. - Navbar, bottom nav, notifications, Atlas and Collections read the dark flag off the .dark class instead of settings.dark_mode, so a forced dark theme reaches them.
165 lines
6.5 KiB
TypeScript
165 lines
6.5 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'
|
|
import { useIsDark } from '../../hooks/useIsDark'
|
|
|
|
export default function InAppNotificationBell(): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const { settings } = useSettingsStore()
|
|
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
|
const dark = useIsDark()
|
|
|
|
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 type="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 -end-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(
|
|
<>
|
|
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
|
<div role="presentation" 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)',
|
|
insetInlineEnd: 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="ms-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 type="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 type="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 type="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>
|
|
)
|
|
}
|