Skip to main content

max / goingson

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