ux: upload: optimized layout for phone/portrait
Massimo Melina committed
May 9, 2026 at 15:06 UTC
5ae37b15634e407f1a1dc8a7f4c6c794c77a0b48
3 files changed
+74
-4
e2e/serial.spec.ts
+9
-1
@@ -40,7 +40,7 @@ test('upload1', async ({ page, context, browserName }, testInfo) => {
40
// can't do without cdp to slow down the upload. I tried using route.continue, but i can't send half-body keeping the full content-length, and i also cannot pass a stream (to throttle)
41
const cdpSession = await context.newCDPSession(page)
42
await cdpSession.send('Network.emulateNetworkConditions', NETWORK_PRESETS.Regular2G)
43
- await page.getByRole('button', { name: 'Edit' }).click()
43
+ await openUploadRename(page)
44
const renameDialog = page.locator('.dialog-prompt')
45
const renameInput = renameDialog.getByRole('textbox')
46
await expect(renameInput).toHaveValue(fileToUpload.name) // promptDialog initializes the field value in useEffect, so we wait for that init to avoid our fill being overwritten
@@ -70,6 +70,14 @@ test('upload1', async ({ page, context, browserName }, testInfo) => {
70
}
71
})
72
73
+async function openUploadRename(page: Page) {
74
+ const editButton = page.getByRole('button', { name: 'Edit' })
75
+ if (await editButton.isVisible())
76
+ return editButton.click()
77
+ await page.locator('.upload-list').getByRole('button', { name: 'Menu' }).click()
78
+ await page.getByRole('link', { name: 'Rename' }).click()
79
+}
80
+
81
const MAX_DIAGNOSTIC_LINES = 300
82
83
async function startUpload1Diagnostics(page: Page) {
frontend/src/index.scss
+23
@@ -509,6 +509,17 @@ button .icon + .label {
509
td:nth-child(2) { text-align: right; width: 0; white-space: nowrap; padding-left: 0.5em; }
510
td:nth-child(3) { padding: .2em .5em; word-break: break-word; }
511
}
512
+.upload-list-menu-button {
513
+ display: none;
514
+}
515
+.upload-action-menu {
516
+ margin-top: 0;
517
+ padding-top: 0;
518
+ border-top: none;
519
+}
520
+.upload-action-properties {
521
+ margin-bottom: 1em;
522
+}
523
.nowrap { white-space: nowrap }
524
525
.login-dialog {
@@ -888,6 +899,18 @@ form label+input { margin-top: .2em; }
899
}
900
}
901
902
+@media (max-width: 30em) {
903
+ .upload-list-inline-actions {
904
+ display: none;
905
+ }
906
+ .upload-list-menu-button {
907
+ display: inline-block;
908
+ }
909
+ .upload-list-size {
910
+ display: none;
911
+ }
912
+}
913
+
914
@media (max-height: 600px) and (orientation: landscape) {
915
.file-dialog {
916
.dialog-content {
frontend/src/upload.ts
+42
-3
@@ -185,9 +185,12 @@ function FileList({ entries, actions }: { entries: ToUpload[], actions: { [icon:
185
const working = e.file === uploading?.file // e is a proxy, so we check 'file' as it's a ref
186
return h(Fragment, { key: i },
187
h('tr', {},
188
- h('td', { className: 'nowrap '}, ..._.map(actions, (cb, icon) =>
189
- cb && iconBtn(icon, () => cb(entries[i]), { className: `action-${icon}` })) ),
190
- h('td', {}, formatBytes(e.file.size)),
188
+ h('td', { className: 'nowrap upload-list-actions' },
189
+ h('span', { className: 'upload-list-inline-actions' }, ..._.map(actions, (cb, icon) =>
190
+ cb && iconBtn(icon, () => cb(entries[i]), { className: `action-${icon}` })) ),
191
+ iconBtn('menu', () => openUploadActions(entries[i], actions), { className: 'upload-list-menu-button' }),
192
+ ),
193
+ h('td', { className: 'upload-list-size' }, formatBytes(e.file.size)),
194
h('td', {},
195
h('span', {}, e.path),
196
working && h('span', { className: 'upload-progress', title }, formatBytes(partial)),
@@ -203,6 +206,42 @@ function FileList({ entries, actions }: { entries: ToUpload[], actions: { [icon:
206
)
207
}
208
209
+function openUploadActions(rec: ToUpload, actions: { [icon:string]: null | ((rec :ToUpload) => any) }) {
210
+ const { close } = newDialog({
211
+ title: t`Menu`,
212
+ icon: () => hIcon('menu'),
213
+ Content() {
214
+ return h(Fragment, {},
215
+ h('dl', { className: 'file-dialog-properties upload-action-properties' },
216
+ h('div', {},
217
+ h('dt', {}, t`Size`),
218
+ h('dd', {}, formatBytes(rec.file.size))
219
+ )
220
+ ),
221
+ h('div', { className: 'upload-action-menu file-menu' },
222
+ ..._.map(actions, (cb, icon) => cb && h('a', {
223
+ href: '#',
224
+ className: `action-${icon}`,
225
+ onClick(ev) {
226
+ ev.preventDefault()
227
+ close()
228
+ void cb(rec)
229
+ }
230
+ },
231
+ hIcon(icon),
232
+ h('label', {}, uploadActionLabel(icon))
233
+ ))
234
+ )
235
+ )
236
+ }
237
+ })
238
+}
239
+
240
+function uploadActionLabel(icon: string) {
241
+ // edit changes the upload path, so users see the familiar rename label
242
+ return icon === 'edit' ? t`Rename` : t(_.capitalize(icon))
243
+}
244
+
245
function formatTime(time: number, decimals=0, length=Infinity) {
246
time /= 1000
247
const ret = [(time % 1).toFixed(decimals).slice(1)]