Files
cinny/src/app/features/toast/LotusToastContainer.tsx
T

208 lines
5.1 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
function ensureKeyframes() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
@keyframes lotusToastIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes lotusToastIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
`;
document.head.appendChild(style);
}
type ToastCardProps = {
toast: ToastNotif;
};
function ToastCard({ toast }: ToastCardProps) {
const dismiss = useSetAtom(dismissToastAtom);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id]);
const handleCardClick = () => {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
dismiss(toast.id);
};
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
dismiss(toast.id);
};
const cardStyle: CSSProperties = {
position: 'relative',
background: 'var(--lt-bg-card)',
border: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
boxShadow: 'var(--lt-box-glow-orange)',
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
};
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginRight: '20px',
};
const avatarStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
};
const initialsStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--lt-accent-orange-dim)',
border: '1px solid var(--lt-accent-orange-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: 'var(--lt-accent-orange)',
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: 'var(--lt-accent-orange)',
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const dismissBtnStyle: CSSProperties = {
position: 'absolute',
top: '8px',
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
padding: '2px 4px',
borderRadius: '4px',
};
const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary)',
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary)',
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const initials = toast.displayName
.split(' ')
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase();
return (
<div
role="button"
tabIndex={0}
style={cardStyle}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
>
<button
type="button"
style={dismissBtnStyle}
onClick={handleDismiss}
aria-label="Dismiss notification"
>
×
</button>
<div style={rowStyle}>
{toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : (
<div style={initialsStyle} aria-hidden="true">
{initials || '?'}
</div>
)}
<span style={nameStyle}>{toast.displayName}</span>
</div>
<div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div>
</div>
);
}
export function LotusToastContainer() {
useEffect(() => {
ensureKeyframes();
}, []);
const toasts = useAtomValue(toastQueueAtom);
if (toasts.length === 0) return null;
const containerStyle: CSSProperties = {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 9997,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}
</div>
);
}