mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 20:03:21 +02:00
cal block initial commit
This commit is contained in:
parent
484231b224
commit
c03882f43f
9 changed files with 932 additions and 41 deletions
|
|
@ -703,7 +703,7 @@ export function setupIpcHandlers() {
|
|||
return { success: false, error: 'Unknown format' };
|
||||
},
|
||||
'meeting:summarize': async (_event, args) => {
|
||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime);
|
||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
return { notes };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
|||
import { toast } from "sonner"
|
||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||
import { useMeetingTranscription, type MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
|
||||
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -283,9 +283,12 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Flatten date-based folder hierarchy under Meetings/ source folders.
|
||||
* Turns Meetings/granola/2026/03/15/Title.md into a flat list under
|
||||
* Meetings/granola/ with display name "2026-03-15 Title".
|
||||
* Organize Meetings/ source folders into date-grouped subfolders.
|
||||
*
|
||||
* - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is
|
||||
* - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders
|
||||
* - Files directly under a source folder (no date subfolder) are grouped
|
||||
* by the date prefix in their filename (e.g. meeting-2026-03-17T...).
|
||||
*/
|
||||
function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.flatMap(node => {
|
||||
|
|
@ -294,14 +297,18 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
|||
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
|
||||
if (sourceNode.kind !== 'dir') return [sourceNode]
|
||||
|
||||
// Collect all files recursively from the date hierarchy
|
||||
const files: TreeNode[] = []
|
||||
// Collect all files with their date group label
|
||||
const dateGroups = new Map<string, TreeNode[]>()
|
||||
|
||||
function collectFiles(n: TreeNode, dateParts: string[]) {
|
||||
for (const child of n.children ?? []) {
|
||||
if (child.kind === 'file') {
|
||||
const dateStr = dateParts.join('-')
|
||||
const displayName = dateStr ? `${dateStr} ${child.name}` : child.name
|
||||
files.push({ ...child, name: displayName })
|
||||
// If file is at root of source folder, try to extract date from filename
|
||||
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
|
||||
const group = dateGroups.get(groupKey) ?? []
|
||||
group.push(child)
|
||||
dateGroups.set(groupKey, group)
|
||||
} else if (child.kind === 'dir') {
|
||||
collectFiles(child, [...dateParts, child.name])
|
||||
}
|
||||
|
|
@ -309,13 +316,24 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
|||
}
|
||||
collectFiles(sourceNode, [])
|
||||
|
||||
// Hide empty source folders
|
||||
if (files.length === 0) return []
|
||||
if (dateGroups.size === 0) return []
|
||||
|
||||
// Sort files reverse chronologically (newest first)
|
||||
files.sort((a, b) => b.name.localeCompare(a.name))
|
||||
// Build date folder nodes, sorted reverse chronologically
|
||||
const dateFolderNodes: TreeNode[] = [...dateGroups.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([dateKey, files]) => {
|
||||
// Sort files within each date group reverse chronologically
|
||||
files.sort((a, b) => b.name.localeCompare(a.name))
|
||||
return {
|
||||
name: dateKey,
|
||||
path: `${sourceNode.path}/${dateKey}`,
|
||||
kind: 'dir' as const,
|
||||
children: files,
|
||||
loaded: true,
|
||||
}
|
||||
})
|
||||
|
||||
return [{ ...sourceNode, children: files }]
|
||||
return [{ ...sourceNode, children: dateFolderNodes }]
|
||||
})
|
||||
|
||||
// Hide Meetings folder entirely if no source folders have files
|
||||
|
|
@ -325,6 +343,12 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
|||
})
|
||||
}
|
||||
|
||||
/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
|
||||
function extractDateFromFilename(name: string): string | null {
|
||||
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// Build tree structure from flat entries
|
||||
function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||
const treeMap = new Map<string, TreeNode>()
|
||||
|
|
@ -3362,13 +3386,16 @@ function App() {
|
|||
}, [loadDirectory, navigateToFile, fileTabs])
|
||||
|
||||
const meetingNotePathRef = useRef<string | null>(null)
|
||||
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
|
||||
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
||||
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
||||
|
||||
const startMeetingAfterPermissions = useCallback(async () => {
|
||||
setShowMeetingPermissions(false)
|
||||
localStorage.setItem('meeting-permissions-acknowledged', '1')
|
||||
const notePath = await meetingTranscription.start()
|
||||
const calEvent = pendingCalendarEventRef.current
|
||||
pendingCalendarEventRef.current = undefined
|
||||
const notePath = await meetingTranscription.start(calEvent)
|
||||
if (notePath) {
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
|
|
@ -3387,16 +3414,25 @@ function App() {
|
|||
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
||||
const fileContent = result.data
|
||||
if (fileContent && fileContent.trim()) {
|
||||
// Extract meeting start time from frontmatter for calendar matching
|
||||
// Extract meeting start time and calendar event from frontmatter
|
||||
const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m)
|
||||
const meetingStartTime = dateMatch?.[1]
|
||||
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime })
|
||||
// If a calendar event was linked, pass it directly so the summarizer
|
||||
// skips scanning and uses this event for attendee/title info.
|
||||
const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m)
|
||||
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
|
||||
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
|
||||
if (notes) {
|
||||
// Prepend meeting notes below the title but above the transcript
|
||||
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
|
||||
// Strip the "# Meeting note" title from transcript body — we'll put it first
|
||||
const bodyWithoutTitle = transcriptBody.replace(/^#\s+Meeting note\s*\n*/, '')
|
||||
const newBody = '# Meeting note\n\n' + notes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
|
||||
// Use frontmatter title as the heading (set from calendar event summary)
|
||||
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
|
||||
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting note'
|
||||
// Strip any existing top-level heading from body
|
||||
const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '')
|
||||
// Also strip any title/heading the LLM may have generated
|
||||
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
|
||||
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n---\n\n## Raw transcript\n\n' + bodyWithoutTitle
|
||||
const newContent = fm ? `${fm}\n${newBody}` : newBody
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
@ -3419,7 +3455,9 @@ function App() {
|
|||
setShowMeetingPermissions(true)
|
||||
return
|
||||
}
|
||||
const notePath = await meetingTranscription.start()
|
||||
const calEvent = pendingCalendarEventRef.current
|
||||
pendingCalendarEventRef.current = undefined
|
||||
const notePath = await meetingTranscription.start(calEvent)
|
||||
if (notePath) {
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
|
|
@ -3428,6 +3466,30 @@ function App() {
|
|||
}, [meetingTranscription, handleVoiceNoteCreated])
|
||||
handleToggleMeetingRef.current = handleToggleMeeting
|
||||
|
||||
// Listen for calendar block "join meeting & take notes" events
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
// Read calendar event data set by the calendar block on window
|
||||
const pending = window.__pendingCalendarEvent
|
||||
window.__pendingCalendarEvent = undefined
|
||||
if (pending) {
|
||||
pendingCalendarEventRef.current = {
|
||||
summary: pending.summary,
|
||||
start: pending.start,
|
||||
end: pending.end,
|
||||
location: pending.location,
|
||||
htmlLink: pending.htmlLink,
|
||||
conferenceLink: pending.conferenceLink,
|
||||
source: pending.source,
|
||||
}
|
||||
}
|
||||
// Use the same toggle flow — it will pick up pendingCalendarEventRef
|
||||
handleToggleMeetingRef.current?.()
|
||||
}
|
||||
window.addEventListener('calendar-block:join-meeting', handler)
|
||||
return () => window.removeEventListener('calendar-block:join-meeting', handler)
|
||||
}, [])
|
||||
|
||||
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||
const resolvedPath = toKnowledgePath(wikiPath)
|
||||
if (!resolvedPath) return null
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import { ImageBlockExtension } from '@/extensions/image-block'
|
|||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
||||
|
||||
// Zero-width space used as invisible marker for blank lines
|
||||
const BLANK_LINE_MARKER = '\u200B'
|
||||
|
|
@ -148,6 +150,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'calendarBlock') {
|
||||
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'codeBlock') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -218,6 +222,117 @@ type WikiLinkConfig = {
|
|||
onCreate: (path: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
// --- Meeting Event Banner ---
|
||||
|
||||
interface ParsedCalendarEvent {
|
||||
summary?: string
|
||||
start?: string
|
||||
end?: string
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null {
|
||||
if (!raw) return null
|
||||
// Strip surrounding quotes if present (YAML single-quoted string)
|
||||
let json = raw
|
||||
if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) {
|
||||
json = json.slice(1, -1)
|
||||
}
|
||||
// Unescape doubled single quotes from YAML
|
||||
json = json.replace(/''/g, "'")
|
||||
try {
|
||||
return JSON.parse(json) as ParsedCalendarEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventTime(start?: string, end?: string): string {
|
||||
if (!start) return ''
|
||||
const s = new Date(start)
|
||||
const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
if (!end) return `${startStr} \u00b7 ${startTime}`
|
||||
const e = new Date(end)
|
||||
const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
function formatEventDate(start?: string): string {
|
||||
if (!start) return ''
|
||||
const s = new Date(start)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (s.toDateString() === today.toDateString()) return 'Today'
|
||||
if (s.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
||||
if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow'
|
||||
return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
if (!frontmatter) return null
|
||||
const fields = extractAllFrontmatterValues(frontmatter)
|
||||
if (fields.type !== 'meeting') return null
|
||||
|
||||
const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined
|
||||
const cal = parseCalendarEvent(calStr)
|
||||
if (!cal) return null
|
||||
|
||||
return (
|
||||
<div className="meeting-event-banner" ref={ref}>
|
||||
<button
|
||||
className="meeting-event-pill"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Calendar size={13} />
|
||||
{formatEventDate(cal.start)}
|
||||
<ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="meeting-event-dropdown">
|
||||
<div className="meeting-event-dropdown-header">
|
||||
<span className="meeting-event-dropdown-dot" />
|
||||
<div className="meeting-event-dropdown-info">
|
||||
<div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
|
||||
<div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{cal.htmlLink && (
|
||||
<button
|
||||
className="meeting-event-dropdown-link"
|
||||
onClick={() => window.open(cal.htmlLink, '_blank')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in Google Calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Editor ---
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
content: string
|
||||
onChange: (markdown: string) => void
|
||||
|
|
@ -447,6 +562,7 @@ export function MarkdownEditor({
|
|||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -1201,6 +1317,7 @@ export function MarkdownEditor({
|
|||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<MeetingEventBanner frontmatter={frontmatter} />
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
|
|||
330
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
330
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Calendar, Video } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getDateParts(dateStr: string): { day: number; month: string; weekday: string } {
|
||||
const d = new Date(dateStr)
|
||||
return {
|
||||
day: d.getDate(),
|
||||
month: d.toLocaleDateString([], { month: 'long' }),
|
||||
weekday: d.toLocaleDateString([], { weekday: 'short' }),
|
||||
}
|
||||
}
|
||||
|
||||
function getEventDate(event: blocks.CalendarEvent): string {
|
||||
return event.start?.dateTime || event.start?.date || ''
|
||||
}
|
||||
|
||||
function isAllDay(event: blocks.CalendarEvent): boolean {
|
||||
return !event.start?.dateTime && !!event.start?.date
|
||||
}
|
||||
|
||||
function getTimeRange(event: blocks.CalendarEvent): string {
|
||||
if (isAllDay(event)) return 'All day'
|
||||
const start = event.start?.dateTime
|
||||
const end = event.end?.dateTime
|
||||
if (!start) return ''
|
||||
const startTime = formatTime(start)
|
||||
if (!end) return startTime
|
||||
const endTime = formatTime(end)
|
||||
return `${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to conferenceLink if already set.
|
||||
*/
|
||||
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
// Check conferenceData.entryPoints for video entry
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
// Check hangoutLink (Google Meet shortcut)
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
// Fall back to conferenceLink if present
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ResolvedEvent {
|
||||
event: blocks.CalendarEvent
|
||||
loaded: blocks.CalendarEvent | null
|
||||
conferenceLink?: string
|
||||
}
|
||||
|
||||
const EVENT_BAR_COLOR = '#7ec8c8'
|
||||
|
||||
// Shared global to pass calendar event data to App.tsx when joining a meeting.
|
||||
// Set before dispatching the custom event, read by the handler in App.tsx.
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingCalendarEvent?: {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.CalendarBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.CalendarBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const [resolvedEvents, setResolvedEvents] = useState<ResolvedEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return
|
||||
|
||||
const eventsWithSources = config.events.filter(e => e.source)
|
||||
if (eventsWithSources.length === 0) {
|
||||
setResolvedEvents(config.events.map(e => ({ event: e, loaded: null })))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<{ data: string }> } }).ipc
|
||||
|
||||
Promise.all(
|
||||
config.events.map(async (event): Promise<ResolvedEvent> => {
|
||||
if (!event.source) return { event, loaded: null }
|
||||
try {
|
||||
const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' })
|
||||
const content = typeof result === 'string' ? result : result.data
|
||||
const rawEvent = JSON.parse(content) as Record<string, unknown>
|
||||
const parsed = blocks.CalendarEventSchema.parse(rawEvent)
|
||||
const conferenceLink = extractConferenceLink(rawEvent)
|
||||
return { event, loaded: parsed, conferenceLink }
|
||||
} catch {
|
||||
return { event, loaded: null }
|
||||
}
|
||||
})
|
||||
).then(results => {
|
||||
setResolvedEvents(results)
|
||||
setLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [raw])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||
<div className="calendar-block-card calendar-block-error">
|
||||
<Calendar size={16} />
|
||||
<span>Invalid calendar block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const showJoinButton = config.showJoinButton === true
|
||||
|
||||
const events = resolvedEvents.map(r => {
|
||||
const e = r.loaded || r.event
|
||||
return {
|
||||
...e,
|
||||
htmlLink: e.htmlLink || r.event.htmlLink,
|
||||
conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink,
|
||||
}
|
||||
})
|
||||
|
||||
// Group events by date
|
||||
const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = []
|
||||
|
||||
let globalIdx = 0
|
||||
for (const event of events) {
|
||||
const dateStr = getEventDate(event)
|
||||
const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown'
|
||||
|
||||
let group = dateGroups.find(g => g.dateKey === dateKey)
|
||||
if (!group) {
|
||||
group = { dateKey, dateStr, events: [] }
|
||||
dateGroups.push(group)
|
||||
}
|
||||
group.events.push({ ...event, _idx: globalIdx++ })
|
||||
}
|
||||
|
||||
const handleEventClick = (event: blocks.CalendarEvent) => {
|
||||
if (event.htmlLink) {
|
||||
window.open(event.htmlLink, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number) => {
|
||||
const meetingUrl = event.conferenceLink
|
||||
if (meetingUrl) {
|
||||
window.open(meetingUrl, '_blank')
|
||||
}
|
||||
|
||||
// Find the original source path from config
|
||||
const originalEvent = config!.events[resolvedIdx]
|
||||
|
||||
// Set calendar event data on window so App.tsx handler can read it
|
||||
window.__pendingCalendarEvent = {
|
||||
summary: event.summary,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
htmlLink: event.htmlLink,
|
||||
conferenceLink: event.conferenceLink,
|
||||
source: originalEvent?.source,
|
||||
}
|
||||
// Dispatch custom event so App.tsx can start meeting transcription
|
||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||
<div className="calendar-block-card">
|
||||
<button
|
||||
className="calendar-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete calendar block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="calendar-block-title">{config.title}</div>}
|
||||
{loading ? (
|
||||
<div className="calendar-block-loading">Loading events...</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="calendar-block-empty">No events</div>
|
||||
) : (
|
||||
<div className="calendar-block-list">
|
||||
{dateGroups.map((group, groupIdx) => {
|
||||
const parts = group.dateStr ? getDateParts(group.dateStr) : null
|
||||
return (
|
||||
<div key={group.dateKey} className="calendar-block-date-group">
|
||||
{groupIdx > 0 && <div className="calendar-block-separator" />}
|
||||
<div className="calendar-block-date-row">
|
||||
<div className="calendar-block-date-left">
|
||||
{parts ? (
|
||||
<>
|
||||
<span className="calendar-block-day">{parts.day}</span>
|
||||
<div className="calendar-block-month-weekday">
|
||||
<span className="calendar-block-month">{parts.month}</span>
|
||||
<span className="calendar-block-weekday">{parts.weekday}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="calendar-block-day">?</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="calendar-block-events">
|
||||
{group.events.map(event => (
|
||||
<div
|
||||
key={event._idx}
|
||||
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
|
||||
>
|
||||
<div
|
||||
className="calendar-block-event-bar"
|
||||
style={{ backgroundColor: EVENT_BAR_COLOR }}
|
||||
/>
|
||||
<div className="calendar-block-event-content">
|
||||
<div className="calendar-block-event-title">
|
||||
{event.summary || 'Untitled event'}
|
||||
</div>
|
||||
<div className="calendar-block-event-time">
|
||||
{getTimeRange(event)}
|
||||
</div>
|
||||
{showJoinButton && event.conferenceLink && (
|
||||
<button
|
||||
className="calendar-block-join-btn"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); handleJoinMeeting(event, event._idx) }}
|
||||
>
|
||||
<Video size={13} />
|
||||
Join meeting & take notes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const CalendarBlockExtension = Node.create({
|
||||
name: 'calendarBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-calendar')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CalendarBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```calendar\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -47,18 +47,46 @@ interface TranscriptEntry {
|
|||
text: string;
|
||||
}
|
||||
|
||||
function formatTranscript(entries: TranscriptEntry[], date: string): string {
|
||||
export interface CalendarEventMeta {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
|
||||
const noteTitle = calendarEvent?.summary || 'Meeting note';
|
||||
const lines = [
|
||||
'---',
|
||||
'type: meeting',
|
||||
'source: rowboat',
|
||||
'title: Meeting note',
|
||||
`title: ${noteTitle}`,
|
||||
`date: "${date}"`,
|
||||
];
|
||||
if (calendarEvent) {
|
||||
// Serialize as a JSON string on one line — the frontmatter system
|
||||
// only supports flat key: value pairs, not nested YAML objects.
|
||||
const eventObj: Record<string, string> = {}
|
||||
if (calendarEvent.summary) eventObj.summary = calendarEvent.summary
|
||||
if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime
|
||||
else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date
|
||||
if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime
|
||||
else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date
|
||||
if (calendarEvent.location) eventObj.location = calendarEvent.location
|
||||
if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink
|
||||
if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink
|
||||
if (calendarEvent.source) eventObj.source = calendarEvent.source
|
||||
lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`)
|
||||
}
|
||||
lines.push(
|
||||
'---',
|
||||
'',
|
||||
'# Meeting note',
|
||||
`# ${noteTitle}`,
|
||||
'',
|
||||
];
|
||||
);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
lines.push('');
|
||||
|
|
@ -87,6 +115,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
|
||||
|
||||
const writeTranscriptToFile = useCallback(async () => {
|
||||
if (!notePathRef.current) return;
|
||||
|
|
@ -100,7 +129,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}
|
||||
}
|
||||
if (entries.length === 0) return;
|
||||
const content = formatTranscript(entries, dateRef.current);
|
||||
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePathRef.current,
|
||||
|
|
@ -151,7 +180,7 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (): Promise<string | null> => {
|
||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||
if (state !== 'idle') return null;
|
||||
setState('connecting');
|
||||
|
||||
|
|
@ -345,10 +374,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
dateRef.current = dateStr;
|
||||
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
|
||||
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
|
||||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/meeting-${timestamp}.md`;
|
||||
const filename = calendarEvent?.summary
|
||||
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
|
||||
: `meeting-${timestamp}`;
|
||||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||
notePathRef.current = notePath;
|
||||
|
||||
const initialContent = formatTranscript([], dateStr);
|
||||
calendarEventRef.current = calendarEvent;
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: initialContent,
|
||||
|
|
|
|||
|
|
@ -616,14 +616,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper {
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card {
|
||||
.tiptap-editor .ProseMirror .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -636,14 +638,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover {
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card {
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
|
@ -651,7 +655,8 @@
|
|||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete {
|
||||
.tiptap-editor .ProseMirror .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
|
|
@ -673,14 +678,16 @@
|
|||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete {
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover {
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
|
@ -836,6 +843,264 @@
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Calendar block */
|
||||
.tiptap-editor .ProseMirror .calendar-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-loading,
|
||||
.tiptap-editor .ProseMirror .calendar-block-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-separator {
|
||||
border: none;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-day {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-month-weekday {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-month {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-weekday {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-clickable:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-time {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-join-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #7ec8c8;
|
||||
background: color-mix(in srgb, #7ec8c8 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #7ec8c8 25%, transparent);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-join-btn:hover {
|
||||
background: color-mix(in srgb, #7ec8c8 22%, transparent);
|
||||
border-color: color-mix(in srgb, #7ec8c8 40%, transparent);
|
||||
}
|
||||
|
||||
/* Meeting event banner */
|
||||
.meeting-event-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 20px 0;
|
||||
}
|
||||
|
||||
.meeting-event-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.meeting-event-pill:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.meeting-event-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.meeting-event-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 20px;
|
||||
z-index: 50;
|
||||
min-width: 260px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: #7ec8c8;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-time {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-link:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .tiptap-editor .ProseMirror {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue