Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
jubnl
2026-08-11 23:26:36 +02:00
57 changed files with 1631 additions and 157 deletions
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
@@ -306,7 +307,7 @@ export default function CollectionPlaceDetail({
)}
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
</div>
)}
{place.links && place.links.length > 0 && (
+7 -3
View File
@@ -333,10 +333,14 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (item.type === 'note') {
const note = item.data
// A coloured note carries its colour into print the same way a
// reservation does (#1629) — the rule and the spine, not a fill,
// because a tint costs ink and reads as grey on a mono printer.
const noteColor = note.color || ''
return `
<div class="note-card">
<div class="note-line"></div>
<span class="note-icon">${noteIconSvg(note.icon)}</span>
<div class="note-card"${noteColor ? ` style="border-left: 3px solid ${noteColor};"` : ''}>
<div class="note-line"${noteColor ? ` style="background: ${noteColor};"` : ''}></div>
<span class="note-icon"${noteColor ? ` style="color: ${noteColor};"` : ''}>${noteIconSvg(note.icon)}</span>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
@@ -1198,6 +1198,48 @@ describe('DayPlanSidebar', () => {
expect(mockDayNotesState.saveNote).toHaveBeenCalledWith(10)
})
// ── Note colours and formatting (#1629) ───────────────────────────────
it('FE-PLANNER-DAYPLAN-192: the note dialog offers the palette and reports the pick', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.noteUi = {
'10': { mode: 'add', text: 'Passport', time: '', icon: 'StickyNote', color: null },
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Exact, not /red/i: "Ordered list" in the formatting bar matches that too.
await user.click(screen.getByRole('button', { name: 'Red' }))
// The dialog owns no state of its own — it reports upwards, like every other field.
expect(mockDayNotesState.setNoteUi).toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-193: a note card is tinted by its colour and renders its body', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = {
'10': [{ id: 1, day_id: 10, text: 'Ferry', time: 'book **early**', icon: 'StickyNote', color: '#dc2626', sort_order: 0 }],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], expandedDays: { 10: true } })} />)
// Markdown, not the asterisks someone typed with the formatting bar.
expect(screen.getByText('early').tagName).toBe('STRONG')
// jsdom normalises the hex inside color-mix() to rgb().
const card = screen.getByText('Ferry').closest('.dp-row') as HTMLElement
expect(card.style.background).toContain('220, 38, 38')
})
it('FE-PLANNER-DAYPLAN-194: a note without a colour keeps the neutral card', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = {
'10': [{ id: 2, day_id: 10, text: 'Lunch', time: '', icon: 'StickyNote', color: null, sort_order: 0 }],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], expandedDays: { 10: true } })} />)
const card = screen.getByText('Lunch').closest('.dp-row') as HTMLElement
expect(card.style.background).toContain('--bg-hover')
})
// ── Note modal edit mode title ────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-061: note modal shows Edit title in edit mode', () => {
@@ -12,6 +12,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -33,6 +34,8 @@ import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationD
import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
import { noteSurface } from './noteSurface'
import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
@@ -1374,7 +1377,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
return (
<div style={{
flexShrink: 0, alignSelf: 'flex-start',
// With weather the badge is a tall stack and has to start at
// the top of the row; without it, it is a lone 26px circle and
// pinning it to the top leaves it floating above the day title.
flexShrink: 0, alignSelf: hasWeather ? 'flex-start' : 'center',
width: hasWeather ? 34 : 26,
borderRadius: hasWeather ? 11 : '50%',
// Selection still wins. A tinted badge mixes the tone INTO
@@ -1385,7 +1391,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
color: isSelected ? 'var(--accent-text)' : (dayTinted(dayTint, 'badge') ? '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 }}>
{/* lineHeight 1, or the digit rides the line box's leading and
sits above centre on the plain badge — visible as soon as
there is no weather block under it to distract from it. */}
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: 1 }}>
{index + 1}
</div>
{hasWeather && (
@@ -2178,6 +2187,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Notizkarte
const note = item.data
const NoteIcon = getNoteIcon(note.icon)
const noteSkin = noteSurface(note.color)
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
@@ -2241,13 +2251,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
if (editBtns) editBtns.style.opacity = '0'
}}
style={{
position: 'relative',
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 2px',
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
border: `1px solid ${noteSkin.border}`,
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: 'var(--bg-hover)',
background: noteSkin.background,
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
@@ -2255,25 +2266,39 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: noteSkin.iconBackground, overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color={noteSkin.iconColor} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{note.text}
</span>
{note.time && (
<div className="collab-note-md" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
<div className="collab-note-md" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.4', marginTop: 2, wordBreak: 'break-word' }}>
{/* A link in a note goes to its own tab, and remarkBreaks
keeps a single newline a line break — people write notes
as lines, not as Markdown paragraphs. */}
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{note.time}</Markdown>
</div>
)}
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
{/* Stacked in a capsule of their own, set off from the text:
a note card is as tall as its body, so the height is free
while the width next to the text is not, and a 10px icon
in a 14px box was barely a target. */}
{/* Floated over the text rather than sharing the row with
it: a note is mostly text, and reserving a column for
two buttons that are invisible until you hover costs
every note a shorter line for the whole time you are
not hovering. */}
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
</div>
</React.Fragment>
)
@@ -1,82 +1,188 @@
import { useRef } from 'react'
import { createPortal } from 'react-dom'
import { NOTE_ICONS } from './DayPlanSidebar.constants'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { NOTE_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import NoteColorPicker from '../shared/NoteColorPicker'
import { noteSurface } from './noteSurface'
import { markdownLinkComponents } from '../shared/markdownLink'
interface NoteModalUi {
mode: 'add' | 'edit'
icon: string
text: string
time: string
color?: string | null
}
type NoteUiMap = Record<string, NoteModalUi | undefined>
interface DayPlanSidebarNoteModalProps {
noteUi: Record<string, NoteModalUi | undefined>
setNoteUi: (updater: (prev: any) => any) => void
noteUi: NoteUiMap
setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void
noteInputRef: React.RefObject<HTMLInputElement>
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
t: (key: string, params?: Record<string, any>) => string
t: (key: string, params?: Record<string, unknown>) => string
}
const BODY_MAX = 2000
/**
* Add/edit dialog for a day note (#1629).
*
* Two columns on anything wider than a phone: how the note looks on the left
* (icon, colour, and a preview of the card it will become), what it says on the
* right. The preview is the reason the colour picker is worth having — a swatch
* row tells you nothing about what a tinted card looks like next to a place.
*/
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
return (
<>
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
<div key={dayId} className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card" style={{
width: 340, borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
{/* Icon-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
<input
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle') + ' *'}
required
className="text-content"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }}
/>
<textarea
value={ui.time}
maxLength={250}
rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteSubtitle')}
className="text-content"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
/>
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: -2 }}>{ui.time?.length || 0}/250</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>,
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
document.body
))}
</>
)
}
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
dayId: string
ui: NoteModalUi
}) {
const bodyRef = useRef<HTMLTextAreaElement | null>(null)
const patch = (fields: Partial<NoteModalUi>) =>
setNoteUi(prev => ({ ...prev, [dayId]: { ...(prev[dayId] as NoteModalUi), ...fields } }))
const color = ui.color ?? null
const surface = noteSurface(color)
const PreviewIcon = getNoteIcon(ui.icon)
const canSave = Boolean(ui.text?.trim())
const bodyLen = ui.time?.length || 0
return (
<div className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card note-modal" style={{
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 16,
}} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
<div className="note-modal-cols" style={{ display: 'grid', gridTemplateColumns: '210px 1fr', gap: 20, alignItems: 'start' }}>
{/* Left — how it looks. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteIcon')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} type="button" onClick={() => patch({ icon: id })} title={id}
aria-pressed={ui.icon === id}
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
style={{ width: 38, height: 38, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={16} strokeWidth={1.8} color={ui.icon === id ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
</button>
))}
</div>
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.color.label')}
</div>
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.preview')}
</div>
{/* The same shape the card takes in the day plan. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px', borderRadius: 6, border: `1px solid ${surface.border}`, background: surface.background }}>
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'grid', placeItems: 'center', borderRadius: '50%', background: surface.iconBackground }}>
<PreviewIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
</div>
<span className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, minWidth: 0, wordBreak: 'break-word' }}>
{ui.text?.trim() || t('dayplan.noteTitle')}
</span>
</div>
</div>
</div>
{/* Right — what it says. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div>
<label className="text-content-faint" htmlFor="note-title" style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteTitle')} *
</label>
<input
id="note-title"
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => patch({ text: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
required
className="text-content bg-surface-input"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }}
/>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
<label className="text-content-faint" htmlFor="note-body" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em' }}>
{t('dayplan.noteSubtitle')}
</label>
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
</div>
<textarea
id="note-body"
ref={bodyRef}
value={ui.time}
maxLength={BODY_MAX}
rows={6}
onChange={e => patch({ time: e.target.value })}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('notes.bodyPlaceholder')}
className="text-content bg-surface-input"
style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.5 }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 4 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('notes.markdownHint')}</span>
<span className={bodyLen >= BODY_MAX - 100 ? 'text-[#d97706]' : 'text-content-faint'} style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{bodyLen}/{BODY_MAX}</span>
</div>
</div>
{ui.time?.trim() && (
<div className="collab-note-md-full text-content-muted" style={{ borderRadius: 10, border: `1px solid ${surface.border}`, background: surface.background, padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.55, wordBreak: 'break-word', overflowWrap: 'anywhere', maxHeight: 200, overflowY: 'auto' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '7px 17px', cursor: !canSave ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from '
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { formatTime, splitReservationDateTime } from '../../utils/formatters'
@@ -186,7 +187,7 @@ export function DayPlanSidebarTransportDetailModal({
{res.notes && (
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
<div className="collab-note-md text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>
</div>
)}
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -601,6 +602,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
// pick — otherwise the dialog would jump sideways mid-typing.
const showDetails = !isMobile && placesEnrichEnabled
const modalSize = isMobile ? 'lg' : showDetails && twoColumn ? '5xl' : showDetails || twoColumn ? '4xl' : 'lg'
const descriptionRef = useRef<HTMLTextAreaElement | null>(null)
const notesRef = useRef<HTMLTextAreaElement | null>(null)
return (
<Modal
isOpen={isOpen}
@@ -740,23 +743,32 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* Description */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formDescription')}</label>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<label className="block text-sm font-medium text-content-secondary">{t('places.formDescription')}</label>
<NoteFormatToolbar textareaRef={descriptionRef} onChange={v => handleChange('description', v)} compact />
</div>
<textarea
ref={descriptionRef}
value={form.description}
onChange={e => handleChange('description', e.target.value)}
rows={2}
rows={3}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
{/* Notes */}
{/* Notes — Markdown, same as the description, and rendered as such in the
inspector. The bar is how anyone finds that out. */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formNotes')}</label>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<label className="block text-sm font-medium text-content-secondary">{t('places.formNotes')}</label>
<NoteFormatToolbar textareaRef={notesRef} onChange={v => handleChange('notes', v)} compact />
</div>
<textarea
ref={notesRef}
value={form.notes}
onChange={e => handleChange('notes', e.target.value)}
rows={2}
rows={3}
maxLength={2000}
placeholder={t('places.formNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
@@ -4,6 +4,7 @@ import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
@@ -405,14 +406,14 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
{/* Notes */}
{place.notes && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.notes}</Markdown>
</div>
)}
@@ -863,7 +864,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -1,4 +1,6 @@
import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Check, Star } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -107,9 +109,13 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
{/* Rendered, like the same line in the day plan: the description is
Markdown everywhere else, and printing it raw here was the one
place a formatted place read as `_underscores_`. Still clamped to
one line — the row is a list entry, not the inspector. */}
<div className="collab-note-md text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
</div>
)}
</div>
@@ -16,6 +16,7 @@ import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration }
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import type { ViewContribution } from '../../api/client'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -406,7 +407,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div>
<div className={fieldLabelClass}>{t('reservations.notes')}</div>
<div className={`collab-note-md ${fieldValueClass}`} style={{ fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{r.notes}</Markdown>
</div>
</div>
)}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { noteSurface } from './noteSurface'
describe('noteSurface', () => {
it('FE-NOTESURFACE-001: no colour keeps the neutral card the notes have always had', () => {
const s = noteSurface(null)
expect(s.background).toBe('var(--bg-hover)')
expect(s.iconColor).toBe('var(--text-muted)')
// Nothing colour-derived leaks in: the neutral card must survive a theme swap.
expect(Object.values(s).every(v => !v.includes('color-mix'))).toBe(true)
})
it('FE-NOTESURFACE-002: empty string and undefined are "no colour", not a broken mix', () => {
expect(noteSurface('')).toEqual(noteSurface(null))
expect(noteSurface(undefined)).toEqual(noteSurface(null))
})
it('FE-NOTESURFACE-003: a colour tints the card and fills the icon with the colour itself', () => {
const s = noteSurface('#dc2626')
expect(s.iconColor).toBe('#dc2626')
// Mixed against transparent, not against a fixed white or grey — the card
// has to sit on whatever surface it lands on, in either theme.
expect(s.background).toBe('color-mix(in srgb, #dc2626 11%, transparent)')
expect(s.border).toBe('color-mix(in srgb, #dc2626 38%, transparent)')
expect(s.iconBackground).toBe('color-mix(in srgb, #dc2626 22%, transparent)')
})
it('FE-NOTESURFACE-004: the card stays a wash while the icon carries the full colour', () => {
const s = noteSurface('#2563eb')
const pct = (v: string) => Number(/(\d+)%/.exec(v)![1])
// Text is read on top of the background, so it must be the lightest of the three.
expect(pct(s.background)).toBeLessThan(pct(s.iconBackground))
expect(pct(s.iconBackground)).toBeLessThan(pct(s.border))
})
})
@@ -0,0 +1,36 @@
/**
* The four surfaces a note colour produces (#1629).
*
* One place decides how a colour becomes a card, so the preview in the dialog,
* the card in the day plan and the block in the inspector cannot drift apart.
*
* Tints rather than fills: a note sits in a list of places and must not shout
* louder than the trip itself, and body text has to stay readable on top. The
* percentages are `color-mix` against `transparent`, so the card picks up
* whatever surface it is dropped on and works in both themes without a second
* palette. `currentColor` is deliberately not used — the icon takes the full
* colour while the card behind it takes a wash of the same hue.
*/
export interface NoteSurface {
background: string
border: string
iconBackground: string
iconColor: string
}
const NEUTRAL: NoteSurface = {
background: 'var(--bg-hover)',
border: 'var(--border-faint)',
iconBackground: 'var(--bg-hover)',
iconColor: 'var(--text-muted)',
}
export function noteSurface(color: string | null | undefined): NoteSurface {
if (!color) return NEUTRAL
return {
background: `color-mix(in srgb, ${color} 11%, transparent)`,
border: `color-mix(in srgb, ${color} 38%, transparent)`,
iconBackground: `color-mix(in srgb, ${color} 22%, transparent)`,
iconColor: color,
}
}
@@ -0,0 +1,65 @@
import { Ban } from 'lucide-react'
import { NOTE_COLORS } from '@trek/shared'
import { useTranslation } from '../../i18n'
interface Props {
/** The picked colour, or null for the neutral card. */
value: string | null
onChange: (color: string | null) => void
}
/**
* Swatch row for a note's colour (#1629).
*
* The palette is deliberately short and lives in @trek/shared, so the picker,
* the card and the PDF cannot drift apart. The first cell clears the colour
* rather than being a colour of its own — "no colour" is the default state, not
* an eighth option.
*/
export default function NoteColorPicker({ value, onChange }: Props) {
const { t } = useTranslation()
const selected = 'outline outline-2 outline-[var(--accent)] outline-offset-2 scale-110'
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onChange(null)}
aria-pressed={value === null}
aria-label={t('notes.color.none')}
title={t('notes.color.none')}
className={`w-7 h-7 rounded-full grid place-items-center border-2 border-dashed transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === null ? `border-solid border-transparent bg-surface-hover ${selected}` : 'border-edge-secondary hover:border-edge'
}`}
>
<Ban size={12} className="text-content-faint" />
</button>
{NOTE_COLORS.map(color => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
aria-pressed={value === color}
aria-label={t(`notes.color.${COLOR_NAMES[color]}`)}
title={t(`notes.color.${COLOR_NAMES[color]}`)}
className={`w-7 h-7 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15 transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === color ? selected : ''
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
)
}
/** Names rather than hex codes in the label, so a screen reader says something useful. */
const COLOR_NAMES: Record<string, string> = {
'#dc2626': 'red',
'#ea580c': 'orange',
'#d97706': 'amber',
'#16a34a': 'green',
'#0891b2': 'cyan',
'#2563eb': 'blue',
'#9333ea': 'purple',
}
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { render } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { useRef, useState } from 'react'
import NoteFormatToolbar from './NoteFormatToolbar'
/**
* The toolbar edits somebody else's controlled field, so every test drives it
* through a real textarea rather than asserting on a callback in isolation —
* the selection handling is the part that breaks, and it only exists in the DOM.
*/
function Harness({ initial = '', onValue }: { initial?: string; onValue?: (v: string) => void }) {
const ref = useRef<HTMLTextAreaElement | null>(null)
const [value, setValue] = useState(initial)
return (
<>
<NoteFormatToolbar textareaRef={ref} onChange={v => { setValue(v); onValue?.(v) }} />
<textarea ref={ref} aria-label="body" value={value} onChange={e => setValue(e.target.value)} />
</>
)
}
const field = () => screen.getByLabelText('body') as HTMLTextAreaElement
const select = (from: number, to: number) => {
const ta = field()
ta.focus()
ta.setSelectionRange(from, to)
}
describe('NoteFormatToolbar', () => {
it('FE-NOTEBAR-001: wraps the selection and leaves the selection on the text, not the markers', async () => {
const user = userEvent.setup()
render(<Harness initial="closes at 5pm" />)
select(0, 6)
await user.click(screen.getByRole('button', { name: /bold/i }))
expect(field().value).toBe('**closes** at 5pm')
// "closes", not "**closes**" — a second format nests instead of wrapping syntax.
// The selection is restored in a rAF, after the field holds the new text.
await waitFor(() => expect(field().value.slice(field().selectionStart, field().selectionEnd)).toBe('closes'))
})
it('FE-NOTEBAR-002: an empty selection inserts the markers and puts the caret between them', async () => {
const user = userEvent.setup()
render(<Harness initial="note" />)
select(4, 4)
await user.click(screen.getByRole('button', { name: /italic/i }))
expect(field().value).toBe('note__')
await waitFor(() => expect(field().selectionStart).toBe(5))
expect(field().selectionEnd).toBe(5)
})
it('FE-NOTEBAR-003: a list prefix applies to the line the caret is on, not the whole field', async () => {
const user = userEvent.setup()
render(<Harness initial={'first\nsecond'} />)
select(8, 8) // inside "second"
await user.click(screen.getByRole('button', { name: /bulleted list/i }))
expect(field().value).toBe('first\n- second')
})
it('FE-NOTEBAR-004: pressing the same line button again removes the prefix', async () => {
const user = userEvent.setup()
render(<Harness initial="- already a list" />)
select(5, 5)
await user.click(screen.getByRole('button', { name: /bulleted list/i }))
expect(field().value).toBe('already a list')
})
it('FE-NOTEBAR-005: the link button leaves a placeholder URL to type over', async () => {
const user = userEvent.setup()
render(<Harness initial="opening hours" />)
select(0, 13)
await user.click(screen.getByRole('button', { name: /^link$/i }))
expect(field().value).toBe('[opening hours](https://)')
})
it('FE-NOTEBAR-006: the buttons do not take focus away from the field', async () => {
const user = userEvent.setup()
render(<Harness initial="text" />)
select(0, 4)
await user.click(screen.getByRole('button', { name: /bold/i }))
// If the button had taken focus, the selection would be gone and the wrap
// would have landed at position 0 instead of around the word.
expect(field().value).toBe('**text**')
await waitFor(() => expect(document.activeElement).toBe(field()))
})
it('FE-NOTEBAR-007: does nothing when the field is not mounted', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
function Detached() {
const ref = useRef<HTMLTextAreaElement | null>(null)
return <NoteFormatToolbar textareaRef={ref} onChange={onChange} />
}
render(<Detached />)
await user.click(screen.getByRole('button', { name: /bold/i }))
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,115 @@
import { useRef } from 'react'
import { Bold, Italic, Strikethrough, List, ListOrdered, Link2, Quote, Code } from 'lucide-react'
import { useTranslation } from '../../i18n'
type FormatAction =
| { type: 'wrap'; before: string; after: string }
| { type: 'line'; prefix: string }
interface Props {
/** The field being formatted. Must be mounted before a button is pressed. */
textareaRef: React.RefObject<HTMLTextAreaElement | null>
/** Receives the whole new value — the field stays controlled by its owner. */
onChange: (value: string) => void
/** Rendered smaller, for the narrow note dialog. */
compact?: boolean
/** Mobile sheets run on their own token family, scoped to `.m-root`. */
variant?: 'desktop' | 'mobile'
}
const ACTIONS: Array<{ icon: typeof Bold; labelKey: string; action: FormatAction }> = [
{ icon: Bold, labelKey: 'notes.format.bold', action: { type: 'wrap', before: '**', after: '**' } },
{ icon: Italic, labelKey: 'notes.format.italic', action: { type: 'wrap', before: '_', after: '_' } },
{ icon: Strikethrough, labelKey: 'notes.format.strike', action: { type: 'wrap', before: '~~', after: '~~' } },
{ icon: Code, labelKey: 'notes.format.code', action: { type: 'wrap', before: '`', after: '`' } },
{ icon: Link2, labelKey: 'notes.format.link', action: { type: 'wrap', before: '[', after: '](https://)' } },
{ icon: List, labelKey: 'notes.format.list', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, labelKey: 'notes.format.orderedList', action: { type: 'line', prefix: '1. ' } },
{ icon: Quote, labelKey: 'notes.format.quote', action: { type: 'line', prefix: '> ' } },
]
/**
* Formatting bar for the note fields (#1629).
*
* Notes have rendered Markdown for a long time, but only ever offered a bare
* textarea to write it in, so unless you already knew the syntax there was no
* way to find out it existed. This is that discovery surface.
*
* It edits Markdown rather than being a WYSIWYG editor on purpose: the stored
* value stays plain text that the PDF export, the plugin API and every existing
* note keep reading unchanged.
*
* Split from Journey's MarkdownToolbar, which is bound to the journal's own
* `--journal-*` variables and hardcodes English labels; this one runs on theme
* tokens and goes through i18n, so it can sit in the planner.
*/
export default function NoteFormatToolbar({ textareaRef, onChange, compact, variant = 'desktop' }: Props) {
const { t } = useTranslation()
// Applied after the re-render, when the field holds the new text.
const pendingSelection = useRef<[number, number] | null>(null)
const apply = (action: FormatAction) => {
const ta = textareaRef.current
if (!ta) return
const { selectionStart: start, selectionEnd: end, value } = ta
const selected = value.slice(start, end)
let next: string
let caret: [number, number]
if (action.type === 'wrap') {
next = value.slice(0, start) + action.before + selected + action.after + value.slice(end)
caret = selected
// Keep the selection on the text, not on the markers around it, so a
// second click on another button nests instead of wrapping the syntax.
? [start + action.before.length, end + action.before.length]
: [start + action.before.length, start + action.before.length]
} else {
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const alreadyPrefixed = value.startsWith(action.prefix, lineStart)
next = alreadyPrefixed
? value.slice(0, lineStart) + value.slice(lineStart + action.prefix.length)
: value.slice(0, lineStart) + action.prefix + value.slice(lineStart)
const shift = alreadyPrefixed ? -action.prefix.length : action.prefix.length
caret = [Math.max(lineStart, start + shift), Math.max(lineStart, end + shift)]
}
pendingSelection.current = caret
onChange(next)
requestAnimationFrame(() => {
const el = textareaRef.current
const sel = pendingSelection.current
if (!el || !sel) return
el.focus()
el.setSelectionRange(sel[0], sel[1])
pendingSelection.current = null
})
}
const size = compact ? 26 : 30
const buttonCls = variant === 'mobile'
? 'grid place-items-center rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted active:bg-m-act active:text-m-actfg'
: 'grid place-items-center rounded-lg text-content-muted hover:text-content hover:bg-surface-hover transition-colors'
return (
<div className="flex flex-wrap items-center gap-[2px]" role="toolbar" aria-label={t('notes.format.toolbar')}>
{ACTIONS.map(({ icon: Icon, labelKey, action }) => (
<button
key={labelKey}
type="button"
title={t(labelKey)}
aria-label={t(labelKey)}
// Buttons in a toolbar above a field must not steal focus, or the
// selection they are about to format is gone before the click lands.
onMouseDown={e => e.preventDefault()}
onClick={() => apply(action)}
className={buttonCls}
style={{ width: size, height: size, cursor: 'pointer', padding: 0, ...(variant === 'mobile' ? {} : { border: 0, background: 'none' }) }}
>
<Icon size={compact ? 13 : 15} strokeWidth={1.9} />
</button>
))}
</div>
)
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { markdownLinkComponents } from './markdownLink'
describe('markdownLinkComponents', () => {
it('FE-MDLINK-001: a link in a note opens in its own tab instead of navigating the planner away', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'[opening hours](https://example.com/hours)'}
</Markdown>
)
const link = screen.getByRole('link', { name: 'opening hours' })
expect(link).toHaveAttribute('href', 'https://example.com/hours')
expect(link).toHaveAttribute('target', '_blank')
})
it('FE-MDLINK-002: the opened page gets no handle on the app', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'[booking](https://example.com)'}
</Markdown>
)
// Without noopener the target can navigate this window through window.opener,
// and a note is content other trip members wrote.
expect(screen.getByRole('link', { name: 'booking' }).getAttribute('rel')).toContain('noopener')
})
it('FE-MDLINK-003: a bare URL typed into a note becomes a link too', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'tickets: https://example.com/tickets'}
</Markdown>
)
expect(screen.getByRole('link', { name: 'https://example.com/tickets' })).toHaveAttribute('target', '_blank')
})
})
@@ -0,0 +1,22 @@
import type { Components } from 'react-markdown'
/**
* How a link inside a note behaves (#1629).
*
* react-markdown renders a bare `<a href>`, which navigates the SPA away from
* the trip: the planner state, the open day, the map viewport are all gone, and
* the way back is the browser's back button. A note is a place to park a
* booking confirmation or an opening-hours page, so the link opens in its own
* tab instead.
*
* `rel` is not decoration. Without `noopener` the opened page gets a handle on
* this one through `window.opener` and can navigate it somewhere else, and a
* note is user-supplied content that other trip members read.
*/
export const markdownLinkComponents: Components = {
a: ({ children, href, ...rest }) => (
<a {...rest} href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
</a>
),
}
+7 -4
View File
@@ -10,6 +10,9 @@ interface NoteUiState {
text: string
time: string
icon: string
/** One of NOTE_COLORS, or null for the neutral card (#1629). Absent on a
* caller that predates colours, which then leaves the stored one alone. */
color?: string | null
sortOrder?: number
}
@@ -28,13 +31,13 @@ export function useDayNotes(tripId: number | string) {
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map((i) => i.sortKey)) : -1
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', color: null, sortOrder: maxKey + 1 } }))
expandDay?.(dayId)
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId: number, note: DayNote) => {
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText', color: note.color ?? null } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
@@ -47,9 +50,9 @@ export function useDayNotes(tripId: number | string) {
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', color: ui.color ?? null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', color: ui.color })
}
cancelNote(dayId)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
+13
View File
@@ -1193,6 +1193,19 @@ img[alt="TREK"] {
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
/* Note card actions: stacked beside the card, and big enough to aim at. */
.note-edit-btn {
transition: background-color 0.12s ease, color 0.12s ease;
}
.note-edit-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.note-edit-btn-danger:hover {
background: rgba(220, 38, 38, 0.13);
color: #dc2626;
}
/* Day-plan header action grid (edit / +transport / note / collapse) */
.dp-day-actions button {
color: var(--text-faint);
@@ -1,8 +1,13 @@
import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Route, Ticket, X, Zap } from 'lucide-react'
import type { ReactNode, MouseEvent } from 'react'
import type { ReactNode, MouseEvent, CSSProperties } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge'
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import { transportSubtitle, type TransitMeta, type TransportEntry } from './planTimelineModel'
@@ -80,9 +85,12 @@ function ActionCircle({ label, onClick, faint = false, children }: {
}
/** 30px go-mode avatar ring (photo for places, icon circle for transports/notes). */
function AvatarRing({ children, className = '' }: { children: ReactNode; className?: string }) {
function AvatarRing({ children, className = '', style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
return (
<span className={`flex h-[30px] w-[30px] flex-none items-center justify-center overflow-hidden rounded-full border-[1.5px] border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] ${className}`}>
<span
className={`flex h-[30px] w-[30px] flex-none items-center justify-center overflow-hidden rounded-full border-[1.5px] border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] ${className}`}
style={style}
>
{children}
</span>
)
@@ -457,11 +465,12 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
onEdit: () => void
}) {
const Icon = getNoteIcon(note.icon)
const skin = noteSurface(note.color)
// The time column is a free detail line; only a leading HH:MM is a real time.
const { time: noteTime, detail } = splitNoteTime(note.time)
const time = noteTime ? fmtTime(noteTime, chrome) : ''
const [title, ...rest] = note.text.split('\n')
const sub = [rest.join(' ').trim(), detail].filter(Boolean).join(' · ')
const titleExtra = rest.join(' ').trim()
return (
<div
@@ -469,17 +478,27 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''}`}
>
{!chrome.editing && (
<AvatarRing>
<Icon size={14} strokeWidth={2} className="text-m-muted" />
<AvatarRing style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
<Icon size={14} strokeWidth={2} style={{ color: skin.iconColor }} />
</AvatarRing>
)}
<div className="min-w-0 flex-1 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
<div
className="min-w-0 flex-1 rounded-[13px] border px-[11px] py-[7px]"
style={{ borderColor: skin.border, background: note.color ? skin.background : 'var(--m-ic)' }}
>
<div className="flex items-center gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
<span className="min-w-0 text-[0.875rem] font-semibold">{title}</span>
</div>
{sub && (
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{sub}</div>
{titleExtra && (
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</div>
)}
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
<div className="collab-note-md mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{detail}</Markdown>
</div>
)}
</div>
{chrome.editing && <span className="flex flex-none items-center gap-1.5">{reorder}</span>}
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import MSheet from '../../../components/MSheet'
import { NOTE_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { ChevronDown } from 'lucide-react'
import { NOTE_COLORS } from '@trek/shared'
import { NOTE_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
import NoteFormatToolbar from '../../../../components/shared/NoteFormatToolbar'
import { useTripStore } from '../../../../store/tripStore'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import type { DayNote } from '../../../../types'
@@ -19,7 +23,7 @@ export interface MNoteSheetProps {
onClose: () => void
}
const DETAIL_MAX = 250
const DETAIL_MAX = 2000
/**
* Day-note sheet: the demo's icon grid over title + detail. Persists through
@@ -31,8 +35,13 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
const { t, toast, tripId, selectedDayId, tripActions } = planner
const [icon, setIcon] = useState('FileText')
const [color, setColor] = useState<string | null>(null)
// Thirty-two icons in a six-wide grid is most of a phone screen, so the grid
// is folded behind the chosen one, which doubles as the colour preview.
const [iconOpen, setIconOpen] = useState(false)
const [title, setTitle] = useState('')
const [detail, setDetail] = useState('')
const detailRef = useRef<HTMLTextAreaElement | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Open-time snapshot — the payload disappears with shell.sheet on close, but
// the sheet still shows through its exit animation.
@@ -47,6 +56,8 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
if (!open) return
setSheetPayload(payload)
setIcon(payload?.note?.icon || 'FileText')
setColor(payload?.note?.color ?? null)
setIconOpen(false)
setTitle(payload?.note?.text || '')
setDetail(payload?.note?.time || '')
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -54,13 +65,15 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
const note = sheetPayload?.note ?? null
const dayId = sheetPayload?.dayId ?? selectedDayId
const skin = noteSurface(color)
const ChosenIcon = getNoteIcon(icon)
const handleSubmit = async () => {
if (!title.trim() || !dayId || isSaving) return
setIsSaving(true)
try {
if (note) {
await tripActions.updateDayNote(tripId, dayId, note.id, { text: title.trim(), time: detail || null, icon })
await tripActions.updateDayNote(tripId, dayId, note.id, { text: title.trim(), time: detail || null, icon, color })
} else {
// Append at the end of the day timeline: after the last assignment or note.
const state = useTripStore.getState()
@@ -73,6 +86,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
text: title.trim(),
time: detail || null,
icon,
color,
sort_order: maxKey + 1,
})
}
@@ -103,23 +117,72 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<div className="grid grid-cols-6 gap-[7px]">
{NOTE_ICONS.map(({ id, Icon }) => (
<Eyebrow className="mb-[7px] uppercase">{t('notes.appearance')}</Eyebrow>
<div className="flex items-center gap-[10px]">
{/* The chosen icon in the chosen colour: the picker and the preview
are the same control, which is the only way both fit above the
fold on a phone. */}
<button
type="button"
onClick={() => setIconOpen(v => !v)}
aria-expanded={iconOpen}
aria-label={t('dayplan.noteIcon')}
className="relative flex h-[46px] w-[46px] flex-none items-center justify-center rounded-[14px] border"
style={{ borderColor: skin.border, background: skin.iconBackground }}
>
<ChosenIcon size={19} strokeWidth={1.9} style={{ color: skin.iconColor }} />
<ChevronDown
size={11}
strokeWidth={2.4}
className={`absolute bottom-[3px] right-[3px] text-m-faint transition-transform duration-200 ${iconOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-[7px]">
<button
key={id}
type="button"
onClick={() => setIcon(id)}
aria-label={id}
aria-pressed={icon === id}
className={`flex h-[42px] items-center justify-center rounded-[12px] border border-[color:var(--m-rowbr)] ${
icon === id ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
onClick={() => setColor(null)}
aria-pressed={color === null}
aria-label={t('notes.color.none')}
className={`h-[26px] w-[26px] rounded-full border-2 border-dashed border-[color:var(--m-rowbr)] ${
color === null ? 'ring-2 ring-[color:var(--m-act)] ring-offset-2 ring-offset-[color:var(--m-card)]' : ''
}`}
>
<Icon size={17} strokeWidth={1.8} />
</button>
))}
/>
{NOTE_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={color === c}
aria-label={c}
className={`h-[26px] w-[26px] rounded-full ${
color === c ? 'ring-2 ring-[color:var(--m-act)] ring-offset-2 ring-offset-[color:var(--m-card)]' : ''
}`}
style={{ background: c }}
/>
))}
</div>
</div>
{iconOpen && (
<div className="mt-[9px] grid grid-cols-6 gap-[7px]">
{NOTE_ICONS.map(({ id, Icon }) => (
<button
key={id}
type="button"
onClick={() => { setIcon(id); setIconOpen(false) }}
aria-label={id}
aria-pressed={icon === id}
className={`flex h-[42px] items-center justify-center rounded-[12px] border border-[color:var(--m-rowbr)] ${
icon === id ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Icon size={17} strokeWidth={1.8} />
</button>
))}
</div>
)}
<Eyebrow className="mb-[5px] mt-[14px] uppercase">{t('dayplan.noteTitle')} *</Eyebrow>
<input
type="text"
@@ -130,21 +193,24 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
className={FIELD_CLS}
/>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dayplan.noteSubtitle')}</Eyebrow>
<div className="mb-[6px] mt-3 flex items-center justify-between gap-2">
<Eyebrow className="uppercase">{t('dayplan.noteSubtitle')}</Eyebrow>
<NoteFormatToolbar textareaRef={detailRef} onChange={setDetail} compact variant="mobile" />
</div>
<textarea
ref={detailRef}
value={detail}
onChange={e => setDetail(e.target.value)}
rows={3}
rows={4}
maxLength={DETAIL_MAX}
placeholder={t('dayplan.noteSubtitle')}
placeholder={t('notes.bodyPlaceholder')}
className={FIELD_AREA_CLS}
/>
<div
className={`mt-1 flex justify-end font-geist text-[0.59375rem] ${
detail.length >= DETAIL_MAX - 10 ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'
}`}
>
{detail.length}/{DETAIL_MAX}
<div className="mt-1 flex items-center justify-between gap-2 font-geist text-[0.59375rem]">
<span className="text-m-faint">{t('notes.markdownHint')}</span>
<span className={detail.length >= DETAIL_MAX - 100 ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'}>
{detail.length}/{DETAIL_MAX}
</span>
</div>
</div>
@@ -15,6 +15,7 @@ const NOTE = {
text: 'Buy museum tickets',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
sort_order: 2,
} as unknown as DayNote
@@ -36,6 +37,14 @@ function typeTitle(value: string) {
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
}
// The icon grid is folded away behind the chosen icon (#1629) — a phone cannot
// spare 290px for 32 icons — so reaching one means opening the grid first.
const openIconGrid = () => fireEvent.click(screen.getByRole('button', { name: 'dayplan.noteIcon' }))
const pickIcon = (id: string) => {
openIconGrid()
fireEvent.click(screen.getByRole('button', { name: id }))
}
describe('MNoteSheet', () => {
beforeEach(() => {
resetAllStores()
@@ -51,13 +60,16 @@ describe('MNoteSheet', () => {
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
openIconGrid()
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Coffee' }))
pickIcon('Coffee')
// Choosing closes the grid, so the assertion reopens it.
openIconGrid()
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
})
@@ -78,9 +90,9 @@ describe('MNoteSheet', () => {
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
})
const { planner, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Plane' }))
pickIcon('Plane')
typeTitle(' Check in online ')
fireEvent.change(screen.getByPlaceholderText('dayplan.noteSubtitle'), { target: { value: '18:00 at the gate' } })
fireEvent.change(screen.getByPlaceholderText('notes.bodyPlaceholder'), { target: { value: '18:00 at the gate' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
@@ -88,6 +100,7 @@ describe('MNoteSheet', () => {
text: 'Check in online',
time: '18:00 at the gate',
icon: 'Plane',
color: null,
sort_order: 6,
})
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
@@ -103,6 +116,7 @@ describe('MNoteSheet', () => {
text: 'Sunrise walk',
time: null,
icon: 'FileText',
color: null,
sort_order: 0,
})
})
@@ -129,7 +143,8 @@ describe('MNoteSheet', () => {
renderSheet({ payload: { dayId: 7, note: NOTE } })
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('09:30 at the kiosk')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('09:30 at the kiosk')
openIconGrid()
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
@@ -145,6 +160,7 @@ describe('MNoteSheet', () => {
text: 'Buy museum tickets online',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
})
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
@@ -191,16 +207,59 @@ describe('MNoteSheet', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-020: the icon grid stays folded until asked for (#1629)', () => {
renderSheet()
// 32 icons in a six-wide grid is most of a phone screen.
expect(screen.queryByRole('button', { name: 'Train' })).toBeNull()
openIconGrid()
expect(screen.getByRole('button', { name: 'Train' })).toBeInTheDocument()
// Choosing one closes it again.
fireEvent.click(screen.getByRole('button', { name: 'Train' }))
expect(screen.queryByRole('button', { name: 'Coffee' })).toBeNull()
})
it('FE-MOB-NOTESH-021: a colour is saved with the note (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
render(<MNoteSheet planner={planner} open payload={{ dayId: 7 }} onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value: 'Passport' } })
fireEvent.click(screen.getByRole('button', { name: '#dc2626' }))
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, expect.objectContaining({
color: '#dc2626',
})))
})
it('FE-MOB-NOTESH-022: an existing colour is loaded and can be cleared again (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const note = { id: 88, day_id: 7, text: 'Ferry', time: '', icon: 'FileText', color: '#16a34a' } as unknown as DayNote
render(<MNoteSheet planner={planner} open payload={{ dayId: 7, note }} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: '#16a34a' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'notes.color.none' }))
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, expect.objectContaining({
color: null,
})))
})
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
renderSheet()
const detail = screen.getByPlaceholderText('dayplan.noteSubtitle')
expect(screen.getByText('0/250')).toBeInTheDocument()
const detail = screen.getByPlaceholderText('notes.bodyPlaceholder')
// The ceiling matches the desktop dialog since #1629 — a formatted note needs
// the room, and the two fields write the same column.
expect(screen.getByText('0/2000')).toBeInTheDocument()
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
const relaxed = screen.getByText('100/250')
const relaxed = screen.getByText('100/2000')
expect(relaxed.className).toContain('text-m-faint')
fireEvent.change(detail, { target: { value: 'a'.repeat(241) } })
const warned = screen.getByText('241/250')
fireEvent.change(detail, { target: { value: 'a'.repeat(1950) } })
const warned = screen.getByText('1950/2000')
expect(warned.className).toContain('--m-st-pending')
})
@@ -239,7 +298,8 @@ describe('MNoteSheet', () => {
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('')
openIconGrid()
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
})
@@ -474,12 +474,23 @@ describe('NoteRow', () => {
const base = { chrome: chrome(), reorder: REORDER, onEdit: vi.fn() }
it('FE-MOB-PLROW-034: splits the leading time off and joins the rest into the subtitle', () => {
it('FE-MOB-PLROW-034: splits the leading time off and keeps the rest of the note below it', () => {
render(<NoteRow {...base} note={note()} />)
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
expect(screen.getByText('Cash only · at the kiosk')).toBeInTheDocument()
// Two separate blocks since #1629: the extra title lines are plain text, the
// detail is rendered Markdown, so they cannot be joined into one string.
expect(screen.getByText('Cash only')).toBeInTheDocument()
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
})
it('FE-MOB-PLROW-039: renders the detail as Markdown and tints the row with the note colour (#1629)', () => {
render(<NoteRow {...base} note={note({ text: 'Ferry', time: 'book **early**', color: '#dc2626' } as never)} />)
expect(screen.getByText('early').tagName).toBe('STRONG')
const card = screen.getByText('Ferry').closest('div[style]') as HTMLElement
expect(card.style.background).toContain('220, 38, 38')
})
it('FE-MOB-PLROW-035: a free-text time stays in the subtitle and shows no time chip', () => {
+9
View File
@@ -3856,6 +3856,15 @@ function runMigrations(db: Database.Database): void {
WHERE note LIKE 'TICKETJSON:%'
`);
},
// Note colours (#1629). A day note is a label as much as a reminder — "watch
// out", "must see", "already booked" — and a wall of identical grey cards
// makes that impossible to see at a glance. NULL keeps the neutral card
// every existing note has today.
() => {
const hasColor = db.prepare("SELECT 1 FROM pragma_table_info('day_notes') WHERE name = 'color'").get();
if (!hasColor) db.exec('ALTER TABLE day_notes ADD COLUMN color TEXT');
},
];
if (currentVersion < migrations.length) {
@@ -57,7 +57,7 @@ export class DayNotesController {
if (!body.text?.trim()) {
throw new HttpException({ error: 'Text required' }, 400);
}
const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order);
const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order, body.color);
this.notes.broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, socketId);
return { note };
}
@@ -76,7 +76,7 @@ export class DayNotesController {
if (!current) {
throw new HttpException({ error: 'Note not found' }, 404);
}
const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order });
const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order, color: body.color });
this.notes.broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note }, socketId);
return { note };
}
+21 -6
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { NOTE_COLORS, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { DayNote, User } from '../../types';
@@ -11,6 +11,20 @@ import { DatabaseService, type TripAccess } from '../database/database.service';
* coercions). Trip access rides DatabaseService.canAccessTrip; the 'day_edit'
* permission reuses the legacy check.
*/
/**
* Only a colour the palette actually offers reaches the column (#1629).
*
* The Zod contract can only say "a short string" — it has no way to express the
* palette without pinning the UI's choices into the wire format — so the check
* that matters lives here. Anything else is stored as "no colour", which is the
* neutral card, rather than being rejected: a bad colour is not a reason to lose
* the note someone just wrote.
*/
export function normalizeNoteColor(color: string | null | undefined): string | null {
if (!color) return null;
return (NOTE_COLORS as readonly string[]).includes(color) ? color : null;
}
@Injectable()
export class DayNotesService {
constructor(
@@ -46,21 +60,22 @@ export class DayNotesService {
return this.dbs.get<DayNote>('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?', id, dayId, tripId);
}
create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number) {
create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number, color?: string | null) {
const result = this.dbs.run(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
dayId, tripId, text.trim(), time || null, icon || '📝', sortOrder ?? 9999,
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color) VALUES (?, ?, ?, ?, ?, ?, ?)',
dayId, tripId, text.trim(), time || null, icon || '📝', sortOrder ?? 9999, normalizeNoteColor(color),
);
return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', result.lastInsertRowid);
}
update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number }) {
update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number; color?: string | null }) {
this.dbs.run(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?',
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ?, color = ? WHERE id = ?',
fields.text !== undefined ? fields.text.trim() : current.text,
fields.time !== undefined ? fields.time : current.time,
fields.icon !== undefined ? fields.icon : current.icon,
fields.sort_order !== undefined ? fields.sort_order : current.sort_order,
fields.color !== undefined ? normalizeNoteColor(fields.color) : (current.color ?? null),
id,
);
return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', id);
+2
View File
@@ -238,6 +238,8 @@ export interface DayNote {
text: string;
time?: string | null;
icon: string;
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color?: string | null;
sort_order: number;
created_at?: string;
}
+1 -1
View File
@@ -43,7 +43,7 @@ const { db } = vi.hoisted(() => {
PRIMARY KEY (assignment_id, user_id));`);
tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL, text TEXT NOT NULL, time TEXT, icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
color TEXT, sort_order REAL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// Reorder/insert touch accommodations + reservation restamping.
tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT,
@@ -153,6 +153,56 @@ describe('create', () => {
});
});
// ── colour (#1629) ────────────────────────────────────────────────────────────
describe('note colours', () => {
it('DAYNOTE-SVC-017: a palette colour is stored as given', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Passport check', null, null, 0, '#dc2626') as DayNote;
expect(note.color).toBe('#dc2626');
});
it('DAYNOTE-SVC-018: no colour is the default, and stays NULL rather than empty string', () => {
const { trip, day } = seedTripAndDay();
expect((svc.create(day.id, trip.id, 'a') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'b', null, null, 0, null) as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'c', null, null, 0, '') as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-019: a colour outside the palette is dropped, not stored', () => {
const { trip, day } = seedTripAndDay();
// Anything can reach this: the Zod contract can only say "a short string".
expect((svc.create(day.id, trip.id, 'a', null, null, 0, '#123456') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'b', null, null, 0, 'red') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'c', null, null, 0, 'javascript:x') as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-020: the note survives a bad colour instead of being rejected', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Ferry at six', '18:00', null, 0, 'nonsense') as DayNote;
expect(note.text).toBe('Ferry at six');
expect(note.time).toBe('18:00');
});
it('DAYNOTE-SVC-021: update changes the colour and can clear it again', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#2563eb') as DayNote;
expect((svc.update(note.id, note, { color: '#16a34a' }) as DayNote).color).toBe('#16a34a');
expect((svc.update(note.id, svc.getNote(note.id, day.id, trip.id)!, { color: null }) as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-022: an update that says nothing about the colour keeps it', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#9333ea') as DayNote;
const updated = svc.update(note.id, note, { text: 'Museum, book ahead' }) as DayNote;
expect(updated.text).toBe('Museum, book ahead');
expect(updated.color).toBe('#9333ea');
});
});
// ── list ──────────────────────────────────────────────────────────────────────
describe('list', () => {
@@ -118,12 +118,15 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
// (day-notes.dto.ts over the @trek/shared schemas) — like the old guard it
// rejects before the trip-access check. The pipe path is exercised end-to-end
// in tests/e2e/days.e2e.test.ts; here we pin the caps on the DTO schemas.
it('DTO schemas carry the legacy length caps and moveDayNote nulls', () => {
it('DTO schemas carry the length caps and moveDayNote nulls', () => {
expect(DayNoteCreateDto.schema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(251) }).success).toBe(false);
// The body cap moved to 2000 with #1629 (formatted Markdown); the title did not.
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ time: 'y'.repeat(251) }).success).toBe(false);
expect(DayNoteUpdateDto.schema.safeParse({ time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteUpdateDto.schema.safeParse({ time: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ color: '#dc2626' }).success).toBe(true);
});
// Trip access and day_edit are TripAccessGuard's now (trip-access.guard.test.ts);
@@ -134,7 +137,7 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
const create = vi.fn().mockReturnValue({ id: 7 }); const broadcast = vi.fn();
const svc = notesSvc({ dayExists: vi.fn().mockReturnValue(true), create, broadcast } as Partial<DayNotesService>);
expect(new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } });
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined);
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined, undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:created', { dayId: 3, note: { id: 7 } }, 'sock');
});
+12 -2
View File
@@ -10,18 +10,28 @@ describe('dayCreateRequestSchema', () => {
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 250', () => {
it('requires non-empty text capped at 500, body capped at 2000', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
// Raised from 250 with #1629: the field carries formatted Markdown now.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(251),
time: 'y'.repeat(2001),
}).success,
).toBe(false);
});
it('takes a note colour, and only as a short string', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: '#dc2626' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: null }).success).toBe(true);
// The palette itself is enforced in the service — the contract only keeps
// the column from taking arbitrary length.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: 'x'.repeat(10) }).success).toBe(false);
});
it('accepts null time/icon (moveDayNote re-sends the nullable entity fields)', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
});
+7 -2
View File
@@ -24,6 +24,8 @@ export const dayNoteSchema = z.object({
text: z.string(),
time: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
});
@@ -83,16 +85,19 @@ export type DayTransportRequest = z.infer<typeof dayTransportRequestSchema>;
// null time on update means "clear" (the legacy raw-body route accepted both).
export const dayNoteCreateRequestSchema = z.object({
text: z.string().min(1).max(500),
time: z.string().max(250).nullable().optional(),
time: z.string().max(2000).nullable().optional(),
icon: z.string().nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
export const dayNoteUpdateRequestSchema = z.object({
text: z.string().max(500).optional(),
time: z.string().max(250).nullable().optional(),
time: z.string().max(2000).nullable().optional(),
icon: z.string().nullable().optional(),
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteUpdateRequest = z.infer<typeof dayNoteUpdateRequestSchema>;
+32
View File
@@ -0,0 +1,32 @@
/**
* Note colour palette (#1629) — shared so the picker, the note card and the PDF
* hand out the exact same set.
*
* These are drawn on UI surfaces rather than on map tiles, which is why they are
* not TRACK_COLORS: a note is rendered as a tint of its colour behind body text
* in both themes, so each entry has to stay legible at ~12% opacity on a white
* card and at ~18% on a dark one. The 500/600 tier does that; the 700s used for
* tracks go muddy once tinted.
*
* The set is small on purpose. A note colour is a label ("watch out", "must
* see", "booked"), and a palette of twenty makes people pick by hue instead of
* by meaning. Seven distinct hues, none of them adjacent, plus "no colour" as
* the default.
*/
export const NOTE_COLORS = [
'#dc2626', // red-600 — warnings
'#ea580c', // orange-600
'#d97706', // amber-600
'#16a34a', // green-600 — done, confirmed
'#0891b2', // cyan-600
'#2563eb', // blue-600 — information
'#9333ea', // purple-600
] as const;
export type NoteColor = (typeof NOTE_COLORS)[number];
/** True for a value the palette actually offers, or for "no colour at all". */
export function isNoteColor(value: unknown): boolean {
if (value === null || value === undefined || value === '') return true;
return typeof value === 'string' && (NOTE_COLORS as readonly string[]).includes(value);
}
+24
View File
@@ -15,8 +15,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'تعديل الملاحظة',
'dayplan.noteAdd': 'إضافة ملاحظة',
'dayplan.noteEdit': 'تعديل الملاحظة',
'dayplan.noteDelete': 'حذف الملاحظة',
'dayplan.noteTitle': 'ملاحظة',
'dayplan.noteSubtitle': 'ملاحظة يومية',
'dayplan.noteIcon': 'أيقونة',
'notes.appearance': 'المظهر',
'notes.preview': 'معاينة',
'notes.bodyPlaceholder': 'تفاصيل وروابط وتذكيرات…',
'notes.markdownHint': 'يدعم Markdown',
'notes.format.toolbar': 'التنسيق',
'notes.format.bold': 'غامق',
'notes.format.italic': 'مائل',
'notes.format.strike': 'يتوسطه خط',
'notes.format.code': 'كود',
'notes.format.link': 'رابط',
'notes.format.list': 'قائمة نقطية',
'notes.format.orderedList': 'قائمة مرقمة',
'notes.format.quote': 'اقتباس',
'notes.color.label': 'اللون',
'notes.color.none': 'بلا لون',
'notes.color.red': 'أحمر',
'notes.color.orange': 'برتقالي',
'notes.color.amber': 'كهرماني',
'notes.color.green': 'أخضر',
'notes.color.cyan': 'سماوي',
'notes.color.blue': 'أزرق',
'notes.color.purple': 'بنفسجي',
'dayplan.totalCost': 'إجمالي التكلفة',
'dayplan.days': 'الأيام',
'dayplan.dayN': 'اليوم {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Adicionar nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteDelete': 'Excluir nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota do dia',
'dayplan.noteIcon': 'Ícone',
'notes.appearance': 'Aparência',
'notes.preview': 'Pré-visualização',
'notes.bodyPlaceholder': 'Detalhes, links, lembretes…',
'notes.markdownHint': 'Markdown suportado',
'notes.format.toolbar': 'Formatação',
'notes.format.bold': 'Negrito',
'notes.format.italic': 'Itálico',
'notes.format.strike': 'Tachado',
'notes.format.code': 'Código',
'notes.format.link': 'Link',
'notes.format.list': 'Lista com marcadores',
'notes.format.orderedList': 'Lista numerada',
'notes.format.quote': 'Citação',
'notes.color.label': 'Cor',
'notes.color.none': 'Sem cor',
'notes.color.red': 'Vermelho',
'notes.color.orange': 'Laranja',
'notes.color.amber': 'Âmbar',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Ciano',
'notes.color.blue': 'Azul',
'notes.color.purple': 'Roxo',
'dayplan.totalCost': 'Custo total',
'dayplan.days': 'Dias',
'dayplan.dayN': 'Dia {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edita la nota',
'dayplan.noteAdd': 'Afegeix una nota',
'dayplan.noteEdit': 'Edita la nota',
'dayplan.noteDelete': 'Elimina la nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota diària',
'dayplan.noteIcon': 'Icona',
'notes.appearance': 'Aparença',
'notes.preview': 'Vista prèvia',
'notes.bodyPlaceholder': 'Detalls, enllaços, recordatoris…',
'notes.markdownHint': 'Admet Markdown',
'notes.format.toolbar': 'Format',
'notes.format.bold': 'Negreta',
'notes.format.italic': 'Cursiva',
'notes.format.strike': 'Ratllat',
'notes.format.code': 'Codi',
'notes.format.link': 'Enllaç',
'notes.format.list': 'Llista amb pics',
'notes.format.orderedList': 'Llista numerada',
'notes.format.quote': 'Cita',
'notes.color.label': 'Color',
'notes.color.none': 'Sense color',
'notes.color.red': 'Vermell',
'notes.color.orange': 'Taronja',
'notes.color.amber': 'Ambre',
'notes.color.green': 'Verd',
'notes.color.cyan': 'Cian',
'notes.color.blue': 'Blau',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Cost total',
'dayplan.days': 'Dies',
'dayplan.dayN': 'Dia {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Upravit poznámku',
'dayplan.noteAdd': 'Přidat poznámku',
'dayplan.noteEdit': 'Upravit poznámku',
'dayplan.noteDelete': 'Smazat poznámku',
'dayplan.noteTitle': 'Poznámka',
'dayplan.noteSubtitle': 'Poznámka ke dni',
'dayplan.noteIcon': 'Ikona',
'notes.appearance': 'Vzhled',
'notes.preview': 'Náhled',
'notes.bodyPlaceholder': 'Podrobnosti, odkazy, připomínky…',
'notes.markdownHint': 'Podporuje Markdown',
'notes.format.toolbar': 'Formátování',
'notes.format.bold': 'Tučné',
'notes.format.italic': 'Kurzíva',
'notes.format.strike': 'Přeškrtnuté',
'notes.format.code': 'Kód',
'notes.format.link': 'Odkaz',
'notes.format.list': 'Odrážkový seznam',
'notes.format.orderedList': 'Číslovaný seznam',
'notes.format.quote': 'Citace',
'notes.color.label': 'Barva',
'notes.color.none': 'Bez barvy',
'notes.color.red': 'Červená',
'notes.color.orange': 'Oranžová',
'notes.color.amber': 'Jantarová',
'notes.color.green': 'Zelená',
'notes.color.cyan': 'Azurová',
'notes.color.blue': 'Modrá',
'notes.color.purple': 'Fialová',
'dayplan.totalCost': 'Celkové náklady',
'dayplan.days': 'Dny',
'dayplan.dayN': 'Den {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
'dayplan.noteDelete': 'Notiz löschen',
'dayplan.noteTitle': 'Notiz',
'dayplan.noteSubtitle': 'Tagesnotiz',
'dayplan.noteIcon': 'Symbol',
'notes.appearance': 'Darstellung',
'notes.preview': 'Vorschau',
'notes.bodyPlaceholder': 'Details, Links, Erinnerungen …',
'notes.markdownHint': 'Markdown wird unterstützt',
'notes.format.toolbar': 'Formatierung',
'notes.format.bold': 'Fett',
'notes.format.italic': 'Kursiv',
'notes.format.strike': 'Durchgestrichen',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Aufzählung',
'notes.format.orderedList': 'Nummerierte Liste',
'notes.format.quote': 'Zitat',
'notes.color.label': 'Farbe',
'notes.color.none': 'Keine Farbe',
'notes.color.red': 'Rot',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Bernstein',
'notes.color.green': 'Grün',
'notes.color.cyan': 'Türkis',
'notes.color.blue': 'Blau',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Gesamtkosten',
'dayplan.days': 'Tage',
'dayplan.dayN': 'Tag {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
'dayplan.noteDelete': 'Delete Note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Daily Note',
'dayplan.noteIcon': 'Icon',
'notes.appearance': 'Appearance',
'notes.preview': 'Preview',
'notes.bodyPlaceholder': 'Details, links, reminders…',
'notes.markdownHint': 'Markdown supported',
'notes.format.toolbar': 'Formatting',
'notes.format.bold': 'Bold',
'notes.format.italic': 'Italic',
'notes.format.strike': 'Strikethrough',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Bulleted list',
'notes.format.orderedList': 'Numbered list',
'notes.format.quote': 'Quote',
'notes.color.label': 'Colour',
'notes.color.none': 'No colour',
'notes.color.red': 'Red',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Amber',
'notes.color.green': 'Green',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Blue',
'notes.color.purple': 'Purple',
'dayplan.totalCost': 'Total Cost',
'dayplan.days': 'Days',
'dayplan.dayN': 'Day {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Añadir nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteDelete': 'Eliminar nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota diaria',
'dayplan.noteIcon': 'Icono',
'notes.appearance': 'Apariencia',
'notes.preview': 'Vista previa',
'notes.bodyPlaceholder': 'Detalles, enlaces, recordatorios…',
'notes.markdownHint': 'Compatible con Markdown',
'notes.format.toolbar': 'Formato',
'notes.format.bold': 'Negrita',
'notes.format.italic': 'Cursiva',
'notes.format.strike': 'Tachado',
'notes.format.code': 'Código',
'notes.format.link': 'Enlace',
'notes.format.list': 'Lista con viñetas',
'notes.format.orderedList': 'Lista numerada',
'notes.format.quote': 'Cita',
'notes.color.label': 'Color',
'notes.color.none': 'Sin color',
'notes.color.red': 'Rojo',
'notes.color.orange': 'Naranja',
'notes.color.amber': 'Ámbar',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Cian',
'notes.color.blue': 'Azul',
'notes.color.purple': 'Morado',
'dayplan.totalCost': 'Coste total',
'dayplan.days': 'Días',
'dayplan.dayN': 'Día {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Modifier la note',
'dayplan.noteAdd': 'Ajouter une note',
'dayplan.noteEdit': 'Modifier la note',
'dayplan.noteDelete': 'Supprimer la note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Note du jour',
'dayplan.noteIcon': 'Icône',
'notes.appearance': 'Apparence',
'notes.preview': 'Aperçu',
'notes.bodyPlaceholder': 'Détails, liens, rappels…',
'notes.markdownHint': 'Markdown pris en charge',
'notes.format.toolbar': 'Mise en forme',
'notes.format.bold': 'Gras',
'notes.format.italic': 'Italique',
'notes.format.strike': 'Barré',
'notes.format.code': 'Code',
'notes.format.link': 'Lien',
'notes.format.list': 'Liste à puces',
'notes.format.orderedList': 'Liste numérotée',
'notes.format.quote': 'Citation',
'notes.color.label': 'Couleur',
'notes.color.none': 'Aucune couleur',
'notes.color.red': 'Rouge',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Ambre',
'notes.color.green': 'Vert',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Bleu',
'notes.color.purple': 'Violet',
'dayplan.totalCost': 'Coût total',
'dayplan.days': 'Jours',
'dayplan.dayN': 'Jour {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Επεξεργασία Σημείωσης',
'dayplan.noteAdd': 'Προσθήκη Σημείωσης',
'dayplan.noteEdit': 'Επεξεργασία Σημείωσης',
'dayplan.noteDelete': 'Διαγραφή σημείωσης',
'dayplan.noteTitle': 'Σημείωση',
'dayplan.noteSubtitle': 'Ημερήσια Σημείωση',
'dayplan.noteIcon': 'Εικονίδιο',
'notes.appearance': 'Εμφάνιση',
'notes.preview': 'Προεπισκόπηση',
'notes.bodyPlaceholder': 'Λεπτομέρειες, σύνδεσμοι, υπενθυμίσεις…',
'notes.markdownHint': 'Υποστηρίζεται Markdown',
'notes.format.toolbar': 'Μορφοποίηση',
'notes.format.bold': 'Έντονα',
'notes.format.italic': 'Πλάγια',
'notes.format.strike': 'Διαγράμμιση',
'notes.format.code': 'Κώδικας',
'notes.format.link': 'Σύνδεσμος',
'notes.format.list': 'Λίστα με κουκκίδες',
'notes.format.orderedList': 'Αριθμημένη λίστα',
'notes.format.quote': 'Παράθεση',
'notes.color.label': 'Χρώμα',
'notes.color.none': 'Χωρίς χρώμα',
'notes.color.red': 'Κόκκινο',
'notes.color.orange': 'Πορτοκαλί',
'notes.color.amber': 'Κεχριμπαρένιο',
'notes.color.green': 'Πράσινο',
'notes.color.cyan': 'Κυανό',
'notes.color.blue': 'Μπλε',
'notes.color.purple': 'Μοβ',
'dayplan.totalCost': 'Συνολικό Κόστος',
'dayplan.days': 'Ημέρες',
'dayplan.dayN': 'Ημέρα {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Jegyzet szerkesztése',
'dayplan.noteAdd': 'Jegyzet hozzáadása',
'dayplan.noteEdit': 'Jegyzet szerkesztése',
'dayplan.noteDelete': 'Megjegyzés törlése',
'dayplan.noteTitle': 'Jegyzet',
'dayplan.noteSubtitle': 'Napi jegyzet',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Megjelenés',
'notes.preview': 'Előnézet',
'notes.bodyPlaceholder': 'Részletek, hivatkozások, emlékeztetők…',
'notes.markdownHint': 'Markdown támogatott',
'notes.format.toolbar': 'Formázás',
'notes.format.bold': 'Félkövér',
'notes.format.italic': 'Dőlt',
'notes.format.strike': 'Áthúzott',
'notes.format.code': 'Kód',
'notes.format.link': 'Hivatkozás',
'notes.format.list': 'Felsorolás',
'notes.format.orderedList': 'Számozott lista',
'notes.format.quote': 'Idézet',
'notes.color.label': 'Szín',
'notes.color.none': 'Nincs szín',
'notes.color.red': 'Piros',
'notes.color.orange': 'Narancs',
'notes.color.amber': 'Borostyán',
'notes.color.green': 'Zöld',
'notes.color.cyan': 'Ciánkék',
'notes.color.blue': 'Kék',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Összköltség',
'dayplan.days': 'nap',
'dayplan.dayN': '{n}. nap',
+24
View File
@@ -16,8 +16,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edit Catatan',
'dayplan.noteAdd': 'Tambah Catatan',
'dayplan.noteEdit': 'Edit Catatan',
'dayplan.noteDelete': 'Hapus catatan',
'dayplan.noteTitle': 'Catatan',
'dayplan.noteSubtitle': 'Catatan Harian',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Tampilan',
'notes.preview': 'Pratinjau',
'notes.bodyPlaceholder': 'Detail, tautan, pengingat…',
'notes.markdownHint': 'Mendukung Markdown',
'notes.format.toolbar': 'Pemformatan',
'notes.format.bold': 'Tebal',
'notes.format.italic': 'Miring',
'notes.format.strike': 'Coret',
'notes.format.code': 'Kode',
'notes.format.link': 'Tautan',
'notes.format.list': 'Daftar berpoin',
'notes.format.orderedList': 'Daftar bernomor',
'notes.format.quote': 'Kutipan',
'notes.color.label': 'Warna',
'notes.color.none': 'Tanpa warna',
'notes.color.red': 'Merah',
'notes.color.orange': 'Oranye',
'notes.color.amber': 'Amber',
'notes.color.green': 'Hijau',
'notes.color.cyan': 'Sian',
'notes.color.blue': 'Biru',
'notes.color.purple': 'Ungu',
'dayplan.totalCost': 'Total Biaya',
'dayplan.days': 'Hari',
'dayplan.dayN': 'Hari {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Modifica nota',
'dayplan.noteAdd': 'Aggiungi nota',
'dayplan.noteEdit': 'Modifica nota',
'dayplan.noteDelete': 'Elimina nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota giornaliera',
'dayplan.noteIcon': 'Icona',
'notes.appearance': 'Aspetto',
'notes.preview': 'Anteprima',
'notes.bodyPlaceholder': 'Dettagli, link, promemoria…',
'notes.markdownHint': 'Markdown supportato',
'notes.format.toolbar': 'Formattazione',
'notes.format.bold': 'Grassetto',
'notes.format.italic': 'Corsivo',
'notes.format.strike': 'Barrato',
'notes.format.code': 'Codice',
'notes.format.link': 'Link',
'notes.format.list': 'Elenco puntato',
'notes.format.orderedList': 'Elenco numerato',
'notes.format.quote': 'Citazione',
'notes.color.label': 'Colore',
'notes.color.none': 'Nessun colore',
'notes.color.red': 'Rosso',
'notes.color.orange': 'Arancione',
'notes.color.amber': 'Ambra',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Ciano',
'notes.color.blue': 'Blu',
'notes.color.purple': 'Viola',
'dayplan.totalCost': 'Costo totale',
'dayplan.days': 'Giorni',
'dayplan.dayN': 'Giorno {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'メモを編集',
'dayplan.noteAdd': 'メモを追加',
'dayplan.noteEdit': 'メモを編集',
'dayplan.noteDelete': 'メモを削除',
'dayplan.noteTitle': 'メモ',
'dayplan.noteSubtitle': '日別メモ',
'dayplan.noteIcon': 'アイコン',
'notes.appearance': '外観',
'notes.preview': 'プレビュー',
'notes.bodyPlaceholder': '詳細、リンク、メモなど…',
'notes.markdownHint': 'Markdown に対応',
'notes.format.toolbar': '書式',
'notes.format.bold': '太字',
'notes.format.italic': '斜体',
'notes.format.strike': '取り消し線',
'notes.format.code': 'コード',
'notes.format.link': 'リンク',
'notes.format.list': '箇条書き',
'notes.format.orderedList': '番号付きリスト',
'notes.format.quote': '引用',
'notes.color.label': '色',
'notes.color.none': '色なし',
'notes.color.red': '赤',
'notes.color.orange': 'オレンジ',
'notes.color.amber': 'アンバー',
'notes.color.green': '緑',
'notes.color.cyan': 'シアン',
'notes.color.blue': '青',
'notes.color.purple': '紫',
'dayplan.totalCost': '合計費用',
'dayplan.days': '日',
'dayplan.dayN': '{n}日目',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '메모 편집',
'dayplan.noteAdd': '메모 추가',
'dayplan.noteEdit': '메모 편집',
'dayplan.noteDelete': '메모 삭제',
'dayplan.noteTitle': '메모',
'dayplan.noteSubtitle': '일별 메모',
'dayplan.noteIcon': '아이콘',
'notes.appearance': '모양',
'notes.preview': '미리 보기',
'notes.bodyPlaceholder': '세부 정보, 링크, 알림…',
'notes.markdownHint': '마크다운 지원',
'notes.format.toolbar': '서식',
'notes.format.bold': '굵게',
'notes.format.italic': '기울임',
'notes.format.strike': '취소선',
'notes.format.code': '코드',
'notes.format.link': '링크',
'notes.format.list': '글머리 기호 목록',
'notes.format.orderedList': '번호 매기기 목록',
'notes.format.quote': '인용',
'notes.color.label': '색상',
'notes.color.none': '색상 없음',
'notes.color.red': '빨강',
'notes.color.orange': '주황',
'notes.color.amber': '호박색',
'notes.color.green': '초록',
'notes.color.cyan': '청록',
'notes.color.blue': '파랑',
'notes.color.purple': '보라',
'dayplan.totalCost': '총 비용',
'dayplan.days': '일',
'dayplan.dayN': '{n}일차',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notitie bewerken',
'dayplan.noteAdd': 'Notitie toevoegen',
'dayplan.noteEdit': 'Notitie bewerken',
'dayplan.noteDelete': 'Notitie verwijderen',
'dayplan.noteTitle': 'Notitie',
'dayplan.noteSubtitle': 'Dagnotitie',
'dayplan.noteIcon': 'Pictogram',
'notes.appearance': 'Weergave',
'notes.preview': 'Voorbeeld',
'notes.bodyPlaceholder': 'Details, links, herinneringen…',
'notes.markdownHint': 'Markdown wordt ondersteund',
'notes.format.toolbar': 'Opmaak',
'notes.format.bold': 'Vet',
'notes.format.italic': 'Cursief',
'notes.format.strike': 'Doorhalen',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Opsomming',
'notes.format.orderedList': 'Genummerde lijst',
'notes.format.quote': 'Citaat',
'notes.color.label': 'Kleur',
'notes.color.none': 'Geen kleur',
'notes.color.red': 'Rood',
'notes.color.orange': 'Oranje',
'notes.color.amber': 'Amber',
'notes.color.green': 'Groen',
'notes.color.cyan': 'Cyaan',
'notes.color.blue': 'Blauw',
'notes.color.purple': 'Paars',
'dayplan.totalCost': 'Totale kosten',
'dayplan.days': 'Dagen',
'dayplan.dayN': 'Dag {n}',
+24
View File
@@ -17,8 +17,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edytuj notatkę',
'dayplan.noteAdd': 'Dodaj notatkę',
'dayplan.noteEdit': 'Edytuj notatkę',
'dayplan.noteDelete': 'Usuń notatkę',
'dayplan.noteTitle': 'Notatka',
'dayplan.noteSubtitle': 'Notatka dnia',
'dayplan.noteIcon': 'Ikona',
'notes.appearance': 'Wygląd',
'notes.preview': 'Podgląd',
'notes.bodyPlaceholder': 'Szczegóły, linki, przypomnienia…',
'notes.markdownHint': 'Obsługiwany Markdown',
'notes.format.toolbar': 'Formatowanie',
'notes.format.bold': 'Pogrubienie',
'notes.format.italic': 'Kursywa',
'notes.format.strike': 'Przekreślenie',
'notes.format.code': 'Kod',
'notes.format.link': 'Link',
'notes.format.list': 'Lista punktowana',
'notes.format.orderedList': 'Lista numerowana',
'notes.format.quote': 'Cytat',
'notes.color.label': 'Kolor',
'notes.color.none': 'Bez koloru',
'notes.color.red': 'Czerwony',
'notes.color.orange': 'Pomarańczowy',
'notes.color.amber': 'Bursztynowy',
'notes.color.green': 'Zielony',
'notes.color.cyan': 'Cyjan',
'notes.color.blue': 'Niebieski',
'notes.color.purple': 'Fioletowy',
'dayplan.totalCost': 'Łączny koszt',
'dayplan.days': 'Dni',
'dayplan.dayN': 'Dzień {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Редактировать заметку',
'dayplan.noteAdd': 'Добавить заметку',
'dayplan.noteEdit': 'Редактировать заметку',
'dayplan.noteDelete': 'Удалить заметку',
'dayplan.noteTitle': 'Заметка',
'dayplan.noteSubtitle': 'Заметка на день',
'dayplan.noteIcon': 'Значок',
'notes.appearance': 'Вид',
'notes.preview': 'Предпросмотр',
'notes.bodyPlaceholder': 'Детали, ссылки, напоминания…',
'notes.markdownHint': 'Поддерживается Markdown',
'notes.format.toolbar': 'Форматирование',
'notes.format.bold': 'Полужирный',
'notes.format.italic': 'Курсив',
'notes.format.strike': 'Зачёркнутый',
'notes.format.code': 'Код',
'notes.format.link': 'Ссылка',
'notes.format.list': 'Маркированный список',
'notes.format.orderedList': 'Нумерованный список',
'notes.format.quote': 'Цитата',
'notes.color.label': 'Цвет',
'notes.color.none': 'Без цвета',
'notes.color.red': 'Красный',
'notes.color.orange': 'Оранжевый',
'notes.color.amber': 'Янтарный',
'notes.color.green': 'Зелёный',
'notes.color.cyan': 'Голубой',
'notes.color.blue': 'Синий',
'notes.color.purple': 'Фиолетовый',
'dayplan.totalCost': 'Общая стоимость',
'dayplan.days': 'Дни',
'dayplan.dayN': 'День {n}',
+24
View File
@@ -19,8 +19,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Redigera notering',
'dayplan.noteAdd': 'Lägg till notering',
'dayplan.noteEdit': 'Redigera notering',
'dayplan.noteDelete': 'Ta bort anteckning',
'dayplan.noteTitle': 'Notering',
'dayplan.noteSubtitle': 'Daglig notering',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Utseende',
'notes.preview': 'Förhandsvisning',
'notes.bodyPlaceholder': 'Detaljer, länkar, påminnelser…',
'notes.markdownHint': 'Markdown stöds',
'notes.format.toolbar': 'Formatering',
'notes.format.bold': 'Fet',
'notes.format.italic': 'Kursiv',
'notes.format.strike': 'Genomstruken',
'notes.format.code': 'Kod',
'notes.format.link': 'Länk',
'notes.format.list': 'Punktlista',
'notes.format.orderedList': 'Numrerad lista',
'notes.format.quote': 'Citat',
'notes.color.label': 'Färg',
'notes.color.none': 'Ingen färg',
'notes.color.red': 'Röd',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Bärnsten',
'notes.color.green': 'Grön',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Blå',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Totala kostnaden',
'dayplan.days': 'Dagar',
'dayplan.dayN': 'Dag {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notu Düzenle',
'dayplan.noteAdd': 'Not Ekle',
'dayplan.noteEdit': 'Notu Düzenle',
'dayplan.noteDelete': 'Notu sil',
'dayplan.noteTitle': 'Not',
'dayplan.noteSubtitle': 'Günlük Not',
'dayplan.noteIcon': 'Simge',
'notes.appearance': 'Görünüm',
'notes.preview': 'Önizleme',
'notes.bodyPlaceholder': 'Ayrıntılar, bağlantılar, hatırlatmalar…',
'notes.markdownHint': 'Markdown destekleniyor',
'notes.format.toolbar': 'Biçimlendirme',
'notes.format.bold': 'Kalın',
'notes.format.italic': 'İtalik',
'notes.format.strike': 'Üstü çizili',
'notes.format.code': 'Kod',
'notes.format.link': 'Bağlantı',
'notes.format.list': 'Madde işaretli liste',
'notes.format.orderedList': 'Numaralı liste',
'notes.format.quote': 'Alıntı',
'notes.color.label': 'Renk',
'notes.color.none': 'Renk yok',
'notes.color.red': 'Kırmızı',
'notes.color.orange': 'Turuncu',
'notes.color.amber': 'Kehribar',
'notes.color.green': 'Yeşil',
'notes.color.cyan': 'Camgöbeği',
'notes.color.blue': 'Mavi',
'notes.color.purple': 'Mor',
'dayplan.totalCost': 'Toplam Maliyet',
'dayplan.days': 'Günler',
'dayplan.dayN': '{n}. gün',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Редагувати нотатку',
'dayplan.noteAdd': 'Додати нотатку',
'dayplan.noteEdit': 'Редагувати нотатку',
'dayplan.noteDelete': 'Видалити нотатку',
'dayplan.noteTitle': 'Нотатка',
'dayplan.noteSubtitle': 'Нотатка на день',
'dayplan.noteIcon': 'Значок',
'notes.appearance': 'Вигляд',
'notes.preview': 'Попередній перегляд',
'notes.bodyPlaceholder': 'Деталі, посилання, нагадування…',
'notes.markdownHint': 'Підтримується Markdown',
'notes.format.toolbar': 'Форматування',
'notes.format.bold': 'Жирний',
'notes.format.italic': 'Курсив',
'notes.format.strike': 'Закреслений',
'notes.format.code': 'Код',
'notes.format.link': 'Посилання',
'notes.format.list': 'Маркований список',
'notes.format.orderedList': 'Нумерований список',
'notes.format.quote': 'Цитата',
'notes.color.label': 'Колір',
'notes.color.none': 'Без кольору',
'notes.color.red': 'Червоний',
'notes.color.orange': 'Помаранчевий',
'notes.color.amber': 'Бурштиновий',
'notes.color.green': 'Зелений',
'notes.color.cyan': 'Блакитний',
'notes.color.blue': 'Синій',
'notes.color.purple': 'Фіолетовий',
'dayplan.collapseAll': 'Згорнути всі дні',
'dayplan.expandAll': 'Розгорнути всі дні',
'dayplan.totalCost': 'Загальна вартість',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Chỉnh sửa ghi chú',
'dayplan.noteAdd': 'Thêm ghi chú',
'dayplan.noteEdit': 'Chỉnh sửa ghi chú',
'dayplan.noteDelete': 'Xóa ghi chú',
'dayplan.noteTitle': 'Ghi chú',
'dayplan.noteSubtitle': 'Ghi chú hàng ngày',
'dayplan.noteIcon': 'Biểu tượng',
'notes.appearance': 'Giao diện',
'notes.preview': 'Xem trước',
'notes.bodyPlaceholder': 'Chi tiết, liên kết, nhắc nhở…',
'notes.markdownHint': 'Hỗ trợ Markdown',
'notes.format.toolbar': 'Định dạng',
'notes.format.bold': 'Đậm',
'notes.format.italic': 'Nghiêng',
'notes.format.strike': 'Gạch ngang',
'notes.format.code': 'Mã',
'notes.format.link': 'Liên kết',
'notes.format.list': 'Danh sách dấu đầu dòng',
'notes.format.orderedList': 'Danh sách đánh số',
'notes.format.quote': 'Trích dẫn',
'notes.color.label': 'Màu',
'notes.color.none': 'Không màu',
'notes.color.red': 'Đỏ',
'notes.color.orange': 'Cam',
'notes.color.amber': 'Hổ phách',
'notes.color.green': 'Xanh lá',
'notes.color.cyan': 'Lục lam',
'notes.color.blue': 'Xanh dương',
'notes.color.purple': 'Tím',
'dayplan.totalCost': 'Tổng chi phí',
'dayplan.days': 'Ngày',
'dayplan.dayN': 'Ngày {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '編輯備註',
'dayplan.noteAdd': '新增備註',
'dayplan.noteEdit': '編輯備註',
'dayplan.noteDelete': '刪除備註',
'dayplan.noteTitle': '備註',
'dayplan.noteSubtitle': '每日備註',
'dayplan.noteIcon': '圖示',
'notes.appearance': '外觀',
'notes.preview': '預覽',
'notes.bodyPlaceholder': '詳情、連結、提醒…',
'notes.markdownHint': '支援 Markdown',
'notes.format.toolbar': '格式',
'notes.format.bold': '粗體',
'notes.format.italic': '斜體',
'notes.format.strike': '刪除線',
'notes.format.code': '程式碼',
'notes.format.link': '連結',
'notes.format.list': '項目符號清單',
'notes.format.orderedList': '編號清單',
'notes.format.quote': '引用',
'notes.color.label': '顏色',
'notes.color.none': '無顏色',
'notes.color.red': '紅色',
'notes.color.orange': '橙色',
'notes.color.amber': '琥珀色',
'notes.color.green': '綠色',
'notes.color.cyan': '青色',
'notes.color.blue': '藍色',
'notes.color.purple': '紫色',
'dayplan.totalCost': '總費用',
'dayplan.days': '天',
'dayplan.dayN': '第 {n} 天',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '编辑备注',
'dayplan.noteAdd': '添加备注',
'dayplan.noteEdit': '编辑备注',
'dayplan.noteDelete': '删除备注',
'dayplan.noteTitle': '备注',
'dayplan.noteSubtitle': '每日备注',
'dayplan.noteIcon': '图标',
'notes.appearance': '外观',
'notes.preview': '预览',
'notes.bodyPlaceholder': '详情、链接、提醒…',
'notes.markdownHint': '支持 Markdown',
'notes.format.toolbar': '格式',
'notes.format.bold': '加粗',
'notes.format.italic': '斜体',
'notes.format.strike': '删除线',
'notes.format.code': '代码',
'notes.format.link': '链接',
'notes.format.list': '项目符号列表',
'notes.format.orderedList': '编号列表',
'notes.format.quote': '引用',
'notes.color.label': '颜色',
'notes.color.none': '无颜色',
'notes.color.red': '红色',
'notes.color.orange': '橙色',
'notes.color.amber': '琥珀色',
'notes.color.green': '绿色',
'notes.color.cyan': '青色',
'notes.color.blue': '蓝色',
'notes.color.purple': '紫色',
'dayplan.totalCost': '总费用',
'dayplan.days': '天',
'dayplan.dayN': '第 {n} 天',
+1
View File
@@ -30,6 +30,7 @@ export * from './reservation/reservation.schema';
export * from './reservation/ki-reservation.schema';
export * from './airtrail/airtrail.schema';
export * from './day/day.schema';
export * from './day/note-colors';
export * from './assignment/assignment.schema';
export * from './place/place.schema';
export * from './place/track-colors';