fix: trash duplicate path, journal tabs, today undefined, mouse back, inbox search

- Trash: removed duplicate fsPath display, folders/files open by clicking icon/name
- Trash: removed separate 'Open' button, only restore/delete right-aligned
- Trash: added ReadTrashFileContent binding + preview for files
- Trash: breadcrumb path under title, compact date display
- Journal: split into 'Предложения' + 'Журнал работы' tabs
- Journal: suggestions tab opens by default
- Today: fixed undefined in feed (null-guard on eventType/title)
- Today: improved event type label readability (blue badge style)
- Today: folder_deleted clicks navigate to trash
- Mouse Back: added mouseup fallback for Wails compat, handle button 3/4
- SearchNodes: case-insensitive with LOWER()
- Inbox assign: added search hint placeholder
This commit is contained in:
mirivlad 2026-06-05 16:49:00 +08:00
parent c512ada386
commit 5257789a4d
11 changed files with 314 additions and 251 deletions

View File

@ -298,6 +298,21 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
return out, nil
}
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.findTrashEntryForNode(nodeID)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err

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

View File

@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-CtkslTth.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main--SNK_nBk.css">
<script type="module" crossorigin src="/assets/main-PQ2CZjSe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bomne4X7.css">
</head>
<body>
<div id="app"></div>

View File

@ -55,6 +55,7 @@
let journalApproxFilter = 'all'
let journalFilteredNodeTitle = ''
let journalStatusMsg = ''
let journalActiveTab = 'suggestions'
let journalSearchQuery = ''
let journalSearchResults = []
let journalShowResults = false
@ -322,11 +323,13 @@
await restoreNavigation(snapshot)
}
function handleAuxClick(e) {
if (e.button !== 3) return
function handleMouseNav(e) {
if (e.button !== 3 && e.button !== 4) return
if (isEditableTarget(e.target)) return
if (closeTopModalForBack() && e.button === 3) return
e.preventDefault()
goBack()
e.stopPropagation()
if (e.button === 3) goBack()
}
function setActiveTab(tabId) {
@ -384,7 +387,8 @@
window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('drop', handleGlobalDrop)
window.addEventListener('auxclick', handleAuxClick)
window.addEventListener('auxclick', handleMouseNav)
window.addEventListener('mouseup', handleMouseNav)
loading = false
loadSyncStatus()
@ -398,7 +402,8 @@
window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('drop', handleGlobalDrop)
window.removeEventListener('auxclick', handleAuxClick)
window.removeEventListener('auxclick', handleMouseNav)
window.removeEventListener('mouseup', handleMouseNav)
})
// ===== System view / Node selection =====
@ -1386,6 +1391,21 @@
trashSelectedIds = []
}
async function openTrashFilePreview(node) {
previewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
previewContent = ''
previewError = ''
previewLoading = true
try {
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
const ext = (node.title || '').split('.').pop().toLowerCase()
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
}
} catch (e) { previewError = String(e) }
previewLoading = false
}
function toggleTrashSelection(id) {
trashSelectedIds = trashSelectedIds.includes(id)
? trashSelectedIds.filter(existing => existing !== id)
@ -3031,23 +3051,22 @@
{#each visibleTrashNodes as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)} class:folder={node.type !== 'file' && node.type !== 'note'}>
<input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} />
<span class="trash-row-icon" aria-hidden="true">
<span class="trash-row-icon"
role="button" tabindex="0"
title={node.type !== 'file' ? t('file.openFolder') : t('common.open')}
on:click|stopPropagation={() => node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node)}
on:keydown={(e) => e.key === 'Enter' && (node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node))}>
{@html actionIcon(node.type !== 'file' && node.type !== 'note' ? 'folder' : 'open')}
</span>
<div class="trash-row-main">
<button class="trash-row-main"
title={node.type !== 'file' ? t('file.openFolder') : t('common.open')}
on:click|stopPropagation={() => node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node)}
on:keydown={(e) => e.key === 'Enter' && (node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node))}>
<span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)}</span>
<span class="trash-row-meta">{formatDate(node.deletedAt)}</span>
</div>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
<span class="trash-row-meta">{node.nodePath}</span>
<span class="trash-row-date">{formatDate(node.deletedAt)}</span>
</button>
<div class="trash-row-actions">
{#if node.type !== 'file' && node.type !== 'note'}
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click={() => openTrashFolderNode(node)}>
{@html actionIcon('open')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('trash.restore')} aria-label={t('trash.restore')} on:click={() => restoreTrash(trashSelectionOr(node.id))}>
{@html actionIcon('restore')}
</button>
@ -3065,223 +3084,235 @@
{:else if selectedSection === 'journal'}
<div class="journal-screen">
<div class="journal-header">
<h2>{t('journal.title')}</h2>
<div class="journal-filter-section">
<div class="journal-filter-heading">{t('journal.filterHeading')}</div>
<div class="journal-filters-row">
<label><span class="label-text">{t('journal.dateFrom')}</span>
<input type="date" bind:value={journalDateFrom} />
</label>
<label><span class="label-text">{t('journal.dateTo')}</span>
<input type="date" bind:value={journalDateTo} />
</label>
<label><span class="label-text">{t('journal.node')}</span>
<div class="journal-node-picker" style="position:relative">
{#if journalFilteredNodeTitle}
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
{journalFilteredNodeTitle} <span class="journal-node-clear"></span>
</button>
{:else}
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
{#if journalShowResults}
<div class="journal-search-dropdown">
{#each journalSearchResults as r}
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
<span class="journal-search-title">{r.title}</span>
<span class="journal-search-path">{r.path}</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</label>
{#if journalNodeID}
<label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<span>{t('journal.includeChildren')}</span>
</label>
{/if}
<label title={t('journal.billableHint')}>
<span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}>
<option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.billableYes')}</option>
<option value="no">{t('journal.billableNo')}</option>
</select>
</label>
<label title={t('journal.approxHint')}>
<span class="label-text">{t('journal.approx')}</span>
<select bind:value={journalApproxFilter}>
<option value="all">{t('common.all')}</option>
<option value="no">{t('journal.approxExact')}</option>
<option value="yes">{t('journal.approxEstimated')}</option>
</select>
</label>
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
<button class="btn btn-sm" on:click={resetJournalFilters}>{t('journal.reset')}</button>
</div>
</div>
<div class="journal-export-section">
<div class="journal-export-heading">{t('journal.exportHeading')}</div>
<div class="journal-export-row">
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
{#if journalStatusMsg}
<span class="journal-status-msg">{journalStatusMsg}</span>
{/if}
</div>
</div>
<div class="journal-tabs">
<button class="journal-tab" class:active={journalActiveTab === 'suggestions'} on:click={() => journalActiveTab = 'suggestions'}>
{t('suggest.title')}
{#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
</button>
<button class="journal-tab" class:active={journalActiveTab === 'worklog'} on:click={() => journalActiveTab = 'worklog'}>
{t('journal.worklogTab')}
</button>
</div>
{#if suggestions.length > 0}
<div class="journal-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>
{#if journalActiveTab === 'suggestions'}
{#if suggestions.length === 0}
<div class="empty-state"><p>{t('suggest.noSuggestions')}</p></div>
{:else}
<div class="journal-suggestions">
{#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={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</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={() => acceptJournalSuggestion(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 journalSummary}
<div class="journal-summary">
<div class="summary-total">{t('journal.total')}: {Math.floor(journalSummary.totalMinutes / 60)}ч {journalSummary.totalMinutes % 60}м ({journalSummary.totalEntries} {t('worklog.min')})</div>
{#if journalSummary.byDay && journalSummary.byDay.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byDay')}</div>
{#each journalSummary.byDay as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
{#if journalSummary.byNode && journalSummary.byNode.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byNode')}</div>
{#each journalSummary.byNode as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
</div>
{/if}
{#if journalRows.length === 0}
<div class="empty-state"><p>{t('journal.empty')}</p></div>
{:else}
<div class="journal-table-wrap">
<table class="journal-table">
<thead>
<tr>
<th class="journal-toggle-col"></th>
<th>{t('journal.title')}</th>
<th>{t('journal.node')}</th>
<th>{t('journal.path')}</th>
<th>{t('worklog.minutes')}</th>
<th>{t('journal.billable')}</th>
<th>{t('journal.approx')}</th>
<th>{t('common.date')}</th>
</tr>
</thead>
<tbody>
{#each journalRows as r}
<tr class="journal-row" class:expanded={r._expanded} on:click={() => toggleJournalRow(r)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && toggleJournalRow(r)}>
<td class="journal-toggle-col"><span class="journal-toggle-icon">{r._expanded ? '▾' : '▸'}</span></td>
<td class="journal-summary-cell">{r.summary}</td>
<td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
<td class="journal-path-cell">{r.nodePath}</td>
<td class="journal-min-cell">{r.minutes}</td>
<td class="journal-bool-cell">{#if r.billable}{/if}</td>
<td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
<td class="journal-date-cell">{r.date}</td>
</tr>
{#if r._expanded}
<tr class="journal-row-detail">
<td colspan="8">
<div class="journal-detail-body">
<div class="journal-detail-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openWorklogModal(r)}>{t('worklog.editEntry')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteWorklogEntry(r)}>{t('worklog.deleteEntry')}</button>
</div>
{#if r.details}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.details')}</span>
<p>{r.details}</p>
</div>
{/if}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span>
{#if r.source === 'manual'}
<p>{t('worklog.sourceManual')}</p>
{:else if r.source === 'suggestion' && r._events && r._events.length > 0}
<p>{t('worklog.sourceSuggestion')}</p>
{:else if r.source === 'suggestion'}
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
{:else if r.source === 'unknown' || r.source === 'imported'}
<p>{t('worklog.sourceUnknown')}</p>
{:else if r._hasEvents}
<p>{t('worklog.sourceSuggestion')}</p>
{:else}
<p>{t('worklog.sourceUnknown')}</p>
{/if}
</div>
{#if r._events}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('journal.relatedEvents')}</span>
{#each r._events as ev}
<div class="journal-event-row">
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
</div>
{/each}
</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>
</td>
</tr>
{/each}
</div>
{/if}
{/each}
</tbody>
</table>
</div>
{/each}
</div>
{/if}
{:else}
<div class="journal-header">
<div class="journal-filter-section">
<div class="journal-filter-heading">{t('journal.filterHeading')}</div>
<div class="journal-filters-row">
<label><span class="label-text">{t('journal.dateFrom')}</span>
<input type="date" bind:value={journalDateFrom} />
</label>
<label><span class="label-text">{t('journal.dateTo')}</span>
<input type="date" bind:value={journalDateTo} />
</label>
<label><span class="label-text">{t('journal.node')}</span>
<div class="journal-node-picker" style="position:relative">
{#if journalFilteredNodeTitle}
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
{journalFilteredNodeTitle} <span class="journal-node-clear"></span>
</button>
{:else}
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
{#if journalShowResults}
<div class="journal-search-dropdown">
{#each journalSearchResults as r}
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
<span class="journal-search-title">{r.title}</span>
<span class="journal-search-path">{r.path}</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</label>
{#if journalNodeID}
<label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<span>{t('journal.includeChildren')}</span>
</label>
{/if}
<label title={t('journal.billableHint')}>
<span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}>
<option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.billableYes')}</option>
<option value="no">{t('journal.billableNo')}</option>
</select>
</label>
<label title={t('journal.approxHint')}>
<span class="label-text">{t('journal.approx')}</span>
<select bind:value={journalApproxFilter}>
<option value="all">{t('common.all')}</option>
<option value="no">{t('journal.approxExact')}</option>
<option value="yes">{t('journal.approxEstimated')}</option>
</select>
</label>
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
<button class="btn btn-sm" on:click={resetJournalFilters}>{t('journal.reset')}</button>
</div>
</div>
<div class="journal-export-section">
<div class="journal-export-heading">{t('journal.exportHeading')}</div>
<div class="journal-export-row">
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
{#if journalStatusMsg}
<span class="journal-status-msg">{journalStatusMsg}</span>
{/if}
</div>
</div>
</div>
{#if journalSummary}
<div class="journal-summary">
<div class="summary-total">{t('journal.total')}: {Math.floor(journalSummary.totalMinutes / 60)}ч {journalSummary.totalMinutes % 60}м ({journalSummary.totalEntries} {t('worklog.min')})</div>
{#if journalSummary.byDay && journalSummary.byDay.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byDay')}</div>
{#each journalSummary.byDay as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
{#if journalSummary.byNode && journalSummary.byNode.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byNode')}</div>
{#each journalSummary.byNode as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
</div>
{/if}
{#if journalRows.length === 0}
<div class="empty-state"><p>{t('journal.empty')}</p></div>
{:else}
<div class="journal-table-wrap">
<table class="journal-table">
<thead>
<tr>
<th class="journal-toggle-col"></th>
<th>{t('journal.title')}</th>
<th>{t('journal.node')}</th>
<th>{t('journal.path')}</th>
<th>{t('worklog.minutes')}</th>
<th>{t('journal.billable')}</th>
<th>{t('journal.approx')}</th>
<th>{t('common.date')}</th>
</tr>
</thead>
<tbody>
{#each journalRows as r}
<tr class="journal-row" class:expanded={r._expanded} on:click={() => toggleJournalRow(r)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && toggleJournalRow(r)}>
<td class="journal-toggle-col"><span class="journal-toggle-icon">{r._expanded ? '▾' : '▸'}</span></td>
<td class="journal-summary-cell">{r.summary}</td>
<td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
<td class="journal-path-cell">{r.nodePath}</td>
<td class="journal-min-cell">{r.minutes}</td>
<td class="journal-bool-cell">{#if r.billable}{/if}</td>
<td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
<td class="journal-date-cell">{r.date}</td>
</tr>
{#if r._expanded}
<tr class="journal-row-detail">
<td colspan="8">
<div class="journal-detail-body">
<div class="journal-detail-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openWorklogModal(r)}>{t('worklog.editEntry')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteWorklogEntry(r)}>{t('worklog.deleteEntry')}</button>
</div>
{#if r.details}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.details')}</span>
<p>{r.details}</p>
</div>
{/if}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span>
{#if r.source === 'manual'}
<p>{t('worklog.sourceManual')}</p>
{:else if r.source === 'suggestion' && r._events && r._events.length > 0}
<p>{t('worklog.sourceSuggestion')}</p>
{:else if r.source === 'suggestion'}
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
{:else if r.source === 'unknown' || r.source === 'imported'}
<p>{t('worklog.sourceUnknown')}</p>
{:else if r._hasEvents}
<p>{t('worklog.sourceSuggestion')}</p>
{:else}
<p>{t('worklog.sourceUnknown')}</p>
{/if}
</div>
{#if r._events}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('journal.relatedEvents')}</span>
{#each r._events as ev}
<div class="journal-event-row">
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
</div>
{/each}
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
@ -3305,6 +3336,7 @@
onDeleteSuggestion={(s) => deleteSuggestion(s)}
onOpenNodeFolder={(id) => openNodeFolder(id)}
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
/>
{:else}
<div class="today-empty">
@ -3559,6 +3591,7 @@
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
on:input={onInboxAssignSearchInput}
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
<div class="assign-hint">{t('inbox.assignSearchHint')}</div>
</label>
{#if inboxAssignResults.length > 0}
<div class="assign-search-results">
@ -3837,21 +3870,26 @@
.trash-section-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; min-height: 36px; }
.trash-section-head h3 { margin: 0 0 2px; }
.trash-section-head p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) minmax(120px, 220px) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; cursor: default; }
.trash-row.folder { background: #1b2132; border-color: #303856; }
.trash-row.selected { border-color: #6366f1; background: #20203a; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; }
.trash-row-icon:hover { background: #222238; color: #e4e4ef; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; padding: 2px 0; }
.trash-row-main:hover .trash-row-title { color: #a5b4fc; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { color: #8888a0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-path { color: #6f7390; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-actions { display: flex; align-items: center; gap: 6px; }
.trash-row-meta { color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-date { color: #666; font-size: 11px; }
.trash-row-actions { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
.journal-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; }
.journal-tab:hover { color: #a5b4fc; }
.journal-tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; }
.journal-header { margin-bottom: 24px; }
.journal-header h2 { margin: 0 0 16px 0; }
.journal-summary { display: flex; flex-wrap: wrap; gap: 24px; margin-bottom: 24px; padding: 16px; background: #1a1a2e; border-radius: 8px; border: 1px solid #2a2a3c; }
.summary-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
.summary-section { flex: 1; min-width: 200px; }
@ -3945,6 +3983,7 @@
.assign-search-result { width: 100%; display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 0; border-bottom: 1px solid #24243a; background: transparent; color: #e4e4ef; text-align: left; cursor: pointer; font-family: inherit; }
.assign-search-result:hover { background: #222238; }
.assign-search-result span:last-child { color: #8888a0; font-size: 12px; flex-shrink: 0; }
.assign-hint { color: #8888a0; font-size: 11px; margin-top: 4px; }
.assign-status { color: #8888a0; font-size: 12px; }
/* Buttons */

View File

@ -20,6 +20,7 @@
export let onDeleteSuggestion = (s) => {}
export let onOpenNodeFolder = (id) => {}
export let onOpenInboxArtifact = (item) => {}
export let onOpenTrashNode = (nodeId) => {}
let activeTab = 'feed'
@ -47,6 +48,7 @@
}
function feedItemLabel(ev) {
if (!ev || !ev.eventType) return ''
return eventLabel(ev.eventType)
}
@ -64,14 +66,15 @@
}
function handleFeedTitleClick(ev) {
if (!ev || !ev.eventType) return
if (ev.eventType === 'folder_deleted') {
onOpenNodeById(ev.nodeId)
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 {
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
@ -79,20 +82,22 @@
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
if (ev.targetType === 'note' && ev.targetId) {
onOpenActivityTarget(ev)
} else {
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
}
if (ev.eventType === 'worklog_added') {
onOpenNodeById(ev.nodeId)
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
if (['action_created','action_done'].includes(ev.eventType)) {
onOpenNodeById(ev.nodeId)
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
onOpenActivityEvent(ev)
if (ev.id || ev.nodeId) {
onOpenActivityEvent(ev)
}
}
function feedItemSubtitle(ev) {
@ -186,9 +191,9 @@
<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-type">{feedItemLabel(ev)}</span>
<span class="feed-colon">:</span>
<span class="feed-title link-btn">{ev.title}</span>
<span class="feed-title link-btn">{ev.title || ''}</span>
</div>
<div class="feed-meta-line">
{#if feedItemSubtitle(ev)}
@ -365,7 +370,7 @@
.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-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; }

View File

@ -45,6 +45,7 @@ export default {
'inbox.assignTitle': 'Assign material',
'inbox.assignTarget': 'Case',
'inbox.assignSearchPlaceholder': 'Find case',
'inbox.assignSearchHint': 'Start typing a Case name',
'inbox.deleteTitle': 'Delete material',
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
'capture.kind.text': 'Text',
@ -278,6 +279,7 @@ export default {
'today.sortAsc': 'ascending',
'today.sortDesc': 'descending',
'journal.title': 'Work Log',
'journal.worklogTab': 'Work Log',
'journal.empty': 'No entries for the selected period',
'journal.dateFrom': 'From',
'journal.dateTo': 'To',

View File

@ -46,6 +46,7 @@ export default {
'inbox.assignTitle': 'Разложить материал',
'inbox.assignTarget': 'Дело',
'inbox.assignSearchPlaceholder': 'Найти дело',
'inbox.assignSearchHint': 'Начните набирать название Дела',
'inbox.deleteTitle': 'Удалить материал',
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
@ -294,6 +295,7 @@ export default {
'today.sortDesc': 'по убыванию',
'journal.title': 'Журнал работы',
'journal.worklogTab': 'Журнал работы',
'journal.empty': 'Нет записей за выбранный период',
'journal.dateFrom': 'От',
'journal.dateTo': 'До',

View File

@ -221,7 +221,7 @@ func (r *Repository) CountChildren(parentID string, types ...string) (int, error
// Search finds active nodes whose title contains the query (case-insensitive).
func (r *Repository) Search(query string, limit int) ([]Node, error) {
q := `SELECT ` + nodeColumns + ` FROM nodes
WHERE deleted_at IS NULL AND title LIKE ? ORDER BY sort_order, title LIMIT ?`
WHERE deleted_at IS NULL AND LOWER(title) LIKE LOWER(?) ORDER BY sort_order, title LIMIT ?`
rows, err := r.db.Query(q, "%"+query+"%", limit)
if err != nil {
return nil, err