Files
TREK/client/src/components/shared/Toast.tsx
T
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
2026-06-05 01:38:25 +02:00

167 lines
5.7 KiB
TypeScript

import React, { useState, useCallback, useEffect, useRef } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: number
message: string
type: ToastType
duration: number
removing: boolean
}
declare global {
interface Window {
__addToast?: (message: string, type?: ToastType, duration?: number) => number
}
}
let toastIdCounter = 0
const ICON_COLORS: Record<ToastType, string> = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#6366f1',
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
timersRef.current.forEach(clearTimeout)
}
}, [])
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
const t1 = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
const t2 = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 400)
timersRef.current.push(t2)
}, duration)
timersRef.current.push(t1)
}
return id
}, [])
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
const t = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 400)
timersRef.current.push(t)
}, [])
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
error: <XCircle size={18} style={{ color: ICON_COLORS.error, flexShrink: 0 }} />,
warning: <AlertCircle size={18} style={{ color: ICON_COLORS.warning, flexShrink: 0 }} />,
info: <Info size={18} style={{ color: ICON_COLORS.info, flexShrink: 0 }} />,
}
return (
<>
<style>{`
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.95); }
}
.nomad-toast {
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0.5px 0 rgba(255,255,255,0.5);
}
.nomad-toast span { color: rgba(0, 0, 0, 0.8) !important; }
.dark .nomad-toast {
background: rgba(30, 30, 40, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 0.5px 0 rgba(255,255,255,0.08);
}
.dark .nomad-toast span { color: rgba(255, 255, 255, 0.9) !important; }
.nomad-toast-close { color: rgba(0, 0, 0, 0.4); }
.dark .nomad-toast-close { color: rgba(255, 255, 255, 0.4); }
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
<div
key={toast.id}
className="nomad-toast"
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px',
borderRadius: 14,
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
pointerEvents: 'auto',
animation: toast.removing ? 'toast-out 0.35s ease forwards' : 'toast-in 0.35s cubic-bezier(0.16,1,0.3,1) forwards',
}}
>
{icons[toast.type] || icons.info}
<span style={{
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
lineHeight: 1.4,
fontFamily: "var(--font-system)",
}}>
{toast.message}
</span>
<button
onClick={() => removeToast(toast.id)}
className="nomad-toast-close"
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', padding: 2,
flexShrink: 0, borderRadius: 6, transition: 'opacity 0.15s',
opacity: 0.35,
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.35'}
>
<X size={14} />
</button>
</div>
))}
</div>
</>
)
}
export const useToast = () => {
const show = useCallback((message: string, type: ToastType, duration?: number) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message: string, duration?: number) => show(message, 'success', duration),
error: (message: string, duration?: number) => show(message, 'error', duration),
warning: (message: string, duration?: number) => show(message, 'warning', duration),
info: (message: string, duration?: number) => show(message, 'info', duration),
}
}
export default useToast