Files
verstak/frontend/src/lib/TodayScreen.svelte
T
mirivlad fc429ac26e feat: ШАГ 4 — UI для browser events в TodayScreen
Go bindings:
- bindings_browser.go: ListBrowserEvents, CountPendingBrowserEvents,
  AcceptBrowserEvent, DismissBrowserEvent, AttachBrowserEventToNode

Frontend:
- BrowserEvents.svelte — компонент списка событий с действиями
  (принять/прикрепить/удалить), статусы, домены, длительность
- TodayScreen.svelte — новая вкладка «Браузер» с badge
- App.svelte — loadBrowserEvents, acceptBrowserEvent,
  dismissBrowserEvent, attachBrowserEvent handlers
2026-06-06 18:58:39 +08:00

459 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { t } from './i18n'
import BrowserEvents from './BrowserEvents.svelte'
export let todayDashboard = null
export let suggestions = []
export let inProgressItems = []
export let todayCaptures = []
export let suggestionCount = 0
export let formatTime = (iso) => ''
export let eventLabel = (type) => type
export let eventIcon = (type) => ''
export let inboxMetaText = (item) => ''
export let onOpenNodeById = (id) => {}
export let onOpenActivityEvent = (ev) => {}
export let onOpenActivityTarget = (ev) => {}
export let onOpenSuggestionWorklog = (s) => {}
export let onAcceptSuggestion = (s) => {}
export let onDeleteSuggestion = (s) => {}
export let onOpenNodeFolder = (id) => {}
export let onOpenInboxArtifact = (item) => {}
export let onOpenTrashNode = (nodeId) => {}
// Browser events
export let browserEvents = []
export let browserLoading = false
export let onBrowserAccept = (ev) => {}
export let onBrowserDismiss = (ev) => {}
export let onBrowserAttach = (ev) => {}
export let onBrowserRefresh = () => {}
let activeTab = 'feed'
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
n %= 10
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
}
function extractEventIds(s) {
if (s.eventIds && s.eventIds.length) return s.eventIds
if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean)
return []
}
function handleAcceptSuggestion(s) {
onAcceptSuggestion(s)
}
function handleDeleteSuggestion(s) {
onDeleteSuggestion(s)
}
function feedItemLabel(ev) {
if (!ev || !ev.eventType) return ''
return eventLabel(ev.eventType)
}
function feedItemIcon(type) {
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'node_updated') return '~'
if (type === 'file_copied') return '⧉'
if (type === 'file_moved' || type === 'folder_moved') return '→'
if (type === 'note_updated') return '✎'
if (type === 'action_created') return '▶'
if (type === 'action_done') return '✓'
if (type === 'worklog_added') return '⏱'
return '•'
}
function handleFeedTitleClick(ev) {
if (!ev || !ev.eventType) return
if (ev.eventType === 'folder_deleted') {
if (ev.targetId) onOpenTrashNode(ev.targetId)
return
}
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
if (ev.targetId) {
onOpenActivityTarget(ev)
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
}
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
if (ev.targetType === 'note' && ev.targetId) {
onOpenActivityTarget(ev)
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
}
if (ev.eventType === 'worklog_added') {
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
if (['action_created','action_done'].includes(ev.eventType)) {
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
if (ev.id || ev.nodeId) {
onOpenActivityEvent(ev)
}
}
function feedItemSubtitle(ev) {
const parts = []
if (ev.nodePath) parts.push(ev.nodePath)
return parts.length > 0 ? parts.join(' → ') : ''
}
function inProgressSort(items, sortBy, sortDir) {
const sorted = [...items]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'type') {
cmp = (eventLabel(a.eventType) || '').localeCompare(eventLabel(b.eventType) || '')
} else {
cmp = (a.createdAt || '').localeCompare(b.createdAt || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}
let ipSortBy = 'time'
let ipSortDir = 'desc'
let captureSortBy = 'time'
let captureSortDir = 'desc'
function captureSort(items, sortBy, sortDir) {
const sorted = [...items]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'type') {
const aKind = a.captureKind || a.sourceKind || ''
const bKind = b.captureKind || b.sourceKind || ''
cmp = aKind.localeCompare(bKind)
} else {
cmp = (a.capturedAt || a.createdAt || '').localeCompare(b.capturedAt || b.createdAt || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}
function captureKindIcon(kind) {
const map = { text: '📝', url: '🔗', file: '📄', image: '🖼', folder: '📁' }
return map[kind] || '📦'
}
</script>
<div class="today-screen">
<div class="today-header">
<span class="today-date">{todayDashboard?.date || ''}</span>
</div>
<div class="today-tabs">
<button class="today-tab" class:active={activeTab === 'feed'} on:click={() => activeTab = 'feed'}>
{t('today.feed')}
</button>
<button class="today-tab" class:active={activeTab === 'suggestions'} on:click={() => activeTab = 'suggestions'}>
{t('today.suggestionsTab')}
{#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
</button>
<button class="today-tab" class:active={activeTab === 'inprogress'} on:click={() => activeTab = 'inprogress'}>
{t('today.inProgress')}
</button>
<button class="today-tab" class:active={activeTab === 'captured'} on:click={() => activeTab = 'captured'}>
{t('today.captured')}
{#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if}
</button>
<button class="today-tab" class:active={activeTab === 'browser'} on:click={() => activeTab = 'browser'}>
Браузер
{#if browserEvents.length > 0}<span class="tab-badge">{browserEvents.length}</span>{/if}
</button>
</div>
{#if activeTab === 'feed'}
<div class="today-tab-content">
{#if !todayDashboard || !todayDashboard.events || todayDashboard.events.length === 0}
<div class="today-empty">
<p>{t('today.feedEmpty')}</p>
<p class="hint">{t('today.feedHint')}</p>
</div>
{:else}
<div class="feed-list">
{#each todayDashboard.events as ev}
<div class="feed-item" role="button" tabindex="0"
on:click={() => handleFeedTitleClick(ev)}
on:keydown={(e) => e.key === 'Enter' && handleFeedTitleClick(ev)}>
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
<div class="feed-body">
<div class="feed-title-line">
<span class="feed-type">{feedItemLabel(ev)}</span>
<span class="feed-colon">:</span>
<span class="feed-title link-btn">{ev.title || ''}</span>
</div>
<div class="feed-meta-line">
{#if feedItemSubtitle(ev)}
<span class="feed-path">{feedItemSubtitle(ev)}</span>
{/if}
<span class="feed-event-type">{eventLabel(ev.eventType)}</span>
<span class="feed-time">{formatTime(ev.createdAt)}</span>
</div>
</div>
<button class="feed-nav-btn" title={t('common.open')} on:click|stopPropagation={() => handleFeedTitleClick(ev)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'suggestions'}
<div class="today-tab-content">
{#if suggestions.length === 0}
<div class="today-empty">
<p>{t('suggest.noSuggestions')}</p>
</div>
{:else}
<div class="suggestions-list">
{#each suggestions as s}
<div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<div class="suggestion-info">
<button class="suggestion-node link-btn" on:click|stopPropagation={() => onOpenNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => onOpenSuggestionWorklog(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => handleAcceptSuggestion(s)}>{t('suggest.apply')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => handleDeleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => onOpenActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => onOpenNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'inprogress'}
<div class="today-tab-content">
<div class="tab-controls">
<div class="sort-controls">
<button class="sort-btn" class:active={ipSortBy === 'time'} on:click={() => { ipSortBy = 'time'; ipSortDir = ipSortBy === 'time' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByTime')}</button>
<button class="sort-btn" class:active={ipSortBy === 'name'} on:click={() => { ipSortBy = 'name'; ipSortDir = ipSortBy === 'name' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByName')}</button>
<button class="sort-btn" class:active={ipSortBy === 'type'} on:click={() => { ipSortBy = 'type'; ipSortDir = ipSortBy === 'type' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByType')}</button>
<span class="sort-dir">{ipSortDir === 'asc' ? '↑' : '↓'}</span>
</div>
</div>
{#if inProgressItems.length === 0}
<div class="today-empty">
<p>{t('today.inProgressEmpty')}</p>
</div>
{:else}
<div class="ip-list">
{#each inProgressSort(inProgressItems, ipSortBy, ipSortDir) as item}
<div class="ip-item" role="button" tabindex="0"
on:click={() => onOpenActivityEvent(item)}
on:keydown={(e) => e.key === 'Enter' && onOpenActivityEvent(item)}>
<span class="ip-icon">{eventIcon(item.eventType)}</span>
<div class="ip-body">
<span class="ip-title">{item.title}</span>
{#if item.nodePath}
<span class="ip-path">{item.nodePath}</span>
{/if}
</div>
<span class="ip-type">{eventLabel(item.eventType)}</span>
<span class="ip-time">{formatTime(item.createdAt)}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'captured'}
<div class="today-tab-content">
<div class="tab-controls">
<div class="sort-controls">
<button class="sort-btn" class:active={captureSortBy === 'time'} on:click={() => { captureSortBy = 'time'; captureSortDir = captureSortBy === 'time' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByTime')}</button>
<button class="sort-btn" class:active={captureSortBy === 'name'} on:click={() => { captureSortBy = 'name'; captureSortDir = captureSortBy === 'name' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByName')}</button>
<button class="sort-btn" class:active={captureSortBy === 'type'} on:click={() => { captureSortBy = 'type'; captureSortDir = captureSortBy === 'type' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByType')}</button>
<span class="sort-dir">{captureSortDir === 'asc' ? '↑' : '↓'}</span>
</div>
</div>
{#if todayCaptures.length === 0}
<div class="today-empty">
<p>{t('today.capturedEmpty')}</p>
</div>
{:else}
<div class="capture-list">
{#each captureSort(todayCaptures, captureSortBy, captureSortDir) as item}
<div class="capture-item" role="button" tabindex="0"
on:click={() => onOpenInboxArtifact(item)}
on:keydown={(e) => e.key === 'Enter' && onOpenInboxArtifact(item)}>
<span class="capture-icon">{captureKindIcon(item.captureKind || item.sourceKind)}</span>
<div class="capture-body">
<span class="capture-title">{item.title}</span>
<span class="capture-meta">{inboxMetaText(item)}</span>
</div>
<span class="capture-time">{formatTime(item.capturedAt || item.createdAt)}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'browser'}
<div class="today-tab-content">
<BrowserEvents
events={browserEvents}
loading={browserLoading}
{formatTime}
onAccept={onBrowserAccept}
onDismiss={onBrowserDismiss}
onAttach={onBrowserAttach}
onRefresh={onBrowserRefresh}
/>
</div>
{/if}
</div>
<style>
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; min-height: 20px; }
.today-date { font-size: 13px; color: #666; }
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
.today-tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; position: relative; }
.today-tab:hover { color: #a5b4fc; }
.today-tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; }
.tab-badge { background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; margin-left: 6px; line-height: 1.4; vertical-align: middle; }
.today-tab-content { flex: 1; }
.today-empty { padding: 48px 24px; text-align: center; }
.today-empty p { color: #666; font-size: 14px; margin: 0; }
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
.tab-controls { margin-bottom: 12px; }
.sort-controls { display: flex; align-items: center; gap: 6px; }
.sort-btn { padding: 4px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #888; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit; }
.sort-btn:hover { border-color: #3a3a5c; color: #ccc; }
.sort-btn.active { border-color: #6366f1; color: #a5b4fc; background: rgba(99,102,241,0.08); }
.sort-dir { font-size: 12px; color: #8888a0; margin-left: 4px; }
/* Feed */
.feed-list { display: flex; flex-direction: column; gap: 2px; }
.feed-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.feed-item:hover { background: #1a1a28; }
.feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
.feed-body { flex: 1; min-width: 0; }
.feed-title-line { display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap; }
.feed-type { font-size: 13px; color: #8888a0; }
.feed-colon { color: #555; }
.feed-title { font-size: 14px; color: #e4e4ef; font-weight: 500; }
.feed-title:hover { text-decoration: underline; }
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-event-type { font-size: 10px; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 2px 8px; border-radius: 8px; font-weight: 500; }
.feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; }
.feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; }
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }
.link-btn { background: none; border: none; padding: 0; color: #a5b4fc; font: inherit; cursor: pointer; text-align: left; }
.link-btn:hover { text-decoration: underline; }
/* Suggestions */
.suggestions-list { display: flex; flex-direction: column; gap: 6px; }
.suggestion-card { display: flex; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 6px; gap: 0; border: 1px solid transparent; }
.suggestion-card:last-child { margin-bottom: 0; }
.suggestion-card.expanded { border-color: #3a3a5c; }
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; cursor: pointer; gap: 10px; min-height: 38px; }
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
.suggestion-node:hover { text-decoration: underline; }
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
.suggestion-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.suggestion-min-input { width: 54px; padding: 3px 7px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; text-align: center; }
.suggestion-min-label { font-size: 12px; color: #8888a0; }
.suggestion-confidence-dot { width: 9px; height: 9px; border-radius: 999px; background: #77798a; box-shadow: 0 0 0 3px rgba(119,121,138,0.12); flex: 0 0 auto; }
.suggestion-confidence-dot.low { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.12); }
.suggestion-confidence-dot.medium { background: #fbbf24; box-shadow: 0 0 0 3px rgba(251,191,36,0.12); }
.suggestion-confidence-dot.high { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.12); }
.suggestion-detail { width: 100%; padding: 6px 10px 8px; border-top: 1px solid #2a2a3c; }
.suggestion-detail-title { font-size: 10px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 4px 0; }
.suggestion-detail-event { display: flex; align-items: center; gap: 7px; padding: 2px 0; font-size: 12px; color: #b0b0c0; }
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
/* In Progress */
.ip-list { display: flex; flex-direction: column; gap: 2px; }
.ip-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.ip-item:hover { background: #1a1a28; }
.ip-icon { width: 20px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
.ip-body { flex: 1; min-width: 0; }
.ip-title { font-size: 14px; color: #e4e4ef; display: block; }
.ip-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.ip-type { font-size: 11px; color: #666; flex-shrink: 0; }
.ip-time { font-size: 11px; color: #555; flex-shrink: 0; margin-left: 8px; }
/* Captured */
.capture-list { display: flex; flex-direction: column; gap: 6px; }
.capture-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
.capture-item:hover { border-color: #3a3a5c; background: #1e1e32; }
.capture-icon { font-size: 18px; width: 28px; text-align: center; flex-shrink: 0; }
.capture-body { flex: 1; min-width: 0; }
.capture-title { font-size: 14px; color: #e4e4ef; font-weight: 500; display: block; }
.capture-meta { font-size: 12px; color: #8888a0; display: block; margin-top: 2px; }
.capture-time { font-size: 11px; color: #555; flex-shrink: 0; }
</style>