mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can already announce "Kanazawa begins" in the day plan, but it can't make day 12 look like it belongs to Kanazawa. dayScheduleProvider can't cover this: it renders a fixed row, and it's capped at 60 items per provider, so one item per day would tint the first 60 days of a six-month trip and silently stop. This hook is bounded by day count instead. GET /api/day-tints/:tripId returns per-day tones for three regions of a day card - the number badge (also the mobile day chip), the header row, and the expanded activity list. `tone` is a shorthand filling every region a contribution doesn't name; unnamed regions render as they do today. Tones only, never hex, so the host can pick the alpha per theme and per region. One contribution per day, resolved whole: first entry wins within a provider, first granted provider wins across plugins.
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
@@ -192,7 +192,7 @@ const PERM_KEYS = [
|
||||
'events:subscribe', 'jobs:run',
|
||||
'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
|
||||
'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read',
|
||||
'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read',
|
||||
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
|
||||
]
|
||||
|
||||
@@ -247,6 +247,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') })
|
||||
if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') })
|
||||
if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') })
|
||||
if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
|
||||
if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { usePluginDaySchedule, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
@@ -1051,6 +1051,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const activePlugins = usePluginStore(s => s.plugins)
|
||||
// Plugin time contributions in the day plan (dayScheduleProvider hook).
|
||||
const daySchedule = usePluginDaySchedule(S.tripId)
|
||||
// Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a
|
||||
// day belongs to. Empty unless a granted plugin provides them.
|
||||
const dayTints = usePluginDayTints(S.tripId)
|
||||
const routeProfileOptions = useMemo(() => {
|
||||
const opts: Array<{ key: string; label: string }> = [
|
||||
{ key: 'driving', label: 'Driving' },
|
||||
@@ -1317,9 +1320,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const merged = mergedItemsMap[day.id] || []
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
const dayTint = dayTints[day.id]
|
||||
// Resolved once per day: the header owns a background that its hover
|
||||
// handlers reassign imperatively, so both the base and the hover value have
|
||||
// to be tint-aware or the first hover-out would wipe the colour.
|
||||
const headerTintBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header') ?? 'transparent'
|
||||
const headerTintHoverBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header-hover') ?? 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
// The card wrapper stays untinted — its three regions (badge, header,
|
||||
// activity list) paint themselves, so a plugin controls them separately.
|
||||
<div key={day.id} title={dayTint?.label || undefined} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
className="dp-day-header"
|
||||
@@ -1332,7 +1343,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 14px 11px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'),
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : headerTintBg),
|
||||
transition: 'background 0.12s',
|
||||
userSelect: 'none',
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
@@ -1340,8 +1351,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderRadius: isDragTarget ? 8 : 0,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
// A tinted header hovers to a deeper mix of its own tone rather than to
|
||||
// the neutral --bg-tertiary, so hovering reads as a state change on
|
||||
// this day instead of momentarily losing its colour. Selection keeps
|
||||
// winning outright, so a selected day is never ambiguous.
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = headerTintHoverBg }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : headerTintBg }}
|
||||
>
|
||||
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||
{(() => {
|
||||
@@ -1356,8 +1371,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
flexShrink: 0, alignSelf: 'flex-start',
|
||||
width: hasWeather ? 34 : 26,
|
||||
borderRadius: hasWeather ? 11 : '50%',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
// Selection still wins. A tinted badge mixes the tone INTO
|
||||
// --bg-hover so it stays the same pill component, and takes
|
||||
// --text-secondary: the number is 11px bold, so 4.5:1 applies
|
||||
// and --text-muted is already borderline on the untinted pill.
|
||||
background: isSelected ? 'var(--accent)' : (dayTintBackground(dayTint?.badgeTone, '--day-tint-badge', 'var(--bg-hover)') ?? 'var(--bg-hover)'),
|
||||
color: isSelected ? 'var(--accent-text)' : (dayTint?.badgeTone ? 'var(--text-secondary)' : 'var(--text-muted)'),
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>
|
||||
@@ -1479,7 +1498,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{/* Aufgeklappte Orte + Notizen */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||
// The activity list — the largest region and the one behind the
|
||||
// densest text, so its tint is the faintest of the three.
|
||||
style={{ background: dayTintBackground(dayTint?.activityTone, '--day-tint-activity', 'var(--bg-hover)') ?? 'var(--bg-hover)', paddingTop: 6 }}
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { pluginsApi, type PluginDayScheduleItem } from '../../api/client'
|
||||
import { pluginsApi, type PluginDayScheduleItem, type PluginDayTint, type PluginDayTintTone } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Host-rendered rows for the `dayScheduleProvider` plugin hook — time
|
||||
@@ -62,6 +62,60 @@ export function usePluginDaySchedule(tripId?: number | string | null): PluginDay
|
||||
}, [items])
|
||||
}
|
||||
|
||||
/** dayId → the per-region tones (and optional tooltip) for that day's card. */
|
||||
export type PluginDayTints = Record<number, Omit<PluginDayTint, 'pluginId' | 'dayId'>>
|
||||
|
||||
const EMPTY_TINTS: PluginDayTints = {}
|
||||
|
||||
/**
|
||||
* Host-rendered day colours for the `dayTintProvider` plugin hook — "day 12 belongs
|
||||
* to the Kanazawa leg". A sibling of usePluginDaySchedule in every respect: same
|
||||
* fail-safe fetch, same vetted-data-only contract. Separate from the schedule hook
|
||||
* because its output is bounded by the trip's day count rather than the schedule
|
||||
* hook's ≤60 items, which a long multi-destination trip would blow straight past.
|
||||
*
|
||||
* The server has already resolved precedence (one tint per day, first granted
|
||||
* provider wins), so this is a flat index — the callers just look a day up.
|
||||
*/
|
||||
export function usePluginDayTints(tripId?: number | string | null): PluginDayTints {
|
||||
const [tints, setTints] = useState<PluginDayTint[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId == null) { setTints([]); return }
|
||||
let alive = true
|
||||
pluginsApi.dayTints(tripId)
|
||||
.then(r => { if (alive) setTints(r.tints || []) })
|
||||
.catch(() => { if (alive) setTints([]) }) // fail-safe: no tints
|
||||
return () => { alive = false }
|
||||
}, [tripId])
|
||||
|
||||
return useMemo(() => {
|
||||
if (tints.length === 0) return EMPTY_TINTS
|
||||
const out: PluginDayTints = {}
|
||||
for (const t of tints) {
|
||||
const { pluginId: _pluginId, dayId: _dayId, ...regions } = t
|
||||
out[t.dayId] = regions
|
||||
}
|
||||
return out
|
||||
}, [tints])
|
||||
}
|
||||
|
||||
/** The `color-mix` background for one tinted region, or undefined when that region has
|
||||
* no tone — in which case the caller keeps whatever it renders without plugins.
|
||||
*
|
||||
* `alphaVar` names a CSS custom property holding the strength, which varies per theme
|
||||
* AND per region: the tones are fixed hexes, so one alpha cannot serve both themes
|
||||
* (#4F46E5 is itself a dark colour and vanishes on a dark surface at a light theme's
|
||||
* alpha), and a large region behind dense text needs a fainter tint than a small badge. */
|
||||
export function dayTintBackground(
|
||||
tone: PluginDayTintTone | undefined,
|
||||
alphaVar: string,
|
||||
base = 'transparent',
|
||||
): string | undefined {
|
||||
if (!tone) return undefined
|
||||
return `color-mix(in srgb, ${TONE_COLORS[tone] ?? TONE_COLORS.default} var(${alphaVar}), ${base})`
|
||||
}
|
||||
|
||||
export function formatScheduleMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
|
||||
Reference in New Issue
Block a user