mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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')) }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}日目',
|
||||
|
||||
@@ -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}일차',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Загальна вартість',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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} 天',
|
||||
|
||||
@@ -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} 天',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user