Skip to main content

max / goingson

7.5 KB · 201 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.escapeAttr;
10
11 // MIME type to icon mapping
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 // Render a compact attachment count badge for task rows
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