Compare commits
2 Commits
c8aaf36533
...
ceee03959b
| Author | SHA1 | Date |
|---|---|---|
|
|
ceee03959b | |
|
|
2ed2ecf77a |
|
|
@ -220,3 +220,43 @@ func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
|
||||||
dto.NodePath = a.nodes.Path(e.NodeID)
|
dto.NodePath = a.nodes.Path(e.NodeID)
|
||||||
return dto
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTodayInProgress returns today's modification events — items the user worked on.
|
||||||
|
func (a *App) ListTodayInProgress() ([]EventDTO, error) {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events, err := a.activity.ListTodayEvents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
modTypes := map[string]bool{
|
||||||
|
activity.TypeNoteCreated: true,
|
||||||
|
activity.TypeNoteUpdated: true,
|
||||||
|
activity.TypeNoteDeleted: true,
|
||||||
|
activity.TypeFileAdded: true,
|
||||||
|
activity.TypeFileDeleted: true,
|
||||||
|
activity.TypeFileRenamed: true,
|
||||||
|
activity.TypeFileCopied: true,
|
||||||
|
activity.TypeFileMoved: true,
|
||||||
|
activity.TypeFolderAdded: true,
|
||||||
|
activity.TypeFolderDeleted: true,
|
||||||
|
activity.TypeFolderRenamed: true,
|
||||||
|
activity.TypeFolderMoved: true,
|
||||||
|
activity.TypeNodeCreated: true,
|
||||||
|
activity.TypeNodeUpdated: true,
|
||||||
|
activity.TypeNodeDeleted: true,
|
||||||
|
activity.TypeActionCreated: true,
|
||||||
|
activity.TypeActionDone: true,
|
||||||
|
}
|
||||||
|
result := make([]EventDTO, 0, len(events))
|
||||||
|
for _, e := range events {
|
||||||
|
if modTypes[e.EventType] {
|
||||||
|
result = append(result, a.eventDTOWithPath(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].CreatedAt > result[j].CreatedAt
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/activity"
|
||||||
|
)
|
||||||
|
|
||||||
type InboxNodeDTO struct {
|
type InboxNodeDTO struct {
|
||||||
NodeDTO
|
NodeDTO
|
||||||
|
|
@ -189,6 +194,34 @@ func (a *App) clearCaptureMeta(nodeID string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTodayCaptures returns inbox nodes captured today.
|
||||||
|
func (a *App) ListTodayCaptures() ([]InboxNodeDTO, error) {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all, err := a.ListInboxNodes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
start, end := activity.TodayBoundaries()
|
||||||
|
startTime, _ := time.Parse(time.RFC3339, start)
|
||||||
|
endTime, _ := time.Parse(time.RFC3339, end)
|
||||||
|
result := make([]InboxNodeDTO, 0, len(all))
|
||||||
|
for _, item := range all {
|
||||||
|
if item.CapturedAt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, item.CapturedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (t.Equal(startTime) || t.After(startTime)) && t.Before(endTime) {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) captureMeta(nodeID, key string) string {
|
func (a *App) captureMeta(nodeID, key string) string {
|
||||||
v, ok, err := a.nodes.MetaGet(nodeID, key)
|
v, ok, err := a.nodes.MetaGet(nodeID, key)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DzZpCe3r.js"></script>
|
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BoZGEeiu.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import VaultRecovery from './lib/VaultRecovery.svelte'
|
import VaultRecovery from './lib/VaultRecovery.svelte'
|
||||||
import SettingsWindow from './lib/SettingsWindow.svelte'
|
import SettingsWindow from './lib/SettingsWindow.svelte'
|
||||||
import SyncStatus from './lib/SyncStatus.svelte'
|
import SyncStatus from './lib/SyncStatus.svelte'
|
||||||
|
import TodayScreen from './lib/TodayScreen.svelte'
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { actionIcon } from './lib/actionIcons.js'
|
import { actionIcon } from './lib/actionIcons.js'
|
||||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||||
|
|
@ -92,10 +93,15 @@
|
||||||
let acceptingSuggestion = null
|
let acceptingSuggestion = null
|
||||||
let suggestions = []
|
let suggestions = []
|
||||||
let suggestionCount = 0
|
let suggestionCount = 0
|
||||||
|
let inProgressItems = []
|
||||||
|
let todayCaptures = []
|
||||||
let inboxNodes = []
|
let inboxNodes = []
|
||||||
let localInboxNodes = []
|
let localInboxNodes = []
|
||||||
let inboxCaptureBusy = false
|
let inboxCaptureBusy = false
|
||||||
let inboxCaptureStatus = ''
|
let inboxCaptureStatus = ''
|
||||||
|
let inboxSortBy = 'date'
|
||||||
|
let inboxSortDir = 'desc'
|
||||||
|
let inboxGroupByContext = false
|
||||||
let links = []
|
let links = []
|
||||||
let editingLink = null
|
let editingLink = null
|
||||||
let linkTitle = ''
|
let linkTitle = ''
|
||||||
|
|
@ -197,6 +203,42 @@
|
||||||
let unlistenDrop = null
|
let unlistenDrop = null
|
||||||
|
|
||||||
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId)
|
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId)
|
||||||
|
|
||||||
|
$: sortedInboxNodes = sortInboxNodes(inboxNodes, inboxSortBy, inboxSortDir)
|
||||||
|
$: groupedInboxNodes = inboxGroupByContext ? groupInboxByContext(sortedInboxNodes) : null
|
||||||
|
|
||||||
|
function sortInboxNodes(nodes, sortBy, sortDir) {
|
||||||
|
const sorted = [...nodes]
|
||||||
|
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 || a.type || ''
|
||||||
|
const bKind = b.captureKind || b.sourceKind || b.type || ''
|
||||||
|
cmp = aKind.localeCompare(bKind)
|
||||||
|
} else {
|
||||||
|
cmp = (a.capturedAt || a.createdAt || '').localeCompare(b.capturedAt || b.createdAt || '')
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupInboxByContext(nodes) {
|
||||||
|
const groups = {}
|
||||||
|
for (const item of nodes) {
|
||||||
|
const key = item.captureContextLabel || t('inbox.noContext')
|
||||||
|
if (!groups[key]) groups[key] = []
|
||||||
|
groups[key].push(item)
|
||||||
|
}
|
||||||
|
const result = []
|
||||||
|
for (const [label, items] of Object.entries(groups)) {
|
||||||
|
result.push({ label, items })
|
||||||
|
}
|
||||||
|
result.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
return result
|
||||||
|
}
|
||||||
$: currentTrashPath = trashFolderStack.length === 0 ? t('nav.trash') : [t('nav.trash'), ...trashFolderStack.map(node => node.title)].join(' / ')
|
$: currentTrashPath = trashFolderStack.length === 0 ? t('nav.trash') : [t('nav.trash'), ...trashFolderStack.map(node => node.title)].join(' / ')
|
||||||
$: currentTrashTitle = currentTrashFolder()?.title || t('trash.deletedNodes')
|
$: currentTrashTitle = currentTrashFolder()?.title || t('trash.deletedNodes')
|
||||||
|
|
||||||
|
|
@ -378,6 +420,8 @@
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
todayDashboard = null
|
todayDashboard = null
|
||||||
|
inProgressItems = []
|
||||||
|
todayCaptures = []
|
||||||
activityFeed = []
|
activityFeed = []
|
||||||
activityOffset = 0
|
activityOffset = 0
|
||||||
activityHasMore = true
|
activityHasMore = true
|
||||||
|
|
@ -388,6 +432,8 @@
|
||||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
suggestions = await wailsCall('GetSuggestions') || []
|
suggestions = await wailsCall('GetSuggestions') || []
|
||||||
suggestionCount = suggestions.length
|
suggestionCount = suggestions.length
|
||||||
|
inProgressItems = await wailsCall('ListTodayInProgress') || []
|
||||||
|
todayCaptures = await wailsCall('ListTodayCaptures') || []
|
||||||
} else if (id === 'inbox') {
|
} else if (id === 'inbox') {
|
||||||
inboxNodes = await wailsCall('ListInboxNodes') || []
|
inboxNodes = await wailsCall('ListInboxNodes') || []
|
||||||
} else if (id === 'trash') {
|
} else if (id === 'trash') {
|
||||||
|
|
@ -1673,7 +1719,7 @@
|
||||||
const labels = {
|
const labels = {
|
||||||
'note_created': t('event.noteCreated'),
|
'note_created': t('event.noteCreated'),
|
||||||
'note_updated': t('event.noteUpdated'),
|
'note_updated': t('event.noteUpdated'),
|
||||||
'note_deleted': 'Заметка удалена',
|
'note_deleted': t('event.noteDeleted'),
|
||||||
'file_added': t('event.fileAdded'),
|
'file_added': t('event.fileAdded'),
|
||||||
'file_deleted': t('event.fileDeleted'),
|
'file_deleted': t('event.fileDeleted'),
|
||||||
'file_renamed': t('event.fileRenamed'),
|
'file_renamed': t('event.fileRenamed'),
|
||||||
|
|
@ -1682,13 +1728,13 @@
|
||||||
'folder_added': t('event.folderAdded'),
|
'folder_added': t('event.folderAdded'),
|
||||||
'folder_deleted': t('event.folderDeleted'),
|
'folder_deleted': t('event.folderDeleted'),
|
||||||
'folder_renamed': t('event.folderRenamed'),
|
'folder_renamed': t('event.folderRenamed'),
|
||||||
'folder_moved': 'Папка перемещена',
|
'folder_moved': t('event.folderMoved'),
|
||||||
'node_created': t('event.caseCreated'),
|
'node_created': t('event.caseCreated'),
|
||||||
'node_updated': t('event.caseUpdated'),
|
'node_updated': t('event.caseUpdated'),
|
||||||
'node_deleted': 'Узел удалён',
|
'node_deleted': t('event.nodeDeleted'),
|
||||||
'action_created': 'Действие создано',
|
'action_created': t('event.actionCreated'),
|
||||||
'action_done': 'Действие выполнено',
|
'action_done': t('event.actionDone'),
|
||||||
'worklog_added': 'Запись времени добавлена',
|
'worklog_added': t('event.worklogAdded'),
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|
@ -2844,13 +2890,71 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if inboxNodes.length > 0}
|
||||||
|
<div class="inbox-controls">
|
||||||
|
<div class="inbox-sort-row">
|
||||||
|
<button class="sort-btn" class:active={inboxSortBy === 'date'} on:click={() => { inboxSortBy = 'date'; inboxSortDir = inboxSortBy === 'date' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByDate')}</button>
|
||||||
|
<button class="sort-btn" class:active={inboxSortBy === 'name'} on:click={() => { inboxSortBy = 'name'; inboxSortDir = inboxSortBy === 'name' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByName')}</button>
|
||||||
|
<button class="sort-btn" class:active={inboxSortBy === 'type'} on:click={() => { inboxSortBy = 'type'; inboxSortDir = inboxSortBy === 'type' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByType')}</button>
|
||||||
|
<span class="sort-dir">{inboxSortDir === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
<span class="inbox-controls-divider"></span>
|
||||||
|
<label class="checkbox-label-inline">
|
||||||
|
<input type="checkbox" bind:checked={inboxGroupByContext} />
|
||||||
|
<span>{t('inbox.groupByContext')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if inboxNodes.length === 0}
|
{#if inboxNodes.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>{t('inbox.empty')}</p>
|
<p>{t('inbox.empty')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if groupedInboxNodes}
|
||||||
|
{#each groupedInboxNodes as group}
|
||||||
|
<div class="inbox-group">
|
||||||
|
<div class="inbox-group-header">{group.label} <span class="inbox-group-count">{group.items.length}</span></div>
|
||||||
|
<div class="inbox-list">
|
||||||
|
{#each group.items as item}
|
||||||
|
<div class="inbox-item" role="button" tabindex="0" on:click={() => openInboxArtifact(item)} on:keydown={(e) => e.key === 'Enter' && openInboxArtifact(item)}>
|
||||||
|
<div class="inbox-item-main">
|
||||||
|
<span class="inbox-item-title">{item.title}</span>
|
||||||
|
<span class="inbox-item-meta">{inboxMetaText(item)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-item-actions">
|
||||||
|
{#if item.suggestedTargetNodeId}
|
||||||
|
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
|
||||||
|
{@html actionIcon('attach')}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="inbox-icon-slot" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
|
||||||
|
{@html actionIcon('move')}
|
||||||
|
</button>
|
||||||
|
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click|stopPropagation={() => openInboxArtifact(item)}>
|
||||||
|
{@html actionIcon('open')}
|
||||||
|
</button>
|
||||||
|
{#if canShowInboxArtifactInFolder(item)}
|
||||||
|
<button class="inbox-icon-btn" title={t('file.showInExplorer')} aria-label={t('file.showInExplorer')} on:click|stopPropagation={() => openNodeFolder(item)}>
|
||||||
|
{@html actionIcon('showInExplorer')}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="inbox-icon-slot" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
|
||||||
|
{@html actionIcon('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="inbox-list">
|
<div class="inbox-list">
|
||||||
{#each inboxNodes as item}
|
{#each sortedInboxNodes as item}
|
||||||
<div class="inbox-item" role="button" tabindex="0" on:click={() => openInboxArtifact(item)} on:keydown={(e) => e.key === 'Enter' && openInboxArtifact(item)}>
|
<div class="inbox-item" role="button" tabindex="0" on:click={() => openInboxArtifact(item)} on:keydown={(e) => e.key === 'Enter' && openInboxArtifact(item)}>
|
||||||
<div class="inbox-item-main">
|
<div class="inbox-item-main">
|
||||||
<span class="inbox-item-title">{item.title}</span>
|
<span class="inbox-item-title">{item.title}</span>
|
||||||
|
|
@ -3181,110 +3285,36 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if selectedSection === 'today' && todayDashboard}
|
{:else if selectedSection === 'today'}
|
||||||
<div class="today-dashboard">
|
{#if todayDashboard}
|
||||||
<div class="today-header">
|
<TodayScreen
|
||||||
<h2>{t('today.title')}</h2>
|
{todayDashboard}
|
||||||
<span class="today-date">{todayDashboard.date}</span>
|
{suggestions}
|
||||||
|
{inProgressItems}
|
||||||
|
{todayCaptures}
|
||||||
|
suggestionCount={suggestions.length}
|
||||||
|
{formatTime}
|
||||||
|
{formatDate}
|
||||||
|
{eventLabel}
|
||||||
|
{eventIcon}
|
||||||
|
{nodeKindLabel}
|
||||||
|
{captureKindLabel}
|
||||||
|
{inboxMetaText}
|
||||||
|
onOpenNodeById={(id) => openNodeById(id)}
|
||||||
|
onOpenActivityEvent={(ev) => openActivityEvent(ev)}
|
||||||
|
onOpenActivityTarget={(ev) => openActivityTarget(ev)}
|
||||||
|
onOpenSuggestionWorklog={(s) => openSuggestionWorklogModal(s)}
|
||||||
|
onAcceptSuggestion={(s) => acceptTodaySuggestion(s)}
|
||||||
|
onDeleteSuggestion={(s) => deleteSuggestion(s)}
|
||||||
|
onOpenNodeFolder={(id) => openNodeFolder(id)}
|
||||||
|
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="today-empty">
|
||||||
|
<p>{t('today.empty')}</p>
|
||||||
|
<p class="hint">{t('today.emptyHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if todayDashboard.summary}
|
{/if}
|
||||||
<div class="today-summary">
|
|
||||||
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, t('today.plural.case_one'), t('today.plural.case_few'), t('today.plural.case_many'))}</span>{/if}
|
|
||||||
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, t('today.plural.note_one'), t('today.plural.note_few'), t('today.plural.note_many'))}</span>{/if}
|
|
||||||
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, t('today.plural.file_one'), t('today.plural.file_few'), t('today.plural.file_many'))}</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if suggestions.length > 0}
|
|
||||||
<div class="today-suggestions">
|
|
||||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
|
||||||
{#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={() => openNodeById(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={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
|
||||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
|
||||||
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(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={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
|
||||||
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
|
||||||
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
|
||||||
{#each todayDashboard.groups as group}
|
|
||||||
<div class="today-case">
|
|
||||||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
|
||||||
<span class="today-case-title">{group.nodeTitle}</span>
|
|
||||||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
|
||||||
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, t('today.plural.event_one'), t('today.plural.event_few'), t('today.plural.event_many'))}</span>{/if}
|
|
||||||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
|
||||||
</div>
|
|
||||||
{#if group.events && group.events.length > 0}
|
|
||||||
<div class="today-events">
|
|
||||||
{#each group.events as ev}
|
|
||||||
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
|
||||||
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
|
||||||
<span class="today-event-title">{ev.title}</span>
|
|
||||||
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
|
||||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
|
||||||
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="today-events-empty">{t('today.changedCases')}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
|
||||||
<div class="today-timeline">
|
|
||||||
<h3>{t('today.timeline')}</h3>
|
|
||||||
{#each todayDashboard.events as ev}
|
|
||||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
|
||||||
<span class="timeline-dot"></span>
|
|
||||||
<span class="timeline-title">{ev.title}</span>
|
|
||||||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
|
||||||
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="today-empty">
|
|
||||||
<p>{t('today.empty')}</p>
|
|
||||||
<p class="hint">{t('today.emptyHint')}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if selectedSection === 'activity'}
|
{:else if selectedSection === 'activity'}
|
||||||
<div class="activity-feed">
|
<div class="activity-feed">
|
||||||
|
|
@ -3773,6 +3803,18 @@
|
||||||
.inbox-icon-btn-danger:hover { color: #fff; border-color: #dc2626; background: #3a1f24; }
|
.inbox-icon-btn-danger:hover { color: #fff; border-color: #dc2626; background: #3a1f24; }
|
||||||
.inbox-tab { padding: 24px; }
|
.inbox-tab { padding: 24px; }
|
||||||
|
|
||||||
|
/* Inbox sort/group controls */
|
||||||
|
.inbox-controls { margin-bottom: 12px; }
|
||||||
|
.inbox-sort-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.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: 2px; }
|
||||||
|
.inbox-controls-divider { width: 1px; height: 20px; background: #2a2a3c; margin: 0 4px; }
|
||||||
|
.inbox-group { margin-bottom: 16px; }
|
||||||
|
.inbox-group-header { font-size: 12px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 0 6px; margin-bottom: 6px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.inbox-group-count { font-size: 10px; color: #8888a0; background: #1e1e30; padding: 1px 6px; border-radius: 8px; }
|
||||||
|
|
||||||
/* Links tab */
|
/* Links tab */
|
||||||
.links-tab { padding: 24px; }
|
.links-tab { padding: 24px; }
|
||||||
.links-list { display: flex; flex-direction: column; gap: 8px; }
|
.links-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
<script>
|
||||||
|
import { t } from './i18n'
|
||||||
|
|
||||||
|
export let todayDashboard = null
|
||||||
|
export let suggestions = []
|
||||||
|
export let inProgressItems = []
|
||||||
|
export let todayCaptures = []
|
||||||
|
export let suggestionCount = 0
|
||||||
|
|
||||||
|
export let formatTime = (iso) => ''
|
||||||
|
export let formatDate = (iso) => ''
|
||||||
|
export let eventLabel = (type) => type
|
||||||
|
export let eventIcon = (type) => ''
|
||||||
|
export let nodeKindLabel = (kind) => kind
|
||||||
|
export let captureKindLabel = (kind) => kind
|
||||||
|
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) => {}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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.eventType === 'folder_deleted') {
|
||||||
|
onOpenNodeById(ev.nodeId)
|
||||||
|
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 {
|
||||||
|
onOpenNodeById(ev.nodeId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
|
||||||
|
if (ev.targetType === 'note' && ev.targetId) {
|
||||||
|
onOpenActivityTarget(ev)
|
||||||
|
} else {
|
||||||
|
onOpenNodeById(ev.nodeId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.eventType === 'worklog_added') {
|
||||||
|
onOpenNodeById(ev.nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['action_created','action_done'].includes(ev.eventType)) {
|
||||||
|
onOpenNodeById(ev.nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<h2>{t('today.title')}</h2>
|
||||||
|
<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>
|
||||||
|
</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.eventType)}</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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
|
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.today-header h2 { font-size: 24px; margin: 0; }
|
||||||
|
.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: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||||
|
.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>
|
||||||
|
|
@ -25,6 +25,13 @@ export default {
|
||||||
'nav.createNode': 'Create element',
|
'nav.createNode': 'Create element',
|
||||||
'nav.moveToRoot': 'Move to root',
|
'nav.moveToRoot': 'Move to root',
|
||||||
'inbox.subtitle': 'Captured materials that still need to be assigned to cases',
|
'inbox.subtitle': 'Captured materials that still need to be assigned to cases',
|
||||||
|
'inbox.sortByDate': 'by date',
|
||||||
|
'inbox.sortByName': 'by name',
|
||||||
|
'inbox.sortByType': 'by type',
|
||||||
|
'inbox.sortAsc': 'ascending',
|
||||||
|
'inbox.sortDesc': 'descending',
|
||||||
|
'inbox.groupByContext': 'Group by capture location',
|
||||||
|
'inbox.noContext': 'No capture location',
|
||||||
'inbox.empty': 'No unprocessed items',
|
'inbox.empty': 'No unprocessed items',
|
||||||
'inbox.pasteClipboard': 'Paste from clipboard',
|
'inbox.pasteClipboard': 'Paste from clipboard',
|
||||||
'inbox.captured': 'Added',
|
'inbox.captured': 'Added',
|
||||||
|
|
@ -116,6 +123,12 @@ export default {
|
||||||
'event.folderRenamed': 'Folder renamed',
|
'event.folderRenamed': 'Folder renamed',
|
||||||
'event.caseCreated': 'Case created',
|
'event.caseCreated': 'Case created',
|
||||||
'event.caseUpdated': 'Case updated',
|
'event.caseUpdated': 'Case updated',
|
||||||
|
'event.noteDeleted': 'Note deleted',
|
||||||
|
'event.folderMoved': 'Folder moved',
|
||||||
|
'event.nodeDeleted': 'Node deleted',
|
||||||
|
'event.actionCreated': 'Action created',
|
||||||
|
'event.actionDone': 'Action completed',
|
||||||
|
'event.worklogAdded': 'Time entry added',
|
||||||
'kind.project': 'Project',
|
'kind.project': 'Project',
|
||||||
'kind.client': 'Client',
|
'kind.client': 'Client',
|
||||||
'kind.document': 'Document',
|
'kind.document': 'Document',
|
||||||
|
|
@ -251,6 +264,19 @@ export default {
|
||||||
'today.plural.event_one': 'event',
|
'today.plural.event_one': 'event',
|
||||||
'today.plural.event_few': 'events',
|
'today.plural.event_few': 'events',
|
||||||
'today.plural.event_many': 'events',
|
'today.plural.event_many': 'events',
|
||||||
|
'today.feed': 'Feed',
|
||||||
|
'today.suggestionsTab': 'Suggestions',
|
||||||
|
'today.inProgress': 'In Progress',
|
||||||
|
'today.captured': 'Captured',
|
||||||
|
'today.feedEmpty': 'No events today',
|
||||||
|
'today.inProgressEmpty': 'Nothing modified today',
|
||||||
|
'today.capturedEmpty': 'Nothing captured today',
|
||||||
|
'today.feedHint': 'Today\'s events: file changes, notes, actions and other events.',
|
||||||
|
'today.sortByTime': 'by time',
|
||||||
|
'today.sortByName': 'by name',
|
||||||
|
'today.sortByType': 'by type',
|
||||||
|
'today.sortAsc': 'ascending',
|
||||||
|
'today.sortDesc': 'descending',
|
||||||
'journal.title': 'Work Log',
|
'journal.title': 'Work Log',
|
||||||
'journal.empty': 'No entries for the selected period',
|
'journal.empty': 'No entries for the selected period',
|
||||||
'journal.dateFrom': 'From',
|
'journal.dateFrom': 'From',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ export default {
|
||||||
'nav.moveToRoot': 'Переместить в корень',
|
'nav.moveToRoot': 'Переместить в корень',
|
||||||
|
|
||||||
'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам',
|
'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам',
|
||||||
|
'inbox.sortByDate': 'по дате',
|
||||||
|
'inbox.sortByName': 'по имени',
|
||||||
|
'inbox.sortByType': 'по типу',
|
||||||
|
'inbox.sortAsc': 'по возрастанию',
|
||||||
|
'inbox.sortDesc': 'по убыванию',
|
||||||
|
'inbox.groupByContext': 'Группировать по месту захвата',
|
||||||
|
'inbox.noContext': 'Без места захвата',
|
||||||
'inbox.empty': 'Неразобранных элементов нет',
|
'inbox.empty': 'Неразобранных элементов нет',
|
||||||
'inbox.pasteClipboard': 'Вставить из буфера',
|
'inbox.pasteClipboard': 'Вставить из буфера',
|
||||||
'inbox.captured': 'Добавлено',
|
'inbox.captured': 'Добавлено',
|
||||||
|
|
@ -123,6 +130,12 @@ export default {
|
||||||
'event.folderRenamed': 'Папка переименована',
|
'event.folderRenamed': 'Папка переименована',
|
||||||
'event.caseCreated': 'Дело создано',
|
'event.caseCreated': 'Дело создано',
|
||||||
'event.caseUpdated': 'Дело изменено',
|
'event.caseUpdated': 'Дело изменено',
|
||||||
|
'event.noteDeleted': 'Заметка удалена',
|
||||||
|
'event.folderMoved': 'Папка перемещена',
|
||||||
|
'event.nodeDeleted': 'Узел удалён',
|
||||||
|
'event.actionCreated': 'Действие создано',
|
||||||
|
'event.actionDone': 'Действие выполнено',
|
||||||
|
'event.worklogAdded': 'Запись времени добавлена',
|
||||||
|
|
||||||
'kind.project': 'Проект',
|
'kind.project': 'Проект',
|
||||||
'kind.client': 'Клиент',
|
'kind.client': 'Клиент',
|
||||||
|
|
@ -266,6 +279,19 @@ export default {
|
||||||
'today.plural.event_one': 'событие',
|
'today.plural.event_one': 'событие',
|
||||||
'today.plural.event_few': 'события',
|
'today.plural.event_few': 'события',
|
||||||
'today.plural.event_many': 'событий',
|
'today.plural.event_many': 'событий',
|
||||||
|
'today.feed': 'Лента',
|
||||||
|
'today.suggestionsTab': 'Предложения',
|
||||||
|
'today.inProgress': 'В работе',
|
||||||
|
'today.captured': 'Захвачено',
|
||||||
|
'today.feedEmpty': 'Сегодня событий нет',
|
||||||
|
'today.inProgressEmpty': 'Сегодня ничего не изменяли',
|
||||||
|
'today.capturedEmpty': 'Сегодня ничего не захвачено',
|
||||||
|
'today.feedHint': 'События за сегодня: изменения файлов, заметок, действий и другие события.',
|
||||||
|
'today.sortByTime': 'по времени',
|
||||||
|
'today.sortByName': 'по имени',
|
||||||
|
'today.sortByType': 'по типу',
|
||||||
|
'today.sortAsc': 'по возрастанию',
|
||||||
|
'today.sortDesc': 'по убыванию',
|
||||||
|
|
||||||
'journal.title': 'Журнал работы',
|
'journal.title': 'Журнал работы',
|
||||||
'journal.empty': 'Нет записей за выбранный период',
|
'journal.empty': 'Нет записей за выбранный период',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue