| 1 |
|
| 2 |
* GoingsOn - Attachments Module |
| 3 |
* File attachment UI for tasks and projects. |
| 4 |
|
| 5 |
|
| 6 |
(function() { |
| 7 |
'use strict'; |
| 8 |
const esc = GoingsOn.utils.escapeHtml; |
| 9 |
const escAttr = GoingsOn.utils.escapeAttr; |
| 10 |
|
| 11 |
|
| 12 |
const MIME_ICONS = { |
| 13 |
'application/pdf': '\uD83D\uDCC4', |
| 14 |
'image/': '\uD83D\uDDBC\uFE0F', |
| 15 |
'audio/': '\uD83C\uDFB5', |
| 16 |
'video/': '\uD83C\uDFA5', |
| 17 |
'text/': '\uD83D\uDCC3', |
| 18 |
'application/zip': '\uD83D\uDCE6', |
| 19 |
'application/gzip': '\uD83D\uDCE6', |
| 20 |
}; |
| 21 |
|
| 22 |
|
| 23 |
* Get the emoji icon for a MIME type. |
| 24 |
* @param {string} mimeType - MIME type string |
| 25 |
* @returns {string} Emoji character for the file type |
| 26 |
|
| 27 |
function getIcon(mimeType) { |
| 28 |
if (MIME_ICONS[mimeType]) return MIME_ICONS[mimeType]; |
| 29 |
for (const [prefix, icon] of Object.entries(MIME_ICONS)) { |
| 30 |
if (prefix.endsWith('/') && mimeType.startsWith(prefix)) return icon; |
| 31 |
} |
| 32 |
return '\uD83D\uDCCE'; |
| 33 |
} |
| 34 |
|
| 35 |
|
| 36 |
* Open the attachments panel modal for a task or project. |
| 37 |
* @param {string|null} taskId - Task ID, or null for project-only |
| 38 |
* @param {string|null} projectId - Project ID, or null for task-only |
| 39 |
|
| 40 |
async function openPanel(taskId, projectId) { |
| 41 |
GoingsOn.ui.closeModal(); |
| 42 |
try { |
| 43 |
const attachments = await GoingsOn.api.attachments.list(taskId, projectId); |
| 44 |
renderPanel(attachments, taskId, projectId); |
| 45 |
} catch (err) { |
| 46 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load attachments'), 'error'); |
| 47 |
} |
| 48 |
} |
| 49 |
|
| 50 |
function renderPanel(attachments, taskId, projectId) { |
| 51 |
const tid = taskId ? escAttr(taskId) : ''; |
| 52 |
const pid = projectId ? escAttr(projectId) : ''; |
| 53 |
|
| 54 |
let listHtml; |
| 55 |
if (attachments.length === 0) { |
| 56 |
listHtml = '<div class="empty-state empty-state--compact"><p class="empty-state-text">No attachments yet</p></div>'; |
| 57 |
} else { |
| 58 |
listHtml = attachments.map(a => ` |
| 59 |
<div class="attachment-item"> |
| 60 |
<span class="attachment-icon">${getIcon(a.mimeType)}</span> |
| 61 |
<div class="attachment-info"> |
| 62 |
<div class="attachment-filename" |
| 63 |
title="${escAttr(a.filename)}">${esc(a.filename)}</div> |
| 64 |
<div class="attachment-meta">${esc(a.fileSizeFormatted)}</div> |
| 65 |
</div> |
| 66 |
<div class="attachment-actions"> |
| 67 |
${a.hasLocalBlob ? ` |
| 68 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.open('${escAttr(a.id)}')" title="Open">Open</button> |
| 69 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.saveAs('${escAttr(a.id)}', '${escAttr(a.filename)}')" title="Save As">Save</button> |
| 70 |
` : ` |
| 71 |
<span class="attachment-sync-warning">Sync needed</span> |
| 72 |
`} |
| 73 |
<button class="btn btn-sm btn-secondary text-accent-red" |
| 74 |
onclick="GoingsOn.attachments.remove('${escAttr(a.id)}', '${tid}', '${pid}')" title="Delete">×</button> |
| 75 |
</div> |
| 76 |
</div> |
| 77 |
`).join(''); |
| 78 |
} |
| 79 |
|
| 80 |
const content = ` |
| 81 |
<div> |
| 82 |
${listHtml} |
| 83 |
<div class="attachment-attach-row"> |
| 84 |
<button class="btn btn-primary" onclick="GoingsOn.attachments.pickAndAttach('${tid}', '${pid}')">Attach File</button> |
| 85 |
</div> |
| 86 |
</div> |
| 87 |
`; |
| 88 |
GoingsOn.ui.openModal('Attachments', content); |
| 89 |
} |
| 90 |
|
| 91 |
|
| 92 |
* Open the native file picker and attach the selected file. |
| 93 |
* @param {string|null} taskId - Task ID to attach to |
| 94 |
* @param {string|null} projectId - Project ID to attach to |
| 95 |
|
| 96 |
async function pickAndAttach(taskId, projectId) { |
| 97 |
try { |
| 98 |
const { open } = window.__TAURI__.dialog; |
| 99 |
const selected = await open({ |
| 100 |
multiple: false, |
| 101 |
title: 'Select file to attach', |
| 102 |
}); |
| 103 |
if (!selected) return; |
| 104 |
|
| 105 |
const filePath = typeof selected === 'string' ? selected : selected.path; |
| 106 |
if (!filePath) return; |
| 107 |
|
| 108 |
await GoingsOn.ui.apiCall( |
| 109 |
GoingsOn.api.attachments.add(taskId || null, projectId || null, filePath), |
| 110 |
{ |
| 111 |
successMessage: 'File attached!', |
| 112 |
errorMessage: 'Failed to attach file', |
| 113 |
reload: () => openPanel(taskId || null, projectId || null), |
| 114 |
} |
| 115 |
); |
| 116 |
} catch (err) { |
| 117 |
if (err && err.toString().includes('cancelled')) return; |
| 118 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to attach file'), 'error'); |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
|
| 123 |
* Open an attachment file using the system default application. |
| 124 |
* @param {string} id - Attachment ID |
| 125 |
|
| 126 |
async function openAttachment(id) { |
| 127 |
try { |
| 128 |
await GoingsOn.api.attachments.open(id); |
| 129 |
} catch (err) { |
| 130 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open file'), 'error'); |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
|
| 135 |
* Save an attachment to a user-chosen location. |
| 136 |
* @param {string} id - Attachment ID |
| 137 |
* @param {string} filename - Default filename for the save dialog |
| 138 |
|
| 139 |
async function saveAs(id, filename) { |
| 140 |
try { |
| 141 |
const { save } = window.__TAURI__.dialog; |
| 142 |
const destination = await save({ |
| 143 |
defaultPath: filename, |
| 144 |
title: 'Save attachment as', |
| 145 |
}); |
| 146 |
if (!destination) return; |
| 147 |
|
| 148 |
await GoingsOn.api.attachments.save(id, destination); |
| 149 |
GoingsOn.ui.showToast('File saved!', 'success'); |
| 150 |
} catch (err) { |
| 151 |
if (err && err.toString().includes('cancelled')) return; |
| 152 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save file'), 'error'); |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
|
| 157 |
* Delete an attachment after confirmation, then refresh the panel. |
| 158 |
* @param {string} id - Attachment ID to delete |
| 159 |
* @param {string} taskId - Parent task ID for panel refresh |
| 160 |
* @param {string} projectId - Parent project ID for panel refresh |
| 161 |
|
| 162 |
async function remove(id, taskId, projectId) { |
| 163 |
const ok = await GoingsOn.ui.showConfirmDialog( |
| 164 |
'Delete attachment', |
| 165 |
'Delete this attachment?', |
| 166 |
{ confirmText: 'Delete', danger: true } |
| 167 |
); |
| 168 |
if (!ok) return; |
| 169 |
|
| 170 |
await GoingsOn.ui.apiCall( |
| 171 |
GoingsOn.api.attachments.delete(id), |
| 172 |
{ |
| 173 |
successMessage: 'Attachment deleted', |
| 174 |
errorMessage: 'Failed to delete attachment', |
| 175 |
reload: () => openPanel(taskId || null, projectId || null), |
| 176 |
} |
| 177 |
); |
| 178 |
} |
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
* Render a compact attachment count badge for task rows. |
| 183 |
* @param {number} attachmentCount - Number of attachments |
| 184 |
* @returns {string} HTML string for the badge, or empty string if no attachments |
| 185 |
|
| 186 |
function renderBadge(attachmentCount) { |
| 187 |
if (!attachmentCount || attachmentCount === 0) return ''; |
| 188 |
return `<span class="task-badge has-items">Files: ${attachmentCount}</span>`; |
| 189 |
} |
| 190 |
|
| 191 |
GoingsOn.attachments = { |
| 192 |
openPanel, |
| 193 |
pickAndAttach, |
| 194 |
open: openAttachment, |
| 195 |
saveAs, |
| 196 |
remove, |
| 197 |
renderBadge, |
| 198 |
getIcon, |
| 199 |
}; |
| 200 |
})(); |
| 201 |
|