openvibers/openvibe.mediahub Commits

openvibe.download looks and works like a drive: sidebar, tiles and cards, drop anywhere, search, rename/move/delete (#305)

OP OpenVibers committed 6 days ago · 545b76fa74 · 10 files, +1158 −109

* openvibe.download looks and works like a drive: sidebar, folder tiles, file cards and rows, drop anywhere with an upload tray, search every folder, sort, and rename/move/delete from each thing's menu

The owner (11-07): "a competitor to Google Drive … sleek and easy to use and intuitive and minimalist while being
powerful … full controls". /files is a sidebar (New, places, storage) beside folder tiles and file cards (the picture,
or a tile coloured for the kind), with a list view of the same cards. Search covers every folder and filters as you
type; sort is remembered. Many files dropped anywhere go up in parts with a tray, and the list refreshes in place.
New routes rename and move a file and rename or delete an (empty) folder. A file's page is the file beside its
share card (who can open it as three cards, expiry as chips), Move, Rename, facts and Delete. Phones get a floating
New button over a bottom sheet, and menus open as sheets. Fixed: a folder made inside a folder went to the top.

* The drive's styles load only on the drive's pages (css/drive.css), so the home page stays inside its CSS budget; the preview test reads the new cards

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7163d3c..b6532a5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- openvibe.download looks and works like a drive (2026-11-07, the owner: "a competitor to Google Drive … sleek and easy
++ to use and intuitive and minimalist while being powerful … full controls"). `/files` is a sidebar (New, My files,
++ Shared links, Backups, the storage meter) beside the files: folder tiles, then file cards with the picture or a
++ coloured tile for the kind (PDF, document, spreadsheet, slides, archive, code, audio, video), or the same cards as
++ rows in the list view. Search covers every folder (`?q=`, and it filters what is on screen as you type); sort by
++ newest, oldest, name or size, remembered. Files dropped anywhere on the page upload into the folder on screen, many
++ at once, with a tray that shows each one's progress and refreshes the list when they are in. Every file and folder
++ has a menu: Download, Share a link, Move to…, Rename, Delete; a folder deletes only when it is empty (there is no bin
++ yet). On a phone, New is a floating button that opens a bottom sheet, and a menu opens as a sheet over the tab bar.
++ A file's page shows the file large beside what you can do with it: who can open a link (three choices, drawn as
++ cards), expiry as chips, Move, Rename, the facts and Delete. Fixed on the way: a folder made inside a folder went to
++ the top level. Everything still works with JavaScript off.
- Content labels show on openvibe.video and OpenVibe.Pics (2026-11-02, L1). Cards, watch pages and pictures say in
words what they carry (Nudity, Strong language, 18+…), and each viewer's own choice on openvibe.network shows,
blurs or hides them. A maker always sees their own; on a video's or picture's own page a hide covers it with a
ddiff --git a/public/css/drive.css b/public/css/drive.css
nnew file mode 100644
iindex 0000000..c1508a0
---- /dev/null
++++ b/public/css/drive.css
@@@ -0,0 +1,321 @@
++/* OpenVibe.Download — the drive's pages only (/files, /files/:id), loaded beside app.css so the home page stays
++ inside its CSS budget. */
++/* ── The drive, redesigned (2026-11-07; the owner: "a competitor to Google Drive … sleek and easy to use and intuitive
++ and minimalist while being powerful"). A sidebar (New, places, storage) beside the files; on a phone, a floating
++ New button over a bottom sheet. Folder tiles, then file cards that are rows in the list view. Theme tokens only. ── */
++.page:has(> .dr), .page:has(> .fv) { max-width: 1320px; }
++.dr, .fv, .dr-tray, .dr-drop {
++ --dr-line: var(--border, #1f2d47); --dr-card: var(--bg-card, #131c2e); --dr-well: var(--bg-secondary, #0f1729);
++ --dr-soft: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); --dr-acc: var(--accent, #3b82f6);
++ --dr-on-acc: var(--accent-contrast, #fff); --dr-muted: var(--text-muted, #7386a3); --dr-text: var(--text-primary, #e6edf7);
++ --dr-shadow: 0 14px 36px rgba(0, 0, 0, .32), 0 2px 6px rgba(0, 0, 0, .18);
++}
++.dr [hidden], .fv [hidden], .dr-tray [hidden] { display: none !important; }
++.dr-i { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++[data-kind] { --k: #7386a3; }
++[data-kind="image"] { --k: #a855f7; } [data-kind="video"] { --k: #ec4899; } [data-kind="audio"] { --k: #06b6d4; }
++[data-kind="pdf"] { --k: #e5484d; } [data-kind="doc"] { --k: #3b82f6; } [data-kind="sheet"] { --k: #16a34a; }
++[data-kind="slides"] { --k: #f59e0b; } [data-kind="archive"] { --k: #c2410c; } [data-kind="code"] { --k: #64748b; }
++@keyframes dr-pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } }
++@keyframes dr-sheet { from { transform: translateY(100%); } }
++@keyframes dr-fade { from { opacity: 0; } }
++
++.dr { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 32px; align-items: start; margin-top: 10px; }
++.dr-side { position: sticky; top: calc(var(--ovnav-h, 56px) + 14px); display: grid; gap: 18px; }
++
++/* New: a button that opens a small panel (desktop) or a bottom sheet (phone). A <details>, so it opens without JavaScript. */
++.dr-new { position: relative; }
++.dr-new > summary { list-style: none; }
++.dr-new > summary::-webkit-details-marker { display: none; }
++.dr-new-btn { display: inline-flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 24px 0 18px; border-radius: 16px; cursor: pointer; user-select: none;
++ background: var(--dr-card); border: 1px solid var(--dr-line); color: var(--dr-text); font-weight: 700; font-size: 15px; box-shadow: 0 1px 2px rgba(0, 0, 0, .2), 0 6px 18px rgba(0, 0, 0, .14); transition: box-shadow .15s, transform .15s; }
++.dr-new-btn:hover { box-shadow: 0 2px 4px rgba(0, 0, 0, .2), 0 10px 26px rgba(0, 0, 0, .2); }
++.dr-new-btn .dr-i { width: 24px; height: 24px; stroke-width: 2.2; color: var(--dr-acc); transition: transform .2s; }
++.dr-new[open] .dr-new-btn .dr-i { transform: rotate(45deg); }
++.dr-new-panel { position: absolute; left: 0; top: calc(100% + 8px); z-index: 40; width: 330px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; padding: 8px;
++ background: var(--dr-card); border: 1px solid var(--dr-line); border-radius: 16px; box-shadow: var(--dr-shadow); animation: dr-pop .16s ease-out; }
++.dr-new-title { margin: 4px 10px 2px; font-size: 12.5px; color: var(--dr-muted); overflow-wrap: anywhere; }
++.dr-new-title b { color: var(--dr-text); font-weight: 600; }
++.dr-upload { display: grid; gap: 6px; margin: 0; }
++.dr-pick-in { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
++.dr-pick, .dr-new-row { display: flex; align-items: center; gap: 12px; padding: 10px; border-radius: 12px; cursor: pointer; color: var(--dr-text); text-decoration: none; }
++.dr-pick:hover, .dr-new-row:hover { background: var(--dr-soft); }
++.dr-upload:has(.dr-pick-in:focus-visible) .dr-pick { outline: 3px solid var(--dr-acc); outline-offset: -3px; }
++.dr-pick > .dr-i, .dr-new-row > .dr-i, .dr-mkdir label .dr-i { width: 38px; height: 38px; padding: 9px; border-radius: 11px; color: var(--dr-acc); background: color-mix(in srgb, var(--dr-acc) 14%, transparent); }
++.dr-pick span, .dr-new-row span { display: grid; gap: 1px; min-width: 0; }
++.dr-pick b, .dr-new-row b { font-size: 14.5px; }
++.dr-pick small, .dr-new-row small { font-size: 12.5px; color: var(--dr-muted); }
++.dr-upload-go { display: flex; gap: 8px; padding: 0 10px; }
++.dr-upload-go input { flex: 1; min-width: 0; }
++.dr-upload progress { margin: 0 10px; width: calc(100% - 20px); }
++.dr-fine { margin: 0 10px; font-size: 12px; color: var(--dr-muted); }
++.dr-fine:empty { display: none; }
++.dr-mkdir { display: flex; align-items: center; gap: 8px; padding: 6px 10px; margin: 0; }
++.dr-mkdir input { flex: 1; min-width: 0; }
++.dr input[type=text], .dr input[type=search], .dr select, .fv input[type=text], .fv select {
++ height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--dr-line); background: var(--dr-well); color: inherit; font: inherit; font-size: 14.5px; }
++.dr input:focus, .dr select:focus, .fv input:focus, .fv select:focus { border-color: var(--dr-acc); }
++
++.dr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--dr-line);
++ background: var(--dr-well); color: var(--dr-text); font: inherit; font-size: 14px; font-weight: 650; text-decoration: none; white-space: nowrap; cursor: pointer; transition: background .15s, border-color .15s; }
++.dr-btn:hover { background: var(--dr-soft); }
++.dr-btn .dr-i { width: 18px; height: 18px; }
++.dr-btn.is-primary { background: var(--dr-acc); border-color: var(--dr-acc); color: var(--dr-on-acc); }
++.dr-btn.is-primary:hover { background: color-mix(in srgb, var(--dr-acc) 86%, #000); }
++.dr-btn.is-quiet { height: 34px; padding: 0 12px; font-size: 13px; background: transparent; }
++.dr-btn.is-danger { background: transparent; color: var(--error-text, #f87171); border-color: color-mix(in srgb, var(--error, #ef4444) 50%, transparent); }
++.dr-btn.is-danger:hover { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); }
++
++.dr-places { display: grid; gap: 2px; }
++.dr-places a { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 0 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-weight: 600; font-size: 14.5px; }
++.dr-places a:hover { background: var(--dr-soft); color: var(--dr-text); }
++.dr-places a[aria-current] { background: color-mix(in srgb, var(--dr-acc) 16%, transparent); color: var(--dr-text); }
++.dr-places a[aria-current] .dr-i { color: var(--dr-acc); }
++.dr-places em { margin-left: auto; font-style: normal; font-size: 12px; min-width: 22px; padding: 1px 7px; border-radius: 999px; text-align: center; background: var(--dr-soft); }
++
++.dr-meter { display: grid; gap: 8px; padding: 14px; border-radius: 14px; background: var(--dr-soft); }
++.dr-meter-head { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 650; font-size: 14px; }
++.dr-meter-head b { margin-left: auto; font-size: 13px; color: var(--dr-muted); }
++.dr-meter-head .dr-i { width: 18px; height: 18px; color: var(--dr-muted); }
++.dr-meter progress { appearance: none; -webkit-appearance: none; display: block; width: 100%; height: 6px; border: 0; border-radius: 99px; overflow: hidden; background: var(--dr-soft); color: var(--dr-acc); }
++.dr-meter progress::-webkit-progress-bar { background: color-mix(in srgb, var(--dr-text) 10%, transparent); border-radius: 99px; }
++.dr-meter progress::-webkit-progress-value { background: var(--dr-acc); border-radius: 99px; }
++.dr-meter progress::-moz-progress-bar { background: var(--dr-acc); border-radius: 99px; }
++.dr-meter.is-high progress::-webkit-progress-value { background: var(--warning, #f59e0b); }
++.dr-meter.is-high progress::-moz-progress-bar { background: var(--warning, #f59e0b); }
++.dr-meter.is-full progress::-webkit-progress-value { background: var(--error, #ef4444); }
++.dr-meter.is-full progress::-moz-progress-bar { background: var(--error, #ef4444); }
++.dr-meter-text { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--dr-muted); }
++.dr-meter-text b { color: var(--dr-text); font-weight: 600; }
++
++/* The bar over the files: where you are, the folder's name, then search, sort and the view. */
++.dr-path { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 4px; margin: 0 0 4px; font-size: 13.5px; color: var(--dr-muted); }
++.dr-path a { display: inline-flex; align-items: center; gap: 6px; padding: 2px 6px; border-radius: 6px; color: var(--text-secondary, #96a7c2); text-decoration: none; overflow-wrap: anywhere; }
++.dr-path a:hover { background: var(--dr-soft); color: var(--dr-text); }
++.dr-path > .dr-i { width: 14px; height: 14px; opacity: .6; }
++.dr-path a .dr-i { width: 16px; height: 16px; }
++.dr-path [aria-current] { padding: 2px 6px; color: var(--dr-text); font-weight: 600; overflow-wrap: anywhere; }
++.dr-bar-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin: 0 0 20px; }
++.dr-title { flex: 1 1 auto; min-width: 0; margin: 0; font-size: clamp(1.5rem, 2.3vw, 2rem); letter-spacing: -.01em; }
++.dr-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.dr-find { position: relative; display: flex; align-items: center; margin: 0; }
++.dr-find > .dr-i { position: absolute; left: 13px; width: 18px; height: 18px; color: var(--dr-muted); pointer-events: none; }
++.dr .dr-find input { width: clamp(190px, 24vw, 320px); padding: 0 14px 0 40px; border-radius: 999px; }
++.dr-sort { margin: 0; }
++.dr .dr-sort select { appearance: none; -webkit-appearance: none; padding: 0 34px 0 14px; border-radius: 999px; cursor: pointer;
++ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a9ab5' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E");
++ background-repeat: no-repeat; background-position: right 10px center; background-size: 16px; }
++.dr-view { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; border: 1px solid var(--dr-line); background: var(--dr-well); }
++.dr-view label { position: relative; display: grid; place-items: center; width: 34px; height: 32px; border-radius: 999px; color: var(--dr-muted); cursor: pointer; }
++.dr-view input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
++.dr-view label:has(input:checked) { background: color-mix(in srgb, var(--dr-acc) 18%, transparent); color: var(--dr-text); }
++.dr-view label:has(input:focus-visible) { outline: 3px solid var(--dr-acc); outline-offset: 1px; }
++.dr-view .dr-i { width: 18px; height: 18px; }
++
++.dr-sec ~ .dr-sec { margin-top: 28px; }
++.dr-h { margin: 0 0 10px; font-size: .78rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--dr-muted); }
++.dr-h span { margin-left: 4px; opacity: .7; }
++.dr-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++
++.dr-folders, .dr-files, .dr-links { list-style: none; margin: 0; padding: 0; }
++.dr-folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; }
++.dr-folder { position: relative; display: flex; align-items: center; border-radius: 14px; background: var(--dr-card); border: 1px solid var(--dr-line); transition: border-color .15s, background .15s; }
++.dr-folder:hover { border-color: color-mix(in srgb, var(--dr-acc) 45%, var(--dr-line)); }
++.dr-folder-open { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 13px 4px 13px 14px; color: var(--dr-text); text-decoration: none; font-weight: 600; font-size: 14.5px; }
++.dr-folder-open .dr-i { width: 24px; height: 24px; color: var(--dr-acc); fill: color-mix(in srgb, var(--dr-acc) 22%, transparent); }
++.dr-folder-open small { margin-left: auto; color: var(--dr-muted); font-weight: 500; }
++.dr-folder > .dr-more { margin-right: 6px; }
++
++.dr-files { display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 14px; }
++.dr-file { position: relative; border-radius: 16px; background: var(--dr-card); border: 1px solid var(--dr-line); transition: border-color .15s, box-shadow .15s, transform .15s; }
++/* No transform on touch: a lifted card would become the box its phone menu (position: fixed) is placed in. */
++@media (hover: hover) and (min-width: 760px) { .dr-file:hover { border-color: color-mix(in srgb, var(--dr-acc) 45%, var(--dr-line)); box-shadow: 0 8px 22px rgba(0, 0, 0, .18); transform: translateY(-1px); } }
++.dr-file:has(.dr-more[open]) { z-index: 5; }
++.dr-file-open { display: flex; flex-direction: column; color: inherit; text-decoration: none; }
++.dr-thumb { position: relative; display: grid; place-items: center; overflow: hidden; color: var(--k); background: color-mix(in srgb, var(--k) 13%, var(--dr-well, #0f1729)); }
++.dr-file .dr-thumb { aspect-ratio: 4 / 3; border-radius: 15px 15px 0 0; }
++.dr-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
++.dr-thumb .dr-i { width: 42px; height: 42px; stroke-width: 1.5; }
++.dr-thumb b { position: absolute; left: 10px; bottom: 9px; padding: 3px 6px; border-radius: 6px; font: 700 10.5px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; background: color-mix(in srgb, var(--k) 22%, transparent); }
++.dr-file-text { display: grid; gap: 2px; min-width: 0; padding: 10px 44px 12px 12px; }
++.dr-file .dr-name { font-weight: 600; font-size: 14.5px; line-height: 1.35; color: var(--dr-text); white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
++.dr-meta { display: flex; flex-wrap: wrap; gap: 0 8px; font-size: 12.5px; color: var(--dr-muted); }
++.dr-meta > * + *::before { content: "·"; margin-right: 8px; }
++.dr-where { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow-wrap: anywhere; }
++.dr-where .dr-i { width: 13px; height: 13px; }
++.dr-file > .dr-more { position: absolute; right: 6px; bottom: 9px; }
++
++/* A thing's menu: Download, Share, Move, Rename, Delete. */
++.dr-more { position: relative; }
++.dr-more > summary { list-style: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 999px; color: var(--dr-muted); cursor: pointer; }
++.dr-more > summary::-webkit-details-marker { display: none; }
++.dr-more > summary:hover, .dr-more[open] > summary { background: var(--dr-soft); color: var(--dr-text); }
++.dr-more > summary .dr-i { stroke-width: 3; }
++.dr-menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 40; display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; width: 268px; padding: 6px;
++ background: var(--dr-card); border: 1px solid var(--dr-line); border-radius: 14px; box-shadow: var(--dr-shadow); animation: dr-pop .14s ease-out; }
++.dr-menu form { margin: 0; }
++.dr-menu > a, .dr-menu > form:not(.dr-rename) > button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 40px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent;
++ color: var(--dr-text); font: inherit; font-size: 14px; text-align: left; text-decoration: none; cursor: pointer; }
++.dr-menu > a:hover, .dr-menu > form:not(.dr-rename) > button:hover { background: var(--dr-soft); }
++.dr-menu .dr-i { width: 18px; height: 18px; color: var(--dr-muted); }
++.dr-menu > form > button.is-danger, .dr-menu .is-danger .dr-i { color: var(--error-text, #f87171); }
++.dr-rename { display: flex; align-items: center; gap: 6px; padding: 4px 4px 4px 10px; }
++.dr-rename label { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; }
++.dr-menu .dr-rename input { flex: 1; min-width: 0; height: 34px; padding: 0 8px; border-radius: 8px; font-size: 13.5px; }
++.dr-rename button { height: 34px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--dr-line); background: transparent; color: var(--dr-text); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
++.dr-rename button:hover { background: var(--dr-soft); }
++
++/* The list view: the same cards, as rows. */
++.dr:has(.dr-view input[value="list"]:checked) .dr-files { grid-template-columns: minmax(0, 1fr); gap: 0; border: 1px solid var(--dr-line); border-radius: 16px; background: var(--dr-card); }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file { border: 0; border-radius: 0; background: transparent; box-shadow: none; transform: none; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file + .dr-file { border-top: 1px solid var(--dr-line); }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file:hover { background: var(--dr-soft); }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file:first-child:hover { border-radius: 15px 15px 0 0; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file:last-child:hover { border-radius: 0 0 15px 15px; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file-open { flex-direction: row; align-items: center; gap: 14px; padding: 9px 52px 9px 10px; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file .dr-thumb { flex: none; width: 42px; height: 42px; aspect-ratio: auto; border-radius: 10px; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-thumb .dr-i { width: 22px; height: 22px; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-thumb b { display: none; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file .dr-name { -webkit-line-clamp: 1; line-clamp: 1; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file-text { flex: 1; padding: 0; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 2px 18px; }
++.dr:has(.dr-view input[value="list"]:checked) .dr-file > .dr-more { top: 50%; bottom: auto; margin-top: -17px; }
++
++.dr-empty { display: grid; justify-items: center; gap: 6px; padding: 64px 20px; border: 2px dashed color-mix(in srgb, var(--dr-text) 16%, transparent); border-radius: 20px; text-align: center; color: var(--dr-muted); cursor: pointer; transition: border-color .15s, background .15s; }
++.dr-empty:hover { border-color: color-mix(in srgb, var(--dr-acc) 60%, transparent); background: color-mix(in srgb, var(--dr-acc) 5%, transparent); }
++.dr-empty .dr-i { width: 54px; height: 54px; padding: 12px; border-radius: 16px; color: var(--dr-acc); background: color-mix(in srgb, var(--dr-acc) 14%, transparent); margin-bottom: 6px; }
++.dr-empty b { font-size: 1.15rem; color: var(--dr-text); }
++.dr-nomatch { padding: 24px 8px; text-align: center; color: var(--dr-muted); }
++
++.dr-links { display: grid; gap: 8px; }
++.dr-link { display: flex; align-items: center; gap: 12px; padding: 10px 10px 10px 14px; border-radius: 14px; background: var(--dr-card); border: 1px solid var(--dr-line); }
++.dr-link > .dr-i { color: var(--dr-muted); }
++.dr-link-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.dr-link-main a { font-weight: 600; color: var(--dr-text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.dr-link-main a:hover { text-decoration: underline; }
++.dr-link-main small { font-size: 12.5px; color: var(--dr-muted); overflow-wrap: anywhere; }
++.dr-link form { margin: 0; }
++
++/* Dropped files and the upload tray (upload.js draws both). */
++.dr-drop { position: fixed; inset: 0; z-index: 9995; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 72%, transparent); backdrop-filter: blur(4px); animation: dr-fade .12s ease-out; pointer-events: none; }
++.dr-drop-box { display: grid; justify-items: center; gap: 8px; padding: 44px 60px; border: 2px dashed var(--dr-acc); border-radius: 26px; background: var(--dr-card); text-align: center; box-shadow: var(--dr-shadow); animation: dr-pop .16s ease-out; }
++.dr-drop-box .dr-i { width: 56px; height: 56px; color: var(--dr-acc); }
++.dr-drop-box b { font-size: 1.2rem; }
++.dr-drop-box span { color: var(--dr-muted); }
++.dr-tray { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(16px + var(--ovnav-appbar-h, 0px)); z-index: 9994; width: min(380px, calc(100vw - 32px));
++ background: var(--dr-card); border: 1px solid var(--dr-line); border-radius: 18px; box-shadow: var(--dr-shadow); overflow: hidden; animation: dr-pop .18s ease-out; }
++.dr-tray-head { display: flex; align-items: center; gap: 10px; padding: 12px 8px 12px 16px; border-bottom: 1px solid var(--dr-line); font-weight: 700; }
++.dr-tray-head span { flex: 1; min-width: 0; }
++.dr-tray-head button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 999px; background: transparent; color: var(--dr-muted); cursor: pointer; }
++.dr-tray-head button:hover { background: var(--dr-soft); color: var(--dr-text); }
++.dr-tray ul { list-style: none; margin: 0; padding: 6px 0; max-height: 280px; overflow-y: auto; }
++.dr-tray li { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 2px 12px; padding: 8px 16px; }
++.dr-tray li > .dr-thumb { grid-row: span 2; width: 32px; height: 32px; border-radius: 8px; }
++.dr-tray li > .dr-thumb .dr-i { width: 18px; height: 18px; }
++.dr-tray-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.dr-tray-state { font-size: 12.5px; color: var(--dr-muted); }
++.dr-tray li.is-done .dr-tray-state { color: var(--success-text, var(--success, #22c55e)); }
++.dr-tray li.is-bad .dr-tray-state { color: var(--error-text, #f87171); }
++.dr-tray-bar { grid-column: 2 / 4; height: 4px; border-radius: 99px; background: var(--dr-soft); overflow: hidden; }
++.dr-tray-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--dr-acc); transition: width .2s; }
++.dr-tray li.is-done .dr-tray-bar i { background: var(--success, #22c55e); }
++.dr-tray li a { font-size: 13px; font-weight: 650; }
++
++/* ── One file's page: the file large on the left, what you can do with it on the right. ── */
++.fv-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, 1fr); gap: 32px; align-items: start; margin-top: 12px; }
++.fv-stage { position: sticky; top: calc(var(--ovnav-h, 56px) + 16px); display: grid; place-items: center; min-height: 360px; margin: 0; overflow: hidden;
++ border-radius: 22px; border: 1px solid var(--dr-line); color: var(--k); background: color-mix(in srgb, var(--k) 10%, var(--dr-well)); }
++.fv-stage.is-pic { background: var(--dr-well); }
++.fv-stage img { display: block; width: 100%; max-height: 72vh; object-fit: contain; }
++.fv-tile { display: grid; justify-items: center; gap: 14px; }
++.fv-tile .dr-i { width: 96px; height: 96px; stroke-width: 1.1; }
++.fv-tile b { padding: 4px 10px; border-radius: 8px; font: 700 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; background: color-mix(in srgb, var(--k) 20%, transparent); }
++.fv-name { margin: 0 0 6px; font-size: clamp(1.4rem, 2.2vw, 1.9rem); line-height: 1.2; overflow-wrap: anywhere; }
++.fv-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; margin: 0 0 18px; font-size: 14px; color: var(--dr-muted); }
++.fv-sub > * + *::before { content: "·"; margin-right: 14px; color: var(--dr-muted); }
++.fv-kind { display: inline-flex; align-items: center; gap: 6px; color: var(--k); font-weight: 650; }
++.fv-kind .dr-i { width: 17px; height: 17px; }
++.fv-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 20px; }
++.fv-card { margin: 0 0 14px; padding: 16px; border-radius: 18px; background: var(--dr-card); border: 1px solid var(--dr-line); scroll-margin-top: calc(var(--ovnav-h, 56px) + 16px); }
++.fv-card h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; font-size: 1.02rem; }
++.fv-card h2 .dr-i { color: var(--dr-acc); }
++.fv-card:target { border-color: color-mix(in srgb, var(--dr-acc) 60%, var(--dr-line)); }
++.fv-card.is-danger h2 .dr-i { color: var(--error-text, #f87171); }
++.fv-card.is-danger p { margin: 0 0 12px; font-size: 14px; color: var(--dr-muted); }
++.fv fieldset { margin: 0 0 16px; padding: 0; border: 0; min-width: 0; }
++.fv legend { margin: 0 0 8px; padding: 0; font-size: .76rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--dr-muted); }
++.fv-who { display: grid; gap: 8px; }
++.fv-opt { position: relative; display: block; cursor: pointer; }
++.fv-opt input, .fv-chips input { position: absolute; opacity: 0; width: 1px; height: 1px; }
++.fv-opt span { display: grid; grid-template-columns: 22px minmax(0, 1fr); column-gap: 12px; align-items: center; padding: 11px 14px; border-radius: 13px; border: 1px solid var(--dr-line); background: var(--dr-well); transition: border-color .15s, background .15s; }
++.fv-opt .dr-i { grid-row: span 2; color: var(--dr-muted); }
++.fv-opt b { font-size: 14.5px; }
++.fv-opt small { font-size: 12.5px; color: var(--dr-muted); }
++.fv-opt:hover span { border-color: color-mix(in srgb, var(--dr-acc) 40%, var(--dr-line)); }
++.fv-opt:has(input:checked) span { border-color: var(--dr-acc); background: color-mix(in srgb, var(--dr-acc) 10%, var(--dr-well)); box-shadow: inset 0 0 0 1px var(--dr-acc); }
++.fv-opt:has(input:checked) .dr-i { color: var(--dr-acc); }
++.fv-opt:has(input:focus-visible) span, .fv-chips label:has(input:focus-visible) span { outline: 3px solid var(--dr-acc); outline-offset: 2px; }
++.fv .fv-names { width: 100%; transition: opacity .15s; }
++.fv-who:not(:has(input[value="named"]:checked)) .fv-names { opacity: .55; }
++.fv-chips { display: flex; flex-wrap: wrap; gap: 8px; }
++.fv-chips label { position: relative; cursor: pointer; }
++.fv-chips span { display: inline-flex; align-items: center; height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--dr-line); background: var(--dr-well); font-size: 13.5px; font-weight: 650; transition: background .15s, border-color .15s; }
++.fv-chips label:has(input:checked) span { background: var(--dr-acc); border-color: var(--dr-acc); color: var(--dr-on-acc); }
++.fv-h3 { margin: 18px 0 8px; font-size: .76rem; letter-spacing: .07em; text-transform: uppercase; color: var(--dr-muted); }
++.fv-card .dr-link { background: var(--dr-well); }
++.fv-card .dr-link small .badge { margin-right: 4px; }
++.fv-row { display: flex; gap: 8px; margin: 0; }
++.fv-row input, .fv-row select { flex: 1; min-width: 0; }
++.fv-facts { display: grid; margin: 0 0 14px; border: 1px solid var(--dr-line); border-radius: 18px; overflow: hidden; }
++.fv-facts > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 12px; padding: 10px 16px; }
++.fv-facts > div + div { border-top: 1px solid var(--dr-line); }
++.fv-facts dt { font-size: 13px; color: var(--dr-muted); }
++.fv-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
++.fv-facts code { font-size: 12px; }
++
++/* Phones and narrow tablets: the files first and full width, New as a floating button over a bottom sheet. */
++@media (max-width: 759px) {
++ .dr { grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 0; }
++ .dr-side { position: static; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; order: 2; }
++ .dr-places { display: flex; gap: 6px; flex: 1 1 100%; overflow-x: auto; scrollbar-width: none; }
++ .dr-places a { flex: none; min-height: 38px; padding: 0 14px; border: 1px solid var(--dr-line); }
++ .dr-meter { flex: 1 1 100%; }
++ .dr-new-btn { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(var(--ovnav-appbar-h, 0px) + 16px); z-index: 9993; min-height: 56px; padding: 0 22px 0 18px; border-radius: 18px;
++ background: var(--dr-acc); border: 0; color: var(--dr-on-acc); box-shadow: 0 10px 26px color-mix(in srgb, var(--dr-acc) 40%, transparent), 0 2px 6px rgba(0, 0, 0, .3); }
++ .dr-new-btn .dr-i { color: inherit; }
++ .dr-new-panel { position: fixed; left: 0; right: 0; top: auto; bottom: 0; z-index: 9996; width: auto; gap: 2px;
++ padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(16px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
++ border-radius: 24px 24px 0 0; border-bottom: 0; box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .5), var(--dr-shadow); animation: dr-sheet .24s cubic-bezier(.2, .8, .2, 1); }
++ .dr-new-panel::before { content: ""; justify-self: center; width: 40px; height: 4px; margin: 2px 0 8px; border-radius: 99px; background: color-mix(in srgb, var(--dr-text) 25%, transparent); }
++ .dr-pick, .dr-new-row { min-height: 60px; }
++ .dr-main { order: 1; padding-bottom: 72px; }
++ .dr-bar-row { margin-bottom: 16px; }
++ .dr-title { flex-basis: 100%; }
++ .dr-tools { flex: 1 1 100%; }
++ .dr-find { flex: 1 1 100%; }
++ .dr .dr-find input { width: 100%; height: 44px; }
++ .dr-sort { flex: 1; }
++ .dr .dr-sort select { width: 100%; max-width: 220px; }
++ .dr-places a { font-size: 14px; padding: 0 12px; }
++ .dr-places .dr-i { display: none; }
++ .dr-link { flex-wrap: wrap; row-gap: 6px; }
++ .dr-link-main { flex: 1 1 calc(100% - 40px); }
++ .dr-link > .dr-btn, .dr-link > form { margin-left: 32px; }
++ .dr-link > .dr-btn:not([hidden]) + form { margin-left: 0; }
++ .dr-folders { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
++ .dr-files { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
++ .dr:has(.dr-view input[value="list"]:checked) .dr-file-text { grid-template-columns: minmax(0, 1fr); }
++ .dr-file:has(.dr-more[open]), .dr-folder:has(.dr-more[open]) { z-index: 9996; }
++ .dr-menu { position: fixed; left: 8px; right: 8px; top: auto; bottom: calc(8px + var(--ovnav-appbar-h, 0px)); width: auto; z-index: 9996; padding: 8px; border-radius: 18px;
++ box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .45), var(--dr-shadow); animation: dr-sheet .2s ease-out; }
++ .dr-menu > a, .dr-menu > form:not(.dr-rename) > button { min-height: 48px; font-size: 15px; }
++ .dr-tray { left: 12px; right: 12px; width: auto; bottom: calc(84px + var(--ovnav-appbar-h, 0px)); }
++ .fv-grid { grid-template-columns: minmax(0, 1fr); gap: 18px; }
++ .fv-stage { position: static; min-height: 220px; border-radius: 18px; }
++ .fv-stage img { max-height: 58vh; }
++ .fv-tile .dr-i { width: 72px; height: 72px; }
++ .fv-actions .dr-btn { flex: 1; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .dr-new-panel, .dr-menu, .dr-tray, .dr-drop, .dr-drop-box { animation: none; }
++ .dr-file:hover { transform: none; }
++}
ddiff --git a/public/js/upload.js b/public/js/upload.js
iindex f1b26d6..46793c2 100644
---- a/public/js/upload.js
++++ b/public/js/upload.js
@@@ -1,42 +1,66 @@
--/* OpenVibe.MediaHub — the chunked upload, and the only JavaScript on any page.
++/* OpenVibe.MediaHub — the drive's one script: the chunked upload, and what makes the drive feel like an app.
*
-- * The form it upgrades works without it: submit posts the whole file to POST /files/upload in one request. With
-- * this script the file goes up in parts instead (POST /api/v1/files, one PUT per part, then complete), so a file
-- * larger than the single-request limit still uploads, a dropped connection can resume, and the person can watch
-- * the progress.
++ * Every form it upgrades works without it: the upload form posts one file to POST /files/upload, the menus are
++ * <details>, sort is a GET form, rename/move/delete are POSTs. With this script:
++ * - files go up in parts (POST /api/v1/files, one PUT per part, then complete), so a file larger than the
++ * single-request limit still uploads, and many files go up one after another;
++ * - files dropped anywhere on a drive page upload into the folder on screen, and a tray shows each one's progress;
++ * when they are in, the list is refreshed in place;
++ * - the grid/list view and the sort are remembered; the search box filters what is on screen as you type;
++ * - one menu is open at a time, and a click outside or Escape closes it; a delete asks first.
*
* Every request is same-origin, so the browser sends Sec-Fetch-Site: same-origin by itself — which is exactly what
* a session-cookie write on this site requires. No token, no key and no file name is ever put in a URL.
++ *
++ * Pages swap in place (openvibe-shared/boost): listeners live on document (bound once), and wire() runs again on
++ * every ov:boost:load.
*/
(function () {
'use strict';
-- var form = document.querySelector('form[data-upload]');
-- if (!form || !window.fetch) return;
-- var input = form.querySelector('input[type=file]');
-- var nameField = form.querySelector('input[name=name]');
-- var folderField = form.querySelector('input[name=folder]');
-- var bar = form.querySelector('progress');
-- var status = form.querySelector('[role=status]');
-- var button = form.querySelector('button[type=submit]');
-- if (!input) return;
--
-- function say(text) { if (status) status.textContent = text; }
-- function progress(done, total) {
-- if (!bar) return;
-- bar.hidden = false;
-- bar.max = total;
-- bar.value = done;
++ if (window.__dlDrive) return;
++ window.__dlDrive = true;
++ var store = {
++ get: function (k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } },
++ set: function (k, v) { try { window.localStorage.setItem(k, v); } catch (e) { /* private window */ } },
++ };
++ var SVG = {
++ upload: 'M12 15V4M7.5 8.5 12 4l4.5 4.5M5 15v3.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V15',
++ file: 'M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8zM14 3v5h5',
++ check: 'M5 12.5l4.5 4.5L19 7.5',
++ close: 'M6 6l12 12M18 6 6 18',
++ };
++ function icon(name) {
++ var ns = 'http://www.w3.org/2000/svg';
++ var svg = document.createElementNS(ns, 'svg');
++ svg.setAttribute('class', 'dr-i');
++ svg.setAttribute('viewBox', '0 0 24 24');
++ svg.setAttribute('aria-hidden', 'true');
++ var path = document.createElementNS(ns, 'path');
++ path.setAttribute('d', SVG[name] || SVG.file);
++ svg.appendChild(path);
++ return svg;
++ }
++ function el(tag, cls, text) {
++ var n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
}
++ function bytes(n) {
++ if (n < 1024) return n + ' B';
++ var units = ['KB', 'MB', 'GB', 'TB'];
++ var v = n / 1024;
++ var i = 0;
++ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
++ return (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ' + units[i];
++ }
++
++ // ── The upload itself: one file, in parts ────────────────────────────────────────────────────────────────
function problem(res) {
return res.json().then(function (p) {
return (p && p.detail) || 'The upload was refused.';
}, function () { return 'The upload was refused (' + res.status + ').'; });
}
-- function busy(on) {
-- if (button) button.disabled = on;
-- if (input) input.disabled = on;
-- }
--
/** One request, answering a promise of its JSON (or rejecting with the problem's detail). */
function request(url, options) {
return fetch(url, options).then(function (res) {
@@@ -44,21 +68,19 @@
return problem(res).then(function (detail) { throw new Error(detail); });
});
}
--
-- function upload(file) {
++ /** Upload one file into a folder (or the root); onProgress(sent, total). Resolves with the stored file. */
++ function sendFile(file, folderId, name, onProgress) {
var sent = 0;
var uploadId = null;
-- busy(true);
-- progress(0, file.size);
-- say('Starting…');
++ onProgress(0, file.size);
return request('/api/v1/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
-- name: (nameField && nameField.value) || file.name,
++ name: name || file.name,
size: file.size,
content_type: file.type || 'application/octet-stream',
-- folder_id: (folderField && folderField.value) || null,
++ folder_id: folderId || null,
}),
}).then(function (started) {
var up = started.upload;
@@@ -76,29 +98,298 @@
body: slice,
}).then(function () {
sent += slice.size;
-- progress(sent, file.size);
-- say('Uploading… ' + Math.round((sent / file.size) * 100) + '%');
++ onProgress(sent, file.size);
});
};
}(n));
}
return chain;
}).then(function () {
-- say('Finishing…');
return request('/api/v1/uploads/' + uploadId + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
-- }).then(function (done) {
-- say('Uploaded. Opening it…');
-- window.location.assign('/files/' + done.file.id);
-- }).catch(function (err) {
-- busy(false);
-- say((err && err.message) || 'The upload did not go through.');
++ }).then(function (done) { return done.file; });
++ }
++
++ // ── The tray: every file being uploaded, one row each ────────────────────────────────────────────────────
++ var tray = null;
++ var queue = [];
++ var running = false;
++ var finished = { ok: 0, bad: 0 };
++ function trayHead() {
++ var left = queue.length + (running ? 1 : 0);
++ var text = left ? 'Uploading ' + left + ' file' + (left === 1 ? '' : 's') + '…'
++ : finished.bad ? finished.ok + ' uploaded, ' + finished.bad + ' did not go through'
++ : finished.ok + ' file' + (finished.ok === 1 ? '' : 's') + ' uploaded';
++ tray.querySelector('.dr-tray-head span').textContent = text;
++ }
++ function ensureTray() {
++ if (tray && document.body.contains(tray)) return tray;
++ tray = el('section', 'dr-tray');
++ tray.setAttribute('aria-label', 'Uploads');
++ tray.setAttribute('aria-live', 'polite');
++ var head = el('div', 'dr-tray-head');
++ head.appendChild(el('span'));
++ var close = el('button');
++ close.type = 'button';
++ close.setAttribute('aria-label', 'Close the upload list');
++ close.appendChild(icon('close'));
++ close.addEventListener('click', function () {
++ if (running || queue.length) return; // stays while anything is still going up
++ tray.remove();
++ tray = null;
++ finished = { ok: 0, bad: 0 };
++ });
++ head.appendChild(close);
++ tray.appendChild(head);
++ tray.appendChild(el('ul'));
++ document.body.appendChild(tray);
++ return tray;
++ }
++ function addRow(file) {
++ var t = ensureTray();
++ var li = el('li');
++ var th = el('span', 'dr-thumb');
++ th.setAttribute('data-kind', /^image\//.test(file.type) ? 'image' : /^video\//.test(file.type) ? 'video' : /^audio\//.test(file.type) ? 'audio' : 'file');
++ th.appendChild(icon('file'));
++ li.appendChild(th);
++ li.appendChild(el('span', 'dr-tray-name', file.name));
++ var state = el('span', 'dr-tray-state', 'Waiting');
++ li.appendChild(state);
++ var bar = el('span', 'dr-tray-bar');
++ bar.appendChild(el('i'));
++ li.appendChild(bar);
++ t.querySelector('ul').appendChild(li);
++ return li;
++ }
++ function enqueue(files, folderId, name) {
++ for (var i = 0; i < files.length; i++) {
++ if (!files[i] || (files[i].size === 0 && !files[i].type)) continue; // a dropped folder arrives as an empty, typeless entry
++ queue.push({ file: files[i], folderId: folderId, name: files.length === 1 ? name : '', row: addRow(files[i]) });
++ }
++ if (tray) trayHead();
++ next();
++ }
++ function next() {
++ if (running) return;
++ var job = queue.shift();
++ if (!job) { if (tray) trayHead(); refreshList(); return; }
++ running = true;
++ trayHead();
++ var state = job.row.querySelector('.dr-tray-state');
++ var fill = job.row.querySelector('.dr-tray-bar i');
++ sendFile(job.file, job.folderId, job.name, function (sent, total) {
++ var pct = total ? Math.round((sent / total) * 100) : 100;
++ fill.style.width = pct + '%';
++ state.textContent = pct + '% of ' + bytes(total);
++ }).then(function (stored) {
++ finished.ok += 1;
++ job.row.classList.add('is-done');
++ fill.style.width = '100%';
++ state.textContent = '';
++ var open = el('a', null, 'Open');
++ open.href = '/files/' + stored.id;
++ state.appendChild(open);
++ }, function (err) {
++ finished.bad += 1;
++ job.row.classList.add('is-bad');
++ state.textContent = (err && err.message) || 'Did not go through';
++ }).then(function () {
++ running = false;
++ next();
});
}
-- form.addEventListener('submit', function (event) {
-- var file = input.files && input.files[0];
-- if (!file) return; // no file chosen: let the server answer the empty form
-- event.preventDefault();
-- upload(file);
++ /** After uploads: draw the folder again in place (the same page, fetched), so new files appear without a reload. */
++ function refreshList() {
++ var main = document.querySelector('[data-main]');
++ if (!main || !finished.ok || !window.DOMParser) return;
++ fetch(window.location.href, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.text() : null; }).then(function (text) {
++ if (!text) return;
++ var doc = new DOMParser().parseFromString(text, 'text/html');
++ var fresh = doc.querySelector('[data-main]');
++ var meter = doc.querySelector('.dr-meter');
++ if (fresh) main.innerHTML = fresh.innerHTML;
++ var oldMeter = document.querySelector('.dr-meter');
++ if (meter && oldMeter) oldMeter.outerHTML = meter.outerHTML;
++ wire();
++ }).catch(function () { /* the next visit shows them */ });
++ }
++
++ function folderOfPage() {
++ var f = document.querySelector('form[data-upload] input[name=folder]');
++ return f ? f.value : null;
++ }
++ function placeName() {
++ var d = document.querySelector('[data-drive]');
++ return (d && d.getAttribute('data-place')) || 'your files';
++ }
++
++ // ── Drop anywhere ────────────────────────────────────────────────────────────────────────────────────────
++ var drop = null;
++ var depth = 0;
++ function hasFiles(e) {
++ var types = e.dataTransfer && e.dataTransfer.types;
++ if (!types) return false;
++ for (var i = 0; i < types.length; i++) if (types[i] === 'Files') return true;
++ return false;
++ }
++ function showDrop(on) {
++ if (on && !drop) {
++ drop = el('div', 'dr-drop');
++ var box = el('div', 'dr-drop-box');
++ box.appendChild(icon('upload'));
++ box.appendChild(el('b', null, 'Drop to upload'));
++ box.appendChild(el('span', null, 'into ' + placeName()));
++ drop.appendChild(box);
++ document.body.appendChild(drop);
++ } else if (!on && drop) {
++ drop.remove();
++ drop = null;
++ }
++ }
++ document.addEventListener('dragenter', function (e) {
++ if (!document.querySelector('form[data-upload]') || !hasFiles(e)) return;
++ e.preventDefault();
++ depth += 1;
++ showDrop(true);
++ });
++ document.addEventListener('dragover', function (e) {
++ if (!document.querySelector('form[data-upload]') || !hasFiles(e)) return;
++ e.preventDefault();
++ e.dataTransfer.dropEffect = 'copy';
++ });
++ document.addEventListener('dragleave', function () {
++ depth = Math.max(0, depth - 1);
++ if (!depth) showDrop(false);
});
++ document.addEventListener('drop', function (e) {
++ if (!document.querySelector('form[data-upload]') || !hasFiles(e)) return;
++ e.preventDefault();
++ depth = 0;
++ showDrop(false);
++ enqueue(e.dataTransfer.files, folderOfPage(), '');
++ });
++
++ // ── The upload form: choosing files starts them; submitting (a name typed) does too ─────────────────────
++ document.addEventListener('change', function (e) {
++ var t = e.target;
++ if (t.matches && t.matches('form[data-upload] input[type=file]') && t.files && t.files.length) {
++ var form = t.form;
++ var nameField = form.querySelector('input[name=name]');
++ enqueue(t.files, folderOfPage(), nameField ? nameField.value : '');
++ t.value = '';
++ if (nameField) nameField.value = '';
++ var det = form.closest('details');
++ if (det) det.open = false;
++ return;
++ }
++ if (t.matches && t.matches('select[data-autosubmit]')) {
++ // Remembered for the next visit: the server reads it when the address names no sort.
++ if (t.name === 'sort') document.cookie = 'dl_sort=' + encodeURIComponent(t.value) + '; path=/; max-age=31536000; samesite=lax';
++ t.form.requestSubmit ? t.form.requestSubmit() : t.form.submit();
++ return;
++ }
++ if (t.matches && t.matches('.dr-view input')) {
++ store.set('dl.view', t.value);
++ return;
++ }
++ if (t.matches && t.matches('.fv-who input[value=named]') && t.checked) {
++ var names = document.getElementById('usernames');
++ if (names) names.focus();
++ }
++ });
++ document.addEventListener('submit', function (e) {
++ var form = e.target;
++ if (form.matches('form[data-upload]')) {
++ var input = form.querySelector('input[type=file]');
++ if (!input || !input.files || !input.files.length) return; // nothing chosen: let the server answer
++ e.preventDefault();
++ var nameField = form.querySelector('input[name=name]');
++ enqueue(input.files, folderOfPage(), nameField ? nameField.value : '');
++ input.value = '';
++ return;
++ }
++ var ask = form.getAttribute('data-confirm');
++ if (ask && !window.confirm(ask)) e.preventDefault();
++ }, true);
++
++ // ── Menus: one open at a time; outside click and Escape close ───────────────────────────────────────────
++ document.addEventListener('toggle', function (e) {
++ var d = e.target;
++ if (!d.matches || !d.matches('.dr-more, .dr-new') || !d.open) return;
++ document.querySelectorAll('.dr-more[open], .dr-new[open]').forEach(function (o) { if (o !== d) o.open = false; });
++ var first = d.querySelector('.dr-menu a, .dr-new-panel .dr-pick');
++ if (first && window.matchMedia('(hover: hover)').matches) first.focus({ preventScroll: true });
++ }, true);
++ document.addEventListener('click', function (e) {
++ document.querySelectorAll('.dr-more[open], .dr-new[open]').forEach(function (o) {
++ var panel = o.querySelector('.dr-menu, .dr-new-panel');
++ if (panel && panel.contains(e.target)) return;
++ if (o.querySelector('summary').contains(e.target)) return;
++ o.open = false;
++ });
++ });
++ document.addEventListener('keydown', function (e) {
++ if (e.key !== 'Escape') return;
++ var open = document.querySelector('.dr-more[open], .dr-new[open]');
++ if (!open) return;
++ open.open = false;
++ open.querySelector('summary').focus();
++ });
++
++ // ── Filter what is on screen as you type; Enter searches every folder ───────────────────────────────────
++ document.addEventListener('input', function (e) {
++ if (!e.target.matches('[data-filter]')) return;
++ var q = e.target.value.trim().toLowerCase();
++ var shown = 0;
++ var total = 0;
++ document.querySelectorAll('[data-items] > li').forEach(function (li) {
++ total += 1;
++ var hit = !q || (li.getAttribute('data-name') || '').indexOf(q) !== -1;
++ li.hidden = !hit;
++ if (hit) shown += 1;
++ });
++ document.querySelectorAll('.dr-sec').forEach(function (sec) {
++ var list = sec.querySelector('[data-items]');
++ if (list) sec.hidden = !!q && !list.querySelector(':scope > li:not([hidden])');
++ });
++ var none = document.querySelector('[data-nomatch]');
++ if (none) none.hidden = !(q && total && !shown);
++ });
++
++ // ── Per page (again after every in-place swap) ──────────────────────────────────────────────────────────
++ function wire() {
++ // The form's "Name + Upload" row is for pages without this script: here, choosing files starts them.
++ document.querySelectorAll('[data-nojs]').forEach(function (n) { n.hidden = true; });
++ document.querySelectorAll('form[data-upload] input[type=file]').forEach(function (i) { i.multiple = true; i.required = false; });
++ var view = store.get('dl.view');
++ if (view) {
++ var radio = document.querySelector('.dr-view input[value="' + (view === 'list' ? 'list' : 'grid') + '"]');
++ if (radio) radio.checked = true;
++ }
++ if (navigator.clipboard) {
++ document.querySelectorAll('[data-copy]').forEach(function (b) {
++ b.hidden = false;
++ if (b.__wired) return;
++ b.__wired = true;
++ b.addEventListener('click', function () {
++ navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function () {
++ var was = b.textContent;
++ b.textContent = 'Copied';
++ setTimeout(function () { b.textContent = was; }, 1600);
++ }, function () { /* the link is on screen to copy by hand */ });
++ });
++ });
++ }
++ // Dates in the reader's own zone, the full time on hover.
++ document.querySelectorAll('time[data-when]').forEach(function (t) {
++ var d = new Date(t.getAttribute('datetime'));
++ if (isNaN(d)) return;
++ var sameYear = d.getFullYear() === new Date().getFullYear();
++ t.textContent = d.toLocaleDateString(undefined, sameYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' });
++ t.title = d.toLocaleString();
++ });
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
++ else wire();
++ document.addEventListener('ov:boost:load', wire);
})();
ddiff --git a/server/drive/service.js b/server/drive/service.js
iindex 23f2159..e419588 100644
---- a/server/drive/service.js
++++ b/server/drive/service.js
@@@ -145,6 +145,31 @@ function createService({ s, media, identity, config, log = console }) {
return { file: row, media: removed };
}
++ /** Rename one of the person's files. The name follows the same rule as an upload's. */
++ async function renameFile(owner, id, name) {
++ const row = await ownFile(owner, id);
++ if (!row) return { error: 'file.not_found' };
++ const clean = rules.cleanName(name);
++ if (!clean) return { error: 'file.invalid_name', detail: 'A file needs a name.' };
++ await store.renameFile(s, row.id, owner, clean);
++ return { file: await store.getFile(s, row.id) };
++ }
++
++ /** Move one of the person's files into one of their folders, or to the root (folderId null). */
++ async function moveFile(owner, id, folderId = null) {
++ const row = await ownFile(owner, id);
++ if (!row) return { error: 'file.not_found' };
++ if (folderId && !await ownFolder(owner, folderId)) return { error: 'folder.not_found', detail: 'That folder does not exist.' };
++ await store.moveFile(s, row.id, owner, folderId || null);
++ return { file: await store.getFile(s, row.id) };
++ }
++
++ /** The person's files whose names hold the words, from every folder. */
++ const searchFiles = (owner, words, opts) => {
++ const clean = rules.cleanText(words, 100);
++ return clean ? store.searchFiles(s, owner, clean, opts) : Promise.resolve([]);
++ };
++
// ── Folders ─────────────────────────────────────────────
/** A folder of this person's, or null. */
@@@ -165,6 +190,33 @@ function createService({ s, media, identity, config, log = console }) {
return { folder: await store.insertFolder(s, { id: s.newId('fld'), owner, parent_id: parentId || null, name: clean, created_at: nowIso() }) };
}
++ /** Rename a folder; two folders in one place still never share a name. */
++ async function renameFolder(owner, id, name) {
++ const folder = await ownFolder(owner, id);
++ if (!folder) return { error: 'folder.not_found', detail: 'That folder does not exist.' };
++ const clean = rules.cleanName(name, rules.FOLDER_NAME_MAX);
++ if (!clean) return { error: 'folder.invalid_name', detail: 'A folder needs a name.' };
++ const existing = await store.listFolders(s, owner);
++ if (existing.some((f) => f.id !== folder.id && (f.parent_id || null) === (folder.parent_id || null) && f.name.toLowerCase() === clean.toLowerCase())) {
++ return { error: 'folder.exists', detail: `You already have a folder called "${clean}" here.` };
++ }
++ await store.renameFolder(s, folder.id, owner, clean);
++ return { folder: await store.getFolder(s, folder.id) };
++ }
++
++ /**
++ * Delete an empty folder. A folder that still holds something is refused rather than emptied: there is no bin
++ * yet, and a file deleted here is deleted in Media too.
++ */
++ async function deleteFolder(owner, id) {
++ const folder = await ownFolder(owner, id);
++ if (!folder) return { error: 'folder.not_found', detail: 'That folder does not exist.' };
++ if (await store.folderInUse(s, folder.id)) return { error: 'folder.not_empty', detail: `"${folder.name}" still has files or folders in it. Move or delete them first.` };
++ await store.deleteSharesForFolder(s, folder.id);
++ await store.deleteFolder(s, folder.id, owner);
++ return { folder };
++ }
++
/** A folder's path from the root, for breadcrumbs and the folder picker. */
async function folderPath(owner, folderId) {
const all = await store.listFolders(s, owner);
@@@ -293,9 +345,9 @@ function createService({ s, media, identity, config, log = console }) {
return {
uploadAllowed, shareAllowed, usage, dayOf,
-- recordFile, ownFile, deleteFile,
++ recordFile, ownFile, deleteFile, renameFile, moveFile, searchFiles,
listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,
-- ownFolder, createFolder, folderPath,
++ ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
createShare, ownShare, revokeShare,
report, restoreShare, removeShared,
stateOf, allowedFor, parseAllow,
ddiff --git a/server/drive/store.js b/server/drive/store.js
iindex 430e4fc..cf49a21 100644
---- a/server/drive/store.js
++++ b/server/drive/store.js
@@@ -51,6 +51,16 @@ const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WH
/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */
const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);
++/** Rename or move one of the person's live files (the owner is in the WHERE: nobody else's file changes). */
++const renameFile = (s, id, owner, name) => s.db.exec(`UPDATE ${FILES} SET name = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, name]);
++const moveFile = (s, id, owner, folderId) => s.db.exec(`UPDATE ${FILES} SET folder_id = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, folderId]);
++
++/** This person's live files whose name holds the words, in every folder, newest first. */
++function searchFiles(s, owner, words, { limit = 100 } = {}) {
++ const like = `%${String(words).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
++ return s.db.many(`SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND name ILIKE $2 ORDER BY id DESC LIMIT $3`, [owner, like, limit]);
++}
++
// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────
async function insertFolder(s, row) {
@@@ -62,6 +72,10 @@ async function insertFolder(s, row) {
const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`, [id]);
const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);
const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);
++const renameFolder = (s, id, owner, name) => s.db.exec(`UPDATE ${FOLDERS} SET name = $3 WHERE id = $1 AND owner = $2`, [id, owner, name]);
++/** Whether a folder still holds a live file or a folder. */
++const folderInUse = (s, id) => s.db.value(
++ `SELECT EXISTS (SELECT 1 FROM ${FILES} WHERE folder_id = $1 AND deleted_at IS NULL) OR EXISTS (SELECT 1 FROM ${FOLDERS} WHERE parent_id = $1)`, [id]);
const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);
// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
@@@ -162,8 +176,8 @@ const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_i
const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);
module.exports = {
-- insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile,
-- insertFolder, getFolder, listFolders, countFolders, deleteFolder,
++ insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile, renameFile, moveFile, searchFiles,
++ insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
deleteSharesForFile, deleteSharesForFolder,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
ddiff --git a/server/drive/ui.js b/server/drive/ui.js
nnew file mode 100644
iindex 0000000..f70dcb7
---- /dev/null
++++ b/server/drive/ui.js
@@@ -0,0 +1,152 @@
++'use strict';
++
++/**
++ * The drive's look (2026-11-07, the owner: "a competitor to Google Drive … sleek and easy to use and intuitive and
++ * minimalist while being powerful"). Markup only: icons, what kind a file is, a folder tile, a file card, the
++ * storage meter. pages.js decides what is on a page; this decides how each thing is drawn.
++ *
++ * Every control is a plain link or form, so the drive still works with JavaScript off; public/js/upload.js adds
++ * drag and drop, many files at once, the upload tray, the view and sort it remembers, and the instant filter.
++ */
++const { html, raw } = require('../render/html');
++const rules = require('./rules');
++
++// Stroke icons on a 24-unit grid, drawn in currentColor. Paths only, so they are cheap to repeat on a long list.
++const PATHS = {
++ folder: 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z',
++ file: 'M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8zM14 3v5h5',
++ doc: 'M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8zM14 3v5h5M8.5 12.5h7M8.5 16h5',
++ sheet: 'M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8zM14 3v5h5M8.5 11.5h7v6h-7zM8.5 14.5h7M12 11.5v6',
++ slides: 'M4 5h16v11H4zM12 16v4M8.5 20h7M8 9h5M8 12h8',
++ image: 'M4.5 4.5h15a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-13a1 1 0 0 1 1-1zM9 10.5a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5zM20.5 15.5l-5-5-10.5 9',
++ video: 'M4.5 6h10a1.5 1.5 0 0 1 1.5 1.5v9a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 3 16.5v-9A1.5 1.5 0 0 1 4.5 6zM16 10.5l5-3v9l-5-3',
++ audio: 'M9 17.5V6l11-2v11.5M9 17.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zM20 15.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z',
++ archive: 'M4 8h16v11.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 19.5zM3 4h18v4H3zM10 12h4',
++ code: 'M8.5 8 4.5 12l4 4M15.5 8l4 4-4 4M13.5 5.5l-3 13',
++ pdf: 'M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8zM14 3v5h5M8.5 17v-4.5h1.3a1.3 1.3 0 0 1 0 2.6H8.5',
++ plus: 'M12 5v14M5 12h14',
++ upload: 'M12 15V4M7.5 8.5 12 4l4.5 4.5M5 15v3.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V15',
++ 'folder-plus': 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5zM12 11v6M9 14h6',
++ sync: 'M20 11a8 8 0 0 0-14.6-4.5M4 4.5v3.8h3.8M4 13a8 8 0 0 0 14.6 4.5M20 19.5v-3.8h-3.8',
++ search: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM20 20l-4.8-4.8',
++ grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
++ list: 'M8.5 6H20M8.5 12H20M8.5 18H20M4 6h.01M4 12h.01M4 18h.01',
++ more: 'M12 6.5h.01M12 12h.01M12 17.5h.01',
++ download: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14',
++ link: 'M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2',
++ pencil: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4',
++ move: 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5zM9 13.5h6.5M13 11l2.5 2.5L13 16',
++ trash: 'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13M10 11v5.5M14 11v5.5',
++ home: 'M4 11 12 4l8 7v8.5a.5.5 0 0 1-.5.5H15v-6H9v6H4.5a.5.5 0 0 1-.5-.5z',
++ drive: 'M3.5 14.5 7 5h10l3.5 9.5M3.5 14.5h17V19a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1zM16.5 17.25h.01',
++ chevron: 'M9.5 6l6 6-6 6',
++ check: 'M5 12.5l4.5 4.5L19 7.5',
++ close: 'M6 6l12 12M18 6 6 18',
++ lock: 'M7 11V8a5 5 0 0 1 10 0v3M5.5 11h13v9.5h-13z',
++ globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.6 5.6 3.6 9S14.5 18.5 12 21c-2.5-2.5-3.6-5.6-3.6-9S9.5 5.5 12 3z',
++ people: 'M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 6.5M18.5 14.5a6.5 6.5 0 0 1 3 5.5',
++ user: 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4.5 21a7.5 7.5 0 0 1 15 0',
++};
++
++/** One icon. Decorative unless given a label. */
++const icon = (name, label = '') => raw(`<svg class="dr-i" viewBox="0 0 24 24" ${label ? `role="img" aria-label="${String(label).replace(/"/g, '&quot;')}"` : 'aria-hidden="true"'} focusable="false"><path d="${PATHS[name] || PATHS.file}"/></svg>`);
++
++// What kind a file is, from its type and then its extension: an icon, a word for people, and a hue.
++const EXT = {
++ pdf: 'pdf',
++ doc: 'doc', docx: 'doc', odt: 'doc', rtf: 'doc', txt: 'doc', md: 'doc', pages: 'doc',
++ xls: 'sheet', xlsx: 'sheet', ods: 'sheet', csv: 'sheet', tsv: 'sheet', numbers: 'sheet',
++ ppt: 'slides', pptx: 'slides', odp: 'slides', key: 'slides',
++ zip: 'archive', rar: 'archive', '7z': 'archive', tar: 'archive', gz: 'archive', tgz: 'archive', bz2: 'archive', xz: 'archive', dmg: 'archive', iso: 'archive',
++ js: 'code', ts: 'code', py: 'code', json: 'code', html: 'code', css: 'code', sh: 'code', go: 'code', rs: 'code', c: 'code', cpp: 'code', java: 'code', rb: 'code', php: 'code', yml: 'code', yaml: 'code', xml: 'code', sql: 'code',
++};
++const KINDS = {
++ folder: { icon: 'folder', word: 'Folder' },
++ image: { icon: 'image', word: 'Image' },
++ video: { icon: 'video', word: 'Video' },
++ audio: { icon: 'audio', word: 'Audio' },
++ pdf: { icon: 'pdf', word: 'PDF' },
++ doc: { icon: 'doc', word: 'Document' },
++ sheet: { icon: 'sheet', word: 'Spreadsheet' },
++ slides: { icon: 'slides', word: 'Slides' },
++ archive: { icon: 'archive', word: 'Archive' },
++ code: { icon: 'code', word: 'Code' },
++ file: { icon: 'file', word: 'File' },
++};
++
++const extOf = (name) => ((/\.([A-Za-z0-9]{1,7})$/.exec(String(name || '')) || [])[1] || '').toLowerCase();
++
++function kindOf(row) {
++ const type = String(row.content_type || '').toLowerCase();
++ if (type.startsWith('image/')) return 'image';
++ if (type.startsWith('video/')) return 'video';
++ if (type.startsWith('audio/')) return 'audio';
++ if (type === 'application/pdf') return 'pdf';
++ if (/zip|x-tar|x-7z|x-rar|gzip|x-bzip/.test(type)) return 'archive';
++ if (/spreadsheet|ms-excel|text\/csv/.test(type)) return 'sheet';
++ if (/presentation|powerpoint/.test(type)) return 'slides';
++ if (/wordprocessing|msword|opendocument\.text|text\/plain|text\/markdown/.test(type)) return 'doc';
++ return EXT[extOf(row.name)] || (type.startsWith('text/') ? 'doc' : 'file');
++}
++
++const kindWord = (row) => KINDS[kindOf(row)].word;
++
++/** "Nov 7" this year, "Nov 7, 2025" before; the full time on hover. upload.js rewrites it in the reader's own zone. */
++function when(iso) {
++ if (!iso) return '';
++ const d = new Date(iso);
++ if (Number.isNaN(d.getTime())) return '';
++ const sameYear = d.getUTCFullYear() === new Date().getUTCFullYear();
++ const text = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }), timeZone: 'UTC' });
++ return html`<time datetime="${iso}" data-when>${text}</time>`;
++}
++
++/** The thumbnail of a file: its own picture when it is an image, else a tile in its kind's colour with its icon. */
++function thumb(row, { big = false } = {}) {
++ const kind = kindOf(row);
++ if (rules.isPreviewable(row.content_type)) {
++ return html`<span class="dr-thumb is-pic" data-kind="${kind}"><img src="/files/${row.id}/preview?w=${big ? 640 : 96}" alt="" loading="lazy" decoding="async"></span>`;
++ }
++ const ext = extOf(row.name);
++ return html`<span class="dr-thumb" data-kind="${kind}">${icon(KINDS[kind].icon)}${ext ? html`<b>${ext.slice(0, 5)}</b>` : ''}</span>`;
++}
++
++/** A folder tile: opens on a click; its menu renames or deletes it (an empty one only). */
++function folderTile(f, { count = null } = {}) {
++ const href = `/files?folder=${encodeURIComponent(f.id)}`;
++ return html`<li class="dr-folder" data-name="${f.name.toLowerCase()}" data-time="${f.created_at || ''}" data-size="0">
++<a class="dr-folder-open" href="${href}">${icon('folder')}<span class="dr-name">${f.name}</span>${count != null ? html`<small>${count}</small>` : ''}</a>
++<details class="dr-more"><summary aria-label="More for the folder ${f.name}">${icon('more')}</summary>
++<div class="dr-menu">
++<a href="${href}">${icon('folder')}Open</a>
++<form class="dr-rename" method="post" action="/folders/${f.id}/rename"><label>${icon('pencil')}<span class="sr-only">New name for ${f.name}</span><input name="name" type="text" value="${f.name}" maxlength="${rules.FOLDER_NAME_MAX}" required></label><button type="submit">Rename</button></form>
++<form method="post" action="/folders/${f.id}/delete" data-confirm="Delete the folder “${f.name}”? Only an empty folder can be deleted."><button class="is-danger" type="submit">${icon('trash')}Delete folder</button></form>
++</div></details></li>`;
++}
++
++/** A file card (the grid) that is also a row (the list): the same markup, laid out by the view. */
++function fileCard(row, { where = null } = {}) {
++ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
++<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
++<span class="dr-file-text"><span class="dr-name">${row.name}</span>
++<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>${when(row.created_at)}</span>${where ? html`<span class="dr-where">${icon('folder')}${where}</span>` : ''}</span></span></a>
++<details class="dr-more"><summary aria-label="More for ${row.name}">${icon('more')}</summary>
++<div class="dr-menu">
++<a href="/files/${row.id}/download">${icon('download')}Download</a>
++<a href="/files/${row.id}#share">${icon('link')}Share a link</a>
++<a href="/files/${row.id}#move">${icon('move')}Move to…</a>
++<form class="dr-rename" method="post" action="/files/${row.id}/rename"><label>${icon('pencil')}<span class="sr-only">New name for ${row.name}</span><input name="name" type="text" value="${row.name}" maxlength="${rules.NAME_MAX}" required></label><button type="submit">Rename</button></form>
++<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="is-danger" type="submit">${icon('trash')}Delete</button></form>
++</div></details></li>`;
++}
++
++/** The storage meter: a real <progress> (it means what it says without a stylesheet), the numbers beside it. */
++function meter(u) {
++ const pct = u.bytes_limit ? Math.min(100, Math.round((u.bytes_used / u.bytes_limit) * 100)) : 0;
++ return html`<div class="dr-meter${pct >= 90 ? ' is-full' : pct >= 75 ? ' is-high' : ''}">
++<p class="dr-meter-head">${icon('drive')}<span>Storage</span><b>${pct}%</b></p>
++<progress id="usage" max="${u.bytes_limit}" value="${u.bytes_used}" aria-describedby="usage-text">${pct}%</progress>
++<p id="usage-text" class="dr-meter-text"><b>${rules.formatBytes(u.bytes_used)}</b> of ${rules.formatBytes(u.bytes_limit)} used<br>${u.files} file${u.files === 1 ? '' : 's'} · ${u.uploads_today} of ${u.uploads_per_day} uploads today</p></div>`;
++}
++
++module.exports = { icon, kindOf, kindWord, when, thumb, folderTile, fileCard, meter, extOf, KINDS };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7ab73fd..cfb1535 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -42,6 +42,7 @@ const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
const { send } = require('../render/layout');
const rules = require('../drive/rules');
++const ui = require('../drive/ui');
const { isStaff } = require('../drive/staff');
const { boundaryOf, readFormData } = require('./form-upload');
@@@ -273,48 +274,126 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
return { folder, path: await drive.folderPath(requesterOf(req), folder.id) };
}
-- const uploadForm = (folder) => html`<form class="card upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
--<h2>Upload a file</h2>
++ // ── The drive's page (the Drive-like redesign, 2026-11-07; markup pieces in server/drive/ui.js) ──
++ const SORTS = { new: 'Newest first', old: 'Oldest first', name: 'Name A–Z', size: 'Largest first' };
++ function sortRows(rows, sort) {
++ const out = rows.slice();
++ if (sort === 'old') out.reverse();
++ else if (sort === 'name') out.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en', { numeric: true, sensitivity: 'base' }));
++ else if (sort === 'size') out.sort((a, b) => (Number(b.size) || 0) - (Number(a.size) || 0));
++ return out;
++ }
++ const folderHref = (id) => (id ? `/files?folder=${encodeURIComponent(id)}` : '/files');
++ /** "Taxes / 2026": a folder named by its path, for the Move picker and search results. */
++ function folderLabels(all) {
++ const byId = new Map(all.map((f) => [f.id, f]));
++ const label = (f) => {
++ const names = [];
++ let cursor = f;
++ let guard = 0;
++ while (cursor && guard++ < 50) { names.unshift(cursor.name); cursor = cursor.parent_id ? byId.get(cursor.parent_id) : null; }
++ return names.join(' / ');
++ };
++ return new Map(all.map((f) => [f.id, label(f)]));
++ }
++ const pathBar = (path, last = null) => html`<nav class="dr-path" aria-label="Where you are"><a href="/files">${ui.icon('home')}<span>My files</span></a>${path.map((f, i) => html`${ui.icon('chevron')}${i === path.length - 1 && !last ? html`<span aria-current="page">${f.name}</span>` : html`<a href="${folderHref(f.id)}">${f.name}</a>`}`)}${last ? html`${ui.icon('chevron')}<span aria-current="page">${last}</span>` : ''}</nav>`;
++ const whoOf = (sh) => (sh.public ? ['globe', 'Anyone with the link'] : sh.allowed_subjects ? ['people', 'Only the people you named'] : ['lock', 'Signed-in people with the link']);
++
++ /** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */
++ const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
--<div class="field"><label for="file">File</label><input id="file" name="file" type="file" required></div>
--<div class="field"><label for="name">Name (optional)</label><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="leave empty to keep the file's own name"></div>
--<p><button class="sc-btn sc-primary" type="submit">Upload</button></p>
--<p class="small muted">Up to ${rules.formatBytes(config.quotas.maxFileBytes)} per file and ${rules.formatBytes(config.quotas.bytes)} in total, ${config.quotas.uploadsPerDay} uploads a day. Files over ${rules.formatBytes(config.singleUploadBytes)} go up in parts, which needs JavaScript; without it, the browser sends one request and files that size are refused.</p>
++<input id="file" class="dr-pick-in" name="file" type="file" required>
++<label class="dr-pick" for="file">${ui.icon('upload')}<span><b>Upload files</b><small>Or drop them anywhere on this page</small></span></label>
++<div class="dr-upload-go" data-nojs><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="Name (optional)" aria-label="Name (optional)"><button class="dr-btn is-primary" type="submit">Upload</button></div>
<progress id="upload-progress" max="100" value="0" hidden></progress>
--<p id="upload-status" class="small muted" role="status"></p>
++<p id="upload-status" class="dr-fine" role="status"></p>
++<p class="dr-fine">Up to ${rules.formatBytes(config.quotas.maxFileBytes)} a file, ${rules.formatBytes(config.quotas.bytes)} in all, ${config.quotas.uploadsPerDay} uploads a day.</p>
++</form>`;
++ const folderForm = (folder) => html`<form class="dr-mkdir" method="post" action="/folders">
++${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
++<label for="new-folder">${ui.icon('folder-plus')}<span class="sr-only">New folder</span></label><input id="new-folder" name="name" type="text" maxlength="${rules.FOLDER_NAME_MAX}" placeholder="New folder" required><button class="dr-btn" type="submit">Create</button>
</form>`;
r.get('/files', mustSignIn('/files'), async (req, res) => {
const owner = requesterOf(req);
const { folder, path } = await folderOf(req);
-- const folders = (await drive.listFolders(owner)).filter((f) => (f.parent_id || null) === (folder ? folder.id : null));
-- const files = await drive.listFiles(owner, { folderId: folder ? folder.id : null, limit: 200 });
-- const shares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
-- const crumbs = [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }];
-- for (const f of path) crumbs.push({ label: f.name, href: `/files?folder=${encodeURIComponent(f.id)}` });
++ const q = rules.cleanText(req.query.q || '', 100);
++ // The sort asked for, else the one this browser picked last (upload.js keeps it in a cookie), else newest.
++ const asked = String(req.query.sort || (/(?:^|;\s*)dl_sort=([a-z]+)/.exec(String(req.get('cookie') || '')) || [])[1] || '');
++ const sort = Object.prototype.hasOwnProperty.call(SORTS, asked) ? asked : 'new';
++ const here = folder ? folder.id : null;
++ const allFolders = await drive.listFolders(owner);
++ const labels = folderLabels(allFolders);
++ const folders = q
++ ? allFolders.filter((f) => f.name.toLowerCase().includes(q.toLowerCase()))
++ : allFolders.filter((f) => (f.parent_id || null) === here);
++ if (sort === 'name') folders.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));
++ else if (sort === 'new') folders.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
++ else if (sort === 'old') folders.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
++ const files = sortRows(q ? await drive.searchFiles(owner, q, { limit: 200 }) : await drive.listFiles(owner, { folderId: here, limit: 200 }), sort);
++ const liveShares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
++ const showShares = !q && !folder && liveShares.length > 0;
++ const shareNames = new Map();
++ if (showShares) {
++ for (const sh of liveShares) {
++ if (sh.file_id) { const f = await drive.ownFile(owner, sh.file_id); if (f) shareNames.set(sh.slug, f.name); }
++ else if (sh.folder_id && labels.has(sh.folder_id)) shareNames.set(sh.slug, labels.get(sh.folder_id));
++ }
++ }
++ const usage = await drive.usage(owner);
++ const title = q ? `“${q}”` : folder ? folder.name : 'My files';
++ const empty = !files.length && !folders.length;
++ const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;
return page(req, res, {
-- title: folder ? `${folder.name} · Your files` : 'Your files',
-- crumbs,
-- // The one page script: it turns the upload form into a chunked, resumable, progressive upload. The
-- // form itself works without it.
++ title: q ? `Search: ${q} · Your files` : folder ? `${folder.name} · Your files` : 'Your files',
++ // The one page script: the chunked, resumable upload (many files, dropped anywhere, a tray), the view and
++ // sort it remembers, the instant filter. Every form here works without it.
scripts: ['js/upload.js'],
-- body: html`<h1>${folder ? folder.name : 'Your files'}</h1>
--${usageBar(await drive.usage(owner))}
--<p class="small"><a href="/sync">Back up a folder from your computer</a></p>
--${folder ? html`<p><a href="/files">← All files</a></p>` : ''}
--<section aria-labelledby="h-upload">${uploadForm(folder)}</section>
--<section aria-labelledby="h-folders"><h2 id="h-folders">Folders</h2>
--${folders.length ? html`<ul class="file-list">${folders.map((f) => html`<li class="file"><span class="file-main"><a class="file-name" href="/files?folder=${encodeURIComponent(f.id)}">${f.name}</a><span class="file-meta small muted">folder</span></span></li>`)}</ul>` : html`<p class="muted">No folders here yet.</p>`}
--<form class="inline" method="post" action="/folders">
--${folder ? html`<input type="hidden" name="parent" value="${folder.id}">` : ''}
--<label for="new-folder">New folder</label> <input id="new-folder" name="name" type="text" maxlength="${rules.FOLDER_NAME_MAX}" required> <button class="sc-btn" type="submit">Create</button>
--</form></section>
--<section aria-labelledby="h-files"><h2 id="h-files">Files</h2>
--${files.length ? html`<ul class="file-list">${files.map(fileRow)}</ul>` : html`<p class="muted">No files here yet.</p>`}</section>
--<section aria-labelledby="h-shares"><h2 id="h-shares">Live share links</h2>
--${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class="file"><span class="file-main"><a class="file-name" href="/s/${sh.slug}">/s/${sh.slug}</a>
--<span class="file-meta small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} download${Number(sh.downloads) === 1 ? '' : 's'}${sh.allowed_subjects ? ' · limited to named people' : ''}</span></span>
--<form class="inline" method="post" action="/shares/${sh.slug}/revoke"><button class="sc-btn" type="submit">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">No live share links. Open a file to make one.</p>`}</section>`,
++ css: ['css/drive.css'],
++ body: html`<div class="dr" data-drive data-place="${title}">
++<aside class="dr-side" aria-label="Your drive">
++<details class="dr-new" data-new><summary class="dr-new-btn">${ui.icon('plus')}<span>New</span></summary>
++<div class="dr-new-panel" role="group" aria-label="Add to ${folder ? folder.name : 'My files'}">
++<p class="dr-new-title">Add to <b>${folder ? folder.name : 'My files'}</b></p>
++${uploadForm(folder)}
++${folderForm(folder)}
++<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a>
++</div></details>
++<nav class="dr-places" aria-label="Places">
++<a href="/files"${!folder && !q ? raw(' aria-current="page"') : ''}>${ui.icon('home')}<span>My files</span></a>
++<a href="/files#shared">${ui.icon('link')}<span>Shared links</span>${liveShares.length ? html`<em>${liveShares.length}</em>` : ''}</a>
++<a href="/sync">${ui.icon('sync')}<span>Backups</span></a>
++</nav>
++${ui.meter(usage)}
++</aside>
++<div class="dr-main" data-main>
++<header class="dr-bar">
++${folder || q ? pathBar(q ? [] : path, q ? `Search: “${q}”` : null) : ''}
++<div class="dr-bar-row">
++<h1 class="dr-title">${title}</h1>
++<div class="dr-tools">
++<form class="dr-find" role="search" method="get" action="/files">${ui.icon('search')}<input type="search" name="q" value="${q}" placeholder="Search your files" aria-label="Search your files" data-filter autocomplete="off"><button class="sr-only" type="submit">Search</button></form>
++<form class="dr-sort" method="get" action="/files">${keep(q ? html`<input type="hidden" name="q" value="${q}">` : '')}<label><span class="sr-only">Sort</span><select name="sort" data-autosubmit>${Object.entries(SORTS).map(([k, v]) => html`<option value="${k}"${k === sort ? raw(' selected') : ''}>${v}</option>`)}</select></label><noscript><button class="dr-btn" type="submit">Sort</button></noscript></form>
++<div class="dr-view" role="radiogroup" aria-label="View">
++<label title="Grid"><input type="radio" name="dr-view" value="grid" checked>${ui.icon('grid')}<span class="sr-only">Grid</span></label>
++<label title="List"><input type="radio" name="dr-view" value="list">${ui.icon('list')}<span class="sr-only">List</span></label>
++</div>
++</div>
++</div>
++</header>
++${empty ? html`<label class="dr-empty" for="file">${ui.icon(q ? 'search' : 'upload')}<b>${q ? `Nothing matches “${q}”` : folder ? 'This folder is empty' : 'Your drive is empty'}</b><span>${q ? 'Try other words, or browse My files.' : 'Drop files here, or tap to choose them. New makes folders and backups.'}</span></label>` : ''}
++${folders.length ? html`<section class="dr-sec" aria-labelledby="h-folders"><h2 id="h-folders" class="dr-h">Folders <span>${folders.length}</span></h2>
++<ul class="dr-folders" data-items>${folders.map((f) => ui.folderTile(f))}</ul></section>` : ''}
++${files.length ? html`<section class="dr-sec" aria-labelledby="h-files"><h2 id="h-files" class="dr-h">Files <span>${files.length}</span></h2>
++<ul class="dr-files" data-items>${files.map((row) => ui.fileCard(row, { where: q ? (row.folder_id ? labels.get(row.folder_id) || 'A folder' : 'My files') : null }))}</ul></section>` : ''}
++<p class="dr-nomatch" data-nomatch hidden>Nothing in this view matches. Press Enter to search every folder.</p>
++${showShares ? html`<section class="dr-sec" id="shared" aria-labelledby="h-shares"><h2 id="h-shares" class="dr-h">Live share links <span>${liveShares.length}</span></h2>
++<ul class="dr-links">${liveShares.map((sh) => { const [ic, who] = whoOf(sh); return html`<li class="dr-link">${ui.icon(ic)}<span class="dr-link-main"><a href="/s/${sh.slug}">${shareNames.get(sh.slug) || `/s/${sh.slug}`}</a>
++<small>${who} · expires ${time(sh.expires_at)} · ${Number(sh.downloads)} download${Number(sh.downloads) === 1 ? '' : 's'}</small></span>
++<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button>
++<form method="post" action="/shares/${sh.slug}/revoke"><button class="dr-btn is-quiet" type="submit">Revoke</button></form></li>`; })}</ul></section>` : ''}
++</div>
++</div>`,
});
});
@@@ -323,29 +402,67 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
const row = await drive.ownFile(owner, String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
++ const allFolders = await drive.listFolders(owner);
++ const labels = folderLabels(allFolders);
++ const path = row.folder_id ? await drive.folderPath(owner, row.folder_id) : [];
++ const kind = ui.kindOf(row);
++ const ext = ui.extOf(row.name);
++ const pic = rules.isPreviewable(row.content_type);
++ const choices = [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1], 'en', { numeric: true, sensitivity: 'base' }));
return page(req, res, {
title: row.name,
styles: ['kit.css'],
-- crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
-- body: html`<h1>${row.name}</h1>
--${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}
--${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
--<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: rules.isPreviewable(row.content_type) ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '' }))}</p>
--<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
--<form method="post" action="/files/${row.id}/share">
--<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
--<fieldset class="field"><legend>Who can open it</legend>
--<label class="check"><input type="radio" name="who" value="signed_in" checked> Anyone signed in to OpenVibe with the link</label>
--<label class="check"><input type="radio" name="who" value="public"> Anyone with the link, no account needed (a public download page)</label>
--<label class="check"><input type="radio" name="who" value="named"> Only these OpenVibe usernames:</label>
--<input id="usernames" name="usernames" type="text" maxlength="300" placeholder="kim, sam" aria-label="OpenVibe usernames"></fieldset>
--<p><button class="sc-btn sc-primary" type="submit">Create the link</button></p>
++ scripts: ['js/upload.js'],
++ css: ['css/drive.css'],
++ body: html`<div class="fv" data-drive>
++${pathBar(path, row.name)}
++<div class="fv-grid">
++<figure class="fv-stage${pic ? ' is-pic' : ''}" data-kind="${kind}">${pic
++ ? html`<img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async">`
++ : html`<span class="fv-tile">${ui.icon(ui.KINDS[kind].icon)}${ext ? html`<b>${ext.slice(0, 6)}</b>` : ''}</span>`}</figure>
++<div class="fv-side">
++<h1 class="fv-name">${row.name}</h1>
++<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
++<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}</div>
++
++<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2>
++<form class="fv-share" method="post" action="/files/${row.id}/share">
++<fieldset class="fv-who"><legend>Who can open it</legend>
++<label class="fv-opt"><input type="radio" name="who" value="signed_in" checked><span>${ui.icon('lock')}<b>Signed-in people</b><small>Anyone signed in to OpenVibe who has the link</small></span></label>
++<label class="fv-opt"><input type="radio" name="who" value="public"><span>${ui.icon('globe')}<b>Anyone</b><small>A public download page, no account needed</small></span></label>
++<label class="fv-opt"><input type="radio" name="who" value="named"><span>${ui.icon('people')}<b>Only people I name</b><small>Their OpenVibe usernames, below</small></span></label>
++<input class="fv-names" id="usernames" name="usernames" type="text" maxlength="300" placeholder="Usernames, e.g. kim, sam" aria-label="OpenVibe usernames">
++</fieldset>
++<fieldset class="fv-exp"><legend>Expires in</legend><div class="fv-chips">${rules.EXPIRY_CHOICES.map((c) => html`<label><input type="radio" name="hours" value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' checked') : ''}><span>${c.label}</span></label>`)}</div></fieldset>
++<button class="dr-btn is-primary" type="submit">Create the link</button>
</form>
--${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh) => html`<li class="file">${shareBadge(drive.stateOf(sh, s.iso()))} <a href="/s/${sh.slug}">/s/${sh.slug}</a> <span class="small muted">expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</span></li>`)}</ul>` : ''}
++${shares.length ? html`<h3 class="fv-h3">Links to this file</h3><ul class="dr-links">${shares.map((sh) => { const state = drive.stateOf(sh, s.iso()); const [ic, who] = whoOf(sh); return html`<li class="dr-link">${ui.icon(ic)}<span class="dr-link-main"><a href="/s/${sh.slug}">/s/${sh.slug}</a>
++<small>${shareBadge(state)} ${who} · expires ${time(sh.expires_at)} · ${Number(sh.downloads)} downloads</small></span>
++${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button><form method="post" action="/shares/${sh.slug}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`; })}</ul>` : ''}
</section>
--<section aria-labelledby="h-delete"><h2 id="h-delete">Delete</h2>
--<p class="muted">Deleting removes every share link to this file at once, and asks OpenVibe.Media to delete the object.</p>
--<form method="post" action="/files/${row.id}/delete"><button class="sc-btn" type="submit">Delete this file</button></form></section>`,
++
++<section class="fv-card" id="move" aria-labelledby="h-move"><h2 id="h-move">${ui.icon('move')}Move</h2>
++<form class="fv-row" method="post" action="/files/${row.id}/move"><label class="sr-only" for="move-to">Move to</label>
++<select id="move-to" name="to"><option value=""${!row.folder_id ? raw(' selected') : ''}>My files (the top)</option>${choices.map(([id, label]) => html`<option value="${id}"${id === row.folder_id ? raw(' selected') : ''}>${label}</option>`)}</select>
++<button class="dr-btn" type="submit">Move</button></form></section>
++
++<section class="fv-card" id="rename" aria-labelledby="h-rename"><h2 id="h-rename">${ui.icon('pencil')}Rename</h2>
++<form class="fv-row" method="post" action="/files/${row.id}/rename"><input type="hidden" name="from" value="file"><label class="sr-only" for="rename-to">New name</label><input id="rename-to" name="name" type="text" value="${row.name}" maxlength="${rules.NAME_MAX}" required><button class="dr-btn" type="submit">Rename</button></form></section>
++
++<dl class="fv-facts">
++<div><dt>Type</dt><dd>${row.content_type}</dd></div>
++<div><dt>Size</dt><dd>${fileSize(row)} (${Number(row.size).toLocaleString('en-US')} bytes)</dd></div>
++<div><dt>Added</dt><dd>${time(row.created_at)}</dd></div>
++<div><dt>Folder</dt><dd><a href="${folderHref(row.folder_id)}">${row.folder_id ? labels.get(row.folder_id) || 'A folder' : 'My files'}</a></dd></div>
++<div><dt>SHA-256</dt><dd><code>${row.sha256 || '—'}</code></dd></div>
++</dl>
++
++<section class="fv-card is-danger" id="delete" aria-labelledby="h-delete"><h2 id="h-delete">${ui.icon('trash')}Delete</h2>
++<p>Every share link to this file stops working at once, and OpenVibe.Media is asked to delete the bytes. There is no undo.</p>
++<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="dr-btn is-danger" type="submit">Delete this file</button></form></section>
++</div>
++</div>
++</div>`,
});
});
@@@ -458,7 +575,48 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
r.post('/shares/:slug/revoke', mustSignIn('/files'), async (req, res) => {
if (!sameSite(req, res, '/files')) return undefined;
await drive.revokeShare(requesterOf(req), String(req.params.slug || ''));
-- return res.redirect(303, '/files');
++ const from = String((req.body || {}).from || '');
++ return res.redirect(303, rules.isFileId(from) ? `/files/${from}#share` : '/files#shared');
++ });
++
++ // ── Rename, move, folder delete (the drive's "full controls", 2026-11-07) ──
++ const refused = (req, res, what, detail, back, status = 422) => page(req, res, {
++ title: what,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
++ body: html`<h1>${what}</h1>${notice(detail, 'warn')}<p><a class="sc-btn" href="${back}">Go back</a></p>`,
++ }, status);
++
++ r.post('/files/:id/rename', mustSignIn('/files'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const owner = requesterOf(req);
++ const out = await drive.renameFile(owner, String(req.params.id || ''), (req.body || {}).name);
++ if (out.error === 'file.not_found') return notFoundFile(req, res);
++ if (out.error) return refused(req, res, 'The file was not renamed', out.detail, `/files/${req.params.id}`);
++ return res.redirect(303, (req.body || {}).from === 'file' ? `/files/${out.file.id}` : folderHref(out.file.folder_id));
++ });
++
++ r.post('/files/:id/move', mustSignIn('/files'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const owner = requesterOf(req);
++ const to = String((req.body || {}).to || '') || null;
++ const out = await drive.moveFile(owner, String(req.params.id || ''), to);
++ if (out.error === 'file.not_found') return notFoundFile(req, res);
++ if (out.error) return refused(req, res, 'The file was not moved', out.detail, `/files/${req.params.id}`, 404);
++ return res.redirect(303, `/files/${out.file.id}`);
++ });
++
++ r.post('/folders/:id/rename', mustSignIn('/files'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const out = await drive.renameFolder(requesterOf(req), String(req.params.id || ''), (req.body || {}).name);
++ if (out.error) return refused(req, res, 'The folder was not renamed', out.detail, '/files', out.error === 'folder.exists' ? 409 : out.error === 'folder.not_found' ? 404 : 422);
++ return res.redirect(303, folderHref(out.folder.parent_id));
++ });
++
++ r.post('/folders/:id/delete', mustSignIn('/files'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const out = await drive.deleteFolder(requesterOf(req), String(req.params.id || ''));
++ if (out.error) return refused(req, res, 'The folder was not deleted', out.detail, folderHref(String(req.params.id || '')), out.error === 'folder.not_found' ? 404 : 409);
++ return res.redirect(303, folderHref(out.folder.parent_id));
});
// ── Downloading ──────────────────────────────────────────
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 51014dd..bab372e 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -48,7 +48,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
++ * css [this site's own extra stylesheets, e.g. 'css/drive.css']
*/
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -96,6 +97,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="media-hub@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
iindex 59a1bd4..9f65645 100644
---- a/test/drive-pages.test.js
++++ b/test/drive-pages.test.js
@@@ -124,6 +124,51 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.ok(drive.text.includes(`/s/${slug}`), 'and so does the drive');
});
++ await check('full controls: rename, move, search, folders inside folders, and only an empty folder is deleted', async () => {
++ const lee = t.network.addUser('lee');
++ assert.strictEqual((await t.get('/folders', { as: lee, form: { name: 'Work' }, headers: SAME })).status, 303);
++ const work = (await t.get('/api/v1/folders', { as: lee })).json().folders.find((f) => f.name === 'Work');
++ // A folder made on a folder's page goes inside it (the form names the folder it is on).
++ const madeInside = await t.get('/folders', { as: lee, form: { name: 'Invoices', folder: work.id }, headers: SAME });
++ assert.strictEqual(madeInside.status, 303);
++ assert.strictEqual(madeInside.headers.get('location'), `/files?folder=${work.id}`);
++ const invoices = (await t.get('/api/v1/folders', { as: lee })).json().folders.find((f) => f.name === 'Invoices');
++ assert.strictEqual(invoices.parent_id, work.id, 'inside Work');
++
++ const file = (await upload(t, lee, bytes(60, 'draft'), { name: 'draft.txt', contentType: 'text/plain' })).json().file;
++ const renamed = await t.get(`/files/${file.id}/rename`, { as: lee, form: { name: 'final.txt', from: 'file' }, headers: SAME });
++ assert.strictEqual(renamed.status, 303);
++ assert.strictEqual(renamed.headers.get('location'), `/files/${file.id}`);
++ assert.ok((await t.get(`/files/${file.id}`, { as: lee })).text.includes('final.txt'), 'the new name shows');
++ assert.strictEqual((await t.get(`/files/${file.id}/rename`, { as: lee, form: { name: ' ' }, headers: SAME })).status, 422, 'an empty name is refused');
++
++ const moved = await t.get(`/files/${file.id}/move`, { as: lee, form: { to: invoices.id }, headers: SAME });
++ assert.strictEqual(moved.status, 303);
++ assert.ok((await t.get(`/files?folder=${invoices.id}`, { as: lee })).text.includes('final.txt'), 'the file is in Invoices');
++ assert.ok(!(await t.get('/files', { as: lee })).text.includes('final.txt'), 'and no longer at the top');
++ // Somebody else's folder is not a place to move to, and somebody else's file cannot be moved at all.
++ assert.strictEqual((await t.get(`/files/${file.id}/move`, { as: lee, form: { to: 'fld_01JABCDEFGHJKMNPQRSTVWXYZ0' }, headers: SAME })).status, 404);
++ assert.strictEqual((await t.get(`/files/${file.id}/move`, { as: sam, form: { to: '' }, headers: SAME })).status, 404);
++
++ const found = await t.get('/files?q=fina', { as: lee });
++ assert.ok(found.text.includes('final.txt'), 'search finds it in any folder');
++ assert.ok(found.text.includes('Work / Invoices'), 'and says where it is');
++ assert.ok(!(await t.get('/files?q=100%25', { as: lee })).text.includes('final.txt'), 'a % is a character, not a wildcard');
++
++ const renamedFolder = await t.get(`/folders/${invoices.id}/rename`, { as: lee, form: { name: 'Bills' }, headers: SAME });
++ assert.strictEqual(renamedFolder.status, 303);
++ assert.ok((await t.get(`/files?folder=${work.id}`, { as: lee })).text.includes('Bills'));
++
++ const notEmpty = await t.get(`/folders/${invoices.id}/delete`, { as: lee, form: {}, headers: SAME });
++ assert.strictEqual(notEmpty.status, 409, 'a folder with a file in it stays');
++ assert.ok(notEmpty.text.includes('still has files or folders'), notEmpty.text.slice(0, 300));
++ await t.get(`/files/${file.id}/delete`, { as: lee, form: {}, headers: SAME });
++ const gone = await t.get(`/folders/${invoices.id}/delete`, { as: lee, form: {}, headers: SAME });
++ assert.strictEqual(gone.status, 303, 'an empty one goes');
++ assert.strictEqual(gone.headers.get('location'), `/files?folder=${work.id}`, 'back to where it was');
++ assert.ok(!(await t.get('/api/v1/folders', { as: lee })).json().folders.some((f) => f.id === invoices.id));
++ });
++
await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {
assert.strictEqual((await t.get('/updates')).status, 200);
const robots = await t.get('/robots.txt');
ddiff --git a/test/drive-previews.test.js b/test/drive-previews.test.js
iindex 7b1e3ed..4768e49 100644
---- a/test/drive-previews.test.js
++++ b/test/drive-previews.test.js
@@@ -22,12 +22,14 @@ const { bytes, upload, share } = require('./helpers/drive');
await check('the list shows a picture of each image and the type of everything else', async () => {
const page = await t.get('/files', { as: kim });
assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes(`<img class="file-thumb" src="/files/${photo.id}/preview?w=96" alt="" width="48" height="48" loading="lazy" decoding="async">`));
-- assert.match(page.text, /<span class="file-thumb file-thumb-type" aria-hidden="true">TXT<\/span>/);
-- assert.match(page.text, /<span class="file-thumb file-thumb-type" aria-hidden="true">SVG<\/span>/, 'an SVG is never rendered: its type, not a picture');
++ // The drive's cards (2026-11-07): an image is its own picture, anything else a tile of its kind with its extension.
++ assert.ok(page.text.includes(`<span class="dr-thumb is-pic" data-kind="image"><img src="/files/${photo.id}/preview?w=640" alt="" loading="lazy" decoding="async"></span>`));
++ assert.match(page.text, /<span class="dr-thumb" data-kind="doc"><svg[^>]*>.*?<\/svg><b>txt<\/b><\/span>/);
++ assert.match(page.text, /<span class="dr-thumb" data-kind="image"><svg[^>]*>.*?<\/svg><b>svg<\/b><\/span>/, 'an SVG is never rendered: its type, not a picture');
++ assert.ok(!page.text.includes(`/files/${vector.id}/preview`), 'and no preview is asked for');
const one = await t.get(`/files/${photo.id}`, { as: kim });
-- assert.ok(one.text.includes(`<figure class="file-preview"><img src="/files/${photo.id}/preview?w=640" alt="holiday.png" decoding="async"></figure>`));
-- assert.ok(!(await t.get(`/files/${notes.id}`, { as: kim })).text.includes('file-preview'));
++ assert.ok(one.text.includes(`<figure class="fv-stage is-pic" data-kind="image"><img src="/files/${photo.id}/preview?w=640" alt="holiday.png" decoding="async"></figure>`));
++ assert.ok(!(await t.get(`/files/${notes.id}`, { as: kim })).text.includes('/preview?w=640'));
});
await check('a preview is Media\'s signed WebP variant, private to the browser and never an attachment', async () => {