openvibers/openvibe.mediahub Commits
openvibe.download looks and works like a drive: sidebar, tiles and cards, drop anywhere, search, rename/move/delete (#305)
Changes
@@@ -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 inwords 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 annew file mode 100644@@@ -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; }++}@@@ -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);})();@@@ -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,@@@ -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_iconst 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,nnew file mode 100644@@@ -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, '"')}"` : '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 };@@@ -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 isreturn { 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 classconst 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 ──────────────────────────────────────────@@@ -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>`,@@@ -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');@@@ -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 () => {
@@ -4,6 +4,18 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,18 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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
- 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,
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
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
new file mode 100644
new file mode 100644
@@ -0,0 +1,321 @@
@@ -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; }
+}
@@ -1,42 +1,66 @@
@@ -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
* 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.
* 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 () {
(function () {
'use strict';
'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) {
function problem(res) {
return res.json().then(function (p) {
return res.json().then(function (p) {
return (p && p.detail) || 'The upload was refused.';
return (p && p.detail) || 'The upload was refused.';
}, function () { return 'The upload was refused (' + res.status + ').'; });
}, 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). */
/** One request, answering a promise of its JSON (or rejecting with the problem's detail). */
function request(url, options) {
function request(url, options) {
return fetch(url, options).then(function (res) {
return fetch(url, options).then(function (res) {
@@ -44,21 +68,19 @@
@@ -44,21 +68,19 @@
return problem(res).then(function (detail) { throw new Error(detail); });
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 sent = 0;
var uploadId = null;
var uploadId = null;
- busy(true);
- progress(0, file.size);
- say('Starting…');
+ onProgress(0, file.size);
return request('/api/v1/files', {
return request('/api/v1/files', {
method: 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: JSON.stringify({
- name: (nameField && nameField.value) || file.name,
+ name: name || file.name,
size: file.size,
size: file.size,
content_type: file.type || 'application/octet-stream',
content_type: file.type || 'application/octet-stream',
- folder_id: (folderField && folderField.value) || null,
+ folder_id: folderId || null,
}),
}),
}).then(function (started) {
}).then(function (started) {
var up = started.upload;
var up = started.upload;
@@ -76,29 +98,298 @@
@@ -76,29 +98,298 @@
body: slice,
body: slice,
}).then(function () {
}).then(function () {
sent += slice.size;
sent += slice.size;
- progress(sent, file.size);
- say('Uploading… ' + Math.round((sent / file.size) * 100) + '%');
+ onProgress(sent, file.size);
});
});
};
};
}(n));
}(n));
}
}
return chain;
return chain;
}).then(function () {
}).then(function () {
- say('Finishing…');
return request('/api/v1/uploads/' + uploadId + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
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);
})();
})();
@@ -145,6 +145,31 @@ function createService({ s, media, identity, config, log = console }) {
@@ -145,6 +145,31 @@ function createService({ s, media, identity, config, log = console }) {
return { file: row, media: removed };
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 ─────────────────────────────────────────────
// ── Folders ─────────────────────────────────────────────
/** A folder of this person's, or null. */
/** A folder of this person's, or null. */
@@ -165,6 +190,33 @@ function createService({ s, media, identity, config, log = console }) {
@@ -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() }) };
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. */
/** A folder's path from the root, for breadcrumbs and the folder picker. */
async function folderPath(owner, folderId) {
async function folderPath(owner, folderId) {
const all = await store.listFolders(s, owner);
const all = await store.listFolders(s, owner);
@@ -293,9 +345,9 @@ function createService({ s, media, identity, config, log = console }) {
@@ -293,9 +345,9 @@ function createService({ s, media, identity, config, log = console }) {
return {
return {
uploadAllowed, shareAllowed, usage, dayOf,
uploadAllowed, shareAllowed, usage, dayOf,
- recordFile, ownFile, deleteFile,
+ recordFile, ownFile, deleteFile, renameFile, moveFile, searchFiles,
listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,
listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,
- ownFolder, createFolder, folderPath,
+ ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
createShare, ownShare, revokeShare,
createShare, ownShare, revokeShare,
report, restoreShare, removeShared,
report, restoreShare, removeShared,
stateOf, allowedFor, parseAllow,
stateOf, allowedFor, parseAllow,
@@ -51,6 +51,16 @@ const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WH
@@ -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. */
/** 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]);
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 ─────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────
async function insertFolder(s, row) {
async function insertFolder(s, row) {
@@ -62,6 +72,10 @@ 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 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 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 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]);
const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);
// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -162,8 +176,8 @@ const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_i
@@ -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]);
const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);
module.exports = {
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,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
deleteSharesForFile, deleteSharesForFolder,
deleteSharesForFile, deleteSharesForFolder,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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, '"')}"` : '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 };
@@ -42,6 +42,7 @@ const { sameOrigin } = require('./principal');
@@ -42,6 +42,7 @@ const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const rules = require('../drive/rules');
const rules = require('../drive/rules');
+const ui = require('../drive/ui');
const { isStaff } = require('../drive/staff');
const { isStaff } = require('../drive/staff');
const { boundaryOf, readFormData } = require('./form-upload');
const { boundaryOf, readFormData } = require('./form-upload');
@@ -273,48 +274,126 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
@@ -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) };
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}">` : ''}
${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>
<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>`;
</form>`;
r.get('/files', mustSignIn('/files'), async (req, res) => {
r.get('/files', mustSignIn('/files'), async (req, res) => {
const owner = requesterOf(req);
const owner = requesterOf(req);
const { folder, path } = await folderOf(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, {
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'],
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
@@ -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 || ''));
const row = await drive.ownFile(owner, String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
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, {
return page(req, res, {
title: row.name,
title: row.name,
styles: ['kit.css'],
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>
</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>
-<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)
@@ -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) => {
r.post('/shares/:slug/revoke', mustSignIn('/files'), async (req, res) => {
if (!sameSite(req, res, '/files')) return undefined;
if (!sameSite(req, res, '/files')) return undefined;
await drive.revokeShare(requesterOf(req), String(req.params.slug || ''));
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 ──────────────────────────────────────────
// ── Downloading ──────────────────────────────────────────
@@ -48,7 +48,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -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],
* 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) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -96,6 +97,7 @@ function renderPage(o) {
@@ -96,6 +97,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(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)}
${render(scripts)}
<meta name="ov-boost" content="media-hub@${esc(RELEASE)}">
<meta name="ov-boost" content="media-hub@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -124,6 +124,51 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -124,6 +124,51 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.ok(drive.text.includes(`/s/${slug}`), 'and so does the 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 () => {
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);
assert.strictEqual((await t.get('/updates')).status, 200);
const robots = await t.get('/robots.txt');
const robots = await t.get('/robots.txt');
@@ -22,12 +22,14 @@ const { bytes, upload, share } = require('./helpers/drive');
@@ -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 () => {
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 });
const page = await t.get('/files', { as: kim });
assert.strictEqual(page.status, 200);
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 });
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 () => {
await check('a preview is Media\'s signed WebP variant, private to the browser and never an attachment', async () => {