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:
parent
c512ada386
commit
5257789a4d
|
|
@ -298,6 +298,21 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||||
return out, nil
|
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 {
|
func (a *App) OpenTrashFolder() error {
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
return err
|
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
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-CtkslTth.js"></script>
|
<script type="module" crossorigin src="/assets/main-PQ2CZjSe.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main--SNK_nBk.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bomne4X7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
let journalApproxFilter = 'all'
|
let journalApproxFilter = 'all'
|
||||||
let journalFilteredNodeTitle = ''
|
let journalFilteredNodeTitle = ''
|
||||||
let journalStatusMsg = ''
|
let journalStatusMsg = ''
|
||||||
|
let journalActiveTab = 'suggestions'
|
||||||
let journalSearchQuery = ''
|
let journalSearchQuery = ''
|
||||||
let journalSearchResults = []
|
let journalSearchResults = []
|
||||||
let journalShowResults = false
|
let journalShowResults = false
|
||||||
|
|
@ -322,11 +323,13 @@
|
||||||
await restoreNavigation(snapshot)
|
await restoreNavigation(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuxClick(e) {
|
function handleMouseNav(e) {
|
||||||
if (e.button !== 3) return
|
if (e.button !== 3 && e.button !== 4) return
|
||||||
if (isEditableTarget(e.target)) return
|
if (isEditableTarget(e.target)) return
|
||||||
|
if (closeTopModalForBack() && e.button === 3) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
goBack()
|
e.stopPropagation()
|
||||||
|
if (e.button === 3) goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveTab(tabId) {
|
function setActiveTab(tabId) {
|
||||||
|
|
@ -384,7 +387,8 @@
|
||||||
window.addEventListener('dragover', handleGlobalDragOver)
|
window.addEventListener('dragover', handleGlobalDragOver)
|
||||||
window.addEventListener('dragleave', handleGlobalDragLeave)
|
window.addEventListener('dragleave', handleGlobalDragLeave)
|
||||||
window.addEventListener('drop', handleGlobalDrop)
|
window.addEventListener('drop', handleGlobalDrop)
|
||||||
window.addEventListener('auxclick', handleAuxClick)
|
window.addEventListener('auxclick', handleMouseNav)
|
||||||
|
window.addEventListener('mouseup', handleMouseNav)
|
||||||
|
|
||||||
loading = false
|
loading = false
|
||||||
loadSyncStatus()
|
loadSyncStatus()
|
||||||
|
|
@ -398,7 +402,8 @@
|
||||||
window.removeEventListener('dragover', handleGlobalDragOver)
|
window.removeEventListener('dragover', handleGlobalDragOver)
|
||||||
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
||||||
window.removeEventListener('drop', handleGlobalDrop)
|
window.removeEventListener('drop', handleGlobalDrop)
|
||||||
window.removeEventListener('auxclick', handleAuxClick)
|
window.removeEventListener('auxclick', handleMouseNav)
|
||||||
|
window.removeEventListener('mouseup', handleMouseNav)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== System view / Node selection =====
|
// ===== System view / Node selection =====
|
||||||
|
|
@ -1386,6 +1391,21 @@
|
||||||
trashSelectedIds = []
|
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) {
|
function toggleTrashSelection(id) {
|
||||||
trashSelectedIds = trashSelectedIds.includes(id)
|
trashSelectedIds = trashSelectedIds.includes(id)
|
||||||
? trashSelectedIds.filter(existing => existing !== id)
|
? trashSelectedIds.filter(existing => existing !== id)
|
||||||
|
|
@ -3031,23 +3051,22 @@
|
||||||
{#each visibleTrashNodes as node}
|
{#each visibleTrashNodes as node}
|
||||||
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)} class:folder={node.type !== 'file' && node.type !== 'note'}>
|
<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)} />
|
<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')}
|
{@html actionIcon(node.type !== 'file' && node.type !== 'note' ? 'folder' : 'open')}
|
||||||
</span>
|
</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-title">{node.title}</span>
|
||||||
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)}</span>
|
<span class="trash-row-meta">{node.nodePath}</span>
|
||||||
<span class="trash-row-meta">{formatDate(node.deletedAt)}</span>
|
<span class="trash-row-date">{formatDate(node.deletedAt)}</span>
|
||||||
</div>
|
</button>
|
||||||
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
|
|
||||||
<div class="trash-row-actions">
|
<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))}>
|
<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')}
|
{@html actionIcon('restore')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -3065,223 +3084,235 @@
|
||||||
|
|
||||||
{:else if selectedSection === 'journal'}
|
{:else if selectedSection === 'journal'}
|
||||||
<div class="journal-screen">
|
<div class="journal-screen">
|
||||||
<div class="journal-header">
|
<div class="journal-tabs">
|
||||||
<h2>{t('journal.title')}</h2>
|
<button class="journal-tab" class:active={journalActiveTab === 'suggestions'} on:click={() => journalActiveTab = 'suggestions'}>
|
||||||
<div class="journal-filter-section">
|
{t('suggest.title')}
|
||||||
<div class="journal-filter-heading">{t('journal.filterHeading')}</div>
|
{#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
|
||||||
<div class="journal-filters-row">
|
</button>
|
||||||
<label><span class="label-text">{t('journal.dateFrom')}</span>
|
<button class="journal-tab" class:active={journalActiveTab === 'worklog'} on:click={() => journalActiveTab = 'worklog'}>
|
||||||
<input type="date" bind:value={journalDateFrom} />
|
{t('journal.worklogTab')}
|
||||||
</label>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{#if suggestions.length > 0}
|
{#if journalActiveTab === 'suggestions'}
|
||||||
<div class="journal-suggestions">
|
{#if suggestions.length === 0}
|
||||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
<div class="empty-state"><p>{t('suggest.noSuggestions')}</p></div>
|
||||||
{#each suggestions as s}
|
{:else}
|
||||||
<div class="suggestion-card" class:expanded={s._expanded}>
|
<div class="journal-suggestions">
|
||||||
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
{#each suggestions as s}
|
||||||
<div class="suggestion-info">
|
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||||
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
||||||
<span class="suggestion-summary">{s.summary}</span>
|
<div class="suggestion-info">
|
||||||
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
|
<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>
|
||||||
<div class="suggestion-actions">
|
{#if s._expanded && s.events && s.events.length > 0}
|
||||||
<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>
|
<div class="suggestion-detail">
|
||||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
||||||
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
{#each s.events as ev}
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
<div class="suggestion-detail-event">
|
||||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
|
||||||
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
|
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
</div>
|
<span class="suggestion-event-title">{ev.title}</span>
|
||||||
</div>
|
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
||||||
{#if s._expanded && s.events && s.events.length > 0}
|
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
||||||
<div class="suggestion-detail">
|
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
||||||
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{/each}
|
||||||
</tr>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
</div>
|
||||||
</tbody>
|
{/each}
|
||||||
</table>
|
</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>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -3305,6 +3336,7 @@
|
||||||
onDeleteSuggestion={(s) => deleteSuggestion(s)}
|
onDeleteSuggestion={(s) => deleteSuggestion(s)}
|
||||||
onOpenNodeFolder={(id) => openNodeFolder(id)}
|
onOpenNodeFolder={(id) => openNodeFolder(id)}
|
||||||
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
|
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
|
||||||
|
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="today-empty">
|
<div class="today-empty">
|
||||||
|
|
@ -3559,6 +3591,7 @@
|
||||||
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
|
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
|
||||||
on:input={onInboxAssignSearchInput}
|
on:input={onInboxAssignSearchInput}
|
||||||
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
|
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
|
||||||
|
<div class="assign-hint">{t('inbox.assignSearchHint')}</div>
|
||||||
</label>
|
</label>
|
||||||
{#if inboxAssignResults.length > 0}
|
{#if inboxAssignResults.length > 0}
|
||||||
<div class="assign-search-results">
|
<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 { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; min-height: 36px; }
|
||||||
.trash-section-head h3 { margin: 0 0 2px; }
|
.trash-section-head h3 { margin: 0 0 2px; }
|
||||||
.trash-section-head p { margin: 0; color: #8888a0; font-size: 12px; }
|
.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.folder { background: #1b2132; border-color: #303856; }
|
||||||
.trash-row.selected { border-color: #6366f1; background: #20203a; }
|
.trash-row.selected { border-color: #6366f1; background: #20203a; }
|
||||||
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; }
|
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; }
|
||||||
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.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-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-meta { color: #8ea0d8; font-size: 11px; 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-date { color: #666; font-size: 11px; }
|
||||||
.trash-row-actions { display: flex; align-items: center; gap: 6px; }
|
.trash-row-actions { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
|
||||||
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
|
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
|
||||||
|
|
||||||
/* Journal screen */
|
/* Journal screen */
|
||||||
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
.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 { 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; }
|
.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-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
|
||||||
.summary-section { flex: 1; min-width: 200px; }
|
.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 { 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:hover { background: #222238; }
|
||||||
.assign-search-result span:last-child { color: #8888a0; font-size: 12px; flex-shrink: 0; }
|
.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; }
|
.assign-status { color: #8888a0; font-size: 12px; }
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
export let onDeleteSuggestion = (s) => {}
|
export let onDeleteSuggestion = (s) => {}
|
||||||
export let onOpenNodeFolder = (id) => {}
|
export let onOpenNodeFolder = (id) => {}
|
||||||
export let onOpenInboxArtifact = (item) => {}
|
export let onOpenInboxArtifact = (item) => {}
|
||||||
|
export let onOpenTrashNode = (nodeId) => {}
|
||||||
|
|
||||||
let activeTab = 'feed'
|
let activeTab = 'feed'
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedItemLabel(ev) {
|
function feedItemLabel(ev) {
|
||||||
|
if (!ev || !ev.eventType) return ''
|
||||||
return eventLabel(ev.eventType)
|
return eventLabel(ev.eventType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,14 +66,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFeedTitleClick(ev) {
|
function handleFeedTitleClick(ev) {
|
||||||
|
if (!ev || !ev.eventType) return
|
||||||
if (ev.eventType === 'folder_deleted') {
|
if (ev.eventType === 'folder_deleted') {
|
||||||
onOpenNodeById(ev.nodeId)
|
if (ev.targetId) onOpenTrashNode(ev.targetId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
|
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
|
||||||
if (ev.targetId) {
|
if (ev.targetId) {
|
||||||
onOpenActivityTarget(ev)
|
onOpenActivityTarget(ev)
|
||||||
} else {
|
} else if (ev.nodeId) {
|
||||||
onOpenNodeById(ev.nodeId)
|
onOpenNodeById(ev.nodeId)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -79,20 +82,22 @@
|
||||||
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
|
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
|
||||||
if (ev.targetType === 'note' && ev.targetId) {
|
if (ev.targetType === 'note' && ev.targetId) {
|
||||||
onOpenActivityTarget(ev)
|
onOpenActivityTarget(ev)
|
||||||
} else {
|
} else if (ev.nodeId) {
|
||||||
onOpenNodeById(ev.nodeId)
|
onOpenNodeById(ev.nodeId)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (ev.eventType === 'worklog_added') {
|
if (ev.eventType === 'worklog_added') {
|
||||||
onOpenNodeById(ev.nodeId)
|
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (['action_created','action_done'].includes(ev.eventType)) {
|
if (['action_created','action_done'].includes(ev.eventType)) {
|
||||||
onOpenNodeById(ev.nodeId)
|
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onOpenActivityEvent(ev)
|
if (ev.id || ev.nodeId) {
|
||||||
|
onOpenActivityEvent(ev)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedItemSubtitle(ev) {
|
function feedItemSubtitle(ev) {
|
||||||
|
|
@ -186,9 +191,9 @@
|
||||||
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
|
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
|
||||||
<div class="feed-body">
|
<div class="feed-body">
|
||||||
<div class="feed-title-line">
|
<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-colon">:</span>
|
||||||
<span class="feed-title link-btn">{ev.title}</span>
|
<span class="feed-title link-btn">{ev.title || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-meta-line">
|
<div class="feed-meta-line">
|
||||||
{#if feedItemSubtitle(ev)}
|
{#if feedItemSubtitle(ev)}
|
||||||
|
|
@ -365,7 +370,7 @@
|
||||||
.feed-title:hover { text-decoration: underline; }
|
.feed-title:hover { text-decoration: underline; }
|
||||||
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
.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-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-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 { 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; }
|
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default {
|
||||||
'inbox.assignTitle': 'Assign material',
|
'inbox.assignTitle': 'Assign material',
|
||||||
'inbox.assignTarget': 'Case',
|
'inbox.assignTarget': 'Case',
|
||||||
'inbox.assignSearchPlaceholder': 'Find case',
|
'inbox.assignSearchPlaceholder': 'Find case',
|
||||||
|
'inbox.assignSearchHint': 'Start typing a Case name',
|
||||||
'inbox.deleteTitle': 'Delete material',
|
'inbox.deleteTitle': 'Delete material',
|
||||||
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
|
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
|
||||||
'capture.kind.text': 'Text',
|
'capture.kind.text': 'Text',
|
||||||
|
|
@ -278,6 +279,7 @@ export default {
|
||||||
'today.sortAsc': 'ascending',
|
'today.sortAsc': 'ascending',
|
||||||
'today.sortDesc': 'descending',
|
'today.sortDesc': 'descending',
|
||||||
'journal.title': 'Work Log',
|
'journal.title': 'Work Log',
|
||||||
|
'journal.worklogTab': 'Work Log',
|
||||||
'journal.empty': 'No entries for the selected period',
|
'journal.empty': 'No entries for the selected period',
|
||||||
'journal.dateFrom': 'From',
|
'journal.dateFrom': 'From',
|
||||||
'journal.dateTo': 'To',
|
'journal.dateTo': 'To',
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export default {
|
||||||
'inbox.assignTitle': 'Разложить материал',
|
'inbox.assignTitle': 'Разложить материал',
|
||||||
'inbox.assignTarget': 'Дело',
|
'inbox.assignTarget': 'Дело',
|
||||||
'inbox.assignSearchPlaceholder': 'Найти дело',
|
'inbox.assignSearchPlaceholder': 'Найти дело',
|
||||||
|
'inbox.assignSearchHint': 'Начните набирать название Дела',
|
||||||
'inbox.deleteTitle': 'Удалить материал',
|
'inbox.deleteTitle': 'Удалить материал',
|
||||||
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
|
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
|
||||||
|
|
||||||
|
|
@ -294,6 +295,7 @@ export default {
|
||||||
'today.sortDesc': 'по убыванию',
|
'today.sortDesc': 'по убыванию',
|
||||||
|
|
||||||
'journal.title': 'Журнал работы',
|
'journal.title': 'Журнал работы',
|
||||||
|
'journal.worklogTab': 'Журнал работы',
|
||||||
'journal.empty': 'Нет записей за выбранный период',
|
'journal.empty': 'Нет записей за выбранный период',
|
||||||
'journal.dateFrom': 'От',
|
'journal.dateFrom': 'От',
|
||||||
'journal.dateTo': 'До',
|
'journal.dateTo': 'До',
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// Search finds active nodes whose title contains the query (case-insensitive).
|
||||||
func (r *Repository) Search(query string, limit int) ([]Node, error) {
|
func (r *Repository) Search(query string, limit int) ([]Node, error) {
|
||||||
q := `SELECT ` + nodeColumns + ` FROM nodes
|
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)
|
rows, err := r.db.Query(q, "%"+query+"%", limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue