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
This commit is contained in:
parent
6bd6c9c5ff
commit
fc429ac26e
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"verstak/internal/core/browser"
|
||||
)
|
||||
|
||||
// ListBrowserEvents returns staged browser events, optionally filtered by status.
|
||||
func (a *App) ListBrowserEvents(status string, limit, offset int) ([]browser.Event, error) {
|
||||
if status == "" || status == "all" {
|
||||
return a.browser.ListAll(limit, offset)
|
||||
}
|
||||
if status == "pending" {
|
||||
return a.browser.ListPending(limit, offset)
|
||||
}
|
||||
return a.browser.ListAll(limit, offset)
|
||||
}
|
||||
|
||||
// CountPendingBrowserEvents returns the number of pending browser events.
|
||||
func (a *App) CountPendingBrowserEvents() (int, error) {
|
||||
return a.browser.CountPending()
|
||||
}
|
||||
|
||||
// AcceptBrowserEvent marks an event as accepted, linking it to a worklog entry.
|
||||
func (a *App) AcceptBrowserEvent(eventID, worklogID string) error {
|
||||
log.Printf("[browser] accept event %s -> worklog %s", eventID, worklogID)
|
||||
return a.browser.Accept(eventID, worklogID)
|
||||
}
|
||||
|
||||
// DismissBrowserEvent marks an event as dismissed (ignored).
|
||||
func (a *App) DismissBrowserEvent(eventID string) error {
|
||||
log.Printf("[browser] dismiss event %s", eventID)
|
||||
return a.browser.Dismiss(eventID)
|
||||
}
|
||||
|
||||
// AttachBrowserEventToNode attaches a browser event to a node, optionally saving a note.
|
||||
func (a *App) AttachBrowserEventToNode(eventID, nodeID string) error {
|
||||
log.Printf("[browser] attach event %s -> node %s", eventID, nodeID)
|
||||
return a.browser.Attach(eventID, nodeID)
|
||||
}
|
||||
|
|
@ -97,6 +97,8 @@
|
|||
let suggestionCount = 0
|
||||
let inProgressItems = []
|
||||
let todayCaptures = []
|
||||
let browserEvents = []
|
||||
let browserLoading = false
|
||||
let inboxNodes = []
|
||||
let localInboxNodes = []
|
||||
let inboxCaptureBusy = false
|
||||
|
|
@ -1525,6 +1527,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Browser event handlers
|
||||
async function loadBrowserEvents() {
|
||||
browserLoading = true
|
||||
try {
|
||||
const events = await wailsCall('ListBrowserEvents', 'pending', 50, 0)
|
||||
browserEvents = events || []
|
||||
} catch (e) {
|
||||
console.warn('[browser] load error:', e)
|
||||
} finally {
|
||||
browserLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptBrowserEvent(ev) {
|
||||
await wailsCall('DismissBrowserEvent', ev.id)
|
||||
await loadBrowserEvents()
|
||||
}
|
||||
|
||||
async function dismissBrowserEvent(ev) {
|
||||
await wailsCall('DismissBrowserEvent', ev.id)
|
||||
browserEvents = browserEvents.filter(e => e.id !== ev.id)
|
||||
}
|
||||
|
||||
async function attachBrowserEvent(ev) {
|
||||
await wailsCall('DismissBrowserEvent', ev.id)
|
||||
await loadBrowserEvents()
|
||||
}
|
||||
|
||||
function writeDebugLog(msg) {
|
||||
try { wailsCall('WriteDebugLog', msg) } catch(e) {}
|
||||
}
|
||||
|
|
@ -3497,6 +3527,12 @@
|
|||
onOpenNodeFolder={(id) => openNodeFolder(id)}
|
||||
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
|
||||
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
|
||||
{browserEvents}
|
||||
{browserLoading}
|
||||
onBrowserAccept={acceptBrowserEvent}
|
||||
onBrowserDismiss={dismissBrowserEvent}
|
||||
onBrowserAttach={attachBrowserEvent}
|
||||
onBrowserRefresh={loadBrowserEvents}
|
||||
/>
|
||||
{:else}
|
||||
<div class="today-empty">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
<script>
|
||||
import { t } from './i18n'
|
||||
|
||||
export let events = []
|
||||
export let loading = false
|
||||
|
||||
export let onAccept = (ev) => {}
|
||||
export let onDismiss = (ev) => {}
|
||||
export let onAttach = (ev) => {}
|
||||
export let onRefresh = () => {}
|
||||
export let formatTime = (iso) => ''
|
||||
|
||||
function eventIcon(type) {
|
||||
if (type === 'page_visit') return '🌐'
|
||||
if (type === 'note_capture') return '📝'
|
||||
if (type === 'screenshot') return '📸'
|
||||
return '•'
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return ''
|
||||
return s.length > n ? s.substring(0, n) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="browser-events">
|
||||
<div class="browser-header">
|
||||
<h3>{t('browserEvents', 'События браузера')}</h3>
|
||||
<button class="refresh-btn" on:click={onRefresh} disabled={loading}>
|
||||
{loading ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if events.length === 0}
|
||||
<div class="empty-state">
|
||||
{#if loading}
|
||||
<p class="loading">{t('loading', 'Загрузка...')}</p>
|
||||
{:else}
|
||||
<p class="empty-text">{t('noBrowserEvents', 'Нет событий браузера')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each events as ev}
|
||||
<div class="event-item" class:pending={ev.status === 'pending'}>
|
||||
<div class="event-icon">{eventIcon(ev.type)}</div>
|
||||
<div class="event-body">
|
||||
<div class="event-domain">{ev.domain || '?'}</div>
|
||||
<div class="event-title" title={ev.title}>{truncate(ev.title || ev.url, 80)}</div>
|
||||
<div class="event-meta">
|
||||
{#if ev.active_seconds > 0}
|
||||
<span class="duration">{ev.active_seconds}с</span>
|
||||
{/if}
|
||||
{#if ev.ts_start}
|
||||
<span class="time">{formatTime(ev.ts_start)}</span>
|
||||
{/if}
|
||||
{#if ev.status !== 'pending'}
|
||||
<span class="status-badge status-{ev.status}">{ev.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ev.selected_text}
|
||||
<div class="event-text">"{truncate(ev.selected_text, 120)}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ev.status === 'pending'}
|
||||
<div class="event-actions">
|
||||
<button class="btn-accept" title="Принять как worklog"
|
||||
on:click={() => onAccept(ev)}>✓</button>
|
||||
<button class="btn-attach" title="Прикрепить к делу"
|
||||
on:click={() => onAttach(ev)}>📎</button>
|
||||
<button class="btn-dismiss" title="Удалить"
|
||||
on:click={() => onDismiss(ev)}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.browser-events {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.browser-header h3 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--surface2, #1e2d50);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--surface, #16213e);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text, .loading {
|
||||
color: var(--text-dim, #8892b0);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface, #16213e);
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.event-item.pending {
|
||||
border-left: 2px solid var(--accent, #0f9b8e);
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-domain {
|
||||
font-size: 11px;
|
||||
color: var(--accent, #0f9b8e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 12px;
|
||||
color: var(--text, #e0e0e0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.duration, .time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.status-dismissed {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.status-attached {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.event-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-actions button {
|
||||
background: none;
|
||||
border: 1px solid var(--surface2, #1e2d50);
|
||||
border-radius: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
border-color: #2ecc71;
|
||||
}
|
||||
|
||||
.btn-attach:hover {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.btn-dismiss:hover {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { t } from './i18n'
|
||||
import BrowserEvents from './BrowserEvents.svelte'
|
||||
|
||||
export let todayDashboard = null
|
||||
export let suggestions = []
|
||||
|
|
@ -22,6 +23,14 @@
|
|||
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) {
|
||||
|
|
@ -172,6 +181,10 @@
|
|||
{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'}
|
||||
|
|
@ -328,6 +341,18 @@
|
|||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue