Add ticket file attachments with simple in-browser Excel editing.

Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:53:17 +01:00
co-authored by Cursor
parent 614cb94b46
commit 6e6b13270a
13 changed files with 1154 additions and 16 deletions
+87
View File
@@ -3,6 +3,7 @@
import { api } from './lib/api.js'
import { parseRoute, buildHash, currentHash } from './lib/route.js'
import MarkdownEditor from './lib/MarkdownEditor.svelte'
import ExcelEditor from './lib/ExcelEditor.svelte'
let user = $state(null)
let loading = $state(true)
@@ -57,6 +58,9 @@
let dragTicketId = $state('')
let dragOverStatus = $state('')
let suppressTicketClick = false
let attachments = $state([])
let excelAtt = $state(null)
let attachBusy = $state(false)
const boardStatuses = [
['todo', 'OPEN'],
@@ -377,15 +381,57 @@
editTitle = t.title
editDesc = t.description || ''
editStatus = t.status
excelAtt = null
cancelEditTime()
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
attachments = await api.listAttachments(projectId, t.id)
await refreshTimer()
topTab = 'projects'
projectView = 'ticket'
}
function formatBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function isExcelName(name) {
return /\.xlsx$/i.test(name || '')
}
async function onAttachFile(e) {
const file = e.target?.files?.[0]
e.target.value = ''
if (!file || !selected) return
attachBusy = true
error = ''
try {
await api.uploadAttachment(projectId, selected.id, file)
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Uploaded'
} catch (err) {
error = err.message
} finally {
attachBusy = false
}
}
async function removeAttachment(a) {
if (!selected || !confirm(`Delete ${a.filename}?`)) return
await api.deleteAttachment(projectId, selected.id, a.id)
if (excelAtt?.id === a.id) excelAtt = null
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Attachment deleted'
}
async function refreshAttachments() {
if (!selected) return
attachments = await api.listAttachments(projectId, selected.id)
}
async function openTicket(t) {
await showTicket(t)
syncRoute()
@@ -892,6 +938,47 @@
<MarkdownEditor bind:value={editDesc} label="Description" />
<button class="primary" onclick={saveTicket}>Save</button>
<h3>Attachments</h3>
<div class="row" style="flex-wrap: wrap; gap: 0.5rem; align-items: center;">
<label class="attach-upload">
<span class="buttonish">{attachBusy ? 'Uploading…' : 'Upload file'}</span>
<input type="file" disabled={attachBusy} onchange={onAttachFile} />
</label>
<span class="muted">Max 10 MiB</span>
</div>
{#if excelAtt}
<ExcelEditor
projectId={projectId}
ticketId={selected.id}
attachment={excelAtt}
onclose={() => (excelAtt = null)}
onsaved={refreshAttachments}
/>
{/if}
<ul class="attach-list">
{#each attachments as a}
<li class="attach-item">
<div class="attach-meta">
<span class="attach-name">{a.filename}</span>
<span class="muted">{formatBytes(a.sizeBytes)}</span>
</div>
<div class="row" style="width: auto; justify-content: flex-end;">
{#if isExcelName(a.filename)}
<button class="primary" onclick={() => (excelAtt = a)}>Edit</button>
{/if}
<a
class="buttonish"
href={api.downloadAttachmentUrl(projectId, selected.id, a.id)}
download={a.filename}
>Download</a>
<button class="danger" onclick={() => removeAttachment(a)}>Delete</button>
</div>
</li>
{:else}
<li class="muted">No attachments yet.</li>
{/each}
</ul>
<h3>Time</h3>
<div class="row">
<button class="primary" onclick={toggleTimer}>