heyquilt 0.0.1 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/ui/files.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// A workspace's file library: the Files section on the workspace page, the All files view
|
|
2
|
+
// (folders, tiles or a list, a preview panel, rename, move, delete, versions), uploads through
|
|
3
|
+
// the app's local server with progress, and a picker other screens use to choose a file.
|
|
4
|
+
// Bytes always come from the local server (/api/workspaces/<id>/files/<fid>/data), so
|
|
5
|
+
// thumbnails and previews stay same-origin under the page's CSP.
|
|
6
|
+
import { I, state, esc, toast, api, ask, ago, bytes, TOKEN, loadingNote } from './common.js'
|
|
7
|
+
import { parseCsv } from './csv.js'
|
|
8
|
+
|
|
9
|
+
const wsUrl = (id, rest = '') => `/api/workspaces/${encodeURIComponent(id)}${rest}`
|
|
10
|
+
const fileUrl = (id, fid, rest = '') => wsUrl(id, '/files/' + encodeURIComponent(fid) + rest)
|
|
11
|
+
/** The file's bytes for <img>, <video>, <audio>, <iframe> and links: the token rides in the query. */
|
|
12
|
+
const dataUrl = (id, f, extra = '') => fileUrl(id, f.id, '/data?t=' + encodeURIComponent(TOKEN || '')) + `&v=${encodeURIComponent(f.version || 1)}${extra}`
|
|
13
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
|
|
14
|
+
const MAX_TEXT_PREVIEW = 2 * 1024 * 1024
|
|
15
|
+
const CSV_ROWS = 200
|
|
16
|
+
const NEWEST = 8
|
|
17
|
+
const MAX_UPLOAD = 500 * 1024 * 1024 // the local server's cap too
|
|
18
|
+
const TOO_BIG = 'File is too large (500 MB at most).'
|
|
19
|
+
const NO_SLASH = 'A name cannot contain a slash. Use Move to change the folder.'
|
|
20
|
+
const viewWorkspace = () => String(state.view).replace(/^(ws|wsfiles):/, '')
|
|
21
|
+
|
|
22
|
+
// ----------------------------------------------------------------- types --
|
|
23
|
+
const EXT_MIME = {
|
|
24
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
|
|
25
|
+
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
26
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
27
|
+
pdf: 'application/pdf', csv: 'text/csv', md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', json: 'application/json'
|
|
28
|
+
}
|
|
29
|
+
const TEXT_EXT = new Set(['txt', 'md', 'markdown', 'json', 'yml', 'yaml', 'toml', 'xml', 'html', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'sh', 'sql', 'log', 'ini', 'env', 'srt', 'vtt'])
|
|
30
|
+
const TEXT_MIME = new Set(['application/json', 'application/xml', 'application/javascript', 'application/x-yaml', 'application/yaml', 'application/toml', 'application/sql'])
|
|
31
|
+
const extOf = (name) => { const m = /\.([A-Za-z0-9]{1,8})$/.exec(String(name)); return m ? m[1].toLowerCase() : '' }
|
|
32
|
+
const mimeOf = (f) => String(f.mime || EXT_MIME[extOf(f.name)] || '').split(';')[0].toLowerCase()
|
|
33
|
+
|
|
34
|
+
/** What the preview panel does with a file: image, video, audio, pdf, csv, text or none. */
|
|
35
|
+
function previewKind (f) {
|
|
36
|
+
const m = mimeOf(f)
|
|
37
|
+
if (m.startsWith('image/')) return 'image'
|
|
38
|
+
if (m.startsWith('video/')) return 'video'
|
|
39
|
+
if (m.startsWith('audio/')) return 'audio'
|
|
40
|
+
if (m === 'application/pdf') return 'pdf'
|
|
41
|
+
if (m === 'text/csv') return 'csv'
|
|
42
|
+
if (m === 'text/markdown' || m.startsWith('text/') || TEXT_MIME.has(m) || TEXT_EXT.has(extOf(f.name))) return 'text'
|
|
43
|
+
return 'none'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const BADGE = { image: '#c8361a', video: '#7b4f9e', audio: '#2e7a80', pdf: '#b3372e', csv: '#a8701c', sheet: '#3d7a4c', text: '#6e6c7e', none: '#6e6c7e' }
|
|
47
|
+
const SHEETS = new Set(['xlsx', 'xls', 'numbers', 'ods'])
|
|
48
|
+
const badgeText = (f) => (extOf(f.name) || 'file').slice(0, 4).toUpperCase()
|
|
49
|
+
const badgeColor = (f) => (SHEETS.has(extOf(f.name)) ? BADGE.sheet : BADGE[previewKind(f)])
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ data --
|
|
52
|
+
const filesOf = (d) => (d.files || []).filter((f) => f.kind !== 'folder')
|
|
53
|
+
const foldersOf = (d) => (d.files || []).filter((f) => f.kind === 'folder')
|
|
54
|
+
const newestFirst = (a, b) => (b.uploadedAt || 0) - (a.uploadedAt || 0)
|
|
55
|
+
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
|
56
|
+
const canEdit = (d) => d.access?.access === 'edit'
|
|
57
|
+
const inside = (folder, path) => folder === '' || path.startsWith(folder + '/')
|
|
58
|
+
|
|
59
|
+
/** Who uploaded: a member's name when the workspace knows them, else the account's kind. */
|
|
60
|
+
function whoOf (d, account) {
|
|
61
|
+
if (!account) return ''
|
|
62
|
+
const people = [d.owner, ...(d.members || [])].filter(Boolean)
|
|
63
|
+
const m = people.find((p) => p.account === account)
|
|
64
|
+
if (m?.name) return m.name
|
|
65
|
+
if (state.account && account === `person:${state.account.id}`) return state.account.name || 'You'
|
|
66
|
+
return String(account).startsWith('agent:') ? 'agent' : 'person'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function folderStats (d, path) {
|
|
70
|
+
const list = filesOf(d).filter((f) => f.path.startsWith(path + '/'))
|
|
71
|
+
return { count: list.length, size: list.reduce((n, f) => n + (f.size || 0), 0) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function usageText (d) {
|
|
75
|
+
const u = d.usage
|
|
76
|
+
if (!u) return ''
|
|
77
|
+
return Number.isFinite(u.quotaBytes) ? `${bytes(u.usedBytes || 0)} of ${bytes(u.quotaBytes)}` : `${bytes(u.usedBytes || 0)} used`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** "5m ago", "3h ago", "now", or a date. */
|
|
81
|
+
const when = (ts) => { if (!ts) return ''; const a = ago(ts); return /^\d+[mh]$/.test(a) ? `${a} ago` : a }
|
|
82
|
+
|
|
83
|
+
// ----------------------------------------------------------- upload state --
|
|
84
|
+
// One upload at a time per page, kept outside the DOM so a re-render (the 20 s poll) still
|
|
85
|
+
// shows its progress.
|
|
86
|
+
let progress = null // { id, n, m, pct }
|
|
87
|
+
const progressLabel = () => `Uploading ${progress.n} of ${progress.m} · ${progress.pct}%`
|
|
88
|
+
const uploadingTo = (id) => !!progress && progress.id === id
|
|
89
|
+
|
|
90
|
+
function paintProgress () {
|
|
91
|
+
document.querySelectorAll('[data-progress]').forEach((el) => { el.textContent = progress ? progressLabel() : (el.dataset.idle || '') })
|
|
92
|
+
document.querySelectorAll('[data-progress-bar]').forEach((el) => { el.style.width = `${progress ? progress.pct : 0}%` })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `reload` redraws the screen the upload started on; `go` redraws whichever screen of the
|
|
96
|
+
* same workspace you moved to meanwhile. Elsewhere, nothing: the 20 s poll catches up. */
|
|
97
|
+
async function startUpload (id, list, folder, { reload, go }) {
|
|
98
|
+
const files = [...list]
|
|
99
|
+
if (!files.length) return
|
|
100
|
+
if (progress) return toast('Wait for the upload in progress to finish.')
|
|
101
|
+
const view = state.view
|
|
102
|
+
progress = { id, n: 1, m: files.length, pct: 0 }
|
|
103
|
+
paintProgress()
|
|
104
|
+
document.querySelectorAll('[data-upload-tile]').forEach((t) => { t.dataset.busy = '' })
|
|
105
|
+
try {
|
|
106
|
+
await uploadFiles(id, files, {
|
|
107
|
+
folder,
|
|
108
|
+
onProgress: (file, loaded, total) => {
|
|
109
|
+
progress = { id, n: files.indexOf(file) + 1, m: files.length, pct: total ? Math.min(100, Math.round((loaded / total) * 100)) : 0 }
|
|
110
|
+
paintProgress()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
} finally {
|
|
114
|
+
progress = null
|
|
115
|
+
paintProgress()
|
|
116
|
+
document.querySelectorAll('[data-upload-tile]').forEach((t) => { delete t.dataset.busy })
|
|
117
|
+
if (state.view === view) await reload()
|
|
118
|
+
else if (viewWorkspace() === id) await go(state.view)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One PUT per file to the local server, which streams it on to the workspace's storage. */
|
|
123
|
+
export async function uploadFiles (id, files, { folder = '', onProgress = () => {} } = {}) {
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
try {
|
|
126
|
+
if (file.size > MAX_UPLOAD) throw new Error(TOO_BIG)
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
const xhr = new XMLHttpRequest()
|
|
129
|
+
xhr.open('PUT', wsUrl(id, '/upload'))
|
|
130
|
+
xhr.setRequestHeader('x-quilt-token', TOKEN || '')
|
|
131
|
+
xhr.setRequestHeader('x-path', encodeURIComponent(folder ? folder + '/' + file.name : file.name))
|
|
132
|
+
xhr.setRequestHeader('content-type', file.type || 'application/octet-stream')
|
|
133
|
+
xhr.upload.onprogress = (e) => onProgress(file, e.loaded, e.lengthComputable ? e.total : file.size)
|
|
134
|
+
xhr.onload = () => {
|
|
135
|
+
let body = {}
|
|
136
|
+
try { body = JSON.parse(xhr.responseText || '{}') } catch {}
|
|
137
|
+
if (xhr.status >= 200 && xhr.status < 300) { onProgress(file, file.size, file.size); return resolve(body) }
|
|
138
|
+
if (xhr.status === 401 && body.signedOut) window.dispatchEvent(new Event('quilt-signed-out'))
|
|
139
|
+
reject(new Error(body.error || `Upload failed (${xhr.status})`))
|
|
140
|
+
}
|
|
141
|
+
xhr.onerror = () => reject(new Error('The upload did not reach Quilt.'))
|
|
142
|
+
xhr.send(file)
|
|
143
|
+
})
|
|
144
|
+
} catch (err) {
|
|
145
|
+
toast(`${file.name}: ${err.message}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ----------------------------------------------------------------- tiles --
|
|
151
|
+
/** A folder's Rename and Delete buttons (All files, for editors). */
|
|
152
|
+
const folderActsHtml = (f) => `<span class="folder-acts"><button type="button" class="btn sm ghost icon" data-folder-rename="${esc(f.id)}" title="Rename ${esc(f.name)}" aria-label="Rename ${esc(f.name)}">${I.pencil}</button><button type="button" class="btn sm ghost icon danger" data-folder-delete="${esc(f.id)}" title="Delete ${esc(f.name)}" aria-label="Delete ${esc(f.name)}">${I.trash}</button></span>`
|
|
153
|
+
|
|
154
|
+
function folderTileHtml (d, f, { acts = false } = {}) {
|
|
155
|
+
const s = folderStats(d, f.path)
|
|
156
|
+
const tile = `
|
|
157
|
+
<button type="button" class="tile folder" data-folder="${esc(f.path)}" title="${esc(f.path)}">
|
|
158
|
+
<span class="thumb">${I.folder}</span>
|
|
159
|
+
<span class="nm">${esc(f.name)}</span><span class="mu">${esc(plural(s.count, 'file', 'files'))}${s.count ? ` · ${esc(bytes(s.size))}` : ''}</span>
|
|
160
|
+
</button>`
|
|
161
|
+
return acts ? `<div class="tile-wrap">${tile}${folderActsHtml(f)}</div>` : tile
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function thumbHtml (d, f) {
|
|
165
|
+
const kind = previewKind(f)
|
|
166
|
+
const inner = kind === 'image'
|
|
167
|
+
? `<img loading="lazy" alt="" src="${esc(dataUrl(d.workspace.id, f))}">`
|
|
168
|
+
: kind === 'video' ? `<span class="play">${I.play}</span>` : `<span class="stem">${esc(f.name.replace(/\.[^.]+$/, '').slice(0, 14))}</span>`
|
|
169
|
+
return `<span class="thumb${kind === 'image' ? ' img' : kind === 'video' ? ' vid' : ''}">${inner}<span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span>${f.version > 1 ? `<span class="ver">v${esc(f.version)}</span>` : ''}</span>`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fileTileHtml (d, f, { meta, on = false }) {
|
|
173
|
+
return `
|
|
174
|
+
<button type="button" class="tile${on ? ' on' : ''}" data-pick-file="${esc(f.id)}" title="${esc(f.path)}"${canEdit(d) ? ' draggable="true"' : ''}>
|
|
175
|
+
${thumbHtml(d, f)}
|
|
176
|
+
<span class="nm">${esc(f.name)}</span><span class="mu">${esc(meta)}</span>
|
|
177
|
+
</button>`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function uploadTileHtml (id, label) {
|
|
181
|
+
const busy = uploadingTo(id)
|
|
182
|
+
return `
|
|
183
|
+
<button type="button" class="tile add" data-upload-tile${busy ? ' data-busy' : ''}>
|
|
184
|
+
<span class="up">${I.upload}</span><span class="lbl" data-progress data-idle="${esc(label)}">${esc(busy ? progressLabel() : label)}</span>
|
|
185
|
+
<span class="bar"><span data-progress-bar style="width:${busy ? progress.pct : 0}%"></span></span>
|
|
186
|
+
</button>
|
|
187
|
+
<input type="file" multiple hidden data-upload-input>`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------- the workspace's section --
|
|
191
|
+
export function filesSectionHtml (d) {
|
|
192
|
+
const root = foldersOf(d).filter((f) => f.folder === '').sort(byName)
|
|
193
|
+
const newest = filesOf(d).filter((f) => f.folder === '').sort(newestFirst).slice(0, NEWEST)
|
|
194
|
+
const count = filesOf(d).length
|
|
195
|
+
const usage = usageText(d)
|
|
196
|
+
const empty = !root.length && !newest.length && !canEdit(d)
|
|
197
|
+
return `
|
|
198
|
+
<section class="sec files-sec" data-files-drop>
|
|
199
|
+
<div class="sec-head"><h2>Files</h2><span class="count">${count}</span>${usage ? `<span class="hint">${esc(usage)}</span>` : ''}<span class="spacer"></span>
|
|
200
|
+
${canEdit(d) ? `<button type="button" class="btn sm ghost" data-new-folder>${I.plus}<span>New folder</span></button>` : ''}
|
|
201
|
+
<a href="#" class="sec-link" data-all-files>All files ${I.caret}</a></div>
|
|
202
|
+
${empty ? '<p class="hint">No files yet.</p>' : `<div class="tiles">
|
|
203
|
+
${root.map((f) => folderTileHtml(d, f)).join('')}
|
|
204
|
+
${newest.map((f) => fileTileHtml(d, f, { meta: [whoOf(d, f.uploadedBy), when(f.uploadedAt)].filter(Boolean).join(' · ') })).join('')}
|
|
205
|
+
${canEdit(d) ? uploadTileHtml(d.workspace.id, 'Upload or drop files') : ''}
|
|
206
|
+
</div>`}
|
|
207
|
+
</section>`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const FILE_DRAG = 'application/x-quilt-file'
|
|
211
|
+
|
|
212
|
+
/** Asks for a name and makes a folder inside `parent` ('' for the top). */
|
|
213
|
+
async function newFolder (id, parent, reload) {
|
|
214
|
+
const name = await ask({ title: 'New folder', message: parent ? `Inside ${parent}.` : 'Folders keep files sorted. Drag a file onto one to move it there.', input: { placeholder: 'Name' }, ok: 'Create' })
|
|
215
|
+
if (!name) return
|
|
216
|
+
if (name.includes('/')) return toast(NO_SLASH)
|
|
217
|
+
try { await api('POST', wsUrl(id, '/folders'), { path: parent ? `${parent}/${name}` : name }); toast('Folder made'); await reload() } catch (err) { toast(err.message) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Files you may edit can be dragged onto a folder (a tile, a list row or a breadcrumb)
|
|
222
|
+
* to move them there. Uploads from the desktop still use the section's own drop zone.
|
|
223
|
+
*/
|
|
224
|
+
function bindMoveToFolder (root, d, id, reload) {
|
|
225
|
+
root.querySelectorAll('[data-pick-file][draggable="true"]').forEach((el) => {
|
|
226
|
+
el.addEventListener('dragstart', (e) => {
|
|
227
|
+
e.dataTransfer.setData(FILE_DRAG, el.dataset.pickFile)
|
|
228
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
229
|
+
el.classList.add('dragging')
|
|
230
|
+
})
|
|
231
|
+
el.addEventListener('dragend', () => el.classList.remove('dragging'))
|
|
232
|
+
})
|
|
233
|
+
const ours = (e) => [...(e.dataTransfer?.types || [])].includes(FILE_DRAG)
|
|
234
|
+
root.querySelectorAll('[data-folder]').forEach((target) => {
|
|
235
|
+
target.addEventListener('dragover', (e) => {
|
|
236
|
+
if (!ours(e)) return
|
|
237
|
+
e.preventDefault()
|
|
238
|
+
e.stopPropagation()
|
|
239
|
+
e.dataTransfer.dropEffect = 'move'
|
|
240
|
+
target.classList.add('drop-on')
|
|
241
|
+
})
|
|
242
|
+
target.addEventListener('dragleave', (e) => { if (!target.contains(e.relatedTarget)) target.classList.remove('drop-on') })
|
|
243
|
+
target.addEventListener('drop', async (e) => {
|
|
244
|
+
if (!ours(e)) return
|
|
245
|
+
e.preventDefault()
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
target.classList.remove('drop-on')
|
|
248
|
+
const f = filesOf(d).find((x) => x.id === e.dataTransfer.getData(FILE_DRAG))
|
|
249
|
+
const folder = target.dataset.folder
|
|
250
|
+
if (!f || f.folder === folder) return
|
|
251
|
+
try {
|
|
252
|
+
await api('POST', fileUrl(id, f.id, '/update'), { path: folder ? `${folder}/${f.name}` : f.name })
|
|
253
|
+
toast(`Moved to ${folder ? folder.split('/').pop() : d.workspace.name}`)
|
|
254
|
+
await reload()
|
|
255
|
+
} catch (err) { toast(err.message) }
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Drag and drop onto an element: highlights while files hover, uploads them on drop. */
|
|
261
|
+
function bindDrop (zone, onFiles) {
|
|
262
|
+
if (!zone) return
|
|
263
|
+
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files')
|
|
264
|
+
zone.addEventListener('dragover', (e) => {
|
|
265
|
+
if (!hasFiles(e)) return
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
268
|
+
zone.classList.add('files-dragging')
|
|
269
|
+
})
|
|
270
|
+
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) zone.classList.remove('files-dragging') })
|
|
271
|
+
zone.addEventListener('drop', (e) => {
|
|
272
|
+
if (!hasFiles(e)) return
|
|
273
|
+
e.preventDefault()
|
|
274
|
+
zone.classList.remove('files-dragging')
|
|
275
|
+
onFiles(e.dataTransfer.files)
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function bindUpload (root, upload) {
|
|
280
|
+
const input = root.querySelector('[data-upload-input]')
|
|
281
|
+
if (!input) return
|
|
282
|
+
input.onchange = () => { const list = [...input.files]; input.value = ''; upload(list) }
|
|
283
|
+
root.querySelectorAll('[data-upload-tile], [data-upload]').forEach((b) => { b.onclick = () => { if (!progress) input.click() } })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function bindFilesSection (root, { id, reload, go }) {
|
|
287
|
+
const d = state.workspace
|
|
288
|
+
const sec = root.querySelector('[data-files-drop]')
|
|
289
|
+
if (!d || !sec) return
|
|
290
|
+
const open = (patch) => { state.filesView = { ...state.filesView, folder: '', picked: null, ...patch }; go(`wsfiles:${id}`) }
|
|
291
|
+
sec.querySelector('[data-all-files]').onclick = (e) => { e.preventDefault(); open({}) }
|
|
292
|
+
sec.querySelectorAll('[data-folder]').forEach((b) => { b.onclick = () => open({ folder: b.dataset.folder }) })
|
|
293
|
+
sec.querySelectorAll('[data-pick-file]').forEach((b) => {
|
|
294
|
+
b.onclick = () => { const f = filesOf(d).find((x) => x.id === b.dataset.pickFile); if (f) open({ folder: f.folder, picked: f.id }) }
|
|
295
|
+
})
|
|
296
|
+
if (!canEdit(d)) return
|
|
297
|
+
const upload = (list) => startUpload(id, list, '', { reload, go })
|
|
298
|
+
bindUpload(sec, upload)
|
|
299
|
+
bindDrop(sec, upload)
|
|
300
|
+
sec.querySelector('[data-new-folder]').onclick = () => newFolder(id, '', reload)
|
|
301
|
+
bindMoveToFolder(sec, d, id, reload)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------- All files --
|
|
305
|
+
function crumbsHtml (d, folder) {
|
|
306
|
+
const parts = folder ? folder.split('/') : []
|
|
307
|
+
const crumbs = [`<a href="#" data-folder="">${esc(d.workspace.name)}</a>`]
|
|
308
|
+
parts.forEach((p, i) => {
|
|
309
|
+
const path = parts.slice(0, i + 1).join('/')
|
|
310
|
+
crumbs.push(i === parts.length - 1 ? `<span class="here">${esc(p)}</span>` : `<a href="#" data-folder="${esc(path)}">${esc(p)}</a>`)
|
|
311
|
+
})
|
|
312
|
+
return `<h1 class="crumbs">${crumbs.join('<span class="sep">/</span>')}</h1>`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function listHtml (d, folders, files, picked) {
|
|
316
|
+
const edit = canEdit(d)
|
|
317
|
+
return `
|
|
318
|
+
<div class="files-scroll"><table class="files-list">
|
|
319
|
+
<thead><tr><th>Name</th><th>Size</th><th>Uploaded by</th><th>Date</th><th>Version</th>${edit ? '<th><span class="sr">Actions</span></th>' : ''}</tr></thead>
|
|
320
|
+
<tbody>
|
|
321
|
+
${folders.map((f) => { const s = folderStats(d, f.path); return `<tr class="is-folder" tabindex="0" data-folder="${esc(f.path)}"><td class="nm">${I.folder}<span>${esc(f.name)}</span></td><td>${esc(plural(s.count, 'file', 'files'))}</td><td></td><td></td><td></td>${edit ? `<td class="acts">${folderActsHtml(f)}</td>` : ''}</tr>` }).join('')}
|
|
322
|
+
${files.map((f) => `<tr tabindex="0" data-pick-file="${esc(f.id)}" class="${f.id === picked ? 'on' : ''}"${edit ? ' draggable="true"' : ''}><td class="nm"><span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span><span>${esc(f.name)}</span></td><td>${esc(bytes(f.size || 0))}</td><td>${esc(whoOf(d, f.uploadedBy))}</td><td>${esc(f.uploadedAt ? new Date(f.uploadedAt).toLocaleDateString() : '')}</td><td>v${esc(f.version || 1)}</td>${edit ? '<td></td>' : ''}</tr>`).join('')}
|
|
323
|
+
</tbody>
|
|
324
|
+
</table></div>`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Preview text and tables, cached per file version so the poll's re-render doesn't flicker.
|
|
328
|
+
const previewCache = new Map() // `${fid}:${version}` -> html
|
|
329
|
+
|
|
330
|
+
function previewMediaHtml (d, f) {
|
|
331
|
+
const id = d.workspace.id
|
|
332
|
+
const src = esc(dataUrl(id, f))
|
|
333
|
+
const kind = previewKind(f)
|
|
334
|
+
if (kind === 'image') return `<img src="${src}" alt="${esc(f.name)}">`
|
|
335
|
+
if (kind === 'video') return `<video controls preload="metadata" src="${src}"></video>`
|
|
336
|
+
if (kind === 'audio') return `<audio controls preload="metadata" src="${src}"></audio>`
|
|
337
|
+
if (kind === 'pdf') return `<iframe src="${src}" title="${esc(f.name)}"></iframe>`
|
|
338
|
+
if (kind === 'csv' || kind === 'text') {
|
|
339
|
+
if ((f.size || 0) > MAX_TEXT_PREVIEW) return '<p class="hint">Too big to preview; download it.</p>'
|
|
340
|
+
const cached = previewCache.get(`${f.id}:${f.version}`)
|
|
341
|
+
return cached || `<div class="fp-text" data-preview-load="${kind}"><p class="hint">${loadingNote()}</p></div>`
|
|
342
|
+
}
|
|
343
|
+
return '<p class="hint">No preview for this kind of file.</p>'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const csvTableHtml = (rows) => `<div class="fp-table"><table>${rows.map((r, i) => `<tr>${r.map((c) => (i === 0 ? `<th>${esc(c)}</th>` : `<td>${esc(c)}</td>`)).join('')}</tr>`).join('')}</table></div>`
|
|
347
|
+
|
|
348
|
+
async function loadPreviewText (root, d, f) {
|
|
349
|
+
const box = root.querySelector('[data-preview-load]')
|
|
350
|
+
if (!box) return
|
|
351
|
+
const kind = box.dataset.previewLoad
|
|
352
|
+
let html
|
|
353
|
+
try {
|
|
354
|
+
const res = await fetch(fileUrl(d.workspace.id, f.id, '/data') + `?v=${encodeURIComponent(f.version || 1)}`, { headers: { 'x-quilt-token': TOKEN || '' } })
|
|
355
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not load it (${res.status}).`)
|
|
356
|
+
const text = await res.text()
|
|
357
|
+
html = kind === 'csv' ? csvTableHtml(parseCsv(text, CSV_ROWS)) : `<pre class="fp-pre">${esc(text)}</pre>`
|
|
358
|
+
previewCache.set(`${f.id}:${f.version}`, html)
|
|
359
|
+
} catch (err) {
|
|
360
|
+
html = `<p class="hint">${esc(err.message)}</p>`
|
|
361
|
+
}
|
|
362
|
+
if (box.isConnected) box.outerHTML = html
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function previewHtml (d, f) {
|
|
366
|
+
const edit = canEdit(d)
|
|
367
|
+
const who = whoOf(d, f.uploadedBy)
|
|
368
|
+
return `
|
|
369
|
+
<aside class="file-preview" aria-label="${esc(f.name)}">
|
|
370
|
+
<div class="fp-top"><b class="fp-name" title="${esc(f.path)}">${esc(f.name)}</b><button class="btn sm ghost icon" data-close-preview title="Close" aria-label="Close the preview">${I.x}</button></div>
|
|
371
|
+
<div class="fp-media">${previewMediaHtml(d, f)}</div>
|
|
372
|
+
<div class="fp-meta">${esc(bytes(f.size || 0))} · ${esc(mimeOf(f) || 'unknown type')}<br>${esc([who, when(f.uploadedAt)].filter(Boolean).join(' · '))}</div>
|
|
373
|
+
${f.note ? `<p class="fp-note">${esc(f.note)}</p>` : ''}
|
|
374
|
+
<div class="fp-acts">
|
|
375
|
+
<a class="btn sm primary" download="${esc(f.name)}" href="${esc(dataUrl(d.workspace.id, f, '&download=1'))}">${I.down}<span>Download</span></a>
|
|
376
|
+
${edit ? `<button class="btn sm" data-rename>Rename</button><button class="btn sm" data-move>Move</button><button class="btn sm ghost danger" data-delete>Delete</button>` : ''}
|
|
377
|
+
</div>
|
|
378
|
+
<div class="fp-versions" data-versions-box>
|
|
379
|
+
<button class="btn sm ghost" data-versions>Versions${f.version > 1 ? ` (${esc(f.version)})` : ''}</button>
|
|
380
|
+
</div>
|
|
381
|
+
</aside>`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function allFilesHtml (d) {
|
|
385
|
+
const id = String(state.view).slice('wsfiles:'.length)
|
|
386
|
+
if (!d || d.workspace.id !== id) return `<p class="hint">${loadingNote()}</p>`
|
|
387
|
+
const fv = state.filesView
|
|
388
|
+
// A folder someone removed (or a stale saved view) falls back to the top.
|
|
389
|
+
if (fv.folder && !foldersOf(d).some((f) => f.path === fv.folder)) fv.folder = ''
|
|
390
|
+
const folders = foldersOf(d).filter((f) => f.folder === fv.folder).sort(byName)
|
|
391
|
+
const files = filesOf(d).filter((f) => f.folder === fv.folder).sort(byName)
|
|
392
|
+
const picked = filesOf(d).find((f) => f.id === fv.picked) || null
|
|
393
|
+
if (!picked) fv.picked = null
|
|
394
|
+
const here = filesOf(d).filter((f) => inside(fv.folder, f.path))
|
|
395
|
+
const usage = usageText(d)
|
|
396
|
+
const edit = canEdit(d)
|
|
397
|
+
const grid = !folders.length && !files.length && !edit ? ''
|
|
398
|
+
: fv.mode === 'list' ? (folders.length || files.length ? listHtml(d, folders, files, fv.picked) : '')
|
|
399
|
+
: `<div class="tiles">
|
|
400
|
+
${folders.map((f) => folderTileHtml(d, f, { acts: edit })).join('')}
|
|
401
|
+
${files.map((f) => fileTileHtml(d, f, { on: f.id === fv.picked, meta: [bytes(f.size || 0), whoOf(d, f.uploadedBy)].filter(Boolean).join(' · ') })).join('')}
|
|
402
|
+
${edit ? uploadTileHtml(id, fv.folder ? `Upload to ${fv.folder.split('/').pop()}` : 'Upload or drop files') : ''}
|
|
403
|
+
</div>`
|
|
404
|
+
return `
|
|
405
|
+
<a class="ws-back" href="#" data-ws-open="${esc(id)}">${I.caret} ${esc(d.workspace.name)}</a>
|
|
406
|
+
<header class="files-head">
|
|
407
|
+
<div class="t">${crumbsHtml(d, fv.folder)}<p class="hint">${esc(`${plural(here.length, 'file', 'files')} · ${bytes(here.reduce((n, f) => n + (f.size || 0), 0))}${usage ? ` · ${usage} in this workspace` : ''}`)}</p></div>
|
|
408
|
+
<span class="spacer"></span>
|
|
409
|
+
<div class="segmented files-mode" role="tablist" aria-label="Show as">
|
|
410
|
+
<button type="button" role="tab" data-files-mode="tiles" class="${fv.mode !== 'list' ? 'on' : ''}" aria-selected="${fv.mode !== 'list'}">Tiles</button>
|
|
411
|
+
<button type="button" role="tab" data-files-mode="list" class="${fv.mode === 'list' ? 'on' : ''}" aria-selected="${fv.mode === 'list'}">List</button>
|
|
412
|
+
</div>
|
|
413
|
+
${edit ? `<button class="btn sm" data-new-folder>${I.plus}<span>New folder</span></button><button class="btn sm primary" data-upload>${I.upload}<span>Upload</span></button>` : ''}
|
|
414
|
+
</header>
|
|
415
|
+
${fv.mode === 'list' && edit ? `<p class="hint files-progress" data-progress>${uploadingTo(id) ? esc(progressLabel()) : ''}</p><input type="file" multiple hidden data-upload-input>` : ''}
|
|
416
|
+
<div class="files-body" data-files-drop>
|
|
417
|
+
<div class="files-main">${grid || '<p class="hint">Nothing in this folder yet.</p>'}</div>
|
|
418
|
+
${picked ? previewHtml(d, picked) : '<aside class="file-preview empty" aria-hidden="true"><p class="hint">Pick a file to see it here.</p></aside>'}
|
|
419
|
+
</div>`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `rerender` draws the page again from state (home.js passes renderShell); without it, go(). */
|
|
423
|
+
export function bindAllFiles (root, { id, reload, go, rerender = () => go(`wsfiles:${id}`) }) {
|
|
424
|
+
const d = state.workspace
|
|
425
|
+
if (!d || d.workspace.id !== id) return
|
|
426
|
+
const fv = state.filesView
|
|
427
|
+
root.querySelector('[data-ws-open]').onclick = (e) => { e.preventDefault(); go(`ws:${id}`) }
|
|
428
|
+
root.querySelectorAll('[data-folder]').forEach((el) => {
|
|
429
|
+
const open = (e) => { e?.preventDefault(); fv.folder = el.dataset.folder; fv.picked = null; rerender() }
|
|
430
|
+
el.onclick = open
|
|
431
|
+
if (el.tagName === 'TR') el.onkeydown = (e) => { if (e.key === 'Enter') open(e) }
|
|
432
|
+
})
|
|
433
|
+
root.querySelectorAll('[data-pick-file]').forEach((el) => {
|
|
434
|
+
const pick = () => { fv.picked = fv.picked === el.dataset.pickFile ? null : el.dataset.pickFile; rerender() }
|
|
435
|
+
el.onclick = pick
|
|
436
|
+
if (el.tagName === 'TR') el.onkeydown = (e) => { if (e.key === 'Enter') pick() }
|
|
437
|
+
})
|
|
438
|
+
root.querySelectorAll('[data-files-mode]').forEach((b) => { b.onclick = () => { fv.mode = b.dataset.filesMode; rerender() } })
|
|
439
|
+
root.querySelector('[data-close-preview]')?.addEventListener('click', () => { fv.picked = null; rerender() })
|
|
440
|
+
|
|
441
|
+
if (canEdit(d)) {
|
|
442
|
+
const upload = (list) => startUpload(id, list, fv.folder, { reload, go })
|
|
443
|
+
bindUpload(root, upload)
|
|
444
|
+
bindDrop(root.querySelector('[data-files-drop]'), upload)
|
|
445
|
+
root.querySelector('[data-new-folder]').onclick = () => newFolder(id, fv.folder, reload)
|
|
446
|
+
bindFolderActs(root, d, id, reload)
|
|
447
|
+
bindMoveToFolder(root, d, id, reload)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const f = filesOf(d).find((x) => x.id === fv.picked)
|
|
451
|
+
if (!f) return
|
|
452
|
+
loadPreviewText(root, d, f)
|
|
453
|
+
const panel = root.querySelector('.file-preview:not(.empty)')
|
|
454
|
+
if (panel && getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
455
|
+
/** Saves the patch; `then` runs only once it saved, before the redraw. */
|
|
456
|
+
const update = async (patch, done, then = () => {}) => {
|
|
457
|
+
try { await api('POST', fileUrl(id, f.id, '/update'), patch) } catch (err) { return toast(err.message) }
|
|
458
|
+
then(); toast(done); await reload()
|
|
459
|
+
}
|
|
460
|
+
root.querySelector('[data-rename]')?.addEventListener('click', async () => {
|
|
461
|
+
const name = await ask({ title: `Rename ${f.name}`, input: { label: 'Name', value: f.name }, ok: 'Rename' })
|
|
462
|
+
if (!name || name === f.name) return
|
|
463
|
+
if (name.includes('/')) return toast(NO_SLASH)
|
|
464
|
+
await update({ path: f.folder ? `${f.folder}/${name}` : name }, 'Renamed')
|
|
465
|
+
})
|
|
466
|
+
root.querySelector('[data-move]')?.addEventListener('click', async () => {
|
|
467
|
+
// A select of the folders there are: '/' stands for the top, since ask() wants a value.
|
|
468
|
+
const options = [{ value: '/', label: d.workspace.name }, ...foldersOf(d).map((x) => ({ value: x.path, label: x.path })).sort((a, b) => a.label.localeCompare(b.label))]
|
|
469
|
+
const to = await ask({ title: `Move ${f.name} to`, input: { label: 'Folder', select: options, value: f.folder || '/' }, ok: 'Move' })
|
|
470
|
+
if (!to) return
|
|
471
|
+
const folder = to === '/' ? '' : to
|
|
472
|
+
if (folder === f.folder) return
|
|
473
|
+
await update({ path: folder ? `${folder}/${f.name}` : f.name }, 'Moved', () => { fv.folder = folder })
|
|
474
|
+
})
|
|
475
|
+
root.querySelector('[data-delete]')?.addEventListener('click', async () => {
|
|
476
|
+
if (!await ask({ title: `Delete ${f.name}?`, message: 'It goes for everyone in this workspace, with its earlier versions.', ok: 'Delete', danger: true })) return
|
|
477
|
+
try { await api('POST', fileUrl(id, f.id, '/delete')); fv.picked = null; toast('Deleted'); await reload() } catch (err) { toast(err.message) }
|
|
478
|
+
})
|
|
479
|
+
const showVersions = async () => {
|
|
480
|
+
const box = root.querySelector('[data-versions-box]')
|
|
481
|
+
const btn = box.querySelector('[data-versions]')
|
|
482
|
+
if (btn) btn.disabled = true
|
|
483
|
+
versionsOpen = f.id
|
|
484
|
+
try {
|
|
485
|
+
// The list holds the earlier versions; the one people get now is the file itself.
|
|
486
|
+
const { versions: earlier } = await api('GET', fileUrl(id, f.id, '/versions'))
|
|
487
|
+
const versions = [f, ...earlier.filter((v) => v.version !== f.version)]
|
|
488
|
+
box.innerHTML = `<b>Versions</b>${versions.map((v) => `
|
|
489
|
+
<div class="fp-version"><span class="mono">v${esc(v.version)}</span><span class="grow">${esc([whoOf(d, v.uploadedBy), when(v.uploadedAt), bytes(v.size || 0)].filter(Boolean).join(' · '))}${v.version === f.version ? ' · <b class="cur">current</b>' : ''}${v.note ? `<span class="hint">${esc(v.note)}</span>` : ''}</span>
|
|
490
|
+
<a href="${esc(fileUrl(id, f.id, '/data?t=' + encodeURIComponent(TOKEN || '')) + `&version=${encodeURIComponent(v.version)}&download=1`)}" download="${esc(f.name)}">Download this version</a></div>`).join('')}${earlier.length ? '' : '<p class="hint">No earlier versions.</p>'}`
|
|
491
|
+
} catch (err) {
|
|
492
|
+
versionsOpen = null
|
|
493
|
+
toast(err.message)
|
|
494
|
+
if (btn?.isConnected) btn.disabled = false
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
root.querySelector('[data-versions]')?.addEventListener('click', showVersions)
|
|
498
|
+
// A redraw (the poll, an upload) keeps an open Versions list open.
|
|
499
|
+
if (versionsOpen === f.id) showVersions()
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Which file's Versions list is open, so a redraw opens it again.
|
|
503
|
+
let versionsOpen = null
|
|
504
|
+
|
|
505
|
+
/** Rename and Delete on folder tiles and rows. The API moves or removes what is inside too. */
|
|
506
|
+
function bindFolderActs (root, d, id, reload) {
|
|
507
|
+
const byId = (fid) => foldersOf(d).find((x) => x.id === fid)
|
|
508
|
+
root.querySelectorAll('[data-folder-rename]').forEach((b) => {
|
|
509
|
+
b.onclick = async (e) => {
|
|
510
|
+
e.stopPropagation()
|
|
511
|
+
const f = byId(b.dataset.folderRename)
|
|
512
|
+
if (!f) return
|
|
513
|
+
const name = await ask({ title: `Rename ${f.name}`, input: { label: 'Name', value: f.name }, ok: 'Rename' })
|
|
514
|
+
if (!name || name === f.name) return
|
|
515
|
+
if (name.includes('/')) return toast(NO_SLASH)
|
|
516
|
+
try { await api('POST', fileUrl(id, f.id, '/update'), { path: f.folder ? `${f.folder}/${name}` : name }); toast('Renamed'); await reload() } catch (err) { toast(err.message) }
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
root.querySelectorAll('[data-folder-delete]').forEach((b) => {
|
|
520
|
+
b.onclick = async (e) => {
|
|
521
|
+
e.stopPropagation()
|
|
522
|
+
const f = byId(b.dataset.folderDelete)
|
|
523
|
+
if (!f) return
|
|
524
|
+
const n = folderStats(d, f.path).count
|
|
525
|
+
const message = n ? `Everything in it goes too (${plural(n, 'file', 'files')}), for everyone in this workspace.` : 'It is empty.'
|
|
526
|
+
if (!await ask({ title: `Delete the folder ${f.name}?`, message, ok: 'Delete', danger: true })) return
|
|
527
|
+
try {
|
|
528
|
+
await api('POST', fileUrl(id, f.id, '/delete'))
|
|
529
|
+
toast('Deleted'); await reload()
|
|
530
|
+
} catch (err) { toast(err.message) }
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
root.querySelectorAll('.folder-acts').forEach((el) => el.addEventListener('keydown', (e) => e.stopPropagation()))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** True while a preview is playing, so the poll's re-render doesn't restart it. */
|
|
537
|
+
export function previewBusy () {
|
|
538
|
+
return [...document.querySelectorAll('.file-preview video, .file-preview audio')].some((m) => !m.paused)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------- picker --
|
|
542
|
+
/** A dialog to choose one of the workspace's files. Resolves { id, name }, or null. */
|
|
543
|
+
export async function workspaceFilePicker (id) {
|
|
544
|
+
let files
|
|
545
|
+
try { files = (await api('GET', wsUrl(id, '/files'))).files.filter((f) => f.kind !== 'folder').sort((a, b) => a.path.localeCompare(b.path)) } catch (err) { toast(err.status === 404 || /not found/i.test(err.message) ? "Only this workspace's members can see its files." : err.message); return null }
|
|
546
|
+
return new Promise((resolve) => {
|
|
547
|
+
const back = document.createElement('div')
|
|
548
|
+
back.className = 'modal-back'
|
|
549
|
+
back.innerHTML = `<div class="card modal file-picker" role="dialog" aria-modal="true" aria-labelledby="fpk-title">
|
|
550
|
+
<h3 id="fpk-title">Choose a file</h3>
|
|
551
|
+
<input class="input" type="search" placeholder="Search files" aria-label="Search files" data-pick-search>
|
|
552
|
+
<div class="fpk-list" data-pick-list></div>
|
|
553
|
+
<div class="actions"><button type="button" class="btn" data-no>Cancel</button></div>
|
|
554
|
+
</div>`
|
|
555
|
+
document.body.appendChild(back)
|
|
556
|
+
const list = back.querySelector('[data-pick-list]')
|
|
557
|
+
const search = back.querySelector('[data-pick-search]')
|
|
558
|
+
const done = (v) => { back.remove(); resolve(v) }
|
|
559
|
+
const draw = () => {
|
|
560
|
+
const q = search.value.trim().toLowerCase()
|
|
561
|
+
const shown = files.filter((f) => !q || f.path.toLowerCase().includes(q))
|
|
562
|
+
list.innerHTML = shown.length
|
|
563
|
+
? shown.map((f) => `<button type="button" class="fpk-row" data-pick="${esc(f.id)}"><span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span><span class="grow">${esc(f.path)}</span><span class="hint">${esc(bytes(f.size || 0))}</span></button>`).join('')
|
|
564
|
+
: `<p class="hint">${files.length ? 'No file matches.' : 'This workspace has no files yet.'}</p>`
|
|
565
|
+
}
|
|
566
|
+
draw()
|
|
567
|
+
search.oninput = draw
|
|
568
|
+
search.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); list.querySelector('[data-pick]')?.click() } }
|
|
569
|
+
list.onclick = (e) => {
|
|
570
|
+
const b = e.target.closest('[data-pick]')
|
|
571
|
+
const f = b && files.find((x) => x.id === b.dataset.pick)
|
|
572
|
+
if (f) done({ id: f.id, name: f.name })
|
|
573
|
+
}
|
|
574
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) done(null) })
|
|
575
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(null) })
|
|
576
|
+
back.querySelector('[data-no]').onclick = () => done(null)
|
|
577
|
+
search.focus()
|
|
578
|
+
})
|
|
579
|
+
}
|