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:
Generated
+105
-1
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"marked": "^18.1.2",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2"
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
@@ -414,6 +415,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
@@ -434,6 +444,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -444,6 +467,27 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -514,6 +558,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -967,6 +1020,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.57.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
|
||||
@@ -1138,6 +1203,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"marked": "^18.1.2",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2"
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+126
@@ -592,3 +592,129 @@ label > :is(input, select, textarea) { color: var(--ink); }
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.attach-upload {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-upload input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.buttonish {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.buttonish:hover {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||
}
|
||||
.attach-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.attach-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #10151a;
|
||||
}
|
||||
.attach-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.attach-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.excel-editor {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.excel-sheets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
.excel-sheets button {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.excel-sheets button.active {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
.excel-scroll {
|
||||
overflow: auto;
|
||||
max-height: min(60vh, 28rem);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #0c1014;
|
||||
}
|
||||
.excel-grid {
|
||||
border-collapse: collapse;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.excel-grid th,
|
||||
.excel-grid td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.excel-rownum {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-2);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.excel-grid td input {
|
||||
width: 7.5rem;
|
||||
min-width: 4rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.excel-grid td input:focus {
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
background: #10151a;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script>
|
||||
import { api } from './api.js'
|
||||
|
||||
let {
|
||||
projectId,
|
||||
ticketId,
|
||||
attachment,
|
||||
onclose = () => {},
|
||||
onsaved = () => {},
|
||||
} = $props()
|
||||
|
||||
let loading = $state(true)
|
||||
let saving = $state(false)
|
||||
let error = $state('')
|
||||
let workbook = $state(null)
|
||||
let sheetNames = $state([])
|
||||
let activeSheet = $state('')
|
||||
/** @type {string[][]} */
|
||||
let grid = $state([])
|
||||
let XLSX = null
|
||||
|
||||
async function ensureXlsx() {
|
||||
if (!XLSX) XLSX = await import('xlsx')
|
||||
return XLSX
|
||||
}
|
||||
|
||||
function sheetToGrid(wb, name) {
|
||||
const ws = wb.Sheets[name]
|
||||
if (!ws) return [['']]
|
||||
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false })
|
||||
if (!rows.length) return [['']]
|
||||
const width = Math.max(...rows.map((r) => r.length), 1)
|
||||
return rows.map((r) => {
|
||||
const row = Array.from({ length: width }, (_, i) => String(r[i] ?? ''))
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
function gridToSheet(rows) {
|
||||
return XLSX.utils.aoa_to_sheet(rows)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
await ensureXlsx()
|
||||
const buf = await api.downloadAttachment(projectId, ticketId, attachment.id)
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
workbook = wb
|
||||
sheetNames = wb.SheetNames.slice()
|
||||
activeSheet = sheetNames[0] || ''
|
||||
grid = activeSheet ? sheetToGrid(wb, activeSheet) : [['']]
|
||||
} catch (e) {
|
||||
error = e.message || String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectSheet(name) {
|
||||
if (!workbook || name === activeSheet) return
|
||||
workbook.Sheets[activeSheet] = gridToSheet(grid)
|
||||
activeSheet = name
|
||||
grid = sheetToGrid(workbook, name)
|
||||
}
|
||||
|
||||
function setCell(ri, ci, value) {
|
||||
grid = grid.map((row, i) =>
|
||||
i === ri ? row.map((c, j) => (j === ci ? value : c)) : row,
|
||||
)
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const width = grid[0]?.length || 1
|
||||
grid = [...grid, Array.from({ length: width }, () => '')]
|
||||
}
|
||||
|
||||
function addCol() {
|
||||
grid = grid.map((row) => [...row, ''])
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!workbook) return
|
||||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
await ensureXlsx()
|
||||
workbook.Sheets[activeSheet] = gridToSheet(grid)
|
||||
const out = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||
await api.putAttachment(projectId, ticketId, attachment.id, out, {
|
||||
contentType:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
onsaved()
|
||||
msgOk()
|
||||
} catch (e) {
|
||||
error = e.message || String(e)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
let justSaved = $state(false)
|
||||
function msgOk() {
|
||||
justSaved = true
|
||||
setTimeout(() => {
|
||||
justSaved = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (attachment?.id) load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="excel-editor">
|
||||
<div class="excel-editor-head row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<div class="row" style="width: auto; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<strong>{attachment.filename}</strong>
|
||||
{#if justSaved}<span class="muted">Saved</span>{/if}
|
||||
{#if error}<span class="error">{error}</span>{/if}
|
||||
</div>
|
||||
<div class="row" style="width: auto;">
|
||||
<button class="primary" disabled={loading || saving || !!error && !workbook} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onclick={onclose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading spreadsheet…</p>
|
||||
{:else if workbook}
|
||||
<div class="excel-sheets" role="tablist">
|
||||
{#each sheetNames as name}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class:active={name === activeSheet}
|
||||
aria-selected={name === activeSheet}
|
||||
onclick={() => selectSheet(name)}
|
||||
>{name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="excel-toolbar row" style="width: auto;">
|
||||
<button type="button" onclick={addRow}>+ Row</button>
|
||||
<button type="button" onclick={addCol}>+ Column</button>
|
||||
</div>
|
||||
<div class="excel-scroll">
|
||||
<table class="excel-grid">
|
||||
<tbody>
|
||||
{#each grid as row, ri}
|
||||
<tr>
|
||||
<th class="excel-rownum">{ri + 1}</th>
|
||||
{#each row as cell, ci}
|
||||
<td>
|
||||
<input
|
||||
value={cell}
|
||||
oninput={(e) => setCell(ri, ci, e.currentTarget.value)}
|
||||
aria-label={`R${ri + 1}C${ci + 1}`}
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+50
-1
@@ -1,9 +1,29 @@
|
||||
async function request(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) }
|
||||
if (opts.body != null && !(opts.body instanceof FormData) && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
...opts,
|
||||
headers,
|
||||
})
|
||||
if (opts.raw) {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
data = { error: text }
|
||||
}
|
||||
const err = new Error((data && data.error) || res.statusText)
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
@@ -95,4 +115,33 @@ export const api = {
|
||||
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listAttachments: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments`),
|
||||
uploadAttachment: (projectId, tid, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request(`/projects/${projectId}/tickets/${tid}/attachments`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
})
|
||||
},
|
||||
downloadAttachment: async (projectId, tid, aid) => {
|
||||
const res = await request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
raw: true,
|
||||
})
|
||||
return res.arrayBuffer()
|
||||
},
|
||||
downloadAttachmentUrl: (projectId, tid, aid) =>
|
||||
`/api/v1/projects/${projectId}/tickets/${tid}/attachments/${aid}`,
|
||||
putAttachment: (projectId, tid, aid, body, { contentType } = {}) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
method: 'PUT',
|
||||
headers: contentType ? { 'Content-Type': contentType } : {},
|
||||
body,
|
||||
}),
|
||||
deleteAttachment: (projectId, tid, aid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user