Просмотр исходного кода

Add experimental Drive interface module

Toby Chui 1 день назад
Родитель
Сommit
b410315260

+ 106 - 0
src/web/Drive/README.md

@@ -0,0 +1,106 @@
+# Drive
+
+A cloud drive style **interface module** for ArozOS. A permission group whose
+default interface module is `Drive` signs in straight into it (from `/`), the
+same way groups can boot into the Desktop or the Dashboard. It can also be
+opened as a float window from the desktop.
+
+Everything lives in this folder. Drive adds **no backend code**: it uses the
+existing REST endpoints and its own AGI scripts in `backend/`.
+
+## Files
+
+| Path | What it does |
+|---|---|
+| `init.agi` | Registers the module (`Group: "Interface Module"`). |
+| `index.html` | Shell: top bar, sidebar, card, app host, overlay layers. |
+| `css/drive.css` | Tokens for light / dark, layout, breakpoints, container queries. |
+| `js/icons.js` | Every glyph as inline SVG: interface icons and the file type tiles. |
+| `js/i18n.js` | `T(key, english, vars)` over `locale/drive.json`. |
+| `js/api.js` | All requests. REST when allowed, `backend/fs.agi` otherwise. |
+| `js/ui.js` | Toasts, menus with submenus, popovers, modals, folder picker. |
+| `js/app.js` | Boot, state, routing, views, top bar, sidebar, toolbar, keys. |
+| `js/listing.js` | List / grid, sorting, paging, selection, drag and drop, thumbnails. |
+| `js/actions.js` | Open / open with, download, move, copy, rename, trash, star, zip ... |
+| `js/transfers.js` | Uploads and server side operations, one progress panel. |
+| `js/share.js` | Share dialog (links, audience, password, expiry, QR). |
+| `js/details.js` | Details panel: General, Permissions, Activity, Versions. |
+| `js/preview.js` | Built-in previewer: images, video, audio, PDF, text. |
+| `js/apphost.js` | Runs web apps and System Settings inside the page. |
+| `js/settings.js` | Drive's own settings page (`#/settings`). |
+| `backend/prefs.agi` | Settings, starred items, open history (table `Drive`). |
+| `backend/scan.agi` | Recent files and the Documents / Photos / ... categories. |
+| `backend/fs.agi` | File operations for users without the File Manager permission. |
+| `locale/drive.json` | en-us, zh-tw, zh-hk, zh-cn, ja-jp, ko-kr. |
+
+## Permissions and the two transports
+
+The file system and share endpoints (`/system/file_system/*`) sit behind the
+**File Manager** module permission. It is a "System Tools" module, so today
+everyone has it, but an administrator setup must not be assumed:
+
+- At boot `app.js` reads `/system/modules/list` and sets
+  `DriveAPI.caps.fm` (File Manager) and `DriveAPI.caps.settings`
+  (System Setting). A REST call that still answers `Permission Denied`
+  switches to the reduced mode as well.
+- `backend/*.agi` is always reachable: an interface module grants access to
+  its own folder.
+- Reduced mode (`fs.agi`) uses `filelib` / `ziplib` / `share` with the user's
+  own file permissions. There is no trash (delete is permanent), folders
+  cannot be renamed, uploads stop at 24 MB, and renaming or duplicating a file
+  over 32 MB is refused (content is copied through memory). Copies and moves
+  that keep the name go through a temporary zip and have no size limit. A
+  banner tells the user.
+- Endpoints that need no module permission are used directly: media
+  (`/media`), quota, user info, notifications, module list, share links.
+- System Settings only appears in the user menu when the user may open it.
+
+## Document base
+
+`index.html` sets `<base href="../">` (the web root), like the desktop page.
+Hosted apps read and write their iframe `src` as web root relative paths
+(`ao_module_makeSingleInstance`, the `embedded.html` launchers of Code Studio,
+NotepadA, Movie ...), so they have to resolve the same way here. As a result:
+
+- Drive's own files are referenced as `Drive/...` and `DriveAPI.ROOT` is empty.
+- In-page links must go through `Drive.href(route)`; a bare `#/...` href would
+  point at the web root page.
+- Bump the `?v=` query on the includes when shipping changed assets.
+
+## App host
+
+`apphost.js` sets `window.isDesktopMode = true` (after Drive's own
+`ao_module.js` has loaded) and implements the float window API apps call on
+`parent`: `newFloatWindow`, `closeFwProcess`, `getFloatWindowByID`,
+`MoveFloatWindowToTop`, `setFloatWindowTitle`, `openFileWithModule`,
+`openModule`, `startFileOperation`, `toggleDesktopTheme`, and no-op stubs for
+sizing and pinning. Windows are tabs over the file area with the markup
+`.floatWindowWrapper > .floatWindow[windowId,parent,callback] > .fwtab > iframe`,
+which is what `ao_module.js` walks for window ids and parent callbacks. Theme
+changes are pushed to every hosted iframe through `desktopThemeChanged`.
+
+## Routes
+
+`#/drive/<vpath>`, `#/shared[?tab=byme]`, `#/recent`, `#/starred`, `#/trash`,
+`#/category/<documents|photos|videos|music|others>`,
+`#/search/<query>?in=<vpath>`, `#/settings`.
+
+## Localisation
+
+Every string is `T("key", "English")`; English stays in the code as the
+fallback. When adding strings, add the key to all six languages in
+`locale/drive.json` (zh-hk mirrors zh-tw, like the File Manager). Server error
+messages are looked up as `srv/<message>`. The language follows
+`localStorage.global_language`, the same setting System Settings writes.
+
+## AGI gotchas met while writing the backend
+
+- POST fields become globals. Read them before declaring a variable of the
+  same name (`var paths = []` is hoisted and wipes the field), and keep the
+  `param()` helper's argument names unusual (`eval("name")` once returned the
+  argument itself).
+- `sendOK()` replies plain text; the JSON client expects `sendJSONResp`.
+- `filelib` does not expose `readBinaryFile` / `writeBinaryFile`; the injected
+  `_filelib_*` globals are used instead.
+- `ziplib.createZipFile([..])` rejects JS arrays (Otto exports `[]string`, the
+  library only accepts `[]interface{}`); pass a single string.

+ 432 - 0
src/web/Drive/backend/fs.agi

@@ -0,0 +1,432 @@
+/*
+    Drive - file system fallback
+
+    The File Manager REST API (/system/file_system/*) sits behind the
+    "File Manager" module permission. When a user's permission group does
+    not include it, Drive falls back to this script, which works through the
+    AGI file library with the user's own file permissions instead.
+
+    It is a reduced mode: there is no trash bin (delete is permanent), folders
+    cannot be renamed, and renaming or duplicating a file larger than
+    COPY_LIMIT is refused, because filelib can only copy file content through
+    memory. Copies and moves that keep the name go through ziplib, so they
+    have no size limit.
+
+    POST opr:
+        list     dir
+        mkdir    dir name
+        newfile  dir name
+        delete   paths=JSON
+        rename   path name
+        copy     paths=JSON dest existsresp=skip|overwrite|keep
+        move     paths=JSON dest existsresp=skip|overwrite|keep
+        props    path
+        search   path keyword
+        upload   dir name index chunk(hex) total
+        thumb    path
+        share    path              create or return the share link
+        unshare  path
+        shareinfo path
+*/
+
+requirelib("filelib");
+
+var COPY_LIMIT = 32 * 1024 * 1024;
+var UPLOAD_LIMIT = 24 * 1024 * 1024;
+var SEARCH_LIMIT = 500;
+var TIME_BUDGET = 12000;
+
+//POST fields arrive as globals. The odd argument names keep eval() from
+//resolving a field named like an argument (param("name") once returned "name")
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null) {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+/*
+    Hex encoded binary IO. requirelib("filelib") injects these two functions
+    as globals but does not attach them to the filelib object, so they are
+    looked up both ways.
+*/
+function readHex(path) {
+    if (typeof _filelib_readBinaryFile === "function") {
+        return _filelib_readBinaryFile(path);
+    }
+    return filelib.readBinaryFile(path);
+}
+
+function writeHex(path, hex) {
+    if (typeof _filelib_writeBinaryFile === "function") {
+        return _filelib_writeBinaryFile(path, hex);
+    }
+    return filelib.writeBinaryFile(path, hex);
+}
+
+function fail(message) {
+    sendJSONResp({ error: String(message) });
+}
+
+function basename(path) {
+    var parts = String(path).replace(/\/+$/, "").split("/");
+    return parts[parts.length - 1];
+}
+
+function dirname(path) {
+    var clean = String(path).replace(/\/+$/, "");
+    var at = clean.lastIndexOf("/");
+    return at < 0 ? clean : clean.substring(0, at + 1);
+}
+
+function join(dir, name) {
+    dir = String(dir);
+    if (dir.substring(dir.length - 1) != "/") {
+        dir = dir + "/";
+    }
+    return dir + name;
+}
+
+function validName(name) {
+    name = String(name);
+    if (name == "" || name == "." || name == ".." || name.length > 255) {
+        return false;
+    }
+    return !/[\/\\:*?"<>|]/.test(name);
+}
+
+function parseList(raw) {
+    try {
+        var arr = JSON.parse(raw);
+        return (arr instanceof Array) ? arr : [];
+    } catch (e) {
+        return [];
+    }
+}
+
+function exists(path) {
+    try {
+        return filelib.fileExists(path);
+    } catch (e) {
+        return false;
+    }
+}
+
+function statOf(path) {
+    var isDir = filelib.isDir(path);
+    return {
+        name: basename(path),
+        path: path,
+        isDir: isDir,
+        size: isDir ? -1 : filelib.filesize(path),
+        mtime: filelib.mtime(path, true)
+    };
+}
+
+//Delete a file or a whole folder. walk includes hidden entries, deepest first
+function removeTree(path) {
+    if (!filelib.isDir(path)) {
+        filelib.deleteFile(path);
+        return;
+    }
+    var all = filelib.walk(path, "all");
+    all.sort(function (a, b) { return b.length - a.length; });
+    for (var i = 0; i < all.length; i++) {
+        if (String(all[i]).replace(/\/+$/, "") == String(path).replace(/\/+$/, "")) {
+            continue;
+        }
+        try {
+            filelib.deleteFile(all[i]);
+        } catch (e) {
+            //Keep going, the folder delete below reports what is left
+        }
+    }
+    filelib.deleteFile(path);
+}
+
+//"name (1).ext", "name (2).ext", ... inside dir
+function freeName(dir, name) {
+    var dot = name.lastIndexOf(".");
+    var stem = dot > 0 ? name.substring(0, dot) : name;
+    var ext = dot > 0 ? name.substring(dot) : "";
+    for (var i = 1; i < 1000; i++) {
+        var candidate = stem + " (" + i + ")" + ext;
+        if (!exists(join(dir, candidate))) {
+            return candidate;
+        }
+    }
+    return stem + " (" + Date.now() + ")" + ext;
+}
+
+//Copy file content through memory, for a copy that changes the name
+function copyFileByContent(src, dest) {
+    if (filelib.filesize(src) > COPY_LIMIT) {
+        throw "File is too large to copy under a new name in limited mode";
+    }
+    var hex = readHex(src);
+    if (hex === null || hex === false) {
+        throw "Unable to read " + basename(src);
+    }
+    writeHex(dest, hex);
+}
+
+//Copy src into destDir keeping its name, through a temporary zip
+function copyKeepName(src, destDir) {
+    requirelib("ziplib");
+    var tmpZip = "tmp:/drive_copy_" + Date.now() + "_" + Math.floor(Math.random() * 100000) + ".zip";
+    //A one item array would be the natural call, but ziplib only accepts an
+    //array whose Go export is []interface{}, and Otto exports string arrays as
+    //[]string, so the single string form is the one that works
+    if (!ziplib.createZipFile(String(src), tmpZip)) {
+        throw "Unable to pack " + basename(src);
+    }
+    var ok = ziplib.extractZipFile(tmpZip, destDir);
+    try {
+        filelib.deleteFile(tmpZip);
+    } catch (e) { }
+    if (!ok) {
+        throw "Unable to unpack " + basename(src);
+    }
+}
+
+function transfer(paths, dest, existsresp, isMove) {
+    var done = [];
+    var errors = [];
+    for (var i = 0; i < paths.length; i++) {
+        var src = String(paths[i]);
+        var name = basename(src);
+        try {
+            if (!exists(src)) {
+                throw "Source not found: " + name;
+            }
+            if (join(dest, "").indexOf(join(src, "")) == 0) {
+                throw "Cannot put a folder inside itself";
+            }
+            var target = join(dest, name);
+            if (target == src) {
+                if (!isMove) {
+                    //Duplicating in place always keeps both
+                    copyFileByContent(src, join(dest, freeName(dest, name)));
+                    done.push(src);
+                }
+                continue;
+            }
+            if (exists(target)) {
+                if (existsresp == "skip") {
+                    continue;
+                } else if (existsresp == "overwrite") {
+                    removeTree(target);
+                    copyKeepName(src, dest);
+                } else {
+                    if (filelib.isDir(src)) {
+                        throw "A folder named " + name + " already exists";
+                    }
+                    copyFileByContent(src, join(dest, freeName(dest, name)));
+                }
+            } else {
+                copyKeepName(src, dest);
+            }
+            if (isMove) {
+                removeTree(src);
+            }
+            done.push(src);
+        } catch (e) {
+            errors.push(String(e));
+        }
+    }
+    if (errors.length > 0) {
+        sendJSONResp({ error: errors.join("; "), done: done });
+    } else {
+        sendJSONResp({ ok: true, done: done });
+    }
+}
+
+var opr = String(param("opr", ""));
+
+try {
+    if (opr == "list") {
+        var dir = String(param("dir", "user:/"));
+        var entries = filelib.readdir(dir, "default");
+        var shareReady = requirelib("share");
+        var items = [];
+        for (var i = 0; i < entries.length; i++) {
+            var e = entries[i];
+            var shared = false;
+            if (shareReady) {
+                try { shared = share.fileIsShared(e.Filepath); } catch (ex) { shared = false; }
+            }
+            items.push({ name: e.Filename, path: e.Filepath, isDir: e.IsDir, size: e.IsDir ? -1 : e.Filesize, mtime: e.Modtime, shared: shared });
+        }
+        sendJSONResp(items);
+
+    } else if (opr == "mkdir" || opr == "newfile") {
+        var parent = String(param("dir", ""));
+        var newName = String(param("name", ""));
+        if (!validName(newName)) {
+            fail("Filename contains illegal characters");
+        } else if (exists(join(parent, newName))) {
+            fail("An item with this name already exists");
+        } else if (opr == "mkdir") {
+            filelib.mkdir(join(parent, newName));
+            sendJSONResp({ ok: true });
+        } else {
+            filelib.writeFile(join(parent, newName), "");
+            sendJSONResp({ ok: true });
+        }
+
+    } else if (opr == "delete") {
+        var targets = parseList(param("paths", "[]"));
+        var failed = [];
+        for (var d = 0; d < targets.length; d++) {
+            try {
+                removeTree(String(targets[d]));
+            } catch (ex) {
+                failed.push(basename(targets[d]));
+            }
+        }
+        if (failed.length > 0) {
+            fail("Unable to delete " + failed.join(", "));
+        } else {
+            sendJSONResp({ ok: true });
+        }
+
+    } else if (opr == "rename") {
+        var src = String(param("path", ""));
+        var renameTo = String(param("name", ""));
+        var renamed = join(dirname(src), renameTo);
+        if (!validName(renameTo)) {
+            fail("Filename contains illegal characters");
+        } else if (!exists(src)) {
+            fail("File not exists");
+        } else if (exists(renamed)) {
+            fail("File already exists");
+        } else if (filelib.isDir(src)) {
+            fail("Renaming folders needs the File Manager permission");
+        } else {
+            copyFileByContent(src, renamed);
+            filelib.deleteFile(src);
+            sendJSONResp({ ok: true });
+        }
+
+    } else if (opr == "copy" || opr == "move") {
+        transfer(parseList(param("paths", "[]")), String(param("dest", "")), String(param("existsresp", "keep")), opr == "move");
+
+    } else if (opr == "props") {
+        var p = String(param("path", ""));
+        if (!exists(p)) {
+            fail("File not exists");
+        } else {
+            var info = statOf(p);
+            if (info.isDir) {
+                var size = 0;
+                var files = filelib.walk(p, "file");
+                for (var f = 0; f < files.length && f < 20000; f++) {
+                    try { size += filelib.filesize(files[f]); } catch (ex) { }
+                }
+                info.size = size;
+                info.fileCount = files.length;
+            }
+            sendJSONResp(info);
+        }
+
+    } else if (opr == "search") {
+        var root = String(param("path", "user:/"));
+        var keyword = String(param("keyword", "")).toLowerCase().trim();
+        var words = keyword.split(/\s+/).filter(function (w) { return w != ""; });
+        var results = [];
+        var queue = [join(root, "")];
+        var started = Date.now();
+        while (queue.length > 0 && results.length < SEARCH_LIMIT && Date.now() - started < TIME_BUDGET) {
+            var current = queue.shift();
+            var list = [];
+            try { list = filelib.readdir(current, "default"); } catch (ex) { continue; }
+            for (var s = 0; s < list.length; s++) {
+                var item = list[s];
+                var lower = item.Filename.toLowerCase();
+                var match = words.length > 0;
+                for (var w = 0; w < words.length; w++) {
+                    if (lower.indexOf(words[w]) < 0) {
+                        match = false;
+                        break;
+                    }
+                }
+                if (match) {
+                    results.push({ name: item.Filename, path: item.Filepath, isDir: item.IsDir, size: item.IsDir ? -1 : item.Filesize, mtime: item.Modtime });
+                }
+                if (item.IsDir) {
+                    queue.push(item.Filepath + "/");
+                }
+            }
+        }
+        sendJSONResp(results);
+
+    } else if (opr == "upload") {
+        var upDir = String(param("dir", ""));
+        var upName = String(param("name", ""));
+        var index = parseInt(param("index", "0"), 10) || 0;
+        var total = parseInt(param("total", "0"), 10) || 0;
+        var chunk = String(param("chunk", ""));
+        var upTarget = join(upDir, upName);
+        if (!validName(upName)) {
+            fail("Filename contains illegal characters");
+        } else if (total > UPLOAD_LIMIT) {
+            fail("Files larger than 24 MB need the File Manager permission to upload");
+        } else if (index == 0) {
+            if (exists(upTarget)) {
+                upTarget = join(upDir, freeName(upDir, upName));
+            }
+            writeHex(upTarget, chunk);
+            sendJSONResp({ ok: true, path: upTarget });
+        } else {
+            //Later chunks name the file the first chunk created
+            upTarget = String(param("target", upTarget));
+            var existing = readHex(upTarget);
+            writeHex(upTarget, String(existing || "") + chunk);
+            sendJSONResp({ ok: true, path: upTarget });
+        }
+
+    } else if (opr == "thumb") {
+        requirelib("imagelib");
+        var tp = String(param("path", ""));
+        var b64 = "";
+        try { b64 = imagelib.loadThumbString(tp); } catch (ex) { b64 = ""; }
+        if (b64 && b64 !== "" && b64 !== false) {
+            sendJSONResp({ data: "data:image/jpeg;base64," + b64 });
+        } else {
+            var resized = "";
+            try { resized = imagelib.resizeImageBase64(tp, 256, 256, "jpeg"); } catch (ex) { resized = ""; }
+            sendJSONResp({ data: resized || "" });
+        }
+
+    } else if (opr == "share" || opr == "unshare" || opr == "shareinfo") {
+        requirelib("share");
+        var sp = String(param("path", ""));
+        if (opr == "share") {
+            var uuid = share.shareFile(sp, 0);
+            if (typeof uuid !== "string" || uuid == "") {
+                fail(uuid && uuid.message ? uuid.message : "Unable to share this item");
+            } else {
+                sendJSONResp({ uuid: uuid });
+            }
+        } else if (opr == "unshare") {
+            var sid = share.getFileShareUUID(sp);
+            if (sid) {
+                share.removeShare(sid);
+            }
+            sendJSONResp({ ok: true });
+        } else {
+            var isShared = share.fileIsShared(sp);
+            sendJSONResp({ shared: isShared, uuid: isShared ? share.getFileShareUUID(sp) : "" });
+        }
+
+    } else {
+        fail("Unknown operation");
+    }
+} catch (err) {
+    fail(err && err.message ? err.message : err);
+}

+ 264 - 0
src/web/Drive/backend/prefs.agi

@@ -0,0 +1,264 @@
+/*
+    Drive - preferences, starred items and open history
+
+    Everything is stored per user in the "Drive" table of the system
+    database, never in the user's own files.
+
+    POST opr:
+        boot                     settings + starred + recent + roots + quota
+        save      settings=JSON  replace the settings, returns the clean copy
+        star      paths=JSON on=true|false
+        starred                  starred items with their current stats
+        recentAdd path=vpath
+        recent                   recently opened items with their stats
+        recentClear
+*/
+
+requirelib("filelib");
+
+var TABLE = "Drive";
+var MAX_STARRED = 500;
+var MAX_RECENT = 60;
+
+newDBTableIfNotExists(TABLE);
+
+//POST fields arrive as globals. The odd argument names keep eval() from
+//resolving a field named like an argument (param("name") once returned "name")
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null) {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+function readJSON(key, def) {
+    var raw = readDBItem(TABLE, USERNAME + "/" + key);
+    if (!raw) {
+        return def;
+    }
+    try {
+        return JSON.parse(raw);
+    } catch (e) {
+        return def;
+    }
+}
+
+function writeJSON(key, value) {
+    writeDBItem(TABLE, USERNAME + "/" + key, JSON.stringify(value));
+}
+
+var DEFAULTS = {
+    theme: "auto",          //auto | light | dark
+    density: "comfortable", //comfortable | compact
+    view: "list",           //list | grid
+    sortKey: "name",        //name | mtime | size | type
+    sortDir: "asc",         //asc | desc
+    foldersFirst: true,
+    showHidden: false,
+    thumbnails: true,
+    pageSize: 100,          //50 | 100 | 200 | 0 (all)
+    openIn: "drive",        //drive | tab
+    confirmTrash: false,
+    detailsOpen: false
+};
+
+function pick(value, allowed, def) {
+    return allowed.indexOf(value) >= 0 ? value : def;
+}
+
+function sanitize(input) {
+    var s = {};
+    input = input || {};
+    s.theme = pick(input.theme, ["auto", "light", "dark"], DEFAULTS.theme);
+    s.density = pick(input.density, ["comfortable", "compact"], DEFAULTS.density);
+    s.view = pick(input.view, ["list", "grid"], DEFAULTS.view);
+    s.sortKey = pick(input.sortKey, ["name", "mtime", "size", "type"], DEFAULTS.sortKey);
+    s.sortDir = pick(input.sortDir, ["asc", "desc"], DEFAULTS.sortDir);
+    s.foldersFirst = input.foldersFirst !== false;
+    s.showHidden = input.showHidden === true;
+    s.thumbnails = input.thumbnails !== false;
+    s.pageSize = pick(parseInt(input.pageSize, 10), [0, 50, 100, 200], DEFAULTS.pageSize);
+    s.openIn = pick(input.openIn, ["drive", "tab"], DEFAULTS.openIn);
+    s.confirmTrash = input.confirmTrash === true;
+    s.detailsOpen = input.detailsOpen === true;
+    return s;
+}
+
+function basename(path) {
+    var parts = String(path).replace(/\/+$/, "").split("/");
+    return parts[parts.length - 1];
+}
+
+//Current stats of a path, or null when it is gone or no longer readable
+function stat(path) {
+    try {
+        if (!filelib.fileExists(path)) {
+            return null;
+        }
+        var isDir = filelib.isDir(path);
+        return {
+            name: basename(path),
+            path: path,
+            isDir: isDir,
+            size: isDir ? -1 : filelib.filesize(path),
+            mtime: filelib.mtime(path, true)
+        };
+    } catch (e) {
+        return null;
+    }
+}
+
+function roots() {
+    var out = [];
+    try {
+        for (var i = 0; i < USER_VROOTS.length; i++) {
+            var h = USER_VROOTS[i];
+            if (h.Closed) {
+                continue;
+            }
+            out.push({
+                name: h.Name,
+                uuid: h.UUID,
+                path: h.UUID + ":/",
+                hierarchy: h.Hierarchy,
+                readonly: h.ReadOnly,
+                fs: h.Filesystem
+            });
+        }
+    } catch (e) {
+        //Fall back to the user root alone
+    }
+    if (out.length == 0) {
+        out.push({ name: "User", uuid: "user", path: "user:/", hierarchy: "user", readonly: false, fs: "" });
+    }
+    return out;
+}
+
+function groups() {
+    try {
+        var raw = JSON.parse(getUserPermissionGroup());
+        if (!(raw instanceof Array)) {
+            raw = [raw];
+        }
+        var names = [];
+        for (var i = 0; i < raw.length; i++) {
+            if (raw[i] && raw[i].Name) {
+                names.push(raw[i].Name);
+            }
+        }
+        return names;
+    } catch (e) {
+        return [];
+    }
+}
+
+function settings() {
+    return sanitize(readJSON("settings", DEFAULTS));
+}
+
+function starredPaths() {
+    var list = readJSON("starred", []);
+    return (list instanceof Array) ? list : [];
+}
+
+function recentEntries() {
+    var list = readJSON("recent", []);
+    return (list instanceof Array) ? list : [];
+}
+
+var opr = String(param("opr", "boot"));
+
+if (opr == "boot") {
+    sendJSONResp({
+        username: USERNAME,
+        isAdmin: userIsAdmin(),
+        groups: groups(),
+        settings: settings(),
+        starred: starredPaths(),
+        recent: recentEntries(),
+        roots: roots(),
+        quota: { total: USERQUOTA_TOTAL, used: USERQUOTA_USED }
+    });
+
+} else if (opr == "save") {
+    var parsed = {};
+    try {
+        parsed = JSON.parse(param("settings", "{}"));
+    } catch (e) {
+        parsed = {};
+    }
+    var clean = sanitize(parsed);
+    writeJSON("settings", clean);
+    sendJSONResp(clean);
+
+} else if (opr == "star") {
+    //Read the POST global before declaring anything: a "var paths" here would
+    //be hoisted over it and wipe the value first
+    var toStar = [];
+    try {
+        toStar = JSON.parse(param("paths", "[]"));
+    } catch (e) {
+        toStar = [];
+    }
+    var on = String(param("on", "true")) == "true";
+    var list = starredPaths();
+    for (var i = 0; i < toStar.length; i++) {
+        var p = String(toStar[i]);
+        var at = list.indexOf(p);
+        if (on && at < 0) {
+            list.unshift(p);
+        } else if (!on && at >= 0) {
+            list.splice(at, 1);
+        }
+    }
+    list = list.slice(0, MAX_STARRED);
+    writeJSON("starred", list);
+    sendJSONResp(list);
+
+} else if (opr == "starred") {
+    var items = [];
+    var missing = [];
+    var starred = starredPaths();
+    for (var j = 0; j < starred.length; j++) {
+        var s = stat(starred[j]);
+        if (s == null) {
+            missing.push(starred[j]);
+        } else {
+            items.push(s);
+        }
+    }
+    sendJSONResp({ items: items, missing: missing });
+
+} else if (opr == "recentAdd") {
+    var path = String(param("path", ""));
+    if (path != "") {
+        var recent = recentEntries().filter(function (r) { return r && r.path != path; });
+        recent.unshift({ path: path, time: Math.floor(Date.now() / 1000) });
+        writeJSON("recent", recent.slice(0, MAX_RECENT));
+    }
+    sendJSONResp({ ok: true });
+
+} else if (opr == "recent") {
+    var out = [];
+    var entries = recentEntries();
+    for (var k = 0; k < entries.length; k++) {
+        var st = stat(entries[k].path);
+        if (st != null && !st.isDir) {
+            st.opened = entries[k].time;
+            out.push(st);
+        }
+    }
+    sendJSONResp(out);
+
+} else if (opr == "recentClear") {
+    writeJSON("recent", []);
+    sendJSONResp({ ok: true });
+
+} else {
+    sendJSONResp({ error: "Unknown operation" });
+}

+ 137 - 0
src/web/Drive/backend/scan.agi

@@ -0,0 +1,137 @@
+/*
+    Drive - recent and category scans
+
+    Walks a root breadth first with filelib.readdir, which hands back the
+    size and modification time of every entry in one call per directory,
+    and returns the newest matching files.
+
+    POST
+        mode     recent | ext
+        root     virtual path to scan, defaults to user:/
+        exts     JSON array of extensions to include (mode ext)
+        exclude  JSON array of extensions to leave out (mode ext, used for
+                 the "Others" category together with an empty exts)
+        days     how far back "recent" looks, defaults to 30
+        limit    maximum number of results, defaults to 300
+
+    Response: {items: [...], truncated: bool, total: number}
+*/
+
+requirelib("filelib");
+
+var MAX_DIRS = 20000;
+var TIME_BUDGET = 12000; //ms
+
+//POST fields arrive as globals. The odd argument names keep eval() from
+//resolving a field named like an argument (param("name") once returned "name")
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null || __value === "") {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+function parseList(raw) {
+    try {
+        var arr = JSON.parse(raw);
+        var set = {};
+        for (var i = 0; i < arr.length; i++) {
+            set[String(arr[i]).toLowerCase()] = true;
+        }
+        return set;
+    } catch (e) {
+        return {};
+    }
+}
+
+var mode = String(param("mode", "recent"));
+var root = String(param("root", "user:/"));
+var days = parseInt(param("days", "30"), 10) || 30;
+var limit = parseInt(param("limit", "300"), 10) || 300;
+var include = parseList(param("exts", "[]"));
+var exclude = parseList(param("exclude", "[]"));
+var includeAll = Object.keys(include).length == 0;
+
+var since = Math.floor(Date.now() / 1000) - days * 86400;
+var started = Date.now();
+var truncated = false;
+var results = [];
+
+function extOf(name) {
+    var dot = name.lastIndexOf(".");
+    if (dot <= 0) {
+        return "";
+    }
+    return name.substring(dot + 1).toLowerCase();
+}
+
+function wanted(entry) {
+    if (mode == "recent") {
+        return entry.Modtime >= since;
+    }
+    var ext = extOf(entry.Filename);
+    if (exclude[ext]) {
+        return false;
+    }
+    return includeAll || include[ext] === true;
+}
+
+if (root.substring(root.length - 1) != "/") {
+    root = root + "/";
+}
+
+var queue = [root];
+var visited = 0;
+while (queue.length > 0) {
+    if (visited >= MAX_DIRS || Date.now() - started > TIME_BUDGET) {
+        truncated = true;
+        break;
+    }
+    var dir = queue.shift();
+    visited++;
+    var entries = [];
+    try {
+        entries = filelib.readdir(dir, "default");
+    } catch (e) {
+        continue;
+    }
+    if (!(entries instanceof Array)) {
+        continue;
+    }
+    for (var i = 0; i < entries.length; i++) {
+        var entry = entries[i];
+        if (entry.Filename.charAt(0) == ".") {
+            continue;
+        }
+        if (entry.IsDir) {
+            queue.push(entry.Filepath + "/");
+            continue;
+        }
+        if (entry.Filename.substring(entry.Filename.length - 9) == ".shortcut") {
+            continue;
+        }
+        if (wanted(entry)) {
+            results.push({
+                name: entry.Filename,
+                path: entry.Filepath,
+                isDir: false,
+                size: entry.Filesize,
+                mtime: entry.Modtime
+            });
+        }
+    }
+}
+
+results.sort(function (a, b) { return b.mtime - a.mtime; });
+var total = results.length;
+if (total > limit) {
+    results = results.slice(0, limit);
+}
+
+//truncated: the walk stopped early; total: matches found before the limit cut
+sendJSONResp({ items: results, truncated: truncated, total: total });

+ 3704 - 0
src/web/Drive/css/drive.css

@@ -0,0 +1,3704 @@
+/*
+    drive.css
+
+    Layout and theme of the Drive interface module. Colours are tokens on
+    :root (light) and html[data-theme="dark"]; every component reads them,
+    so the two themes never diverge.
+
+    Breakpoints
+        1100  details panel floats over the list instead of sitting beside it
+        (the list columns follow the width of the list itself through
+        container queries, see "Columns follow the width of the list")
+        1024  sidebar becomes an off-canvas drawer
+         760  toolbar collapses to icons, Type column hides, search folds away
+         600  phone layout: one line rows with meta under the name
+*/
+
+:root {
+    --bg: #f3f6fb;
+    --surface: #ffffff;
+    --surface-2: #f7f9fc;
+    --surface-3: #eef2f8;
+    --hover: #f2f5fa;
+    --border: #e4e9f1;
+    --border-soft: #edf1f6;
+    --text: #1d2433;
+    --text-2: #566072;
+    --text-3: #8a94a6;
+    --accent: #2f6ff0;
+    --accent-hover: #255fd6;
+    --accent-soft: #e7efff;
+    --accent-softer: #f1f6ff;
+    --accent-text: #1f5fe0;
+    --on-accent: #ffffff;
+    --danger: #e5484d;
+    --danger-soft: #fdecec;
+    --success: #16a34a;
+    --success-soft: #e7f6ec;
+    --warning: #d97706;
+    --warning-soft: #fdf3e2;
+    --scrim: rgba(15, 23, 42, .38);
+    --shadow-sm: 0 1px 2px rgba(16, 24, 40, .05);
+    --shadow: 0 1px 3px rgba(16, 24, 40, .06), 0 1px 2px rgba(16, 24, 40, .04);
+    --shadow-lg: 0 12px 32px rgba(16, 24, 40, .14), 0 2px 6px rgba(16, 24, 40, .06);
+    --focus: 0 0 0 3px rgba(47, 111, 240, .25);
+    --radius: 14px;
+    --radius-sm: 9px;
+    --radius-xs: 6px;
+    --topbar-h: 66px;
+    --sidebar-w: 252px;
+    --row-h: 50px;
+    --details-w: 360px;
+    --font: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Arial, "Noto Sans",
+        "PingFang TC", "Microsoft JhengHei", "Hiragino Sans", "Malgun Gothic", sans-serif;
+    color-scheme: light;
+}
+
+html[data-theme="dark"] {
+    --bg: #0e1117;
+    --surface: #161a22;
+    --surface-2: #1b2029;
+    --surface-3: #222833;
+    --hover: #1e242e;
+    --border: #2a313d;
+    --border-soft: #232a35;
+    --text: #e6e9ef;
+    --text-2: #a6aebc;
+    --text-3: #778194;
+    --accent: #4b8bff;
+    --accent-hover: #6a9eff;
+    --accent-soft: rgba(75, 139, 255, .16);
+    --accent-softer: rgba(75, 139, 255, .09);
+    --accent-text: #8fb5ff;
+    --danger: #f06367;
+    --danger-soft: rgba(240, 99, 103, .14);
+    --success: #34c46a;
+    --success-soft: rgba(52, 196, 106, .14);
+    --warning: #f0a33a;
+    --warning-soft: rgba(240, 163, 58, .14);
+    --scrim: rgba(0, 0, 0, .55);
+    --shadow-sm: 0 1px 2px rgba(0, 0, 0, .3);
+    --shadow: 0 1px 3px rgba(0, 0, 0, .35);
+    --shadow-lg: 0 16px 40px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .3);
+    --focus: 0 0 0 3px rgba(75, 139, 255, .35);
+    color-scheme: dark;
+}
+
+html.density-compact {
+    --row-h: 40px;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Base                                                            */
+/* ---------------------------------------------------------------- */
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+html,
+body {
+    height: 100%;
+    margin: 0;
+}
+
+body {
+    font-family: var(--font);
+    font-size: 14px;
+    line-height: 1.45;
+    color: var(--text);
+    background: var(--bg);
+    -webkit-font-smoothing: antialiased;
+    overflow: hidden;
+}
+
+button,
+input,
+select,
+textarea {
+    font: inherit;
+    color: inherit;
+}
+
+button {
+    cursor: pointer;
+    background: none;
+    border: 0;
+    padding: 0;
+}
+
+a {
+    color: var(--accent-text);
+    text-decoration: none;
+}
+
+[hidden] {
+    display: none !important;
+}
+
+.ic {
+    width: 1.15em;
+    height: 1.15em;
+    flex: none;
+    display: inline-block;
+    vertical-align: middle;
+}
+
+.ft {
+    width: 100%;
+    height: 100%;
+    display: block;
+}
+
+::selection {
+    background: var(--accent-soft);
+}
+
+.scroll-y {
+    overflow-y: auto;
+    scrollbar-width: thin;
+    scrollbar-color: var(--border) transparent;
+}
+
+.scroll-y::-webkit-scrollbar {
+    width: 10px;
+    height: 10px;
+}
+
+.scroll-y::-webkit-scrollbar-thumb {
+    background: var(--border);
+    border-radius: 10px;
+    border: 3px solid transparent;
+    background-clip: padding-box;
+}
+
+.sr-only {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    overflow: hidden;
+    clip: rect(0 0 0 0);
+    white-space: nowrap;
+}
+
+.muted {
+    color: var(--text-3);
+}
+
+.mono {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Buttons and inputs                                              */
+/* ---------------------------------------------------------------- */
+.btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 7px;
+    height: 36px;
+    padding: 0 14px;
+    border-radius: var(--radius-sm);
+    border: 1px solid var(--border);
+    background: var(--surface);
+    color: var(--text);
+    font-weight: 500;
+    white-space: nowrap;
+    transition: background .12s, border-color .12s, box-shadow .12s, color .12s;
+    box-shadow: var(--shadow-sm);
+}
+
+.btn:hover {
+    background: var(--hover);
+}
+
+.btn:focus-visible,
+.icon-btn:focus-visible,
+.nav-item:focus-visible {
+    outline: none;
+    box-shadow: var(--focus);
+}
+
+.btn:disabled,
+.btn.disabled {
+    opacity: .45;
+    pointer-events: none;
+}
+
+.btn .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.btn.primary {
+    background: var(--accent);
+    border-color: var(--accent);
+    color: var(--on-accent);
+    box-shadow: 0 1px 2px rgba(47, 111, 240, .3);
+}
+
+.btn.primary:hover {
+    background: var(--accent-hover);
+    border-color: var(--accent-hover);
+}
+
+.btn.danger {
+    background: var(--danger);
+    border-color: var(--danger);
+    color: #fff;
+}
+
+.btn.ghost {
+    border-color: transparent;
+    background: transparent;
+    box-shadow: none;
+}
+
+.btn.ghost:hover {
+    background: var(--hover);
+}
+
+.btn.small {
+    height: 30px;
+    padding: 0 10px;
+    font-size: 13px;
+}
+
+.btn .caret {
+    width: 15px;
+    height: 15px;
+    margin-left: 2px;
+    opacity: .85;
+}
+
+.btn.split-main {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+}
+
+.icon-btn {
+    width: 38px;
+    height: 38px;
+    border-radius: 10px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--text-2);
+    position: relative;
+    transition: background .12s, color .12s;
+    flex: none;
+}
+
+.icon-btn:hover {
+    background: var(--hover);
+    color: var(--text);
+}
+
+.icon-btn.active {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.icon-btn .ic {
+    width: 20px;
+    height: 20px;
+}
+
+.icon-btn.small {
+    width: 30px;
+    height: 30px;
+    border-radius: 8px;
+}
+
+.icon-btn.small .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.badge {
+    position: absolute;
+    top: 5px;
+    right: 5px;
+    min-width: 16px;
+    height: 16px;
+    padding: 0 4px;
+    border-radius: 8px;
+    background: var(--danger);
+    color: #fff;
+    font-size: 10px;
+    font-weight: 700;
+    line-height: 16px;
+    text-align: center;
+    border: 2px solid var(--surface);
+    box-sizing: content-box;
+}
+
+.field {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    margin-bottom: 14px;
+}
+
+.field>label,
+.field-label {
+    font-size: 12.5px;
+    font-weight: 600;
+    color: var(--text-2);
+}
+
+.input,
+.select,
+textarea.input {
+    width: 100%;
+    height: 38px;
+    padding: 0 12px;
+    border-radius: var(--radius-sm);
+    border: 1px solid var(--border);
+    background: var(--surface);
+    color: var(--text);
+    outline: none;
+    transition: border-color .12s, box-shadow .12s;
+}
+
+textarea.input {
+    height: auto;
+    padding: 10px 12px;
+    resize: vertical;
+}
+
+.input:focus,
+.select:focus {
+    border-color: var(--accent);
+    box-shadow: var(--focus);
+}
+
+.select {
+    appearance: none;
+    -webkit-appearance: none;
+    padding-right: 34px;
+    background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a94a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+    background-repeat: no-repeat;
+    background-position: right 10px center;
+    background-size: 16px;
+}
+
+.check {
+    appearance: none;
+    -webkit-appearance: none;
+    width: 17px;
+    height: 17px;
+    border-radius: 5px;
+    border: 1.6px solid #c4ccd8;
+    background: var(--surface);
+    display: inline-grid;
+    place-content: center;
+    cursor: pointer;
+    margin: 0;
+    flex: none;
+    transition: background .1s, border-color .1s;
+}
+
+html[data-theme="dark"] .check {
+    border-color: #4a5363;
+}
+
+.check:checked,
+.check:indeterminate {
+    background: var(--accent);
+    border-color: var(--accent);
+}
+
+.check:checked::after {
+    content: "";
+    width: 9px;
+    height: 5px;
+    border: 2px solid #fff;
+    border-top: 0;
+    border-right: 0;
+    transform: rotate(-45deg) translate(1px, -1px);
+}
+
+.check:indeterminate::after {
+    content: "";
+    width: 9px;
+    height: 2px;
+    background: #fff;
+    border-radius: 1px;
+}
+
+.switch {
+    position: relative;
+    width: 38px;
+    height: 22px;
+    flex: none;
+}
+
+.switch input {
+    opacity: 0;
+    width: 0;
+    height: 0;
+    position: absolute;
+}
+
+.switch span {
+    position: absolute;
+    inset: 0;
+    border-radius: 22px;
+    background: #cdd4df;
+    transition: background .15s;
+    cursor: pointer;
+}
+
+html[data-theme="dark"] .switch span {
+    background: #3b4352;
+}
+
+.switch span::after {
+    content: "";
+    position: absolute;
+    left: 3px;
+    top: 3px;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    background: #fff;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
+    transition: transform .15s;
+}
+
+.switch input:checked+span {
+    background: var(--accent);
+}
+
+.switch input:checked+span::after {
+    transform: translateX(16px);
+}
+
+.switch input:focus-visible+span {
+    box-shadow: var(--focus);
+}
+
+.segmented {
+    display: inline-flex;
+    padding: 3px;
+    gap: 2px;
+    border-radius: 11px;
+    background: var(--surface-3);
+}
+
+.segmented button {
+    height: 30px;
+    min-width: 34px;
+    padding: 0 8px;
+    border-radius: 8px;
+    color: var(--text-2);
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+}
+
+.segmented button .ic {
+    width: 18px;
+    height: 18px;
+}
+
+.segmented button.active {
+    background: var(--surface);
+    color: var(--accent-text);
+    box-shadow: var(--shadow);
+}
+
+html[data-theme="dark"] .segmented button.active {
+    background: var(--surface-2);
+}
+
+.chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    height: 22px;
+    padding: 0 8px;
+    border-radius: 11px;
+    font-size: 11.5px;
+    font-weight: 600;
+    background: var(--surface-3);
+    color: var(--text-2);
+    white-space: nowrap;
+}
+
+.chip .ic {
+    width: 13px;
+    height: 13px;
+}
+
+.chip.accent {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.chip.success {
+    background: var(--success-soft);
+    color: var(--success);
+}
+
+.chip.danger {
+    background: var(--danger-soft);
+    color: var(--danger);
+}
+
+.chip.warning {
+    background: var(--warning-soft);
+    color: var(--warning);
+}
+
+.spinner {
+    width: 22px;
+    height: 22px;
+    border-radius: 50%;
+    border: 2.5px solid var(--border);
+    border-top-color: var(--accent);
+    animation: spin .7s linear infinite;
+    flex: none;
+}
+
+.spinner.small {
+    width: 15px;
+    height: 15px;
+    border-width: 2px;
+}
+
+@keyframes spin {
+    to {
+        transform: rotate(360deg);
+    }
+}
+
+/* ---------------------------------------------------------------- */
+/*  Shell                                                           */
+/* ---------------------------------------------------------------- */
+.app {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+}
+
+.topbar {
+    height: var(--topbar-h);
+    flex: none;
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    padding: 0 18px 0 20px;
+    position: relative;
+    z-index: 30;
+}
+
+.brand {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    color: var(--text);
+    width: calc(var(--sidebar-w) - 20px);
+    flex: none;
+    min-width: 0;
+}
+
+.brand .logo {
+    flex: none;
+}
+
+.brand-name {
+    font-weight: 700;
+    font-size: 17px;
+    letter-spacing: -.2px;
+}
+
+.brand-sep {
+    width: 1px;
+    height: 18px;
+    background: var(--border);
+    margin: 0 4px;
+}
+
+.brand-app {
+    font-size: 16px;
+    color: var(--text-2);
+    font-weight: 500;
+}
+
+.search {
+    flex: 1;
+    max-width: 640px;
+    height: 44px;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 0 8px 0 14px;
+    border-radius: 12px;
+    background: var(--surface);
+    border: 1px solid var(--border);
+    box-shadow: var(--shadow-sm);
+    transition: box-shadow .15s, border-color .15s;
+    position: relative;
+}
+
+.search:focus-within {
+    border-color: var(--accent);
+    box-shadow: var(--focus);
+}
+
+.search>.ic {
+    color: var(--text-3);
+    width: 19px;
+    height: 19px;
+}
+
+.search input {
+    flex: 1;
+    min-width: 0;
+    height: 100%;
+    border: 0;
+    outline: none;
+    background: transparent;
+}
+
+.search input::placeholder {
+    color: var(--text-3);
+}
+
+.search .scope {
+    height: 30px;
+    padding: 0 10px;
+    border-radius: 8px;
+    background: var(--surface-3);
+    color: var(--text-2);
+    font-size: 12.5px;
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    white-space: nowrap;
+}
+
+.search .scope .ic {
+    width: 14px;
+    height: 14px;
+}
+
+.top-actions {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.apps-pill {
+    height: 34px;
+    padding: 0 12px;
+    border-radius: 17px;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    font-weight: 600;
+    font-size: 13px;
+    display: inline-flex;
+    align-items: center;
+    gap: 7px;
+}
+
+.apps-pill .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.user-chip {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 4px 8px 4px 4px;
+    border-radius: 12px;
+    margin-left: 6px;
+    min-width: 0;
+    text-align: left;
+}
+
+.user-chip:hover {
+    background: var(--hover);
+}
+
+.avatar {
+    width: 38px;
+    height: 38px;
+    border-radius: 50%;
+    object-fit: cover;
+    flex: none;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-weight: 700;
+    position: relative;
+}
+
+.avatar-wrap {
+    position: relative;
+    flex: none;
+}
+
+.avatar-wrap::after {
+    content: "";
+    position: absolute;
+    right: 1px;
+    bottom: 1px;
+    width: 9px;
+    height: 9px;
+    border-radius: 50%;
+    background: var(--success);
+    border: 2px solid var(--bg);
+}
+
+.user-text {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    line-height: 1.25;
+}
+
+.user-text b {
+    font-weight: 600;
+    font-size: 14px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 160px;
+}
+
+.user-text small {
+    font-size: 12px;
+    color: var(--text-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 160px;
+}
+
+.user-chip>.ic {
+    color: var(--text-3);
+    width: 16px;
+    height: 16px;
+}
+
+.shell {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    position: relative;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Sidebar                                                         */
+/* ---------------------------------------------------------------- */
+.sidebar {
+    width: var(--sidebar-w);
+    flex: none;
+    display: flex;
+    flex-direction: column;
+    padding: 6px 14px 14px 14px;
+    min-height: 0;
+}
+
+.sidebar-scroll {
+    flex: 1;
+    min-height: 0;
+    padding-right: 2px;
+}
+
+.nav-group {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+}
+
+.nav-title {
+    font-size: 13.5px;
+    font-weight: 600;
+    color: var(--text-2);
+    padding: 14px 12px 6px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+
+.nav-sep {
+    height: 1px;
+    background: var(--border);
+    margin: 10px 8px 0;
+}
+
+.nav-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    height: 38px;
+    padding: 0 12px;
+    border-radius: 10px;
+    color: var(--text-2);
+    font-weight: 500;
+    width: 100%;
+    text-align: left;
+    position: relative;
+    transition: background .12s, color .12s;
+}
+
+.nav-item .ic {
+    width: 19px;
+    height: 19px;
+    flex: none;
+}
+
+.nav-item span {
+    flex: 1;
+    min-width: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.nav-item small {
+    font-size: 11px;
+    color: var(--text-3);
+}
+
+.nav-item:hover {
+    background: var(--hover);
+    color: var(--text);
+}
+
+.nav-item.active {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.nav-item.drop-target {
+    background: var(--accent-soft);
+    box-shadow: inset 0 0 0 1.5px var(--accent);
+}
+
+.storage {
+    flex: none;
+    padding: 14px 12px 4px;
+    border-top: 1px solid var(--border);
+    margin-top: 10px;
+}
+
+.storage-head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    font-weight: 600;
+    font-size: 13px;
+    color: var(--text);
+}
+
+.storage-head .ic {
+    width: 19px;
+    height: 19px;
+    color: var(--accent-text);
+}
+
+.meter {
+    height: 6px;
+    border-radius: 3px;
+    background: var(--surface-3);
+    overflow: hidden;
+    margin: 9px 0 6px 29px;
+}
+
+.meter i {
+    display: block;
+    height: 100%;
+    border-radius: 3px;
+    background: var(--accent);
+    width: 0;
+    transition: width .4s;
+}
+
+.meter i.warn {
+    background: var(--warning);
+}
+
+.meter i.full {
+    background: var(--danger);
+}
+
+.storage-text {
+    font-size: 12px;
+    color: var(--text-3);
+    padding-left: 29px;
+}
+
+.scrim {
+    display: none;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Main card                                                       */
+/* ---------------------------------------------------------------- */
+.main {
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    padding: 0 18px 18px 4px;
+    position: relative;
+    display: flex;
+}
+
+.card {
+    flex: 1;
+    min-width: 0;
+    background: var(--surface);
+    border: 1px solid var(--border);
+    border-radius: var(--radius);
+    box-shadow: var(--shadow);
+    display: flex;
+    overflow: hidden;
+    position: relative;
+}
+
+.pane {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+    container-type: inline-size;
+    container-name: pane;
+}
+
+.pane-head {
+    padding: 20px 24px 0;
+    flex: none;
+}
+
+.title-row {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    min-height: 44px;
+}
+
+.title-block {
+    flex: 1;
+    min-width: 0;
+}
+
+.crumbs {
+    display: flex;
+    align-items: center;
+    flex-wrap: nowrap;
+    gap: 2px;
+    min-width: 0;
+    overflow: hidden;
+}
+
+.crumb {
+    font-size: 21px;
+    font-weight: 600;
+    color: var(--text-2);
+    padding: 2px 6px;
+    border-radius: 8px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 260px;
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    letter-spacing: -.2px;
+}
+
+.crumb:first-child {
+    margin-left: -6px;
+}
+
+.crumb:hover {
+    background: var(--hover);
+    color: var(--text);
+}
+
+.crumb.current {
+    color: var(--text);
+}
+
+.crumb .ic {
+    width: 16px;
+    height: 16px;
+}
+
+.crumb.drop-target {
+    background: var(--accent-soft);
+    box-shadow: inset 0 0 0 1.5px var(--accent);
+}
+
+.crumb-sep {
+    color: var(--text-3);
+    display: inline-flex;
+    flex: none;
+}
+
+.crumb-sep .ic {
+    width: 18px;
+    height: 18px;
+}
+
+.crumb-more {
+    font-size: 19px;
+}
+
+.subtitle {
+    font-size: 12.5px;
+    color: var(--text-3);
+    margin-top: 2px;
+    min-height: 18px;
+}
+
+.view-tools {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex: none;
+}
+
+.toolbar {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-top: 14px;
+    flex-wrap: wrap;
+}
+
+.toolbar .spacer {
+    flex: 1;
+}
+
+.select-bar {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 36px;
+    padding: 0 6px 0 12px;
+    border-radius: var(--radius-sm);
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    font-weight: 600;
+    font-size: 13px;
+}
+
+.banner {
+    margin: 14px 24px 0;
+    padding: 10px 14px;
+    border-radius: var(--radius-sm);
+    background: var(--warning-soft);
+    color: var(--warning);
+    font-size: 13px;
+    display: flex;
+    gap: 10px;
+    align-items: center;
+}
+
+.banner .ic {
+    width: 18px;
+    height: 18px;
+}
+
+.banner.info {
+    background: var(--accent-softer);
+    color: var(--accent-text);
+}
+
+/* ---------------------------------------------------------------- */
+/*  File list                                                       */
+/* ---------------------------------------------------------------- */
+.list-wrap {
+    flex: 1;
+    min-height: 0;
+    padding: 12px 24px 0;
+    position: relative;
+    outline: none;
+}
+
+.list-head,
+.row {
+    display: grid;
+    grid-template-columns: 34px minmax(0, 1fr) 168px 108px 128px 40px;
+    align-items: center;
+    column-gap: 10px;
+    padding: 0 8px 0 12px;
+}
+
+.list-head {
+    height: 40px;
+    font-size: 12.5px;
+    font-weight: 600;
+    color: var(--text-2);
+    border-bottom: 1px solid var(--border);
+    position: sticky;
+    top: 0;
+    background: var(--surface);
+    z-index: 2;
+}
+
+.list-head .col {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    cursor: pointer;
+    user-select: none;
+    white-space: nowrap;
+    border-radius: 6px;
+    padding: 4px 4px;
+    margin-left: -4px;
+    width: max-content;
+}
+
+.list-head .col:hover {
+    color: var(--text);
+    background: var(--hover);
+}
+
+.list-head .col .ic {
+    width: 14px;
+    height: 14px;
+    opacity: 0;
+}
+
+.list-head .col.sorted .ic {
+    opacity: 1;
+}
+
+.rows {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding: 6px 0 12px;
+}
+
+.row {
+    min-height: var(--row-h);
+    border-radius: 10px;
+    border: 1px solid var(--border-soft);
+    background: var(--surface);
+    cursor: default;
+    user-select: none;
+    transition: background .08s, border-color .08s;
+    position: relative;
+}
+
+.row:hover {
+    background: var(--hover);
+}
+
+.row.selected {
+    background: var(--accent-softer);
+    border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+
+.row.focused {
+    box-shadow: inset 0 0 0 1.5px var(--accent);
+}
+
+.row.drop-target {
+    background: var(--accent-soft);
+    border-color: var(--accent);
+}
+
+.row.cut,
+.tile.cut {
+    opacity: .5;
+}
+
+.row>div:first-child,
+.list-head>div:first-child {
+    display: flex;
+    align-items: center;
+}
+
+.row .cell {
+    min-width: 0;
+    color: var(--text-2);
+    font-size: 13px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.name-cell {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    min-width: 0;
+}
+
+.thumb {
+    width: 30px;
+    height: 30px;
+    flex: none;
+    position: relative;
+    display: grid;
+    place-items: center;
+}
+
+html.density-compact .thumb {
+    width: 26px;
+    height: 26px;
+}
+
+.thumb img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    border-radius: 6px;
+    display: block;
+    background: var(--surface-3);
+}
+
+.thumb .star-mark {
+    position: absolute;
+    right: -5px;
+    bottom: -4px;
+    width: 14px;
+    height: 14px;
+    color: #f5b301;
+    background: var(--surface);
+    border-radius: 50%;
+    padding: 1px;
+}
+
+.name-text {
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    line-height: 1.3;
+}
+
+.name-text .name {
+    font-weight: 500;
+    color: var(--text);
+    font-size: 13.8px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.name-text .name>span {
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.name-text .name .ic {
+    width: 14px;
+    height: 14px;
+    color: var(--text-3);
+    flex: none;
+}
+
+.name-text .sub {
+    font-size: 12px;
+    color: var(--text-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.name-text .sub-mobile {
+    display: none;
+}
+
+.row .more-btn {
+    opacity: .75;
+}
+
+.row:hover .more-btn,
+.row.selected .more-btn {
+    opacity: 1;
+}
+
+.rename-input {
+    height: 30px;
+    width: 100%;
+    max-width: 420px;
+    padding: 0 8px;
+    border-radius: 7px;
+    border: 1.5px solid var(--accent);
+    box-shadow: var(--focus);
+    background: var(--surface);
+    outline: none;
+    font-weight: 500;
+}
+
+/* Grid view */
+.grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
+    gap: 14px;
+    padding: 6px 0 16px;
+}
+
+.tile {
+    border: 1px solid var(--border-soft);
+    border-radius: 12px;
+    background: var(--surface);
+    overflow: hidden;
+    position: relative;
+    user-select: none;
+    transition: background .1s, border-color .1s, box-shadow .1s;
+    display: flex;
+    flex-direction: column;
+}
+
+.tile:hover {
+    box-shadow: var(--shadow);
+    border-color: var(--border);
+}
+
+.tile.selected {
+    border-color: var(--accent);
+    background: var(--accent-softer);
+    box-shadow: 0 0 0 1px var(--accent);
+}
+
+.tile.focused {
+    box-shadow: 0 0 0 2px var(--accent);
+}
+
+.tile.drop-target {
+    border-color: var(--accent);
+    background: var(--accent-soft);
+}
+
+.tile-preview {
+    aspect-ratio: 4 / 3;
+    background: var(--surface-2);
+    display: grid;
+    place-items: center;
+    position: relative;
+    overflow: hidden;
+}
+
+.tile-preview .tile-icon {
+    width: 46%;
+    max-width: 78px;
+    aspect-ratio: 1;
+}
+
+.tile-preview img {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.tile .tile-check {
+    position: absolute;
+    top: 9px;
+    left: 9px;
+    opacity: 0;
+    transition: opacity .1s;
+    z-index: 2;
+}
+
+.tile:hover .tile-check,
+.tile.selected .tile-check,
+.grid.has-selection .tile-check {
+    opacity: 1;
+}
+
+.tile .tile-star {
+    position: absolute;
+    top: 9px;
+    right: 9px;
+    color: #f5b301;
+    z-index: 2;
+    width: 18px;
+    height: 18px;
+    filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .25));
+}
+
+.tile-body {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 9px 6px 9px 11px;
+    min-width: 0;
+}
+
+.tile-body .mini {
+    width: 20px;
+    height: 20px;
+    flex: none;
+}
+
+.tile-body .name-text {
+    flex: 1;
+}
+
+.tile-body .name {
+    font-size: 13px;
+}
+
+/* Empty and loading states */
+.state {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    text-align: center;
+    padding: 60px 20px;
+    color: var(--text-3);
+    gap: 10px;
+}
+
+.state .art {
+    width: 140px;
+    height: 110px;
+    margin-bottom: 6px;
+}
+
+.state h3 {
+    margin: 0;
+    color: var(--text);
+    font-size: 17px;
+    font-weight: 600;
+}
+
+.state p {
+    margin: 0;
+    max-width: 380px;
+    font-size: 13.5px;
+}
+
+.state .btn {
+    margin-top: 8px;
+}
+
+.skeleton-row {
+    height: var(--row-h);
+    border-radius: 10px;
+    background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 40%, var(--surface-2) 80%);
+    background-size: 200% 100%;
+    animation: shimmer 1.2s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+    from {
+        background-position: 100% 0;
+    }
+
+    to {
+        background-position: -100% 0;
+    }
+}
+
+.pane-foot {
+    flex: none;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px 24px 16px;
+    font-size: 12.5px;
+    color: var(--text-3);
+    border-top: 1px solid var(--border-soft);
+}
+
+.pager {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.pager button {
+    min-width: 32px;
+    height: 32px;
+    border-radius: 8px;
+    border: 1px solid var(--border);
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--text-2);
+    font-size: 13px;
+    padding: 0 6px;
+    background: var(--surface);
+}
+
+.pager button:hover:not(:disabled) {
+    background: var(--hover);
+}
+
+.pager button.active {
+    background: var(--accent-soft);
+    border-color: transparent;
+    color: var(--accent-text);
+    font-weight: 600;
+}
+
+.pager button:disabled {
+    opacity: .4;
+    cursor: default;
+}
+
+.pager .ic {
+    width: 16px;
+    height: 16px;
+}
+
+.marquee {
+    position: absolute;
+    border: 1px solid var(--accent);
+    background: color-mix(in srgb, var(--accent) 12%, transparent);
+    border-radius: 3px;
+    pointer-events: none;
+    z-index: 5;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Details panel                                                   */
+/* ---------------------------------------------------------------- */
+.details {
+    width: var(--details-w);
+    flex: none;
+    border-left: 1px solid var(--border);
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    background: var(--surface);
+}
+
+.details-head {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    padding: 20px 18px 0 20px;
+}
+
+.details-head .big-icon {
+    width: 40px;
+    height: 40px;
+    flex: none;
+}
+
+.details-head .meta {
+    flex: 1;
+    min-width: 0;
+}
+
+.details-head .meta b {
+    display: block;
+    font-weight: 600;
+    font-size: 15px;
+    word-break: break-word;
+    line-height: 1.3;
+}
+
+.details-head .meta small {
+    color: var(--text-3);
+    font-size: 12.5px;
+}
+
+.tabs {
+    display: flex;
+    gap: 2px;
+    border-bottom: 1px solid var(--border);
+    padding: 0 12px;
+    margin-top: 16px;
+    overflow-x: auto;
+    scrollbar-width: none;
+}
+
+.tabs::-webkit-scrollbar {
+    display: none;
+}
+
+.tab {
+    padding: 10px 10px 11px;
+    color: var(--text-2);
+    font-size: 13px;
+    font-weight: 500;
+    position: relative;
+    white-space: nowrap;
+}
+
+.tab:hover {
+    color: var(--text);
+}
+
+.tab.active {
+    color: var(--accent-text);
+}
+
+.tab.active::after {
+    content: "";
+    position: absolute;
+    left: 8px;
+    right: 8px;
+    bottom: -1px;
+    height: 2.5px;
+    border-radius: 2px;
+    background: var(--accent);
+}
+
+.details-body {
+    flex: 1;
+    min-height: 0;
+    padding: 18px 20px 24px;
+}
+
+.preview-box {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    padding-bottom: 18px;
+    border-bottom: 1px solid var(--border-soft);
+}
+
+.preview-card {
+    width: 150px;
+    height: 104px;
+    border-radius: 10px;
+    background: var(--surface-2);
+    border: 1px solid var(--border-soft);
+    display: grid;
+    place-items: center;
+    overflow: hidden;
+    flex: none;
+    position: relative;
+}
+
+.preview-card img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.preview-card .ft {
+    width: 46px;
+    height: 46px;
+}
+
+.preview-box .btn {
+    flex: 1;
+    justify-content: center;
+}
+
+.section {
+    padding: 16px 0;
+    border-bottom: 1px solid var(--border-soft);
+}
+
+.section:last-child {
+    border-bottom: 0;
+}
+
+.section h4 {
+    margin: 0 0 12px;
+    font-size: 13.5px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.section h4 .ic {
+    width: 17px;
+    height: 17px;
+    color: var(--text-2);
+}
+
+.kv {
+    display: grid;
+    grid-template-columns: 92px minmax(0, 1fr);
+    row-gap: 11px;
+    column-gap: 12px;
+    font-size: 13px;
+}
+
+.kv dt {
+    color: var(--text-3);
+}
+
+.kv dd {
+    margin: 0;
+    color: var(--text);
+    min-width: 0;
+    word-break: break-word;
+}
+
+.kv dd small {
+    display: block;
+    color: var(--text-3);
+    font-size: 12px;
+}
+
+.kv .with-action {
+    display: flex;
+    align-items: flex-start;
+    gap: 6px;
+    justify-content: space-between;
+}
+
+.kv a {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+}
+
+.share-summary {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+
+.share-summary .bubble {
+    width: 34px;
+    height: 34px;
+    border-radius: 50%;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    display: grid;
+    place-items: center;
+    flex: none;
+}
+
+.share-summary .bubble .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.share-summary .txt {
+    flex: 1;
+    min-width: 0;
+    font-size: 13px;
+}
+
+.share-summary .txt small {
+    display: block;
+    color: var(--text-3);
+}
+
+.quick-actions {
+    display: flex;
+    flex-direction: column;
+}
+
+.quick-actions button {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 9px 8px;
+    margin: 0 -8px;
+    border-radius: 8px;
+    color: var(--accent-text);
+    font-size: 13.5px;
+    text-align: left;
+}
+
+.quick-actions button:hover {
+    background: var(--hover);
+}
+
+.quick-actions button span {
+    flex: 1;
+}
+
+.quick-actions button .ic {
+    width: 18px;
+    height: 18px;
+}
+
+.quick-actions button .ic:last-child {
+    color: var(--text-3);
+    width: 15px;
+    height: 15px;
+}
+
+.timeline {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    position: relative;
+}
+
+.timeline li {
+    display: flex;
+    gap: 12px;
+    padding-bottom: 18px;
+    position: relative;
+}
+
+.timeline li::before {
+    content: "";
+    position: absolute;
+    left: 15px;
+    top: 32px;
+    bottom: 2px;
+    width: 1.5px;
+    background: var(--border);
+}
+
+.timeline li:last-child::before {
+    display: none;
+}
+
+.timeline .dot {
+    width: 32px;
+    height: 32px;
+    border-radius: 50%;
+    display: grid;
+    place-items: center;
+    background: var(--surface-3);
+    color: var(--text-2);
+    flex: none;
+}
+
+.timeline .dot .ic {
+    width: 16px;
+    height: 16px;
+}
+
+.timeline .dot.accent {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.timeline .dot.success {
+    background: var(--success-soft);
+    color: var(--success);
+}
+
+.timeline .dot.warning {
+    background: var(--warning-soft);
+    color: var(--warning);
+}
+
+.timeline .what {
+    font-size: 13px;
+    color: var(--text);
+    padding-top: 2px;
+}
+
+.timeline .when {
+    font-size: 12px;
+    color: var(--text-3);
+}
+
+.list-plain {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+.item-card {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 12px;
+    border: 1px solid var(--border-soft);
+    border-radius: 10px;
+    background: var(--surface);
+}
+
+.item-card .ic-wrap {
+    width: 34px;
+    height: 34px;
+    border-radius: 9px;
+    display: grid;
+    place-items: center;
+    background: var(--surface-3);
+    color: var(--text-2);
+    flex: none;
+}
+
+.item-card .ic-wrap .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.item-card .ic-wrap.accent {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.item-card .body {
+    flex: 1;
+    min-width: 0;
+    font-size: 13px;
+}
+
+.item-card .body b {
+    font-weight: 600;
+    display: block;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.item-card .body small {
+    color: var(--text-3);
+    font-size: 12px;
+    display: block;
+}
+
+.item-card .actions {
+    display: flex;
+    gap: 2px;
+    flex: none;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Menus and popovers                                              */
+/* ---------------------------------------------------------------- */
+.menu {
+    position: fixed;
+    z-index: 200;
+    min-width: 210px;
+    max-width: 320px;
+    padding: 6px;
+    border-radius: 12px;
+    background: var(--surface);
+    border: 1px solid var(--border);
+    box-shadow: var(--shadow-lg);
+    animation: pop .12s ease-out;
+    max-height: calc(100vh - 20px);
+    overflow-y: auto;
+}
+
+@keyframes pop {
+    from {
+        opacity: 0;
+        transform: scale(.97) translateY(-3px);
+    }
+}
+
+.menu-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    width: 100%;
+    height: 36px;
+    padding: 0 10px;
+    border-radius: 8px;
+    color: var(--text);
+    font-size: 13.5px;
+    text-align: left;
+    white-space: nowrap;
+}
+
+.menu-item .ic {
+    width: 18px;
+    height: 18px;
+    color: var(--text-2);
+}
+
+.menu-item .label {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.menu-item .hint {
+    font-size: 12px;
+    color: var(--text-3);
+    margin-left: 18px;
+}
+
+.menu-item .sub-caret {
+    width: 15px;
+    height: 15px;
+    color: var(--text-3);
+}
+
+.menu-item:hover,
+.menu-item.hover {
+    background: var(--hover);
+}
+
+.menu-item.danger,
+.menu-item.danger .ic {
+    color: var(--danger);
+}
+
+.menu-item.checked .check-mark {
+    color: var(--accent-text);
+}
+
+.menu-item .check-mark {
+    width: 16px;
+    height: 16px;
+    color: transparent;
+}
+
+.menu-item:disabled {
+    opacity: .4;
+    pointer-events: none;
+}
+
+.menu-item img {
+    width: 20px;
+    height: 20px;
+    object-fit: contain;
+    flex: none;
+}
+
+.menu-sep {
+    height: 1px;
+    background: var(--border);
+    margin: 5px 4px;
+}
+
+.menu-title {
+    padding: 8px 10px 4px;
+    font-size: 11.5px;
+    font-weight: 600;
+    color: var(--text-3);
+    text-transform: uppercase;
+    letter-spacing: .4px;
+}
+
+.popover {
+    position: fixed;
+    z-index: 190;
+    width: 360px;
+    max-width: calc(100vw - 20px);
+    border-radius: 14px;
+    background: var(--surface);
+    border: 1px solid var(--border);
+    box-shadow: var(--shadow-lg);
+    animation: pop .12s ease-out;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    max-height: calc(100vh - 90px);
+}
+
+.popover-head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 14px 16px;
+    border-bottom: 1px solid var(--border);
+    font-weight: 600;
+}
+
+.popover-head .spacer {
+    flex: 1;
+}
+
+.popover-body {
+    overflow-y: auto;
+    min-height: 0;
+}
+
+.user-card {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    padding: 18px 16px;
+    border-bottom: 1px solid var(--border);
+}
+
+.user-card .avatar {
+    width: 50px;
+    height: 50px;
+    font-size: 19px;
+}
+
+.user-card b {
+    font-size: 15.5px;
+    display: block;
+}
+
+.user-card small {
+    color: var(--text-3);
+    font-size: 12.5px;
+}
+
+.notif {
+    display: flex;
+    gap: 12px;
+    padding: 12px 16px;
+    border-bottom: 1px solid var(--border-soft);
+    cursor: default;
+}
+
+.notif.clickable {
+    cursor: pointer;
+}
+
+.notif.clickable:hover {
+    background: var(--hover);
+}
+
+.notif .dot {
+    width: 34px;
+    height: 34px;
+    border-radius: 50%;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    display: grid;
+    place-items: center;
+    flex: none;
+}
+
+.notif .dot.high {
+    background: var(--danger-soft);
+    color: var(--danger);
+}
+
+.notif .dot .ic {
+    width: 17px;
+    height: 17px;
+}
+
+.notif .body {
+    min-width: 0;
+    flex: 1;
+    font-size: 13px;
+}
+
+.notif .body b {
+    display: block;
+    font-weight: 600;
+}
+
+.notif .body p {
+    margin: 2px 0 0;
+    color: var(--text-2);
+    word-break: break-word;
+}
+
+.notif .body small {
+    color: var(--text-3);
+    font-size: 11.5px;
+}
+
+.notif.unread .body b::after {
+    content: "";
+    display: inline-block;
+    width: 7px;
+    height: 7px;
+    border-radius: 50%;
+    background: var(--accent);
+    margin-left: 7px;
+    vertical-align: middle;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Modals                                                          */
+/* ---------------------------------------------------------------- */
+.modal-backdrop {
+    position: fixed;
+    inset: 0;
+    background: var(--scrim);
+    z-index: 150;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px;
+    animation: fade .15s ease-out;
+}
+
+@keyframes fade {
+    from {
+        opacity: 0;
+    }
+}
+
+.modal {
+    width: 460px;
+    max-width: 100%;
+    max-height: calc(100vh - 40px);
+    background: var(--surface);
+    border-radius: 16px;
+    box-shadow: var(--shadow-lg);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    animation: rise .18s ease-out;
+    border: 1px solid var(--border);
+}
+
+.modal.wide {
+    width: 640px;
+}
+
+.modal.xwide {
+    width: 820px;
+}
+
+@keyframes rise {
+    from {
+        opacity: 0;
+        transform: translateY(10px) scale(.98);
+    }
+}
+
+.modal-head {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 18px 20px 14px;
+}
+
+.modal-head h3 {
+    margin: 0;
+    font-size: 17px;
+    font-weight: 600;
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.modal-head .head-icon {
+    width: 36px;
+    height: 36px;
+    border-radius: 10px;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    display: grid;
+    place-items: center;
+    flex: none;
+}
+
+.modal-head .head-icon .ic {
+    width: 19px;
+    height: 19px;
+}
+
+.modal-head .head-icon.danger {
+    background: var(--danger-soft);
+    color: var(--danger);
+}
+
+.modal-head .head-icon.file {
+    background: none;
+}
+
+.modal-body {
+    padding: 4px 20px 18px;
+    overflow-y: auto;
+    min-height: 0;
+}
+
+.modal-body p {
+    margin: 0 0 12px;
+    color: var(--text-2);
+}
+
+.modal-foot {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 10px;
+    padding: 14px 20px;
+    border-top: 1px solid var(--border);
+    background: var(--surface-2);
+}
+
+.modal-foot .left {
+    margin-right: auto;
+    display: flex;
+    gap: 8px;
+    align-items: center;
+}
+
+.name-list {
+    max-height: 180px;
+    overflow-y: auto;
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    padding: 6px;
+    margin-bottom: 4px;
+}
+
+.name-list div {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 6px 8px;
+    font-size: 13px;
+}
+
+.name-list .ft {
+    width: 20px;
+    height: 20px;
+}
+
+/* Folder picker */
+.picker {
+    display: flex;
+    flex-direction: column;
+    height: min(440px, calc(100vh - 230px));
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    overflow: hidden;
+}
+
+.picker-bar {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 8px 8px;
+    border-bottom: 1px solid var(--border);
+    background: var(--surface-2);
+    min-width: 0;
+}
+
+.picker-crumbs {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 2px;
+    overflow: hidden;
+    font-size: 13px;
+}
+
+.picker-crumbs button {
+    padding: 4px 6px;
+    border-radius: 6px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 160px;
+    color: var(--text-2);
+}
+
+.picker-crumbs button:last-child {
+    color: var(--text);
+    font-weight: 600;
+}
+
+.picker-crumbs button:hover {
+    background: var(--hover);
+}
+
+.picker-crumbs .ic {
+    width: 14px;
+    height: 14px;
+    color: var(--text-3);
+    flex: none;
+}
+
+.picker-list {
+    flex: 1;
+    min-height: 0;
+    padding: 6px;
+}
+
+.picker-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    width: 100%;
+    height: 42px;
+    padding: 0 10px;
+    border-radius: 8px;
+    text-align: left;
+    color: var(--text);
+}
+
+.picker-item .ft,
+.picker-item .ic-box {
+    width: 24px;
+    height: 24px;
+    flex: none;
+    color: var(--text-2);
+}
+
+.picker-item .ic-box .ic {
+    width: 22px;
+    height: 22px;
+}
+
+.picker-item span {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.picker-item .go {
+    width: 16px;
+    height: 16px;
+    color: var(--text-3);
+}
+
+.picker-item:hover {
+    background: var(--hover);
+}
+
+.picker-item.selected {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.picker-item.disabled {
+    opacity: .45;
+    pointer-events: none;
+}
+
+/* Share dialog */
+.share-link-row {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 4px;
+}
+
+.share-link-row .input {
+    flex: 1;
+    min-width: 0;
+    background: var(--surface-2);
+    font-size: 13px;
+}
+
+.audience {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    margin-bottom: 10px;
+}
+
+.audience .bubble {
+    width: 38px;
+    height: 38px;
+    border-radius: 50%;
+    display: grid;
+    place-items: center;
+    flex: none;
+    background: var(--success-soft);
+    color: var(--success);
+}
+
+.audience .bubble.private {
+    background: var(--surface-3);
+    color: var(--text-2);
+}
+
+.audience .bubble .ic {
+    width: 19px;
+    height: 19px;
+}
+
+.audience .body {
+    flex: 1;
+    min-width: 0;
+}
+
+.audience .body b {
+    font-weight: 600;
+    font-size: 13.5px;
+    display: block;
+}
+
+.audience .body small {
+    color: var(--text-3);
+    font-size: 12px;
+    display: block;
+}
+
+.audience .body .chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 5px;
+    margin-top: 6px;
+}
+
+.share-form {
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    padding: 14px 14px 2px;
+    margin-top: 6px;
+    background: var(--surface-2);
+}
+
+.form-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 0 12px;
+}
+
+.toggle-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 14px;
+    font-size: 13.5px;
+}
+
+.toggle-row small {
+    display: block;
+    color: var(--text-3);
+    font-size: 12px;
+}
+
+.picklist {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    padding: 6px;
+    min-height: 40px;
+    border: 1px solid var(--border);
+    border-radius: var(--radius-sm);
+    background: var(--surface);
+    max-height: 132px;
+    overflow-y: auto;
+}
+
+.picklist label {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 14px;
+    background: var(--surface-3);
+    font-size: 12.5px;
+    cursor: pointer;
+}
+
+.picklist label.on {
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.picklist input {
+    display: none;
+}
+
+.qr-box {
+    display: grid;
+    place-items: center;
+    padding: 12px;
+}
+
+.qr-box canvas,
+.qr-box img {
+    background: #fff;
+    padding: 10px;
+    border-radius: 10px;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Transfers panel                                                 */
+/* ---------------------------------------------------------------- */
+.transfers {
+    position: fixed;
+    right: 22px;
+    bottom: 22px;
+    width: 380px;
+    max-width: calc(100vw - 24px);
+    background: var(--surface);
+    border-radius: 14px;
+    border: 1px solid var(--border);
+    box-shadow: var(--shadow-lg);
+    z-index: 120;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    animation: rise .18s ease-out;
+}
+
+.transfers-head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px 10px 12px 16px;
+    background: var(--surface-2);
+    border-bottom: 1px solid var(--border);
+}
+
+.transfers-head b {
+    flex: 1;
+    font-weight: 600;
+    font-size: 14px;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.transfers-head small {
+    font-weight: 400;
+    color: var(--text-3);
+    font-size: 12px;
+    display: block;
+}
+
+.transfers-list {
+    max-height: 290px;
+}
+
+.transfers.collapsed .transfers-list {
+    display: none;
+}
+
+.xfer {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 12px 10px 16px;
+    border-bottom: 1px solid var(--border-soft);
+}
+
+.xfer:last-child {
+    border-bottom: 0;
+}
+
+.xfer .ft-box {
+    width: 28px;
+    height: 28px;
+    flex: none;
+}
+
+.xfer .body {
+    flex: 1;
+    min-width: 0;
+}
+
+.xfer .body b {
+    display: block;
+    font-weight: 500;
+    font-size: 13px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.xfer .body small {
+    display: block;
+    font-size: 11.5px;
+    color: var(--text-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.xfer .body small.err {
+    color: var(--danger);
+}
+
+.xfer .bar {
+    height: 4px;
+    border-radius: 2px;
+    background: var(--surface-3);
+    margin-top: 6px;
+    overflow: hidden;
+}
+
+.xfer .bar i {
+    display: block;
+    height: 100%;
+    background: var(--accent);
+    border-radius: 2px;
+    width: 0;
+    transition: width .25s;
+}
+
+.xfer .bar.indeterminate i {
+    width: 35%;
+    animation: indet 1.1s ease-in-out infinite;
+}
+
+@keyframes indet {
+    from {
+        transform: translateX(-100%);
+    }
+
+    to {
+        transform: translateX(300%);
+    }
+}
+
+.xfer .state-ic {
+    width: 22px;
+    height: 22px;
+    flex: none;
+    display: grid;
+    place-items: center;
+}
+
+.xfer .state-ic.ok {
+    color: var(--success);
+}
+
+.xfer .state-ic.bad {
+    color: var(--danger);
+}
+
+/* ---------------------------------------------------------------- */
+/*  Toasts                                                          */
+/* ---------------------------------------------------------------- */
+.toasts {
+    position: fixed;
+    left: 50%;
+    bottom: 26px;
+    transform: translateX(-50%);
+    z-index: 300;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 8px;
+    pointer-events: none;
+    width: max-content;
+    max-width: calc(100vw - 24px);
+}
+
+.toast {
+    pointer-events: auto;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    min-height: 44px;
+    padding: 8px 10px 8px 16px;
+    border-radius: 12px;
+    background: #1f2633;
+    color: #f2f4f8;
+    box-shadow: var(--shadow-lg);
+    font-size: 13.5px;
+    animation: rise .18s ease-out;
+    max-width: 100%;
+}
+
+html[data-theme="dark"] .toast {
+    background: #2a313d;
+}
+
+.toast .ic {
+    width: 18px;
+    height: 18px;
+    flex: none;
+}
+
+.toast.error .ic {
+    color: #ff8a8d;
+}
+
+.toast.success .ic {
+    color: #5ad48a;
+}
+
+.toast .msg {
+    flex: 1;
+    min-width: 0;
+}
+
+.toast button {
+    color: #9cc0ff;
+    font-weight: 600;
+    padding: 6px 8px;
+    border-radius: 7px;
+    white-space: nowrap;
+}
+
+.toast button:hover {
+    background: rgba(255, 255, 255, .08);
+}
+
+.toast.leaving {
+    opacity: 0;
+    transform: translateY(6px);
+    transition: opacity .2s, transform .2s;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Drop overlay                                                    */
+/* ---------------------------------------------------------------- */
+.drop-overlay {
+    position: absolute;
+    inset: 0;
+    z-index: 20;
+    border: 2px dashed var(--accent);
+    border-radius: var(--radius);
+    background: color-mix(in srgb, var(--accent) 7%, var(--surface));
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+}
+
+.drop-overlay div {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 10px;
+    color: var(--accent-text);
+    font-weight: 600;
+    font-size: 15px;
+    padding: 18px 26px;
+    border-radius: 14px;
+    background: var(--surface);
+    box-shadow: var(--shadow-lg);
+}
+
+.drop-overlay .ic {
+    width: 34px;
+    height: 34px;
+}
+
+.drag-ghost {
+    position: fixed;
+    top: -100px;
+    left: -100px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 7px 12px;
+    border-radius: 10px;
+    background: var(--accent);
+    color: #fff;
+    font-weight: 600;
+    font-size: 13px;
+    box-shadow: var(--shadow-lg);
+    z-index: 999;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Preview                                                         */
+/* ---------------------------------------------------------------- */
+.preview {
+    position: fixed;
+    inset: 0;
+    z-index: 160;
+    background: rgba(10, 12, 16, .94);
+    color: #eef1f6;
+    display: flex;
+    flex-direction: column;
+    animation: fade .15s ease-out;
+}
+
+.preview-bar {
+    height: 60px;
+    flex: none;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 0 12px 0 16px;
+}
+
+.preview-bar .ft-box {
+    width: 28px;
+    height: 28px;
+    flex: none;
+}
+
+.preview-bar .title {
+    flex: 1;
+    min-width: 0;
+    line-height: 1.3;
+}
+
+.preview-bar .title b {
+    display: block;
+    font-weight: 600;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.preview-bar .title small {
+    color: #9aa3b2;
+    font-size: 12px;
+}
+
+.preview .icon-btn {
+    color: #d5dae3;
+}
+
+.preview .icon-btn:hover {
+    background: rgba(255, 255, 255, .1);
+    color: #fff;
+}
+
+.preview .btn {
+    background: rgba(255, 255, 255, .08);
+    border-color: rgba(255, 255, 255, .14);
+    color: #fff;
+    box-shadow: none;
+}
+
+.preview .btn:hover {
+    background: rgba(255, 255, 255, .16);
+}
+
+.preview-stage {
+    flex: 1;
+    min-height: 0;
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 8px 70px 24px;
+    overflow: hidden;
+}
+
+.preview-stage img {
+    max-width: 100%;
+    max-height: 100%;
+    object-fit: contain;
+    transition: transform .2s;
+    box-shadow: 0 8px 40px rgba(0, 0, 0, .5);
+}
+
+.preview-stage video {
+    max-width: 100%;
+    max-height: 100%;
+    background: #000;
+    border-radius: 8px;
+}
+
+.preview-stage audio {
+    width: min(520px, 100%);
+}
+
+.preview-stage iframe {
+    width: 100%;
+    height: 100%;
+    border: 0;
+    border-radius: 8px;
+    background: #fff;
+}
+
+.preview-stage pre {
+    width: min(980px, 100%);
+    height: 100%;
+    margin: 0;
+    overflow: auto;
+    padding: 20px 24px;
+    border-radius: 10px;
+    background: #f8f9fb;
+    color: #1d2433;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 13px;
+    line-height: 1.55;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+.preview-none {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 14px;
+    text-align: center;
+    color: #c4cad6;
+}
+
+.preview-none .big {
+    width: 96px;
+    height: 96px;
+}
+
+.preview-nav {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 48px;
+    height: 48px;
+    border-radius: 50%;
+    background: rgba(255, 255, 255, .1);
+    color: #fff;
+    display: grid;
+    place-items: center;
+}
+
+.preview-nav:hover {
+    background: rgba(255, 255, 255, .2);
+}
+
+.preview-nav.prev {
+    left: 14px;
+}
+
+.preview-nav.next {
+    right: 14px;
+}
+
+.preview-nav .ic {
+    width: 24px;
+    height: 24px;
+}
+
+/* ---------------------------------------------------------------- */
+/*  App host (in viewport windows)                                  */
+/* ---------------------------------------------------------------- */
+.apphost {
+    position: absolute;
+    inset: 0 18px 18px 4px;
+    z-index: 25;
+    background: var(--surface);
+    border: 1px solid var(--border);
+    border-radius: var(--radius);
+    box-shadow: var(--shadow);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    animation: rise .16s ease-out;
+}
+
+.apphost-bar {
+    height: 48px;
+    flex: none;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 0 8px;
+    border-bottom: 1px solid var(--border);
+    background: var(--surface-2);
+}
+
+.apphost-tabs {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    overflow-x: auto;
+    scrollbar-width: none;
+}
+
+.apphost-tabs::-webkit-scrollbar {
+    display: none;
+}
+
+.app-tab {
+    height: 34px;
+    max-width: 220px;
+    min-width: 0;
+    flex: none;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 0 6px 0 10px;
+    border-radius: 9px;
+    color: var(--text-2);
+    font-size: 13px;
+    cursor: pointer;
+    user-select: none;
+}
+
+.app-tab:hover {
+    background: var(--hover);
+}
+
+.app-tab.active {
+    background: var(--surface);
+    color: var(--text);
+    box-shadow: var(--shadow);
+    font-weight: 500;
+}
+
+.app-tab img {
+    width: 18px;
+    height: 18px;
+    object-fit: contain;
+    flex: none;
+}
+
+.app-tab .t {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.app-tab .x {
+    width: 22px;
+    height: 22px;
+    border-radius: 6px;
+    display: grid;
+    place-items: center;
+    color: var(--text-3);
+    flex: none;
+}
+
+.app-tab .x:hover {
+    background: var(--surface-3);
+    color: var(--text);
+}
+
+.app-tab .x .ic {
+    width: 14px;
+    height: 14px;
+}
+
+.apphost-body {
+    flex: 1;
+    min-height: 0;
+    position: relative;
+    background: var(--surface);
+}
+
+.apphost-body .floatWindowWrapper {
+    position: absolute;
+    inset: 0;
+    display: none;
+}
+
+.apphost-body .floatWindowWrapper.on {
+    display: block;
+}
+
+.apphost-body .floatWindow,
+.apphost-body .fwtab {
+    width: 100%;
+    height: 100%;
+}
+
+.apphost-body iframe {
+    width: 100%;
+    height: 100%;
+    border: 0;
+    display: block;
+    background: var(--surface);
+}
+
+.apphost-loading {
+    position: absolute;
+    inset: 0;
+    display: grid;
+    place-items: center;
+    background: var(--surface);
+    pointer-events: none;
+    transition: opacity .2s;
+}
+
+.apphost-loading.done {
+    opacity: 0;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Settings page                                                   */
+/* ---------------------------------------------------------------- */
+.list-wrap.is-settings {
+    padding: 0;
+}
+
+.settings {
+    padding: 6px 24px 30px;
+}
+
+.settings-inner {
+    max-width: 780px;
+}
+
+.set-group {
+    border: 1px solid var(--border);
+    border-radius: 14px;
+    margin-top: 18px;
+    overflow: hidden;
+}
+
+.set-group-head {
+    padding: 14px 18px;
+    border-bottom: 1px solid var(--border);
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    background: var(--surface-2);
+}
+
+.set-group-head .ic {
+    width: 19px;
+    height: 19px;
+    color: var(--accent-text);
+}
+
+.set-group-head b {
+    font-weight: 600;
+    font-size: 14.5px;
+}
+
+.set-row {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    padding: 14px 18px;
+    border-bottom: 1px solid var(--border-soft);
+}
+
+.set-row:last-child {
+    border-bottom: 0;
+}
+
+.set-row .txt {
+    flex: 1;
+    min-width: 0;
+}
+
+.set-row .txt b {
+    font-weight: 500;
+    display: block;
+    font-size: 13.8px;
+}
+
+.set-row .txt small {
+    color: var(--text-3);
+    font-size: 12.5px;
+    display: block;
+}
+
+.set-row .select {
+    width: 220px;
+}
+
+.theme-cards {
+    display: flex;
+    gap: 10px;
+    flex-wrap: wrap;
+}
+
+.theme-card {
+    width: 116px;
+    border: 1.5px solid var(--border);
+    border-radius: 12px;
+    padding: 8px;
+    text-align: center;
+    font-size: 12.5px;
+    color: var(--text-2);
+    background: var(--surface);
+}
+
+.theme-card.active {
+    border-color: var(--accent);
+    color: var(--accent-text);
+    box-shadow: var(--focus);
+}
+
+.theme-card .swatch {
+    height: 58px;
+    border-radius: 8px;
+    margin-bottom: 7px;
+    display: flex;
+    overflow: hidden;
+    border: 1px solid rgba(0, 0, 0, .08);
+}
+
+.theme-card .swatch i {
+    flex: 1;
+}
+
+.theme-card .swatch.light i:first-child {
+    background: #f3f6fb;
+    flex: .4;
+}
+
+.theme-card .swatch.light i:last-child {
+    background: #fff;
+}
+
+.theme-card .swatch.dark i:first-child {
+    background: #0e1117;
+    flex: .4;
+}
+
+.theme-card .swatch.dark i:last-child {
+    background: #161a22;
+}
+
+.theme-card .swatch.auto i:first-child {
+    background: linear-gradient(135deg, #f3f6fb 50%, #0e1117 50%);
+    flex: .4;
+}
+
+.theme-card .swatch.auto i:last-child {
+    background: linear-gradient(135deg, #fff 50%, #161a22 50%);
+}
+
+.kbd {
+    display: inline-block;
+    min-width: 22px;
+    padding: 1px 6px;
+    border-radius: 5px;
+    border: 1px solid var(--border);
+    border-bottom-width: 2px;
+    background: var(--surface-2);
+    font-size: 11.5px;
+    font-family: inherit;
+    text-align: center;
+    color: var(--text-2);
+}
+
+.shortcut-grid {
+    display: grid;
+    grid-template-columns: 1fr auto;
+    gap: 8px 18px;
+    font-size: 13px;
+    align-items: center;
+}
+
+/* ---------------------------------------------------------------- */
+/*  Columns follow the width of the list, not of the window, so the */
+/*  details panel beside it squeezes them the same way a phone does  */
+/* ---------------------------------------------------------------- */
+@container pane (max-width: 860px) {
+
+    .list-head,
+    .row {
+        grid-template-columns: 34px minmax(0, 1fr) 136px 78px 104px 40px;
+    }
+
+    .toolbar .btn .lbl {
+        display: none;
+    }
+
+    .toolbar .btn {
+        padding: 0 10px;
+    }
+
+    .toolbar .btn.primary .lbl {
+        display: inline;
+    }
+}
+
+@container pane (max-width: 700px) {
+
+    .list-head,
+    .row {
+        grid-template-columns: 32px minmax(0, 1fr) 134px 76px 40px;
+    }
+
+    .col-type {
+        display: none !important;
+    }
+}
+
+@container pane (max-width: 560px) {
+
+    .list-head,
+    .row {
+        grid-template-columns: 30px minmax(0, 1fr) 128px 40px;
+    }
+
+    .col-size {
+        display: none !important;
+    }
+}
+
+@container pane (max-width: 460px) {
+    .list-head {
+        display: none !important;
+    }
+
+    .list-head,
+    .row {
+        grid-template-columns: 28px minmax(0, 1fr) 36px;
+        padding: 0 4px 0 8px;
+    }
+
+    .col-mtime {
+        display: none !important;
+    }
+
+    .row {
+        min-height: 58px;
+    }
+
+    .name-text .sub-mobile {
+        display: block;
+    }
+
+    .name-text .sub-desktop {
+        display: none;
+    }
+}
+
+/* ---------------------------------------------------------------- */
+/*  Responsive                                                      */
+/* ---------------------------------------------------------------- */
+.only-mobile,
+.only-sm {
+    display: none !important;
+}
+
+@media (max-width: 1100px) {
+    .details {
+        position: absolute;
+        right: 0;
+        top: 0;
+        bottom: 0;
+        z-index: 15;
+        box-shadow: -10px 0 30px rgba(16, 24, 40, .12);
+    }
+
+    html[data-theme="dark"] .details {
+        box-shadow: -10px 0 30px rgba(0, 0, 0, .45);
+    }
+}
+
+
+@media (max-width: 1024px) {
+    .only-mobile {
+        display: inline-flex !important;
+    }
+
+    .brand {
+        width: auto;
+    }
+
+    .topbar {
+        padding: 0 12px;
+        gap: 8px;
+    }
+
+    .sidebar {
+        position: fixed;
+        top: 0;
+        bottom: 0;
+        left: 0;
+        z-index: 140;
+        background: var(--surface);
+        box-shadow: var(--shadow-lg);
+        transform: translateX(-105%);
+        transition: transform .22s ease;
+        padding-top: 16px;
+        width: min(300px, 86vw);
+    }
+
+    .sidebar .drawer-brand {
+        display: flex !important;
+    }
+
+    html.nav-open .sidebar {
+        transform: none;
+    }
+
+    html.nav-open .scrim {
+        display: block;
+        position: fixed;
+        inset: 0;
+        background: var(--scrim);
+        z-index: 130;
+        animation: fade .15s ease-out;
+    }
+
+    .main {
+        padding: 0 12px 12px;
+    }
+
+    .apphost {
+        inset: 0 12px 12px 12px;
+    }
+}
+
+.drawer-brand {
+    display: none !important;
+    align-items: center;
+    gap: 10px;
+    padding: 0 8px 12px;
+    font-weight: 700;
+    font-size: 16px;
+}
+
+@media (max-width: 760px) {
+    :root {
+        --topbar-h: 58px;
+    }
+
+    .brand-name,
+    .brand-sep {
+        display: none;
+    }
+
+    .search {
+        display: none;
+        position: absolute;
+        left: 10px;
+        right: 10px;
+        top: 7px;
+        max-width: none;
+        z-index: 5;
+        box-shadow: var(--shadow-lg);
+    }
+
+    html.search-open .search {
+        display: flex;
+    }
+
+    .user-text,
+    .user-chip>.ic {
+        display: none;
+    }
+
+    .user-chip {
+        padding: 2px;
+        margin-left: 2px;
+    }
+
+    .avatar {
+        width: 34px;
+        height: 34px;
+    }
+
+    .hide-sm {
+        display: none !important;
+    }
+
+    .only-sm {
+        display: inline-flex !important;
+    }
+
+    .pane-head {
+        padding: 16px 16px 0;
+    }
+
+    .list-wrap {
+        padding: 10px 12px 0;
+    }
+
+    .pane-foot {
+        padding: 10px 16px 12px;
+    }
+
+    .banner {
+        margin: 12px 16px 0;
+    }
+
+    .settings {
+        padding: 4px 16px 24px;
+    }
+
+    .crumb {
+        font-size: 18px;
+        max-width: 180px;
+    }
+
+
+    .set-row {
+        flex-wrap: wrap;
+    }
+
+    .set-row .select {
+        width: 100%;
+    }
+
+    .set-row .txt {
+        flex-basis: 100%;
+    }
+
+    .form-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .apphost-bar .lbl {
+        display: none;
+    }
+}
+
+@media (max-width: 600px) {
+    body {
+        font-size: 14.5px;
+    }
+
+    .grid {
+        grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+        gap: 10px;
+    }
+
+    html.details-open .scrim {
+        display: block;
+        position: fixed;
+        inset: 0;
+        background: var(--scrim);
+        z-index: 144;
+        animation: fade .15s ease-out;
+    }
+
+    .details {
+        position: fixed;
+        inset: auto 0 0 0;
+        width: 100%;
+        height: 86vh;
+        border-left: 0;
+        border-radius: 18px 18px 0 0;
+        z-index: 145;
+        box-shadow: var(--shadow-lg);
+    }
+
+    .transfers {
+        right: 10px;
+        left: 10px;
+        bottom: 10px;
+        width: auto;
+        max-width: none;
+    }
+
+    .modal-backdrop {
+        padding: 0;
+        align-items: flex-end;
+    }
+
+    .modal,
+    .modal.wide,
+    .modal.xwide {
+        width: 100%;
+        max-height: 92vh;
+        border-radius: 18px 18px 0 0;
+    }
+
+    .preview-stage {
+        padding: 6px 8px 16px;
+    }
+
+    .preview-nav {
+        display: none;
+    }
+
+    .preview-bar .hide-xs {
+        display: none;
+    }
+
+    .apphost {
+        position: fixed;
+        inset: var(--topbar-h) 0 0 0;
+        border-radius: 0;
+        border: 0;
+    }
+
+    .view-tools .seg-sort .lbl {
+        display: none;
+    }
+
+    .theme-card {
+        width: calc(33.3% - 7px);
+    }
+
+    .popover {
+        left: 8px !important;
+        right: 8px;
+        width: auto;
+    }
+
+    .hide-xs {
+        display: none !important;
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    * {
+        animation-duration: .01ms !important;
+        transition-duration: .01ms !important;
+    }
+}

+ 16 - 0
src/web/Drive/img/module_icon.svg

@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#4f8ff8"/>
+      <stop offset="1" stop-color="#1d5fe0"/>
+    </linearGradient>
+    <linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#ffffff"/>
+      <stop offset="1" stop-color="#e3edff"/>
+    </linearGradient>
+  </defs>
+  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#bg)"/>
+  <path d="M26 42a8 8 0 0 1 8-8h18.5a6 6 0 0 1 4.6 2.2L62 42h32a8 8 0 0 1 8 8v4H26z" fill="#a9c8ff"/>
+  <path d="M26 52a6 6 0 0 1 6-6h64a6 6 0 0 1 6 6v36a8 8 0 0 1-8 8H34a8 8 0 0 1-8-8z" fill="url(#front)"/>
+  <path d="M64 58l13 13h-8.5v13h-9V71H51z" fill="#2b6ff0"/>
+</svg>

+ 128 - 0
src/web/Drive/index.html

@@ -0,0 +1,128 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="theme-color" content="#f3f6fb">
+    <!--
+        The document base is the web root, exactly like the desktop's. Hosted
+        apps read and write their iframe src as web root relative paths (see
+        ao_module_makeSingleInstance and the embedded.html launchers), so they
+        must resolve the same way here. Every Drive asset is therefore written
+        as Drive/..., and in-page links carry the full path (Drive.href).
+    -->
+    <base href="../">
+    <title>Drive</title>
+    <link rel="icon" type="image/svg+xml" href="Drive/img/module_icon.svg">
+    <link rel="stylesheet" href="Drive/css/drive.css?v=1.0">
+    <script>
+        /*
+            Apply the last used theme and density before the first paint so a
+            dark theme user never sees a white flash while settings load.
+        */
+        (function () {
+            try {
+                var cached = JSON.parse(localStorage.getItem("drive_ui_cache") || "{}");
+                var theme = cached.theme || "auto";
+                if (theme == "auto") {
+                    theme = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+                }
+                document.documentElement.setAttribute("data-theme", theme);
+                if (cached.density == "compact") {
+                    document.documentElement.classList.add("density-compact");
+                }
+            } catch (e) {
+                document.documentElement.setAttribute("data-theme", "light");
+            }
+        })();
+    </script>
+    <script src="script/jquery.min.js"></script>
+    <script src="script/ao_module.js"></script>
+    <script src="script/chunkupload.js"></script>
+</head>
+
+<body>
+    <div class="app" id="app">
+        <header class="topbar">
+            <button class="icon-btn only-mobile" id="navToggle" data-i18n-title="top/menu" title="Menu"></button>
+            <a class="brand" id="brand">
+                <span id="brandLogo"></span>
+                <span class="brand-name">ArozOS</span>
+                <span class="brand-sep"></span>
+                <span class="brand-app" data-i18n="app/name">Drive</span>
+            </a>
+            <form class="search" id="searchBox" role="search" autocomplete="off">
+                <span id="searchIcon"></span>
+                <input type="search" id="searchInput" data-i18n-placeholder="top/search"
+                    placeholder="Search files, folders, or anything..." spellcheck="false" enterkeyhint="search">
+                <button type="button" class="scope" id="searchScope" title="Search location"></button>
+                <button type="button" class="icon-btn small only-sm" id="searchClose" data-i18n-title="common/close" title="Close"></button>
+            </form>
+            <div class="top-actions">
+                <button class="icon-btn only-sm" id="searchToggle" data-i18n-title="top/searchBtn" title="Search"></button>
+                <button class="apps-pill" id="appsPill" hidden></button>
+                <button class="icon-btn" id="bellBtn" data-i18n-title="top/notifications" title="Notifications"></button>
+                <button class="icon-btn hide-sm" id="helpBtn" data-i18n-title="top/help" title="Help"></button>
+                <button class="user-chip" id="userBtn" aria-haspopup="true">
+                    <span class="avatar-wrap"><span class="avatar" id="userAvatar"></span></span>
+                    <span class="user-text"><b id="userName">&nbsp;</b><small id="userSub">&nbsp;</small></span>
+                    <span id="userCaret"></span>
+                </button>
+            </div>
+        </header>
+
+        <div class="shell">
+            <aside class="sidebar" id="sidebar" aria-label="Navigation">
+                <div class="drawer-brand"><span id="drawerLogo"></span><span>ArozOS Drive</span></div>
+                <div class="sidebar-scroll scroll-y" id="sidebarNav"></div>
+                <div class="storage" id="storage"></div>
+            </aside>
+            <div class="scrim" id="scrim"></div>
+
+            <main class="main" id="main">
+                <section class="card" id="card">
+                    <div class="pane" id="pane">
+                        <div class="pane-head" id="paneHead"></div>
+                        <div id="paneBanner"></div>
+                        <div class="list-wrap scroll-y" id="listWrap" tabindex="0"></div>
+                        <div class="pane-foot" id="paneFoot"></div>
+                    </div>
+                    <aside class="details" id="details" hidden></aside>
+                </section>
+                <section class="apphost" id="apphost" hidden>
+                    <div class="apphost-bar">
+                        <button class="btn ghost small" id="hostBack"></button>
+                        <div class="apphost-tabs" id="hostTabs"></div>
+                        <button class="icon-btn small" id="hostNewTab" data-i18n-title="host/newTab" title="Open in new browser tab"></button>
+                        <button class="icon-btn small" id="hostClose" data-i18n-title="host/close" title="Close this app"></button>
+                    </div>
+                    <div class="apphost-body" id="fw-body"></div>
+                </section>
+            </main>
+        </div>
+    </div>
+
+    <div id="transfers" class="transfers" hidden></div>
+    <div id="menuLayer"></div>
+    <div id="modalLayer"></div>
+    <div id="toastLayer" class="toasts" aria-live="polite"></div>
+    <div id="dragGhost" class="drag-ghost"></div>
+
+    <script src="Drive/js/icons.js?v=1.0"></script>
+    <script src="Drive/js/i18n.js?v=1.0"></script>
+    <script src="Drive/js/api.js?v=1.0"></script>
+    <script src="Drive/js/ui.js?v=1.0"></script>
+    <script src="Drive/js/listing.js?v=1.0"></script>
+    <script src="Drive/js/transfers.js?v=1.0"></script>
+    <script src="Drive/js/actions.js?v=1.0"></script>
+    <script src="Drive/js/share.js?v=1.0"></script>
+    <script src="Drive/js/details.js?v=1.0"></script>
+    <script src="Drive/js/preview.js?v=1.0"></script>
+    <script src="Drive/js/apphost.js?v=1.0"></script>
+    <script src="Drive/js/settings.js?v=1.0"></script>
+    <script src="Drive/js/app.js?v=1.0"></script>
+</body>
+
+</html>

+ 25 - 0
src/web/Drive/init.agi

@@ -0,0 +1,25 @@
+/*
+	ArozOS Drive
+
+	A cloud drive style interface module. Users whose permission group
+	boots into "Drive" sign in straight into a file centric workspace
+	(My Drive, sharing, recent, starred, trash) instead of the web desktop.
+
+	It only talks to existing REST endpoints and its own backend AGI
+	scripts, see README.md in this folder.
+*/
+
+var root = "Drive/";
+var moduleLaunchInfo = {
+	Name: "Drive",
+	Desc: "Cloud drive interface for browsing, sharing and managing your files",
+	Group: "Interface Module",
+	IconPath: root + "img/module_icon.svg",
+	Version: "1.0",
+	StartDir: root + "index.html",
+	SupportFW: true,
+	LaunchFWDir: root + "index.html",
+	InitFWSize: [1180, 720]
+};
+
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 1024 - 0
src/web/Drive/js/actions.js

@@ -0,0 +1,1024 @@
+/*
+    actions.js
+
+    Everything the user can do to an item, and the menus that offer it:
+    open / open with, download, move and copy, rename, trash (with undo),
+    restore, star, compress, extract, clipboard and new items.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    function S() {
+        return Drive.state;
+    }
+
+    function view() {
+        return S().view || {};
+    }
+
+    function plural(n, one, many, key) {
+        return T(n == 1 ? key + "1" : key, n == 1 ? one : many, { n: n });
+    }
+
+    function copyText(text) {
+        if (navigator.clipboard && window.isSecureContext) {
+            return navigator.clipboard.writeText(text);
+        }
+        return new Promise(function (resolve, reject) {
+            var ta = document.createElement("textarea");
+            ta.value = text;
+            ta.style.position = "fixed";
+            ta.style.opacity = "0";
+            document.body.appendChild(ta);
+            ta.select();
+            try {
+                document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
+            } catch (e) {
+                reject(e);
+            }
+            ta.remove();
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Openers                                                            */
+    /* ------------------------------------------------------------------ */
+    function extOf(item) {
+        return DriveIcons.getExt(item.name);
+    }
+
+    function launchURL(mod) {
+        if (mod.SupportEmb && mod.LaunchEmb) {
+            return mod.LaunchEmb;
+        }
+        if (mod.SupportFW && mod.LaunchFWDir) {
+            return mod.LaunchFWDir;
+        }
+        return mod.StartDir;
+    }
+
+    function fileHash(items) {
+        return "#" + encodeURIComponent(JSON.stringify(items.map(function (i) {
+            return { filename: i.name, filepath: i.path };
+        })));
+    }
+
+    //Modules the user may open that registered this extension
+    function compatibleModules(item) {
+        var ext = "." + extOf(item);
+        if (ext == ".") {
+            return [];
+        }
+        return S().modules.filter(function (m) {
+            if (!m.SupportedExt || m.Group == "Interface Module" || m.Name == "Trash Bin") {
+                return false;
+            }
+            if (!launchURL(m)) {
+                return false;
+            }
+            return m.SupportedExt.some(function (e) { return String(e).toLowerCase() == ext; });
+        }).sort(function (a, b) { return a.Name.localeCompare(b.Name); });
+    }
+
+    function defaultModuleFor(item) {
+        var ext = "." + extOf(item);
+        return API.defaultOpener(ext).then(function (info) {
+            if (info && info.Name && Drive.module(info.Name)) {
+                return Drive.module(info.Name);
+            }
+            var list = compatibleModules(item);
+            return list.length == 1 ? list[0] : null;
+        });
+    }
+
+    function openWithModule(item, mod) {
+        API.recentAdd(item.path);
+        DriveHost.open({
+            url: launchURL(mod) + fileHash([item]),
+            title: mod.Name,
+            icon: mod.IconPath,
+            appName: mod.Name
+        });
+    }
+
+    function openShortcut(item) {
+        API.readText(item.path).then(function (text) {
+            var lines = String(text).replace(/\r\n/g, "\n").trim().split("\n");
+            var type = lines[0], name = lines[1], target = lines[2], icon = lines[3];
+            if (type == "folder") {
+                location.hash = Drive.folderRoute(target);
+            } else if (type == "module") {
+                var m = Drive.module(target);
+                if (m) {
+                    DriveHost.openModule(m.Name);
+                } else {
+                    DriveUI.toast(T("err/noModule", "You do not have access to {name}", { name: target }), { type: "error" });
+                }
+            } else if (type == "url") {
+                DriveHost.open({ url: target, title: name, icon: icon });
+            } else if (type == "app") {
+                window.open(API.ROOT + "app/" + encodeURIComponent(target) + "/");
+            } else {
+                DrivePreview.open(item);
+            }
+        }).catch(DriveUI.error);
+    }
+
+    function open(item) {
+        if (!item) {
+            return;
+        }
+        var v = view();
+        if (item.trashed) {
+            if (!item.isDir) {
+                DrivePreview.open(item);
+            }
+            return;
+        }
+        if (item.shareOnly) {
+            DriveHost.open({ url: "/share/" + item.share.UUID, title: item.name, icon: "img/system/favicon.png" });
+            return;
+        }
+        if (item.isDir) {
+            location.hash = Drive.folderRoute(item.path);
+            return;
+        }
+        if (extOf(item) == "shortcut") {
+            openShortcut(item);
+            return;
+        }
+        if (S().settings.openIn == "tab") {
+            openInNewTab(item);
+            return;
+        }
+        defaultModuleFor(item).then(function (mod) {
+            if (mod) {
+                openWithModule(item, mod);
+            } else if (DrivePreview.canPreview(item)) {
+                API.recentAdd(item.path);
+                DrivePreview.open(item);
+            } else {
+                openWithChooser(item);
+            }
+        });
+        if (v.kind == "recent") {
+            //Bump the item in the open history without a full reload
+            item.activity = Math.floor(Date.now() / 1000);
+            item.openedLast = true;
+        }
+    }
+
+    function openInNewTab(item) {
+        if (item.isDir) {
+            window.open(location.pathname + Drive.folderRoute(item.path), "_blank");
+            return;
+        }
+        if (item.shareOnly) {
+            window.open("/share/" + item.share.UUID, "_blank");
+            return;
+        }
+        API.recentAdd(item.path);
+        defaultModuleFor(item).then(function (mod) {
+            if (mod) {
+                window.open(API.ROOT + launchURL(mod) + fileHash([item]), "_blank");
+            } else {
+                window.open(API.mediaURL(item.path), "_blank");
+            }
+        });
+    }
+
+    function openWithItems(item) {
+        var mods = compatibleModules(item);
+        var list = mods.map(function (m) {
+            return { img: API.ROOT + m.IconPath, label: m.Name, action: function () { openWithModule(item, m); } };
+        });
+        if (DrivePreview.canPreview(item)) {
+            list.unshift({ icon: "eye", label: T("open/preview", "Drive preview"), action: function () { DrivePreview.open(item); } });
+        }
+        list.push("-", { icon: "apps", label: T("open/choose", "Choose another app..."), action: function () { openWithChooser(item); } });
+        return list;
+    }
+
+    function openWithChooser(item) {
+        var ext = extOf(item);
+        var mods = compatibleModules(item);
+        var all = S().modules.filter(function (m) {
+            return launchURL(m) && m.Group != "Interface Module" && mods.indexOf(m) < 0 && m.SupportedExt && m.SupportedExt.length;
+        }).sort(function (a, b) { return a.Name.localeCompare(b.Name); });
+        var body = DriveUI.el('<div></div>');
+        function row(m, idx) {
+            return '<button class="picker-item" data-mod="' + esc(m.Name) + '"><img src="' + esc(API.ROOT + m.IconPath) + '" alt="" style="width:26px;height:26px;object-fit:contain">' +
+                '<span>' + esc(m.Name) + '<small class="muted" style="display:block;font-size:12px">' + esc(m.Desc || m.Group || "") + '</small></span></button>';
+        }
+        var html = "";
+        if (mods.length) {
+            html += '<div class="field-label" style="margin-bottom:6px">' + esc(T("open/suggested", "Suggested apps")) + '</div><div class="list-plain" style="margin-bottom:14px">' + mods.map(row).join("") + '</div>';
+        } else {
+            html += '<p>' + esc(T("open/noApp", "No installed app registered .{ext} files. You can still preview, download, or try another app.", { ext: ext })) + '</p>';
+        }
+        if (all.length) {
+            html += '<details><summary class="field-label" style="cursor:pointer;margin-bottom:6px">' + esc(T("open/otherApps", "Other apps")) + '</summary><div class="list-plain scroll-y" style="max-height:220px">' + all.map(row).join("") + '</div></details>';
+        }
+        if (ext) {
+            html += '<label class="toggle-row" style="margin:16px 0 0"><span>' + esc(T("open/always", "Always open .{ext} files with the selected app", { ext: ext })) +
+                '</span><span class="switch"><input type="checkbox" id="openAlways"><span></span></span></label>';
+        }
+        body.innerHTML = html;
+        var ctx = DriveUI.modal({
+            title: T("open/withTitle", "Open \"{name}\" with", { name: item.name }),
+            iconHTML: DriveIcons.file(item.name, false),
+            body: body,
+            footLeft: DrivePreview.canPreview(item) ? '<button class="btn ghost small" id="owPreview">' + I("eye") + esc(T("open/preview", "Drive preview")) + '</button>' : "",
+            buttons: [
+                { label: T("toolbar/download", "Download"), action: function () { download([item]); } },
+                { label: T("common/cancel", "Cancel") }
+            ]
+        });
+        body.querySelectorAll("[data-mod]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var m = Drive.module(b.getAttribute("data-mod"));
+                var always = body.querySelector("#openAlways");
+                if (always && always.checked && ext) {
+                    API.setDefaultOpener("." + ext, m.Name).catch(DriveUI.error);
+                }
+                ctx.close();
+                openWithModule(item, m);
+            });
+        });
+        var pv = ctx.node.querySelector("#owPreview");
+        if (pv) {
+            pv.addEventListener("click", function () {
+                ctx.close();
+                DrivePreview.open(item);
+            });
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Download and links                                                 */
+    /* ------------------------------------------------------------------ */
+    function download(items) {
+        items = (items || []).filter(function (i) { return i && !i.shareOnly; });
+        if (!items.length) {
+            return;
+        }
+        if (items.length == 1 && !items[0].isDir) {
+            DriveTransfers.saveURL(API.downloadURL(items[0].path), items[0].name);
+            return;
+        }
+        if (!API.caps.fm) {
+            var files = items.filter(function (i) { return !i.isDir; });
+            if (files.length < items.length) {
+                DriveUI.toast(T("err/zipNeedFM", "Folders can only be downloaded as a zip with the File Manager permission"), { type: "error" });
+            }
+            files.forEach(function (f, idx) {
+                setTimeout(function () { DriveTransfers.saveURL(API.downloadURL(f.path), f.name); }, idx * 400);
+            });
+            return;
+        }
+        DriveTransfers.zipDownload(items);
+    }
+
+    function activeShare(info) {
+        var list = (info && info.Shares) || [];
+        for (var i = 0; i < list.length; i++) {
+            if (!list[i].IsExpired) {
+                return list[i];
+            }
+        }
+        return null;
+    }
+
+    function copyLink(item) {
+        if (item.share && item.share.UUID) {
+            copyText(API.shareURL(item.share.UUID)).then(function () {
+                DriveUI.toast(T("share/copied", "Link copied"), { type: "success" });
+            }).catch(DriveUI.error);
+            return;
+        }
+        API.shareInfo(item.path).then(function (info) {
+            var share = activeShare(info);
+            if (share) {
+                return share;
+            }
+            if (info && info.CanManage === false) {
+                throw new Error(T("share/notOwnerShort", "This item is shared by someone else"));
+            }
+            return API.shareCreate(item.path, { permission: "anyone" });
+        }).then(function (share) {
+            var url = API.shareURL(share.UUID || share.uuid);
+            return copyText(url).then(function () {
+                DriveUI.toast(T("share/linkCopied", "Link copied. Anyone with the link can view."), {
+                    type: "success",
+                    action: { label: T("share/manage", "Manage"), fn: function () { DriveShare.open(item); } }
+                });
+                if (!item.shared) {
+                    item.shared = true;
+                    DriveList.render();
+                }
+            });
+        }).catch(DriveUI.error);
+    }
+
+    function copyPath(item) {
+        copyText(item.path).then(function () {
+            DriveUI.toast(T("misc/pathCopied", "Path copied"), { type: "success" });
+        }).catch(DriveUI.error);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Move and copy                                                      */
+    /* ------------------------------------------------------------------ */
+    function askConflict(dupes, total) {
+        return new Promise(function (resolve) {
+            var answered = false;
+            var names = dupes.slice(0, 8).map(function (p) {
+                return '<div>' + DriveIcons.file(API.baseName(p), false) + '<span>' + esc(API.baseName(p)) + '</span></div>';
+            }).join("") + (dupes.length > 8 ? '<div class="muted">+ ' + (dupes.length - 8) + '</div>' : "");
+            DriveUI.modal({
+                title: T("conflict/title", "Some items already exist"),
+                icon: "alert",
+                body: '<p>' + esc(T("conflict/text", "{n} of {total} items have the same name as items in the destination.", { n: dupes.length, total: total })) + '</p><div class="name-list">' + names + '</div>',
+                buttons: [
+                    { label: T("conflict/skip", "Skip"), action: function () { answered = true; resolve("skip"); } },
+                    { label: T("conflict/replace", "Replace"), action: function () { answered = true; resolve("overwrite"); } },
+                    { label: T("conflict/keep", "Keep both"), cls: "primary", action: function () { answered = true; resolve("keep"); } }
+                ],
+                onClose: function () {
+                    if (!answered) {
+                        resolve(null);
+                    }
+                }
+            });
+        });
+    }
+
+    function transfer(items, dest, mode) {
+        dest = Drive.normDir(dest);
+        items = items.filter(Boolean);
+        if (mode == "move") {
+            items = items.filter(function (i) {
+                return Drive.normDir(API.parentOf(i.path)) != dest;
+            });
+        }
+        if (!items.length) {
+            return Promise.resolve();
+        }
+        for (var i = 0; i < items.length; i++) {
+            if (items[i].isDir && dest.indexOf(Drive.normDir(items[i].path)) == 0) {
+                DriveUI.toast(T("err/intoItself", "A folder cannot be moved into itself"), { type: "error" });
+                return Promise.resolve();
+            }
+        }
+        if (!Drive.isWritable(dest)) {
+            DriveUI.toast(T("err/readonly", "This location is read only"), { type: "error" });
+            return Promise.resolve();
+        }
+        var paths = items.map(function (i) { return i.path; });
+        return API.duplicates(paths, dest).catch(function () { return []; }).then(function (dupes) {
+            if (dupes && dupes.length) {
+                return askConflict(dupes, items.length);
+            }
+            return "keep";
+        }).then(function (resp) {
+            if (!resp) {
+                return;
+            }
+            var label = Drive.pathParts(dest);
+            label = label[label.length - 1].label;
+            return DriveTransfers.operation(mode, items, dest, resp, {
+                doneText: mode == "move" ? T("xfer/movedTo", "Moved to {name}", { name: label }) : T("xfer/copiedTo", "Copied to {name}", { name: label })
+            }).then(function () {
+                if (mode == "move") {
+                    //Keep stars on the items that moved
+                    var moved = paths.filter(function (p) { return S().starred.has(p); });
+                    if (moved.length) {
+                        var renamed = moved.map(function (p) { return API.joinPath(dest, API.baseName(p)); });
+                        API.star(moved, false).then(function () { return API.star(renamed, true); }).then(function (list) {
+                            S().starred = new Set(list || []);
+                        }).catch(function () { });
+                    }
+                }
+                DriveUI.toast(mode == "move" ? plural(items.length, "Moved 1 item to {name}", "Moved {n} items to {name}", "toast/moved").replace("{name}", label) :
+                    plural(items.length, "Copied 1 item to {name}", "Copied {n} items to {name}", "toast/copied").replace("{name}", label), {
+                    type: "success",
+                    action: Drive.normDir(view().path || "") != dest ? { label: T("toast/show", "Show"), fn: function () { location.hash = Drive.folderRoute(dest); } } : null
+                });
+            }).catch(function (err) {
+                DriveUI.error(err);
+            });
+        });
+    }
+
+    function startFolder() {
+        var v = view();
+        if (v.kind == "drive") {
+            return v.path;
+        }
+        return null;
+    }
+
+    function moveTo(items) {
+        items = items || Drive.selectedItems();
+        if (!items.length) {
+            return;
+        }
+        DriveUI.pickFolder({
+            title: items.length == 1 ? T("move/titleOne", "Move \"{name}\"", { name: items[0].name }) : T("move/title", "Move {n} items", { n: items.length }),
+            okLabel: T("move/here", "Move here"), icon: "move", start: startFolder(),
+            sources: items.filter(function (i) { return i.isDir; }).map(function (i) { return i.path; })
+        }).then(function (dest) {
+            if (dest) {
+                transfer(items, dest, "move");
+            }
+        });
+    }
+
+    function copyTo(items) {
+        items = items || Drive.selectedItems();
+        if (!items.length) {
+            return;
+        }
+        DriveUI.pickFolder({
+            title: items.length == 1 ? T("copy/titleOne", "Copy \"{name}\"", { name: items[0].name }) : T("copy/title", "Copy {n} items", { n: items.length }),
+            okLabel: T("copy/here", "Copy here"), icon: "copy", start: startFolder(),
+            sources: items.filter(function (i) { return i.isDir; }).map(function (i) { return i.path; })
+        }).then(function (dest) {
+            if (dest) {
+                transfer(items, dest, "copy");
+            }
+        });
+    }
+
+    function duplicate(items) {
+        items = items.filter(function (i) { return !i.isDir; });
+        if (!items.length) {
+            DriveUI.toast(T("err/copyFolder", "Use Copy to... for folders"), { type: "error" });
+            return;
+        }
+        var parent = API.parentOf(items[0].path);
+        DriveTransfers.operation("copy", items, parent, "keep", { doneText: T("xfer/copyMade", "Copy created") }).then(function () {
+            DriveUI.toast(plural(items.length, "Made a copy", "Made {n} copies", "toast/duplicated"), { type: "success" });
+        }).catch(DriveUI.error);
+    }
+
+    /* Clipboard -------------------------------------------------------------- */
+    function clipboard(mode, items) {
+        S().clipboard = { mode: mode, paths: items.map(function (i) { return i.path; }), items: items.slice() };
+        DriveList.render();
+        DriveUI.toast(mode == "cut" ? plural(items.length, "1 item cut. Paste it in another folder.", "{n} items cut. Paste them in another folder.", "toast/cut") :
+            plural(items.length, "1 item copied to the clipboard", "{n} items copied to the clipboard", "toast/clip"));
+    }
+
+    function paste(target) {
+        var clip = S().clipboard;
+        var v = view();
+        target = target || (v.kind == "drive" ? v.path : null);
+        if (!clip || !target) {
+            return;
+        }
+        var mode = clip.mode == "cut" ? "move" : "copy";
+        if (mode == "move") {
+            S().clipboard = null;
+        }
+        transfer(clip.items, target, mode);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Rename, trash, delete, restore                                     */
+    /* ------------------------------------------------------------------ */
+    function commitRename(item, newName) {
+        if (/[\/\\]/.test(newName)) {
+            DriveUI.toast(T("err/badName", "Names cannot contain / or \\"), { type: "error" });
+            DriveList.render();
+            return;
+        }
+        var oldPath = item.path;
+        API.rename(oldPath, newName).then(function () {
+            var newPath = API.joinPath(API.parentOf(oldPath), newName);
+            if (S().starred.has(oldPath)) {
+                API.star([oldPath], false).then(function () { return API.star([newPath], true); }).then(function (list) {
+                    S().starred = new Set(list || []);
+                }).catch(function () { });
+            }
+            if (item.isCurrentFolder) {
+                location.hash = Drive.folderRoute(newPath);
+                return;
+            }
+            Drive.setSelection([newPath], { anchor: newPath, focus: newPath });
+            Drive.refresh();
+            DriveUI.toast(T("toast/renamed", "Renamed to {name}", { name: newName }), { type: "success" });
+        }).catch(function (err) {
+            DriveUI.error(err);
+            DriveList.render();
+        });
+    }
+
+    function rename(item) {
+        if (!item || item.trashed || item.shareOnly) {
+            return;
+        }
+        if (!Drive.isWritable(item.path)) {
+            DriveUI.toast(T("err/readonly", "This location is read only"), { type: "error" });
+            return;
+        }
+        if (!item.isCurrentFolder && DriveList.startRename(item, function (v) { commitRename(item, v); })) {
+            return;
+        }
+        DriveUI.prompt({
+            title: T("menu/rename", "Rename"), label: T("prompt/newName", "New name"), value: item.name,
+            selectStem: !item.isDir, ok: T("menu/rename", "Rename")
+        }).then(function (name) {
+            if (name && name != item.name) {
+                commitRename(item, name);
+            }
+        });
+    }
+
+    function undoTrash(items) {
+        API.trashList().then(function (list) {
+            var jobs = items.map(function (it) {
+                var parent = Drive.normDir(API.parentOf(it.path));
+                var match = list.filter(function (t) {
+                    return t.name == it.name && Drive.normDir(t.origin || "") == parent;
+                }).sort(function (a, b) { return b.mtime - a.mtime; })[0];
+                return match ? API.trashRestore(match.path) : Promise.resolve();
+            });
+            return Promise.all(jobs);
+        }).then(function () {
+            DriveUI.toast(T("toast/restored", "Restored"), { type: "success" });
+            Drive.refresh();
+        }).catch(DriveUI.error);
+    }
+
+    function trash(items) {
+        items = (items || []).filter(function (i) { return !i.trashed && !i.shareOnly && !i.isRoot; });
+        if (!items.length || view().kind == "shared") {
+            return;
+        }
+        if (items.some(function (i) { return !Drive.isWritable(i.path); })) {
+            DriveUI.toast(T("err/readonly", "This location is read only"), { type: "error" });
+            return;
+        }
+        var permanent = !API.caps.fm;
+        var ask = permanent || S().settings.confirmTrash ? DriveUI.confirm({
+            title: permanent ? T("delete/title", "Delete forever?") : T("trash/confirmTitle", "Move to trash?"),
+            message: permanent ? T("delete/reducedText", "Deleted items cannot be restored in limited mode.") :
+                plural(items.length, "\"{name}\" will be moved to the trash.", "{n} items will be moved to the trash.", "trash/confirmText").replace("{name}", items[0].name),
+            ok: permanent ? T("delete/ok", "Delete forever") : T("trash/move", "Move to trash"), danger: true
+        }) : Promise.resolve(true);
+        ask.then(function (ok) {
+            if (!ok) {
+                return;
+            }
+            var paths = items.map(function (i) { return i.path; });
+            API.recycle(paths).then(function () {
+                Drive.setSelection([]);
+                Drive.refresh();
+                Drive.refreshQuota();
+                if (permanent) {
+                    DriveUI.toast(plural(items.length, "Deleted 1 item", "Deleted {n} items", "toast/deleted"), { type: "success" });
+                } else {
+                    DriveUI.toast(plural(items.length, "Moved 1 item to the trash", "Moved {n} items to the trash", "toast/trashed"), {
+                        type: "success", timeout: 7000,
+                        action: { label: T("toast/undo", "Undo"), fn: function () { undoTrash(items); } }
+                    });
+                }
+            }).catch(function (err) {
+                if (err.message == "TRASH_QUOTA_EXCEEDED" || /Incompatible File System/i.test(err.message)) {
+                    DriveUI.confirm({
+                        title: T("trash/fullTitle", "Cannot move to trash"),
+                        message: err.message == "TRASH_QUOTA_EXCEEDED" ? T("trash/fullText", "The trash is full. Delete these items forever instead?") :
+                            T("trash/unsupported", "This storage has no trash. Delete these items forever instead?"),
+                        ok: T("delete/ok", "Delete forever"), danger: true
+                    }).then(function (yes) {
+                        if (yes) {
+                            removeNow(items);
+                        }
+                    });
+                } else {
+                    DriveUI.error(err);
+                }
+            });
+        });
+    }
+
+    function removeNow(items) {
+        API.remove(items.map(function (i) { return i.path; })).then(function () {
+            Drive.setSelection([]);
+            Drive.refresh();
+            Drive.refreshQuota();
+            DriveUI.toast(plural(items.length, "Deleted 1 item", "Deleted {n} items", "toast/deleted"), { type: "success" });
+        }).catch(DriveUI.error);
+    }
+
+    function deleteForever(items) {
+        items = (items || []).filter(function (i) { return !i.shareOnly && !i.isRoot; });
+        if (!items.length || view().kind == "shared") {
+            return;
+        }
+        DriveUI.confirm({
+            title: T("delete/title", "Delete forever?"),
+            message: plural(items.length, "\"{name}\" will be deleted forever. This cannot be undone.", "{n} items will be deleted forever. This cannot be undone.", "delete/text").replace("{name}", items[0].name),
+            ok: T("delete/ok", "Delete forever"), danger: true
+        }).then(function (ok) {
+            if (ok) {
+                removeNow(items);
+            }
+        });
+    }
+
+    function restore(items) {
+        items = (items || []).filter(function (i) { return i.trashed; });
+        if (!items.length) {
+            return;
+        }
+        items.reduce(function (p, it) {
+            return p.then(function () { return API.trashRestore(it.path); });
+        }, Promise.resolve()).then(function () {
+            Drive.setSelection([]);
+            Drive.refresh();
+            DriveUI.toast(plural(items.length, "Restored 1 item", "Restored {n} items", "toast/restoredN"), {
+                type: "success",
+                action: items.length == 1 && items[0].origin ? { label: T("toast/show", "Show"), fn: function () { location.hash = Drive.folderRoute(items[0].origin); } } : null
+            });
+        }).catch(DriveUI.error);
+    }
+
+    function emptyTrash() {
+        DriveUI.confirm({
+            title: T("trash/emptyTitle", "Empty trash?"),
+            message: T("trash/emptyText", "All items in the trash will be deleted forever. This cannot be undone."),
+            ok: T("trash/empty", "Empty trash"), danger: true
+        }).then(function (ok) {
+            if (!ok) {
+                return;
+            }
+            API.trashClear().then(function () {
+                Drive.refresh();
+                Drive.refreshQuota();
+                DriveUI.toast(T("toast/trashEmptied", "Trash emptied"), { type: "success" });
+            }).catch(DriveUI.error);
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Star, compress, extract                                            */
+    /* ------------------------------------------------------------------ */
+    function star(items, on) {
+        var paths = items.map(function (i) { return i.path; });
+        API.star(paths, on).then(function (list) {
+            S().starred = new Set(list || []);
+            if (view().kind == "starred" && !on) {
+                Drive.refresh();
+            } else {
+                DriveList.render();
+                DriveDetails.refresh();
+            }
+            DriveUI.toast(on ? plural(items.length, "Added to Starred", "Added {n} items to Starred", "toast/starred") :
+                plural(items.length, "Removed from Starred", "Removed {n} items from Starred", "toast/unstarred"));
+        }).catch(DriveUI.error);
+    }
+
+    function allStarred(items) {
+        return items.length > 0 && items.every(function (i) { return S().starred.has(i.path); });
+    }
+
+    function zip(items) {
+        if (!API.caps.fm) {
+            DriveUI.toast(T("err/needFM", "This action needs the File Manager permission"), { type: "error" });
+            return;
+        }
+        var dest = API.parentOf(items[0].path);
+        DriveTransfers.operation("zip", items, dest, "keep", { doneText: T("xfer/zipped", "Archive created") }).catch(function () { });
+    }
+
+    function isArchive(item) {
+        return !item.isDir && ["zip"].indexOf(DriveIcons.getExt(item.name)) >= 0;
+    }
+
+    function unzip(item) {
+        if (!API.caps.fm) {
+            DriveUI.toast(T("err/needFM", "This action needs the File Manager permission"), { type: "error" });
+            return;
+        }
+        var dest = API.parentOf(item.path);
+        DriveTransfers.operation("unzip", [item], dest, "keep", { doneText: T("xfer/extracted", "Extracted") }).catch(function () { });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  New items and uploads                                              */
+    /* ------------------------------------------------------------------ */
+    function freeName(base, ext) {
+        var names = {};
+        S().items.forEach(function (i) { names[i.name.toLowerCase()] = true; });
+        var candidate = base + (ext ? "." + ext : "");
+        var n = 2;
+        while (names[candidate.toLowerCase()]) {
+            candidate = base + " (" + n + ")" + (ext ? "." + ext : "");
+            n++;
+        }
+        return candidate;
+    }
+
+    function targetFolder() {
+        var v = view();
+        return v.kind == "drive" && v.writable ? v.path : null;
+    }
+
+    function newFolder() {
+        var dir = targetFolder();
+        if (!dir) {
+            return;
+        }
+        DriveUI.prompt({
+            title: T("toolbar/newFolder", "New folder"), label: T("prompt/folderName", "Folder name"),
+            value: freeName(T("prompt/untitledFolder", "Untitled folder")), icon: "folderPlus", ok: T("common/create", "Create")
+        }).then(function (name) {
+            if (!name) {
+                return;
+            }
+            API.mkdir(dir, name).then(function () {
+                var p = API.joinPath(dir, name);
+                Drive.setSelection([p], { anchor: p, focus: p });
+                Drive.refresh();
+            }).catch(DriveUI.error);
+        });
+    }
+
+    function newFile(tpl) {
+        var dir = targetFolder();
+        if (!dir) {
+            return;
+        }
+        DriveUI.prompt({
+            title: T("new/fileTitle", "New {type}", { type: tpl.Desc }), label: T("prompt/fileName", "File name"),
+            value: freeName(T("new/untitled", "Untitled"), tpl.Ext), selectStem: true, icon: "filePlus", ok: T("common/create", "Create")
+        }).then(function (name) {
+            if (!name) {
+                return;
+            }
+            if (tpl.Ext && name.toLowerCase().lastIndexOf("." + tpl.Ext.toLowerCase()) != name.length - tpl.Ext.length - 1) {
+                name += "." + tpl.Ext;
+            }
+            API.newFile(dir, name).then(function () {
+                var p = API.joinPath(dir, name);
+                Drive.setSelection([p], { anchor: p, focus: p });
+                Drive.refresh();
+                DriveUI.toast(T("toast/created", "Created {name}", { name: name }), {
+                    type: "success", action: { label: T("toast/open", "Open"), fn: function () { open({ name: name, path: p, isDir: false, size: 0, mtime: 0 }); } }
+                });
+            }).catch(DriveUI.error);
+        });
+    }
+
+    var templateCache = null;
+    function templateItems() {
+        var p = templateCache ? Promise.resolve(templateCache) : API.templates().then(function (list) {
+            templateCache = list || [];
+            return templateCache;
+        });
+        return p.then(function (list) {
+            return list.map(function (tpl) {
+                return { img: "data:image/svg+xml;charset=utf-8," + encodeURIComponent(DriveIcons.file("x." + tpl.Ext, false).replace("<svg ", '<svg xmlns="http://www.w3.org/2000/svg" ')), label: tpl.Desc, hint: "." + tpl.Ext, action: function () { newFile(tpl); } };
+            });
+        }).catch(function () { return []; });
+    }
+
+    function upload() {
+        var dir = targetFolder();
+        if (dir) {
+            DriveTransfers.pick(dir, false);
+        }
+    }
+
+    function uploadFolder() {
+        var dir = targetFolder();
+        if (dir) {
+            DriveTransfers.pick(dir, true);
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Menus                                                              */
+    /* ------------------------------------------------------------------ */
+    function newMenuItems() {
+        return [
+            { icon: "folderPlus", label: T("toolbar/newFolder", "New folder"), hint: "Shift+F", action: newFolder },
+            "-",
+            { icon: "fileUp", label: T("toolbar/uploadFiles", "File upload"), hint: "Shift+U", action: upload },
+            { icon: "folderUp", label: T("toolbar/uploadFolder", "Folder upload"), action: uploadFolder },
+            "-",
+            { icon: "filePlus", label: T("toolbar/newFile", "New file"), submenu: templateItems }
+        ];
+    }
+
+    function newMenu(pos) {
+        DriveUI.menu(pos, newMenuItems());
+    }
+
+    function pasteItem() {
+        var clip = S().clipboard;
+        if (!clip || !targetFolder()) {
+            return null;
+        }
+        return { icon: "paste", label: T("menu/paste", "Paste") + " (" + clip.paths.length + ")", hint: "Ctrl+V", action: function () { paste(); } };
+    }
+
+    function folderMenu(pos) {
+        var folder = Drive.currentFolderItem();
+        var w = view().writable;
+        DriveUI.menu(pos, [
+            w ? { icon: "folderPlus", label: T("toolbar/newFolder", "New folder"), action: newFolder } : null,
+            w ? { icon: "fileUp", label: T("toolbar/uploadFiles", "File upload"), action: upload } : null,
+            w ? { icon: "folderUp", label: T("toolbar/uploadFolder", "Folder upload"), action: uploadFolder } : null,
+            w ? { icon: "filePlus", label: T("toolbar/newFile", "New file"), submenu: templateItems } : null,
+            pasteItem(),
+            "-",
+            !folder.isRoot ? { icon: "link", label: T("toolbar/share", "Share"), action: function () { DriveShare.open(folder); } } : null,
+            !folder.isRoot ? { icon: "link", label: T("menu/copyLink", "Copy link"), action: function () { copyLink(folder); } } : null,
+            { icon: "download", label: T("menu/downloadFolder", "Download as zip"), action: function () { download([folder]); } },
+            !folder.isRoot && w ? { icon: "pencil", label: T("menu/rename", "Rename"), action: function () { rename(folder); } } : null,
+            !folder.isRoot ? { icon: S().starred.has(folder.path) ? "starFill" : "star", label: S().starred.has(folder.path) ? T("menu/unstar", "Remove from Starred") : T("menu/star", "Add to Starred"), action: function () { star([folder], !S().starred.has(folder.path)); } } : null,
+            "-",
+            { icon: "info", label: T("menu/details", "View details"), action: function () { Drive.setSelection([]); Drive.toggleDetails(true); } },
+            { icon: "refresh", label: T("menu/refresh", "Refresh"), action: function () { Drive.refresh(); } }
+        ]);
+    }
+
+    function moreMenu(pos) {
+        var items = Drive.selectedItems();
+        var v = view();
+        if (!items.length) {
+            DriveUI.menu(pos, [
+                { icon: "checkSquare", label: T("menu/selectAll", "Select all"), hint: "Ctrl+A", action: function () { DriveList.selectAll(); } },
+                pasteItem(),
+                { icon: "refresh", label: T("menu/refresh", "Refresh"), action: function () { Drive.refresh(); } },
+                "-",
+                { checkable: true, checked: S().settings.showHidden, label: T("menu/showHidden", "Show hidden files"), action: function () { Drive.saveSettings({ showHidden: !S().settings.showHidden }); Drive.refresh(); } },
+                v.kind == "drive" ? { icon: "info", label: T("menu/details", "View details"), action: function () { Drive.toggleDetails(true); } } : null,
+                v.kind == "recent" ? { icon: "x", label: T("recent/clear", "Clear open history"), action: function () { API.recentClear().then(function () { Drive.refresh(); }); } } : null
+            ]);
+            return;
+        }
+        DriveUI.menu(pos, selectionMenu(items, true));
+    }
+
+    //The full set of actions for the selected items, shared by More and right click
+    function selectionMenu(items, fromToolbar) {
+        var v = view();
+        var one = items.length == 1 ? items[0] : null;
+        var writable = items.every(function (i) { return Drive.isWritable(i.path); }) && v.kind != "shared";
+        var starredAll = allStarred(items);
+
+        if (v.kind == "trash") {
+            return [
+                one && !one.isDir ? { icon: "eye", label: T("menu/preview", "Preview"), action: function () { DrivePreview.open(one); } } : null,
+                { icon: "restore", label: T("trash/restore", "Restore"), action: function () { restore(items); } },
+                { icon: "trash", label: T("trash/deleteForever", "Delete forever"), danger: true, hint: "Del", action: function () { deleteForever(items); } },
+                "-",
+                { icon: "info", label: T("menu/details", "View details"), action: function () { Drive.toggleDetails(true); } }
+            ];
+        }
+
+        var moreSub = function () {
+            return [
+                one && !one.isDir ? { icon: "eye", label: T("menu/preview", "Preview"), hint: "Space", action: function () { DrivePreview.open(one); } } : null,
+                one && !one.isDir && !one.shareOnly ? { icon: "apps", label: T("menu/openWith", "Open with"), submenu: function () { return openWithItems(one); } } : null,
+                writable ? { icon: "cut", label: T("menu/cut", "Cut"), hint: "Ctrl+X", action: function () { clipboard("cut", items); } } : null,
+                !fromToolbar ? null : { icon: "copy", label: T("menu/copyTo", "Copy to..."), action: function () { copyTo(items); } },
+                writable && items.every(function (i) { return !i.isDir; }) ? { icon: "copy", label: T("menu/duplicate", "Make a copy"), action: function () { duplicate(items); } } : null,
+                writable && API.caps.fm ? { icon: "archive", label: T("menu/compress", "Compress to ZIP"), action: function () { zip(items); } } : null,
+                one && isArchive(one) && writable && API.caps.fm ? { icon: "extract", label: T("menu/extract", "Extract here"), action: function () { unzip(one); } } : null,
+                v.kind != "shared" ? { icon: starredAll ? "starFill" : "star", label: starredAll ? T("menu/unstar", "Remove from Starred") : T("menu/star", "Add to Starred"), action: function () { star(items, !starredAll); } } : null,
+                one ? { icon: "code", label: T("menu/copyPath", "Copy path"), action: function () { copyPath(one); } } : null,
+                one && v.kind != "drive" && !one.shareOnly ? { icon: "folderOpen", label: T("menu/showLocation", "Show file location"), action: function () { location.hash = Drive.folderRoute(API.parentOf(one.path)); } } : null,
+                one && !one.isDir && API.caps.fm && !one.shareOnly ? { icon: "history", label: T("menu/versions", "Version history"), action: function () { Drive.setSelection([one.path]); DriveDetails.open("versions"); Drive.toggleDetails(true); } } : null
+            ];
+        };
+
+        if (fromToolbar) {
+            return [
+                one && !one.isDir && !one.shareOnly ? { icon: "apps", label: T("menu/openWith", "Open with"), submenu: function () { return openWithItems(one); } } : null,
+                { icon: "download", label: T("toolbar/download", "Download"), hint: "Ctrl+S", action: function () { download(items); } },
+                one ? { icon: "link", label: T("menu/copyLink", "Copy link"), action: function () { copyLink(one); } } : null,
+                writable && one ? { icon: "pencil", label: T("menu/rename", "Rename"), hint: "F2", action: function () { rename(one); } } : null,
+                "-"
+            ].concat(moreSub()).concat([
+                "-",
+                writable ? { icon: "trash", label: T("menu/trash", "Move to trash"), danger: true, hint: "Del", action: function () { trash(items); } } : null
+            ]);
+        }
+
+        if (v.kind == "shared") {
+            var s = one && one.share;
+            return [
+                one ? { icon: "open", label: T("menu/open", "Open"), hint: "Enter", action: function () { open(one); } } : null,
+                one ? { icon: "newTab", label: T("menu/openNewTab", "Open in new tab"), action: function () { openInNewTab(one); } } : null,
+                one && s ? { icon: "link", label: T("menu/copyLink", "Copy link"), action: function () { copyLink(one); } } : null,
+                one && s && v.key == "shared:byme" ? { icon: "users", label: T("share/manageSharing", "Manage sharing"), action: function () { DriveShare.open(one); } } : null,
+                items.some(function (i) { return !i.shareOnly; }) ? { icon: "download", label: T("toolbar/download", "Download"), action: function () { download(items); } } : null,
+                "-",
+                { icon: "info", label: T("menu/details", "View details"), action: function () { Drive.toggleDetails(true); } },
+                v.key == "shared:byme" ? {
+                    icon: "x", label: T("share/stop", "Stop sharing"), danger: true, action: function () {
+                        Promise.all(items.map(function (i) { return API.shareDelete(i.share.UUID, i.path); })).then(function () {
+                            Drive.refresh();
+                            DriveUI.toast(T("share/stopped", "Sharing stopped"), { type: "success" });
+                        }).catch(DriveUI.error);
+                    }
+                } : null
+            ];
+        }
+
+        return [
+            one ? { icon: "open", label: T("menu/open", "Open"), hint: "Enter", action: function () { open(one); } } : null,
+            one ? { icon: "newTab", label: T("menu/openNewTab", "Open in new tab"), action: function () { openInNewTab(one); } } : null,
+            "-",
+            one ? { icon: "share", label: T("toolbar/share", "Share"), action: function () { DriveShare.open(one); } } : null,
+            one ? { icon: "link", label: T("menu/copyLink", "Copy link"), action: function () { copyLink(one); } } : null,
+            "-",
+            { icon: "download", label: T("toolbar/download", "Download"), hint: "Ctrl+S", action: function () { download(items); } },
+            writable ? { icon: "move", label: T("toolbar/move", "Move"), action: function () { moveTo(items); } } : null,
+            { icon: "copy", label: T("menu/copy", "Copy"), hint: "Ctrl+C", action: function () { clipboard("copy", items); } },
+            { icon: "folder", label: T("menu/copyTo", "Copy to..."), action: function () { copyTo(items); } },
+            writable && one ? { icon: "pencil", label: T("menu/rename", "Rename"), hint: "F2", action: function () { rename(one); } } : null,
+            one && one.isDir && S().clipboard && writable ? { icon: "paste", label: T("menu/pasteInto", "Paste into folder"), action: function () { paste(Drive.normDir(one.path)); } } : null,
+            "-",
+            writable ? { icon: "trash", label: T("menu/delete", "Delete"), danger: true, hint: "Del", action: function () { trash(items); } } : null,
+            "-",
+            { icon: "info", label: T("menu/details", "View details"), action: function () { Drive.toggleDetails(true); } },
+            { icon: "moreH", label: T("toolbar/more", "More"), submenu: moreSub }
+        ];
+    }
+
+    function contextMenu(pos, item) {
+        DriveUI.menu(pos, selectionMenu(Drive.selectedItems().length ? Drive.selectedItems() : [item], false));
+    }
+
+    function backgroundMenu(pos) {
+        var v = view();
+        var items = [];
+        if (v.kind == "drive" && v.writable) {
+            items = items.concat(newMenuItems(), ["-"]);
+        }
+        items.push(pasteItem());
+        items.push({ icon: "checkSquare", label: T("menu/selectAll", "Select all"), hint: "Ctrl+A", action: function () { DriveList.selectAll(); } });
+        items.push({ icon: "refresh", label: T("menu/refresh", "Refresh"), action: function () { Drive.refresh(); } });
+        items.push("-");
+        var grid = Drive.currentViewMode() == "grid";
+        items.push({ icon: grid ? "list" : "grid", label: grid ? T("view/list", "List view") : T("view/grid", "Grid view"), action: function () { document.querySelector('[data-mode="' + (grid ? "list" : "grid") + '"]').click(); } });
+        if (v.kind == "drive") {
+            items.push({ icon: "info", label: T("menu/folderDetails", "Folder details"), action: function () { Drive.setSelection([]); Drive.toggleDetails(true); } });
+        }
+        if (v.kind == "trash" && S().items.length) {
+            items.push("-", { icon: "x", label: T("trash/empty", "Empty trash"), danger: true, action: emptyTrash });
+        }
+        DriveUI.menu(pos, items);
+    }
+
+    global.DriveActions = {
+        open: open,
+        openInNewTab: openInNewTab,
+        openWithModule: openWithModule,
+        openWithChooser: openWithChooser,
+        openWithItems: openWithItems,
+        compatibleModules: compatibleModules,
+        defaultModuleFor: defaultModuleFor,
+        download: download,
+        copyLink: copyLink,
+        copyText: copyText,
+        copyPath: copyPath,
+        moveTo: moveTo,
+        copyTo: copyTo,
+        transfer: transfer,
+        duplicate: duplicate,
+        clipboard: clipboard,
+        paste: paste,
+        rename: rename,
+        trash: trash,
+        deleteForever: deleteForever,
+        restore: restore,
+        emptyTrash: emptyTrash,
+        star: star,
+        zip: zip,
+        unzip: unzip,
+        isArchive: isArchive,
+        newFolder: newFolder,
+        newFile: newFile,
+        upload: upload,
+        uploadFolder: uploadFolder,
+        newMenu: newMenu,
+        folderMenu: folderMenu,
+        moreMenu: moreMenu,
+        contextMenu: contextMenu,
+        backgroundMenu: backgroundMenu,
+        launchURL: launchURL,
+        fileHash: fileHash
+    };
+})(window);

+ 473 - 0
src/web/Drive/js/api.js

@@ -0,0 +1,473 @@
+/*
+    api.js
+
+    Every request Drive makes goes through here. Two transports:
+
+      rest  the existing system endpoints (/system/file_system/*, share,
+            media, modules, users, notification ...). The file system and
+            share endpoints are gated by the "File Manager" module
+            permission, so they are only used when the user has it.
+      agi   Drive/backend/*.agi, reachable by every user who can open
+            Drive (an interface module always grants access to its own
+            folder). fs.agi mirrors the file operations in a reduced form
+            for users without File Manager.
+
+    Every call returns a Promise. Server side {error: "..."} replies and
+    HTTP failures reject with an Error whose message is shown to the user.
+*/
+
+(function (global) {
+    "use strict";
+
+    //Empty: the document base (index.html) is the web root
+    var ROOT = "";
+    var caps = {
+        fm: true,          //File Manager REST endpoints usable
+        settings: false    //System Setting module accessible
+    };
+
+    function sessionLost() {
+        window.location.href = ROOT + "login.html?redirect=" + encodeURIComponent(window.location.pathname);
+    }
+
+    function errorOf(xhr, fallback) {
+        if (xhr && xhr.status == 401) {
+            return new Error("Session expired");
+        }
+        if (xhr && xhr.responseText) {
+            try {
+                var parsed = JSON.parse(xhr.responseText);
+                if (parsed && parsed.error) {
+                    return new Error(parsed.error);
+                }
+            } catch (e) { }
+            if (xhr.responseText.length < 200 && xhr.responseText.indexOf("<") < 0) {
+                return new Error(xhr.responseText.trim());
+            }
+        }
+        if (xhr && xhr.status == 0) {
+            return new Error(T("err/network", "Cannot reach the server"));
+        }
+        return new Error(fallback || ("HTTP " + (xhr ? xhr.status : "?")));
+    }
+
+    /*
+        request({url, method, data, json, timeout}) -> Promise
+        json=false returns the raw text.
+    */
+    function request(opts) {
+        return new Promise(function (resolve, reject) {
+            $.ajax({
+                url: opts.absolute ? opts.url : ROOT + opts.url,
+                method: opts.method || "GET",
+                data: opts.data || {},
+                dataType: opts.json === false ? "text" : "json",
+                timeout: opts.timeout || 0,
+                cache: false,
+                success: function (resp, status, xhr) {
+                    if (opts.json !== false && resp && typeof resp === "object" && !Array.isArray(resp) && resp.error !== undefined) {
+                        reject(new Error(String(resp.error)));
+                        return;
+                    }
+                    resolve(resp);
+                },
+                error: function (xhr, status) {
+                    var text = xhr.responseText || "";
+                    if (status == "parsererror" && /<html|<!doctype/i.test(text) && /login/i.test(text)) {
+                        //The session ended: the server answered with the login page
+                        sessionLost();
+                    }
+                    if (xhr.status == 401 && opts.url.indexOf("csrf") < 0) {
+                        reject(errorOf(xhr));
+                        return;
+                    }
+                    reject(errorOf(xhr));
+                }
+            });
+        });
+    }
+
+    function get(url, data) {
+        return request({ url: url, data: data });
+    }
+
+    function post(url, data) {
+        return request({ url: url, method: "POST", data: data });
+    }
+
+    function agi(script, data) {
+        return request({
+            url: "system/ajgi/interface?script=" + encodeURIComponent("Drive/backend/" + script),
+            method: "POST",
+            data: data || {}
+        });
+    }
+
+    function csrf() {
+        return request({ url: "system/csrf/new" });
+    }
+
+    function withToken(url, data) {
+        return csrf().then(function (token) {
+            data = data || {};
+            data.csrft = token;
+            return post(url, data);
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Item normalisation                                                 */
+    /* ------------------------------------------------------------------ */
+    function fromFileData(f) {
+        return {
+            name: f.Filename,
+            path: f.Filepath,
+            isDir: !!f.IsDir,
+            size: f.IsDir ? -1 : (f.Filesize || 0),
+            mtime: f.ModTime || f.Modtime || 0,
+            shared: !!f.IsShared,
+            shortcut: f.Shortcut || null
+        };
+    }
+
+    function joinPath(dir, name) {
+        if (dir.substring(dir.length - 1) != "/") {
+            dir += "/";
+        }
+        return dir + name;
+    }
+
+    function parentOf(path) {
+        var clean = String(path).replace(/\/+$/, "");
+        var at = clean.lastIndexOf("/");
+        if (at < 0) {
+            return clean + "/";
+        }
+        return clean.substring(0, at + 1);
+    }
+
+    function baseName(path) {
+        var clean = String(path).replace(/\/+$/, "");
+        var at = clean.lastIndexOf("/");
+        return at < 0 ? clean : clean.substring(at + 1);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Session, user and system                                           */
+    /* ------------------------------------------------------------------ */
+    var API = {
+        caps: caps,
+        ROOT: ROOT,
+        joinPath: joinPath,
+        parentOf: parentOf,
+        baseName: baseName,
+        request: request,
+        agi: agi,
+
+        boot: function () {
+            return agi("prefs.agi", { opr: "boot" });
+        },
+        userInfo: function () {
+            return get("system/users/userinfo");
+        },
+        modules: function () {
+            return get("system/modules/list");
+        },
+        interfaces: function () {
+            return get("system/users/interfaceinfo");
+        },
+        hostInfo: function () {
+            return get("system/info/getArOZInfo");
+        },
+        quota: function () {
+            return get("system/disk/quota/quotaInfo");
+        },
+        notifications: function () {
+            return get("system/notification/desktop/list");
+        },
+        logout: function () {
+            return request({ url: "system/auth/logout", json: false });
+        },
+        profilePicURL: function (username) {
+            return ROOT + "system/users/profilepic?user=" + encodeURIComponent(username);
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Preferences (always AGI)                                       */
+        /* -------------------------------------------------------------- */
+        saveSettings: function (settings) {
+            return agi("prefs.agi", { opr: "save", settings: JSON.stringify(settings) });
+        },
+        star: function (paths, on) {
+            return agi("prefs.agi", { opr: "star", paths: JSON.stringify(paths), on: on ? "true" : "false" });
+        },
+        starred: function () {
+            return agi("prefs.agi", { opr: "starred" });
+        },
+        recentAdd: function (path) {
+            if (global.Drive && Drive.state.recentOpened) {
+                Drive.state.recentOpened[path] = Math.floor(Date.now() / 1000);
+            }
+            return agi("prefs.agi", { opr: "recentAdd", path: path }).catch(function () { });
+        },
+        recentOpened: function () {
+            return agi("prefs.agi", { opr: "recent" });
+        },
+        recentClear: function () {
+            return agi("prefs.agi", { opr: "recentClear" });
+        },
+        scan: function (opts) {
+            return agi("scan.agi", opts);
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Listing                                                        */
+        /* -------------------------------------------------------------- */
+        list: function (dir, showHidden) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "list", dir: dir });
+            }
+            return post("system/file_system/listDir", {
+                dir: dir, sort: "default", showHidden: showHidden ? "true" : "false"
+            }).then(function (data) {
+                return (data || []).map(fromFileData);
+            });
+        },
+
+        search: function (root, keyword) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "search", path: root, keyword: keyword });
+            }
+            return post("system/file_system/search", { path: root, keyword: keyword, casesensitive: "false" })
+                .then(function (data) {
+                    return (data || []).map(fromFileData);
+                });
+        },
+
+        props: function (path) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "props", path: path }).then(function (p) {
+                    return {
+                        VirtualPath: p.path, Basename: p.name, VirtualDirname: parentOf(p.path),
+                        Filesize: p.size, LastModUnix: p.mtime, CreationUnix: 0, IsDirectory: p.isDir,
+                        Owner: "", MimeType: "", Permission: "", FileCount: p.fileCount
+                    };
+                });
+            }
+            return post("system/file_system/getProperties", { path: path });
+        },
+
+        templates: function () {
+            if (!caps.fm) {
+                return Promise.resolve([{ Desc: "Text Document", Ext: "txt" }]);
+            }
+            return withToken("system/file_system/newItem", {}).then(function (list) {
+                return list || [];
+            });
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Creating, renaming and deleting                                */
+        /* -------------------------------------------------------------- */
+        mkdir: function (dir, name) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "mkdir", dir: dir, name: name });
+            }
+            return withToken("system/file_system/newItem", { type: "folder", src: dir, filename: name });
+        },
+
+        newFile: function (dir, name) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "newfile", dir: dir, name: name });
+            }
+            return withToken("system/file_system/newItem", { type: "file", src: dir, filename: name });
+        },
+
+        rename: function (path, newName) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "rename", path: path, name: newName });
+            }
+            return withToken("system/file_system/fileOpr", {
+                opr: "rename", src: JSON.stringify([path]), new: JSON.stringify([newName])
+            });
+        },
+
+        //Move to the trash bin. Permanent in the reduced mode (no trash there)
+        recycle: function (paths) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "delete", paths: JSON.stringify(paths) });
+            }
+            return withToken("system/file_system/fileOpr", { opr: "recycle", src: JSON.stringify(paths) });
+        },
+
+        remove: function (paths) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "delete", paths: JSON.stringify(paths) });
+            }
+            return withToken("system/file_system/fileOpr", { opr: "delete", src: JSON.stringify(paths) });
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Background operations (move / copy / zip / unzip)              */
+        /* -------------------------------------------------------------- */
+        duplicates: function (paths, dest) {
+            if (!caps.fm) {
+                return Promise.resolve([]);
+            }
+            return post("system/file_system/validateFileOpr", { src: JSON.stringify(paths), dest: dest });
+        },
+
+        /*
+            Resolves {oprid} for a background task, or {sync: true} when the
+            reduced mode finished it in the request itself.
+        */
+        startOperation: function (opr, paths, dest, existsresp) {
+            if (!caps.fm) {
+                if (opr != "copy" && opr != "move") {
+                    return Promise.reject(new Error(T("err/needFM", "This action needs the File Manager permission")));
+                }
+                return agi("fs.agi", {
+                    opr: opr, paths: JSON.stringify(paths), dest: dest, existsresp: existsresp || "keep"
+                }).then(function () {
+                    return { sync: true };
+                });
+            }
+            return withToken("system/file_system/fileOprAsync", {
+                opr: opr, src: JSON.stringify(paths), dest: dest, existsresp: existsresp || "keep"
+            });
+        },
+
+        ongoing: function () {
+            return get("system/file_system/ongoing", { all: "true" });
+        },
+
+        controlOperation: function (oprid, flag) {
+            return get("system/file_system/ongoing", { flag: flag, oprid: oprid });
+        },
+
+        zipForDownload: function (paths) {
+            return post("system/file_system/zipHandler", { opr: "tmpzipAsync", src: JSON.stringify(paths), dest: "" });
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Trash                                                          */
+        /* -------------------------------------------------------------- */
+        trashList: function () {
+            return get("system/file_system/listTrash").then(function (list) {
+                return (list || []).map(function (t) {
+                    return {
+                        name: t.OriginalFilename,
+                        path: t.Filepath,
+                        isDir: t.IsDir,
+                        size: t.IsDir ? -1 : t.Filesize,
+                        mtime: t.RemoveTimestamp,
+                        origin: t.OriginalPath,
+                        trashed: true
+                    };
+                });
+            });
+        },
+        trashRestore: function (path) {
+            return post("system/file_system/restoreTrash", { src: path });
+        },
+        trashClear: function () {
+            return post("system/file_system/clearTrash", {});
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Versions                                                       */
+        /* -------------------------------------------------------------- */
+        versions: function (path) {
+            return post("system/file_system/versionHistory", { path: path });
+        },
+        versionOp: function (path, opr, histid) {
+            var data = { path: path, opr: opr };
+            if (histid) {
+                data.histid = histid;
+            }
+            return post("system/file_system/versionHistory", data);
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Share                                                          */
+        /* -------------------------------------------------------------- */
+        shareInfo: function (path) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "shareinfo", path: path }).then(function (r) {
+                    return {
+                        Path: path, IsShared: r.shared, CanManage: true, reduced: true,
+                        Shares: r.shared ? [{
+                            UUID: r.uuid, Permission: "anyone", Accessibles: [], AccessLevel: "view",
+                            AllowDownload: true, ShowFileList: true, HasPassword: false, ExpireAt: 0, IsExpired: false
+                        }] : []
+                    };
+                });
+            }
+            return get("system/file_system/share/info", { path: path });
+        },
+        shareCreate: function (path, settings) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "share", path: path });
+            }
+            var data = $.extend({ path: path }, settings || {});
+            return post("system/file_system/share/create", data);
+        },
+        shareUpdate: function (uuid, settings) {
+            var data = $.extend({ uuid: uuid }, settings || {});
+            return post("system/file_system/share/update", data);
+        },
+        shareDelete: function (uuid, path) {
+            if (!caps.fm) {
+                return agi("fs.agi", { opr: "unshare", path: path });
+            }
+            return post("system/file_system/share/delete", { uuid: uuid });
+        },
+        shareRecipients: function () {
+            return get("system/file_system/share/recipients");
+        },
+        shareList: function () {
+            return get("system/file_system/share/list");
+        },
+        shareURL: function (uuid) {
+            return window.location.origin + "/share/" + uuid;
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Files as URLs                                                  */
+        /* -------------------------------------------------------------- */
+        mediaURL: function (path) {
+            return ROOT + "media/?file=" + encodeURIComponent(path);
+        },
+        downloadURL: function (path) {
+            return ROOT + "media/download/?file=" + encodeURIComponent(path);
+        },
+        thumbURL: function (path) {
+            return ROOT + "system/file_system/loadThumbnail?bytes=true&vpath=" + encodeURIComponent(path);
+        },
+        thumbFallback: function (path) {
+            return agi("fs.agi", { opr: "thumb", path: path }).then(function (r) {
+                return r && r.data ? r.data : "";
+            });
+        },
+        readText: function (path) {
+            return request({ url: "media/?file=" + encodeURIComponent(path), json: false });
+        },
+
+        /* -------------------------------------------------------------- */
+        /*  Openers                                                        */
+        /* -------------------------------------------------------------- */
+        defaultOpener: function (ext) {
+            return get("system/modules/getDefault", { opr: "launch", ext: ext }).catch(function () {
+                return null;
+            });
+        },
+        setDefaultOpener: function (ext, moduleName) {
+            return get("system/modules/getDefault", { opr: "set", ext: ext, module: moduleName });
+        },
+        launchInfo: function (moduleName) {
+            return get("system/modules/getLaunchPara", { module: moduleName });
+        }
+    };
+
+    global.DriveAPI = API;
+})(window);

+ 1736 - 0
src/web/Drive/js/app.js

@@ -0,0 +1,1736 @@
+/*
+    app.js
+
+    Boot, state, routing and the chrome around the file list: top bar,
+    sidebar, view header and toolbar, notifications and keyboard shortcuts.
+
+    Routes (location.hash)
+        #/drive/<vpath>        a folder (user:/ is "My Drive")
+        #/shared[?tab=byme]    shares with me / by me
+        #/recent  #/starred  #/trash
+        #/category/<group>     documents | photos | videos | music | others
+        #/search/<query>?in=<vpath>
+        #/settings             Drive settings (System Settings opens in the
+                               app host instead, see apphost.js)
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    var DEFAULT_SETTINGS = {
+        theme: "auto", density: "comfortable", view: "list", sortKey: "name", sortDir: "asc",
+        foldersFirst: true, showHidden: false, thumbnails: true, pageSize: 100, openIn: "drive",
+        confirmTrash: false, detailsOpen: false
+    };
+
+    var state = {
+        user: { name: "", icon: "", groups: [], isAdmin: false },
+        hostName: "",
+        version: "",
+        settings: $.extend({}, DEFAULT_SETTINGS),
+        starred: new Set(),
+        roots: [],
+        modules: [],
+        moduleByName: {},
+        interfaces: [],
+        quota: { total: -1, used: 0 },
+        route: null,
+        view: null,
+        items: [],
+        sorted: [],
+        page: 1,
+        selection: new Set(),
+        anchor: null,
+        focus: null,
+        clipboard: null,
+        loadToken: 0,
+        loading: false,
+        loadError: null,
+        searchScope: null,
+        recentOpened: {},
+        notifications: [],
+        detailsOpen: false
+    };
+
+    /* ------------------------------------------------------------------ */
+    /*  Paths and roots                                                    */
+    /* ------------------------------------------------------------------ */
+    function rootById(id) {
+        for (var i = 0; i < state.roots.length; i++) {
+            if (state.roots[i].uuid == id) {
+                return state.roots[i];
+            }
+        }
+        return null;
+    }
+
+    function rootIdOf(vpath) {
+        var at = String(vpath).indexOf(":/");
+        return at > 0 ? vpath.substring(0, at) : "";
+    }
+
+    function rootOf(vpath) {
+        return rootById(rootIdOf(vpath));
+    }
+
+    function rootLabel(root) {
+        if (!root) {
+            return "";
+        }
+        if (root.uuid == "user") {
+            return T("nav/myDrive", "My Drive");
+        }
+        if (root.uuid == "tmp" && (!root.name || root.name.toLowerCase() == "tmp")) {
+            return T("nav/tmp", "Temporary");
+        }
+        return root.name || root.uuid;
+    }
+
+    function rootIcon(root) {
+        if (!root) {
+            return "drive";
+        }
+        var fs = String(root.fs || "").toLowerCase();
+        if (root.uuid == "user") {
+            return "home";
+        }
+        if (root.uuid == "tmp") {
+            return "hourglass";
+        }
+        if (root.uuid == "cluster" || fs == "clusterfs") {
+            return "cluster";
+        }
+        if (["smb", "ftp", "sftp", "webdav", "nfs"].indexOf(fs) >= 0) {
+            return "globe";
+        }
+        if (root.hierarchy == "public") {
+            return "users";
+        }
+        if (root.hierarchy == "backup") {
+            return "history";
+        }
+        return "drive";
+    }
+
+    function normDir(path) {
+        path = String(path || "user:/");
+        if (path.substring(path.length - 1) != "/") {
+            path += "/";
+        }
+        return path.replace(/([^:])\/\/+/g, "$1/");
+    }
+
+    function pathParts(vpath) {
+        var m = /^([^:\/]+):\/(.*)$/.exec(vpath || "");
+        if (!m) {
+            return [{ label: vpath, path: vpath }];
+        }
+        var root = rootById(m[1]) || { uuid: m[1], name: m[1] };
+        var parts = [{ label: rootLabel(root), path: m[1] + ":/" }];
+        var acc = m[1] + ":/";
+        m[2].split("/").filter(Boolean).forEach(function (seg) {
+            acc += seg + "/";
+            parts.push({ label: seg, path: acc });
+        });
+        return parts;
+    }
+
+    function locationLabel(vpath) {
+        var parts = pathParts(API.parentOf(vpath));
+        return parts.map(function (p) { return p.label; }).join(" / ");
+    }
+
+    function isWritable(vpath) {
+        var r = rootOf(vpath);
+        return !(r && r.readonly);
+    }
+
+    function folderRoute(vpath) {
+        return "#/drive/" + encodeURIComponent(normDir(vpath));
+    }
+
+    //An in-page link. The document base is the web root, so a bare "#/..."
+    //href would point at the root page instead of Drive
+    function href(route) {
+        return location.pathname + location.search + route;
+    }
+
+    function module(name) {
+        return state.moduleByName[String(name).toLowerCase()] || null;
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Settings and theme                                                 */
+    /* ------------------------------------------------------------------ */
+    var darkQuery = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
+
+    function effectiveTheme() {
+        var t = state.settings.theme;
+        if (t == "auto") {
+            return darkQuery && darkQuery.matches ? "dark" : "light";
+        }
+        return t;
+    }
+
+    function applyTheme() {
+        var theme = effectiveTheme();
+        document.documentElement.setAttribute("data-theme", theme);
+        document.documentElement.classList.toggle("density-compact", state.settings.density == "compact");
+        var meta = document.querySelector('meta[name="theme-color"]');
+        if (meta) {
+            meta.setAttribute("content", theme == "dark" ? "#0e1117" : "#f3f6fb");
+        }
+        try {
+            localStorage.setItem("drive_ui_cache", JSON.stringify({ theme: state.settings.theme, density: state.settings.density }));
+        } catch (e) { }
+        if (global.DriveHost) {
+            DriveHost.broadcastTheme(theme);
+        }
+    }
+
+    if (darkQuery) {
+        var onScheme = function () {
+            if (state.settings.theme == "auto") {
+                applyTheme();
+            }
+        };
+        if (darkQuery.addEventListener) {
+            darkQuery.addEventListener("change", onScheme);
+        } else if (darkQuery.addListener) {
+            darkQuery.addListener(onScheme);
+        }
+    }
+
+    var saveTimer = null;
+    function saveSettings(partial, silent) {
+        $.extend(state.settings, partial || {});
+        applyTheme();
+        clearTimeout(saveTimer);
+        saveTimer = setTimeout(function () {
+            API.saveSettings(state.settings).then(function (clean) {
+                if (clean && typeof clean === "object") {
+                    $.extend(state.settings, clean);
+                }
+            }).catch(function (err) {
+                if (!silent) {
+                    DriveUI.error(err);
+                }
+            });
+        }, 300);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Boot                                                               */
+    /* ------------------------------------------------------------------ */
+    function paintStaticIcons() {
+        document.getElementById("brandLogo").innerHTML = DriveIcons.logo(34);
+        document.getElementById("brand").setAttribute("href", href(folderRoute("user:/")));
+        document.getElementById("drawerLogo").innerHTML = DriveIcons.logo(30);
+        document.getElementById("navToggle").innerHTML = I("menu");
+        document.getElementById("searchIcon").outerHTML = I("search");
+        document.getElementById("searchToggle").innerHTML = I("search");
+        document.getElementById("searchClose").innerHTML = I("x");
+        document.getElementById("bellBtn").innerHTML = I("bell") + '<span class="badge" id="bellBadge" hidden></span>';
+        document.getElementById("helpBtn").innerHTML = I("help");
+        document.getElementById("userCaret").outerHTML = I("chevronDown");
+        document.getElementById("hostNewTab").innerHTML = I("external");
+        document.getElementById("hostClose").innerHTML = I("x");
+    }
+
+    function boot() {
+        paintStaticIcons();
+        DriveI18n.load(function () {
+            DriveI18n.translate(document);
+            document.getElementById("hostBack").innerHTML = I("arrowLeft") + '<span class="lbl">' + esc(T("host/back", "Back to Drive")) + '</span>';
+            renderSkeletonHead();
+            loadSession();
+        });
+    }
+
+    function renderSkeletonHead() {
+        document.getElementById("listWrap").innerHTML = skeletonRows();
+    }
+
+    function skeletonRows() {
+        var html = '<div class="rows">';
+        for (var i = 0; i < 8; i++) {
+            html += '<div class="skeleton-row" style="opacity:' + (1 - i * 0.1) + '"></div>';
+        }
+        return html + "</div>";
+    }
+
+    function loadSession() {
+        var bootP = API.boot().catch(function (err) {
+            console.warn("[Drive] prefs.agi unavailable, using defaults", err);
+            return null;
+        });
+        var userP = API.userInfo().catch(function () { return null; });
+        var modP = API.modules().catch(function () { return null; });
+        var ifP = API.interfaces().catch(function () { return []; });
+        var hostP = API.hostInfo().catch(function () { return null; });
+
+        Promise.all([bootP, userP, modP, ifP, hostP]).then(function (r) {
+            var bootData = r[0], user = r[1], modules = r[2], interfaces = r[3], host = r[4];
+
+            if (user && user.Username) {
+                state.user.name = user.Username;
+                state.user.icon = user.Icondata || "";
+                state.user.groups = user.Usergroup || [];
+            }
+            if (bootData) {
+                state.user.name = state.user.name || bootData.username;
+                state.user.isAdmin = !!bootData.isAdmin;
+                if (!state.user.groups.length) {
+                    state.user.groups = bootData.groups || [];
+                }
+                state.settings = $.extend({}, DEFAULT_SETTINGS, bootData.settings || {});
+                state.starred = new Set(bootData.starred || []);
+                state.recentOpened = {};
+                (bootData.recent || []).forEach(function (r) {
+                    if (r && r.path) {
+                        state.recentOpened[r.path] = r.time;
+                    }
+                });
+                state.roots = bootData.roots || [];
+                state.quota = bootData.quota || state.quota;
+            }
+
+            if (Array.isArray(modules)) {
+                state.modules = modules;
+                state.moduleByName = {};
+                modules.forEach(function (m) {
+                    state.moduleByName[String(m.Name).toLowerCase()] = m;
+                });
+                API.caps.fm = !!module("File Manager");
+                API.caps.settings = !!module("System Setting");
+            } else {
+                //Unknown: try the REST API and fall back on the first denial
+                API.caps.fm = true;
+                API.caps.settings = false;
+            }
+            state.interfaces = Array.isArray(interfaces) ? interfaces : [];
+            if (host) {
+                state.hostName = host.HostName || "";
+                state.version = host.BuildVersion || "";
+            }
+            //Only a wide screen keeps the panel open across visits; on a phone it is a sheet
+            state.detailsOpen = !!state.settings.detailsOpen && window.innerWidth > 1100;
+
+            applyTheme();
+
+            var rootsReady = state.roots.length ? Promise.resolve() : loadRootsREST();
+            rootsReady.then(function () {
+                renderUser();
+                renderSidebar();
+                renderStorage();
+                DriveHost.install();
+                bindGlobalEvents();
+                startNotifications();
+                if (!location.hash || location.hash == "#" || location.hash == "#/") {
+                    history.replaceState(null, "", href(folderRoute("user:/")));
+                }
+                route();
+                setInterval(refreshQuota, 90000);
+            });
+        });
+    }
+
+    function loadRootsREST() {
+        return API.request({ url: "system/file_system/listRoots" }).then(function (list) {
+            state.roots = (list || []).map(function (r) {
+                var uuid = String(r.RootPath).replace(":/", "");
+                return { name: r.RootName, uuid: uuid, path: r.RootPath, hierarchy: uuid == "user" ? "user" : "", readonly: false, fs: "" };
+            });
+        }).catch(function () {
+            state.roots = [{ name: "User", uuid: "user", path: "user:/", hierarchy: "user", readonly: false, fs: "" }];
+        });
+    }
+
+    function refreshQuota() {
+        API.quota().then(function (q) {
+            if (q && q.Total !== undefined) {
+                state.quota = { total: q.Total, used: q.Used };
+                renderStorage();
+            }
+        }).catch(function () { });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Top bar: user, notifications, help                                 */
+    /* ------------------------------------------------------------------ */
+    function avatarHTML(cls) {
+        if (state.user.icon) {
+            return '<img class="avatar ' + (cls || "") + '" src="' + esc(state.user.icon) + '" alt="">';
+        }
+        return '<span class="avatar ' + (cls || "") + '">' + esc((state.user.name || "?").charAt(0).toUpperCase()) + '</span>';
+    }
+
+    function userSubtitle() {
+        if (state.user.groups.length) {
+            return state.user.groups.join(", ");
+        }
+        return state.hostName ? state.user.name + "@" + state.hostName : "";
+    }
+
+    function renderUser() {
+        var holder = document.getElementById("userAvatar");
+        holder.outerHTML = avatarHTML().replace('class="avatar ', 'id="userAvatar" class="avatar ');
+        document.getElementById("userName").textContent = state.user.name;
+        document.getElementById("userSub").textContent = userSubtitle();
+        document.title = T("app/title", "Drive") + (state.hostName ? " - " + state.hostName : "");
+    }
+
+    function openUserMenu() {
+        var anchor = document.getElementById("userBtn");
+        var node = DriveUI.el('<div class="popover" style="width:300px"></div>');
+        node.innerHTML = '<div class="user-card">' + avatarHTML() + '<div style="min-width:0"><b>' + esc(state.user.name) + '</b><small>' +
+            esc(userSubtitle()) + '</small></div></div><div class="popover-body" style="padding:6px"></div>';
+        var body = node.querySelector(".popover-body");
+        var isDark = effectiveTheme() == "dark";
+        var items = [
+            { icon: "sliders", label: T("user/driveSettings", "Drive settings"), action: function () { location.hash = "#/settings"; } },
+            API.caps.settings ? { icon: "cog", label: T("user/systemSettings", "System Settings"), action: function () { DriveHost.openSystemSettings(); } } : null,
+            { icon: isDark ? "sun" : "moon", label: isDark ? T("user/lightMode", "Switch to light theme") : T("user/darkMode", "Switch to dark theme"), action: function () { saveSettings({ theme: isDark ? "light" : "dark" }); } },
+            state.interfaces.length > 1 ? { icon: "apps", label: T("user/switchInterface", "Switch workspace"), action: function () { window.location.href = API.ROOT + "SystemAO/boot/interface_selector.html"; } } : null,
+            "-",
+            { icon: "logout", label: T("user/logout", "Sign out"), danger: true, action: logout }
+        ];
+        items.forEach(function (item) {
+            if (!item) {
+                return;
+            }
+            if (item === "-") {
+                body.appendChild(DriveUI.el('<div class="menu-sep"></div>'));
+                return;
+            }
+            var b = DriveUI.el('<button class="menu-item' + (item.danger ? " danger" : "") + '">' + I(item.icon) + '<span class="label">' + esc(item.label) + '</span></button>');
+            b.addEventListener("click", function () {
+                DriveUI.closePopover();
+                item.action();
+            });
+            body.appendChild(b);
+        });
+        DriveUI.openPopover(anchor, node);
+    }
+
+    function logout() {
+        DriveUI.confirm({ title: T("user/logout", "Sign out"), message: T("user/logoutConfirm", "Sign out of ArozOS on this browser?"), ok: T("user/logout", "Sign out") }).then(function (ok) {
+            if (!ok) {
+                return;
+            }
+            API.logout().then(function () {
+                window.location.href = API.ROOT;
+            }).catch(function () {
+                window.location.href = API.ROOT;
+            });
+        });
+    }
+
+    function openHelpMenu() {
+        var anchor = document.getElementById("helpBtn");
+        DriveUI.menu({ anchor: anchor, align: "end" }, [
+            { icon: "keyboard", label: T("help/shortcuts", "Keyboard shortcuts"), hint: "?", action: showShortcuts },
+            { icon: "info", label: T("help/about", "About Drive"), action: showAbout },
+            state.interfaces.length > 1 ? { icon: "apps", label: T("user/switchInterface", "Switch workspace"), action: function () { window.location.href = API.ROOT + "SystemAO/boot/interface_selector.html"; } } : null
+        ]);
+    }
+
+    function showShortcuts() {
+        var rows = [
+            ["shortcut/open", "Open", "Enter"], ["shortcut/preview", "Preview", "Space"],
+            ["shortcut/select", "Select all", "Ctrl + A"], ["shortcut/copy", "Copy / Cut / Paste", "Ctrl + C / X / V"],
+            ["shortcut/rename", "Rename", "F2"], ["shortcut/trash", "Move to trash", "Delete"],
+            ["shortcut/delete", "Delete forever", "Shift + Delete"], ["shortcut/download", "Download", "Ctrl + S"],
+            ["shortcut/newFolder", "New folder", "Shift + F"], ["shortcut/upload", "Upload files", "Shift + U"],
+            ["shortcut/up", "Parent folder", "Backspace"], ["shortcut/details", "Show or hide details", "I"],
+            ["shortcut/search", "Search", "/"], ["shortcut/move", "Move the focus", "Arrow keys"]
+        ];
+        var html = '<div class="shortcut-grid">' + rows.map(function (r) {
+            return '<span>' + esc(T(r[0], r[1])) + '</span><span>' + r[2].split(" / ").map(function (k) {
+                return k.split(" + ").map(function (p) { return '<span class="kbd">' + esc(p) + '</span>'; }).join(" + ");
+            }).join(" / ") + '</span>';
+        }).join("") + '</div>';
+        DriveUI.modal({ title: T("help/shortcuts", "Keyboard shortcuts"), icon: "keyboard", body: html, buttons: [{ label: T("common/close", "Close"), cls: "primary" }] });
+    }
+
+    function showAbout() {
+        var html = '<div style="display:flex;gap:16px;align-items:center;margin-bottom:14px">' +
+            '<img src="Drive/img/module_icon.svg" width="64" height="64" alt="">' +
+            '<div><b style="font-size:16px">ArozOS Drive</b><div class="muted">' + esc(T("about/desc", "A cloud drive workspace for your ArozOS files")) + '</div></div></div>' +
+            '<dl class="kv"><dt>' + esc(T("about/host", "Host")) + '</dt><dd>' + esc(state.hostName || "-") + '</dd>' +
+            '<dt>' + esc(T("about/version", "ArozOS")) + '</dt><dd>' + esc(state.version || "-") + '</dd>' +
+            '<dt>' + esc(T("about/mode", "File access")) + '</dt><dd>' + esc(API.caps.fm ? T("about/full", "Full (File Manager API)") : T("about/reduced", "Limited (AGI fallback)")) + '</dd></dl>';
+        DriveUI.modal({ title: T("help/about", "About Drive"), icon: "info", body: html, buttons: [{ label: T("common/close", "Close"), cls: "primary" }] });
+    }
+
+    /* Notifications ---------------------------------------------------- */
+    function notifKey() {
+        return "drive_notifications_" + state.user.name;
+    }
+
+    function loadStoredNotifications() {
+        try {
+            state.notifications = JSON.parse(localStorage.getItem(notifKey()) || "[]");
+        } catch (e) {
+            state.notifications = [];
+        }
+    }
+
+    function storeNotifications() {
+        try {
+            localStorage.setItem(notifKey(), JSON.stringify(state.notifications.slice(0, 60)));
+        } catch (e) { }
+    }
+
+    function updateBell() {
+        var unread = state.notifications.filter(function (n) { return !n.read; }).length;
+        var badge = document.getElementById("bellBadge");
+        if (!badge) {
+            return;
+        }
+        badge.hidden = unread == 0;
+        badge.textContent = unread > 9 ? "9+" : String(unread);
+    }
+
+    function pollNotifications() {
+        API.notifications().then(function (items) {
+            if (!Array.isArray(items) || items.length == 0) {
+                return;
+            }
+            var known = {};
+            state.notifications.forEach(function (n) { known[n.id] = true; });
+            items.forEach(function (item) {
+                if (item.id && known[item.id]) {
+                    return;
+                }
+                item.read = false;
+                state.notifications.unshift(item);
+                DriveUI.toast((item.title || T("notif/title", "Notification")) + (item.message ? ": " + item.message : ""), {
+                    action: notificationTarget(item) ? { label: T("notif/open", "Open"), fn: function () { openNotification(item); } } : null
+                });
+            });
+            storeNotifications();
+            updateBell();
+        }).catch(function () { });
+    }
+
+    function startNotifications() {
+        loadStoredNotifications();
+        updateBell();
+        pollNotifications();
+        setInterval(pollNotifications, 15000);
+    }
+
+    function notificationTarget(item) {
+        if (!item.payload) {
+            return null;
+        }
+        try {
+            var p = JSON.parse(item.payload);
+            if (p && typeof p.url === "string") {
+                return p;
+            }
+        } catch (e) { }
+        return null;
+    }
+
+    function openNotification(item) {
+        var target = notificationTarget(item);
+        item.read = true;
+        storeNotifications();
+        updateBell();
+        if (target) {
+            DriveHost.open({ url: target.url, title: target.title || item.title, icon: target.appicon });
+        }
+    }
+
+    function openNotifications() {
+        var anchor = document.getElementById("bellBtn");
+        var node = DriveUI.el('<div class="popover"><div class="popover-head">' + I("bell") + '<span>' + esc(T("top/notifications", "Notifications")) +
+            '</span><span class="spacer"></span><button class="btn ghost small" data-act="read">' + esc(T("notif/markRead", "Mark all read")) +
+            '</button><button class="icon-btn small" data-act="clear" title="' + esc(T("notif/clear", "Clear all")) + '">' + I("trash") + '</button></div>' +
+            '<div class="popover-body"></div></div>');
+        var body = node.querySelector(".popover-body");
+        function paint() {
+            if (state.notifications.length == 0) {
+                body.innerHTML = '<div class="state" style="padding:36px 16px">' + I("bell", { size: 34 }) + '<p>' + esc(T("notif/none", "You are all caught up")) + '</p></div>';
+                return;
+            }
+            body.innerHTML = "";
+            state.notifications.forEach(function (n) {
+                var clickable = !!notificationTarget(n);
+                var row = DriveUI.el('<div class="notif' + (n.read ? "" : " unread") + (clickable ? " clickable" : "") + '">' +
+                    '<div class="dot ' + (n.priority == "high" || n.priority == "critical" ? "high" : "") + '">' + I(n.priority == "high" ? "alert" : "bell") + '</div>' +
+                    '<div class="body"><b>' + esc(n.title || T("notif/title", "Notification")) + '</b>' +
+                    (n.message ? '<p>' + esc(n.message) + '</p>' : "") +
+                    '<small>' + esc((n.sender ? n.sender + " · " : "") + DriveUI.fmtRelative(n.timestamp)) + '</small></div></div>');
+                if (clickable) {
+                    row.addEventListener("click", function () {
+                        DriveUI.closePopover();
+                        openNotification(n);
+                    });
+                }
+                body.appendChild(row);
+            });
+        }
+        node.querySelector('[data-act="read"]').addEventListener("click", function () {
+            state.notifications.forEach(function (n) { n.read = true; });
+            storeNotifications();
+            updateBell();
+            paint();
+        });
+        node.querySelector('[data-act="clear"]').addEventListener("click", function () {
+            state.notifications = [];
+            storeNotifications();
+            updateBell();
+            paint();
+        });
+        paint();
+        DriveUI.openPopover(anchor, node, {
+            onClose: function () {
+                state.notifications.forEach(function (n) { n.read = true; });
+                storeNotifications();
+                updateBell();
+            }
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Sidebar                                                            */
+    /* ------------------------------------------------------------------ */
+    function navItem(route, icon, label, extra) {
+        extra = extra || {};
+        return '<a class="nav-item" href="' + esc(href(route)) + '" data-route="' + esc(extra.key || route) + '"' +
+            (extra.drop ? ' data-drop="' + esc(extra.drop) + '"' : "") + (extra.title ? ' title="' + esc(extra.title) + '"' : "") + '>' +
+            I(icon) + '<span>' + esc(label) + '</span>' + (extra.small ? '<small>' + esc(extra.small) + '</small>' : "") + '</a>';
+    }
+
+    function renderSidebar() {
+        var nav = document.getElementById("sidebarNav");
+        var html = '<nav class="nav-group">' +
+            navItem(folderRoute("user:/"), "home", T("nav/myDrive", "My Drive"), { key: "drive:user", drop: "user:/" }) +
+            navItem("#/shared", "users", T("nav/shared", "Shared with me"), { key: "shared" }) +
+            navItem("#/recent", "clock", T("nav/recent", "Recent"), { key: "recent" }) +
+            navItem("#/starred", "star", T("nav/starred", "Starred"), { key: "starred" }) +
+            navItem("#/trash", "trash", T("nav/trash", "Trash"), { key: "trash" }) +
+            '</nav>';
+        var others = state.roots.filter(function (r) { return r.uuid != "user"; });
+        if (others.length) {
+            html += '<div class="nav-sep"></div><div class="nav-title">' + esc(T("nav/drives", "Storage")) + '</div><nav class="nav-group">';
+            others.forEach(function (r) {
+                html += navItem(folderRoute(r.path), rootIcon(r), rootLabel(r), {
+                    key: "drive:" + r.uuid, drop: r.readonly ? "" : r.path, title: rootLabel(r) + " (" + r.path + ")",
+                    small: r.readonly ? T("nav/readonly", "Read only") : ""
+                });
+            });
+            html += '</nav>';
+        }
+        html += '<div class="nav-sep"></div><div class="nav-title">' + esc(T("nav/categories", "Categories")) + '</div><nav class="nav-group">' +
+            navItem("#/category/documents", "doc", T("cat/documents", "Documents"), { key: "category:documents" }) +
+            navItem("#/category/photos", "image", T("cat/photos", "Photos"), { key: "category:photos" }) +
+            navItem("#/category/videos", "video", T("cat/videos", "Videos"), { key: "category:videos" }) +
+            navItem("#/category/music", "music", T("cat/music", "Music"), { key: "category:music" }) +
+            navItem("#/category/others", "layers", T("cat/others", "Others"), { key: "category:others" }) +
+            '</nav>';
+        nav.innerHTML = html;
+        nav.querySelectorAll(".nav-item").forEach(function (a) {
+            a.addEventListener("click", function () {
+                closeDrawer();
+                if (DriveHost.isVisible()) {
+                    DriveHost.hide();
+                }
+                if (a.getAttribute("href") == href(location.hash)) {
+                    refresh();
+                }
+            });
+        });
+        DriveList.bindDropTargets(nav);
+        highlightNav();
+    }
+
+    function navKey() {
+        var r = state.route;
+        if (!r) {
+            return "";
+        }
+        if (r.kind == "drive") {
+            return "drive:" + rootIdOf(r.arg || "user:/");
+        }
+        if (r.kind == "category") {
+            return "category:" + r.arg;
+        }
+        return r.kind;
+    }
+
+    function highlightNav() {
+        var key = navKey();
+        document.querySelectorAll("#sidebarNav .nav-item").forEach(function (a) {
+            a.classList.toggle("active", a.getAttribute("data-route") == key);
+        });
+    }
+
+    function renderStorage() {
+        var box = document.getElementById("storage");
+        var q = state.quota;
+        var text, pct = 0;
+        if (q.total > 0) {
+            pct = Math.min(100, q.used / q.total * 100);
+            text = T("storage/usedOf", "{used} of {total} used", { used: DriveUI.fmtSize(q.used), total: DriveUI.fmtSize(q.total) });
+        } else {
+            text = T("storage/usedUnlimited", "{used} used · no quota", { used: DriveUI.fmtSize(q.used || 0) });
+        }
+        box.innerHTML = '<div class="storage-head">' + I("cloud") + '<span>' + esc(T("storage/title", "Storage")) + '</span></div>' +
+            '<div class="meter"><i class="' + (pct > 95 ? "full" : pct > 80 ? "warn" : "") + '" style="width:' + (q.total > 0 ? pct.toFixed(1) : 2) + '%"></i></div>' +
+            '<div class="storage-text">' + esc(text) + '</div>';
+    }
+
+    function openDrawer() {
+        document.documentElement.classList.add("nav-open");
+    }
+
+    function closeDrawer() {
+        document.documentElement.classList.remove("nav-open");
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Routing and views                                                  */
+    /* ------------------------------------------------------------------ */
+    function parseRoute() {
+        var h = location.hash.replace(/^#\/?/, "");
+        var query = "";
+        var qi = h.indexOf("?");
+        if (qi >= 0) {
+            query = h.substring(qi + 1);
+            h = h.substring(0, qi);
+        }
+        var seg = h.split("/");
+        var kind = seg[0] || "drive";
+        var arg = "";
+        try {
+            arg = decodeURIComponent(seg.slice(1).join("/"));
+        } catch (e) {
+            arg = seg.slice(1).join("/");
+        }
+        var params = {};
+        query.split("&").forEach(function (kv) {
+            if (!kv) {
+                return;
+            }
+            var p = kv.split("=");
+            try {
+                params[decodeURIComponent(p[0])] = decodeURIComponent(p.slice(1).join("="));
+            } catch (e) { }
+        });
+        return { kind: kind, arg: arg, params: params };
+    }
+
+    function colDefault() {
+        return [
+            { key: "mtime", label: T("col/modified", "Modified"), cls: "col-mtime" },
+            { key: "size", label: T("col/size", "Size"), cls: "col-size" },
+            { key: "type", label: T("col/type", "Type"), cls: "col-type" }
+        ];
+    }
+
+    function colWithLocation() {
+        return [
+            { key: "mtime", label: T("col/modified", "Modified"), cls: "col-mtime" },
+            { key: "size", label: T("col/size", "Size"), cls: "col-size" },
+            { key: "location", label: T("col/location", "Location"), cls: "col-type" }
+        ];
+    }
+
+    function buildView(r) {
+        var v;
+        switch (r.kind) {
+            case "shared":
+                var byMe = r.params.tab == "byme";
+                v = {
+                    kind: "shared", key: "shared:" + (byMe ? "byme" : "withme"),
+                    crumbs: [{ label: T("nav/shared", "Shared with me") }],
+                    subtitle: byMe ? T("sub/sharedByMe", "Links you have created") : T("sub/sharedWithMe", "Files other people shared with you"),
+                    tabs: [
+                        { label: T("shared/withMe", "Shared with me"), route: "#/shared", active: !byMe },
+                        { label: T("shared/byMe", "Shared by me"), route: "#/shared?tab=byme", active: byMe }
+                    ],
+                    toolbar: "items",
+                    columns: [
+                        { key: "owner", label: T("col/owner", "Owner"), cls: "col-mtime" },
+                        { key: "access", label: T("col/access", "Who has access"), cls: "col-size" },
+                        { key: "expire", label: T("col/expires", "Expires"), cls: "col-type" }
+                    ],
+                    noSize: true,
+                    empty: byMe ?
+                        { icon: "link", title: T("empty/sharedByMe", "You have not shared anything yet"), text: T("empty/sharedByMeText", "Select a file and press Share to create a link.") } :
+                        { icon: "users", title: T("empty/shared", "Nothing shared with you yet"), text: T("empty/sharedText", "Files and folders other people share with you appear here.") },
+                    load: function () { return loadShares(byMe); }
+                };
+                if (!API.caps.fm) {
+                    v.load = function () { return Promise.resolve([]); };
+                    v.empty = { icon: "lock", title: T("empty/needFM", "Not available in limited mode"), text: T("empty/needFMText", "Your account does not have the File Manager permission this view needs.") };
+                }
+                return v;
+            case "recent":
+                return {
+                    kind: "recent", key: "recent",
+                    crumbs: [{ label: T("nav/recent", "Recent") }],
+                    subtitle: T("sub/recent", "Files you opened or changed in the last 30 days"),
+                    toolbar: "items",
+                    columns: [
+                        { key: "activity", label: T("col/activity", "Last activity"), cls: "col-mtime" },
+                        { key: "size", label: T("col/size", "Size"), cls: "col-size" },
+                        { key: "location", label: T("col/location", "Location"), cls: "col-type" }
+                    ],
+                    fixedSort: { key: "activity", dir: "desc" },
+                    showLocation: true,
+                    empty: { icon: "clock", title: T("empty/recent", "No recent files"), text: T("empty/recentText", "Files you open or edit show up here.") },
+                    load: loadRecent
+                };
+            case "starred":
+                return {
+                    kind: "starred", key: "starred",
+                    crumbs: [{ label: T("nav/starred", "Starred") }],
+                    subtitle: T("sub/starred", "Quick access to the items you starred"),
+                    toolbar: "items",
+                    columns: colWithLocation(),
+                    showLocation: true,
+                    empty: { icon: "star", title: T("empty/starred", "No starred items"), text: T("empty/starredText", "Star files and folders you use often to find them here.") },
+                    load: loadStarred
+                };
+            case "trash":
+                v = {
+                    kind: "trash", key: "trash",
+                    crumbs: [{ label: T("nav/trash", "Trash") }],
+                    subtitle: T("sub/trash", "Items in the trash are removed for good after the retention period"),
+                    toolbar: "trash",
+                    columns: [
+                        { key: "mtime", label: T("col/deleted", "Deleted"), cls: "col-mtime" },
+                        { key: "size", label: T("col/size", "Size"), cls: "col-size" },
+                        { key: "origin", label: T("col/origin", "Original location"), cls: "col-type" }
+                    ],
+                    fixedSort: null,
+                    empty: { icon: "trash", title: T("empty/trash", "Trash is empty"), text: T("empty/trashText", "Items you delete are kept here until the trash is emptied.") },
+                    load: function () { return API.trashList(); }
+                };
+                if (!API.caps.fm) {
+                    v.load = function () { return Promise.resolve([]); };
+                    v.empty = { icon: "lock", title: T("empty/needFM", "Not available in limited mode"), text: T("empty/trashNeedFM", "Without the File Manager permission deleted files are removed immediately.") };
+                }
+                return v;
+            case "category":
+                var group = ["documents", "photos", "videos", "music", "others"].indexOf(r.arg) >= 0 ? r.arg : "documents";
+                var labels = { documents: T("cat/documents", "Documents"), photos: T("cat/photos", "Photos"), videos: T("cat/videos", "Videos"), music: T("cat/music", "Music"), others: T("cat/others", "Others") };
+                var icons = { documents: "doc", photos: "image", videos: "video", music: "music", others: "layers" };
+                return {
+                    kind: "category", key: "category:" + group, group: group,
+                    crumbs: [{ label: labels[group] }],
+                    subtitle: T("sub/category", "Newest {what} in My Drive", { what: labels[group].toLowerCase() }),
+                    toolbar: "items",
+                    columns: colWithLocation(),
+                    showLocation: true,
+                    defaultView: group == "photos" || group == "videos" ? "grid" : null,
+                    empty: { icon: icons[group], title: T("empty/category", "Nothing here yet"), text: T("empty/categoryText", "Files of this kind in My Drive appear here.") },
+                    load: function () { return loadCategory(group); }
+                };
+            case "search":
+                var scope = normDir(r.params["in"] || "user:/");
+                return {
+                    kind: "search", key: "search:" + r.arg + "@" + scope, query: r.arg, scope: scope,
+                    crumbs: [{ label: T("search/title", "Search results") }],
+                    subtitle: T("search/sub", "\"{q}\" in {where}", { q: r.arg, where: pathParts(scope).map(function (p) { return p.label; }).join(" / ") }),
+                    toolbar: "items",
+                    columns: colWithLocation(),
+                    showLocation: true,
+                    empty: { icon: "search", title: T("empty/search", "No matching files"), text: T("empty/searchText", "Try another keyword, or search a different location.") },
+                    load: function () { return API.search(scope, r.arg); }
+                };
+            case "settings":
+                return {
+                    kind: "settings", key: "settings",
+                    crumbs: [{ label: T("user/driveSettings", "Drive settings") }],
+                    subtitle: T("sub/settings", "These settings only apply to Drive. System wide options live in System Settings."),
+                    toolbar: "none"
+                };
+            default:
+                var path = normDir(r.arg || "user:/");
+                var root = rootOf(path);
+                var parts = pathParts(path);
+                var isRoot = parts.length == 1;
+                return {
+                    kind: "drive", key: "drive:" + path, path: path, folder: path, root: root,
+                    crumbs: parts.map(function (p) { return { label: p.label, path: p.path }; }),
+                    subtitle: isRoot ? (root && root.uuid == "user" ? T("sub/myDrive", "Personal storage space") :
+                        (root ? (root.readonly ? T("sub/readonlyRoot", "{name} · read only", { name: root.path }) : root.path) : "")) : "",
+                    toolbar: "folder",
+                    writable: isWritable(path),
+                    columns: colDefault(),
+                    empty: {
+                        icon: "folderOpen", title: T("empty/folder", "This folder is empty"),
+                        text: isWritable(path) ? T("empty/folderText", "Drop files here or use the New button to add something.") : T("empty/readonlyText", "This location is read only."),
+                        action: isWritable(path) ? { label: T("toolbar/upload", "Upload"), icon: "upload", fn: function () { DriveActions.upload(); } } : null
+                    },
+                    load: function () { return loadFolder(path); }
+                };
+        }
+    }
+
+    function loadFolder(path) {
+        return API.list(path, state.settings.showHidden).catch(function (err) {
+            if (API.caps.fm && /permission denied/i.test(err.message)) {
+                //No File Manager permission after all: switch to the reduced mode
+                API.caps.fm = false;
+                return API.list(path, state.settings.showHidden);
+            }
+            throw err;
+        }).then(function (items) {
+            return (items || []).filter(function (i) {
+                return state.settings.showHidden || i.name.charAt(0) != ".";
+            });
+        });
+    }
+
+    function loadShares(byMe) {
+        return API.shareList().then(function (list) {
+            return (list || []).filter(function (s) {
+                return byMe ? s.IsOwnerOfShare : (!s.IsOwnerOfShare && s.CanAccess);
+            }).map(function (s) {
+                return {
+                    name: API.baseName(s.FileVirtualPath) || s.FileVirtualPath,
+                    path: s.FileVirtualPath,
+                    isDir: s.IsFolder,
+                    size: -1,
+                    mtime: 0,
+                    share: s,
+                    owner: s.Owner,
+                    shareOnly: !byMe && !s.CanOpenInFileManager
+                };
+            });
+        });
+    }
+
+    function loadRecent() {
+        return Promise.all([
+            API.recentOpened().catch(function () { return []; }),
+            API.scan({ mode: "recent", days: 30, limit: 200 }).catch(function () { return { items: [] }; })
+        ]).then(function (r) {
+            var map = {};
+            (r[0] || []).forEach(function (i) {
+                i.activity = Math.max(i.opened || 0, i.mtime || 0);
+                i.openedLast = (i.opened || 0) >= (i.mtime || 0);
+                map[i.path] = i;
+            });
+            ((r[1] && r[1].items) || []).forEach(function (i) {
+                if (map[i.path]) {
+                    return;
+                }
+                i.activity = i.mtime;
+                i.openedLast = false;
+                map[i.path] = i;
+            });
+            return Object.keys(map).map(function (k) { return map[k]; });
+        });
+    }
+
+    function loadStarred() {
+        return API.starred().then(function (r) {
+            if (r.missing && r.missing.length) {
+                r.missing.forEach(function (p) { state.starred.delete(p); });
+                API.star(r.missing, false).catch(function () { });
+            }
+            return r.items || [];
+        });
+    }
+
+    function knownExtensions() {
+        var all = [];
+        ["documents", "photos", "videos", "music"].forEach(function (g) {
+            all = all.concat(DriveIcons.extensionsOf(g));
+        });
+        return all;
+    }
+
+    function loadCategory(group) {
+        var opts = { mode: "ext", limit: 500 };
+        if (group == "others") {
+            opts.exts = "[]";
+            opts.exclude = JSON.stringify(knownExtensions());
+        } else {
+            opts.exts = JSON.stringify(DriveIcons.extensionsOf(group));
+        }
+        return API.scan(opts).then(function (r) {
+            state.view.truncated = !!(r && r.truncated);
+            state.view.total = r ? r.total : 0;
+            return (r && r.items) || [];
+        });
+    }
+
+    function route() {
+        var r = parseRoute();
+        var prevKey = state.view ? state.view.key : null;
+        state.route = r;
+        var view = buildView(r);
+        var sameView = prevKey == view.key;
+        state.view = view;
+        if (!sameView) {
+            state.selection.clear();
+            state.anchor = null;
+            state.focus = null;
+            state.page = 1;
+        }
+        highlightNav();
+        closeDrawer();
+        DriveUI.closeMenus(0);
+        renderHead();
+        var wrap = document.getElementById("listWrap");
+        wrap.classList.toggle("is-settings", view.kind == "settings");
+        if (view.kind == "settings") {
+            state.items = [];
+            document.getElementById("paneFoot").hidden = true;
+            document.getElementById("paneBanner").innerHTML = "";
+            DriveSettings.render(document.getElementById("listWrap"));
+            DriveDetails.close(true);
+            return;
+        }
+        document.getElementById("paneFoot").hidden = false;
+        renderBanner();
+        load(!sameView);
+    }
+
+    function load(showSkeleton) {
+        var view = state.view;
+        var token = ++state.loadToken;
+        state.loading = true;
+        state.loadError = null;
+        if (showSkeleton) {
+            state.items = [];
+            document.getElementById("listWrap").innerHTML = skeletonRows();
+            renderFoot();
+        }
+        view.load().then(function (items) {
+            if (token != state.loadToken) {
+                return;
+            }
+            state.loading = false;
+            state.items = items || [];
+            //Drop selections that no longer exist
+            var paths = {};
+            state.items.forEach(function (i) { paths[i.path] = true; });
+            state.selection.forEach(function (p) {
+                if (!paths[p]) {
+                    state.selection.delete(p);
+                }
+            });
+            if (view.kind == "drive") {
+                view.subtitle = view.subtitle || "";
+            }
+            renderHead();
+            renderBanner();
+            DriveList.render();
+            DriveDetails.invalidate();
+            DriveDetails.refresh();
+        }).catch(function (err) {
+            if (token != state.loadToken) {
+                return;
+            }
+            state.loading = false;
+            state.loadError = err;
+            state.items = [];
+            document.getElementById("listWrap").innerHTML = '<div class="state">' + I("alert", { size: 40 }) +
+                '<h3>' + esc(T("err/loadTitle", "This location cannot be opened")) + '</h3><p>' + esc(T("srv/" + err.message, err.message)) + '</p>' +
+                '<button class="btn" id="retryBtn">' + I("refresh") + esc(T("common/retry", "Try again")) + '</button></div>';
+            document.getElementById("retryBtn").addEventListener("click", function () { refresh(); });
+            renderFoot();
+        });
+    }
+
+    function refresh() {
+        if (!state.view || state.view.kind == "settings") {
+            return;
+        }
+        load(false);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  View header and toolbar                                            */
+    /* ------------------------------------------------------------------ */
+    function renderHead() {
+        var view = state.view;
+        var head = document.getElementById("paneHead");
+        var crumbsHTML = "";
+        var crumbs = view.crumbs || [];
+        //Long paths keep the root and the last two folders
+        var shown = crumbs;
+        var collapsed = [];
+        if (crumbs.length > 4) {
+            shown = [crumbs[0], { more: true }].concat(crumbs.slice(-2));
+            collapsed = crumbs.slice(1, -2);
+        }
+        shown.forEach(function (c, i) {
+            if (i > 0) {
+                crumbsHTML += '<span class="crumb-sep">' + I("chevronRight") + '</span>';
+            }
+            if (c.more) {
+                crumbsHTML += '<button class="crumb crumb-more" data-more>' + I("moreH") + '</button>';
+                return;
+            }
+            var isLast = i == shown.length - 1;
+            if (isLast) {
+                var menuable = view.kind == "drive";
+                crumbsHTML += '<button class="crumb current" ' + (menuable ? 'data-folder-menu' : '') + (c.path ? ' data-drop="' + esc(c.path) + '"' : "") + '>' +
+                    '<span style="overflow:hidden;text-overflow:ellipsis">' + esc(c.label) + '</span>' + (menuable ? I("chevronDown") : "") + '</button>';
+            } else {
+                crumbsHTML += '<a class="crumb" href="' + esc(href(folderRoute(c.path))) + '" data-drop="' + esc(c.path) + '">' + esc(c.label) + '</a>';
+            }
+        });
+
+        var tools = '';
+        if (view.toolbar != "none") {
+            var gridOn = currentViewMode() == "grid";
+            tools = '<div class="view-tools">' +
+                '<div class="segmented hide-xs" role="group">' +
+                '<button data-mode="list" class="' + (gridOn ? "" : "active") + '" title="' + esc(T("view/list", "List view")) + '">' + I("list") + '</button>' +
+                '<button data-mode="grid" class="' + (gridOn ? "active" : "") + '" title="' + esc(T("view/grid", "Grid view")) + '">' + I("grid") + '</button></div>' +
+                (view.fixedSort ? "" : '<button class="btn seg-sort" id="sortBtn">' + I(sortState().dir == "asc" ? "sortAsc" : "sortDesc") +
+                    '<span class="lbl">' + esc(sortLabel(sortState().key)) + '</span>' + I("chevronDown", { cls: "caret" }) + '</button>') +
+                '<button class="icon-btn' + (state.detailsOpen ? " active" : "") + '" id="detailsBtn" title="' + esc(T("view/details", "Show details")) + '">' + I("panel") + '</button>' +
+                '</div>';
+        }
+
+        var html = '<div class="title-row"><div class="title-block"><div class="crumbs">' + crumbsHTML + '</div>' +
+            '<div class="subtitle">' + esc(view.subtitle || "") + '</div></div>' + tools + '</div>';
+
+        if (view.tabs) {
+            html += '<div class="tabs" style="margin:10px -12px 0;padding:0 12px">' + view.tabs.map(function (t) {
+                return '<a class="tab' + (t.active ? " active" : "") + '" href="' + esc(href(t.route)) + '">' + esc(t.label) + '</a>';
+            }).join("") + '</div>';
+        }
+
+        if (view.toolbar != "none") {
+            html += '<div class="toolbar" id="toolbar"></div>';
+        }
+        head.innerHTML = html;
+
+        var more = head.querySelector("[data-more]");
+        if (more) {
+            more.addEventListener("click", function () {
+                DriveUI.menu({ anchor: more }, collapsed.map(function (c) {
+                    return { icon: "folder", label: c.label, action: function () { location.hash = folderRoute(c.path); } };
+                }));
+            });
+        }
+        var fm = head.querySelector("[data-folder-menu]");
+        if (fm) {
+            fm.addEventListener("click", function () {
+                DriveActions.folderMenu({ anchor: fm });
+            });
+        }
+        head.querySelectorAll("[data-mode]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                setViewMode(b.getAttribute("data-mode"));
+            });
+        });
+        var sortBtn = head.querySelector("#sortBtn");
+        if (sortBtn) {
+            sortBtn.addEventListener("click", function () { openSortMenu(sortBtn); });
+        }
+        var detailsBtn = head.querySelector("#detailsBtn");
+        if (detailsBtn) {
+            detailsBtn.addEventListener("click", function () { toggleDetails(); });
+        }
+        DriveList.bindDropTargets(head);
+        renderToolbar();
+    }
+
+    function tb(id, icon, label, opts) {
+        opts = opts || {};
+        return '<button class="btn ' + (opts.cls || "") + '" id="' + id + '"' + (opts.disabled ? " disabled" : "") +
+            ' title="' + esc(opts.title || label) + '">' + I(icon) + '<span class="lbl">' + esc(label) + '</span>' +
+            (opts.caret ? I("chevronDown", { cls: "caret" }) : "") + '</button>';
+    }
+
+    function renderToolbar() {
+        var bar = document.getElementById("toolbar");
+        if (!bar) {
+            return;
+        }
+        var view = state.view;
+        var sel = selectedItems();
+        var n = sel.length;
+        var html = "";
+        if (view.toolbar == "folder") {
+            var w = view.writable;
+            html += tb("tbNew", "plus", T("toolbar/new", "New"), { cls: "primary", caret: true, disabled: !w });
+            html += tb("tbUpload", "upload", T("toolbar/upload", "Upload"), { disabled: !w });
+            html += tb("tbShare", "link", T("toolbar/share", "Share"), { disabled: n > 1 || (n == 0 && pathParts(view.path).length == 1) });
+            html += tb("tbMove", "move", T("toolbar/move", "Move"), { disabled: n == 0 || !w });
+            html += tb("tbMore", "moreH", T("toolbar/more", "More"), { caret: true });
+        } else if (view.toolbar == "items") {
+            html += tb("tbShare", "link", T("toolbar/share", "Share"), { disabled: n != 1 });
+            html += tb("tbDownload", "download", T("toolbar/download", "Download"), { disabled: n == 0 });
+            html += tb("tbMove", "move", T("toolbar/move", "Move"), { disabled: n == 0 || view.kind == "shared" });
+            html += tb("tbMore", "moreH", T("toolbar/more", "More"), { caret: true });
+        } else if (view.toolbar == "trash") {
+            html += tb("tbRestore", "restore", T("trash/restore", "Restore"), { cls: "primary", disabled: n == 0 });
+            html += tb("tbPurge", "trash", T("trash/deleteForever", "Delete forever"), { disabled: n == 0 });
+            html += tb("tbEmpty", "x", T("trash/empty", "Empty trash"), { disabled: state.items.length == 0 });
+        }
+        if (n > 0) {
+            html += '<span class="select-bar">' + esc(T("sel/count", "{n} selected", { n: n })) +
+                '<button class="icon-btn small" id="tbClear" title="' + esc(T("sel/clear", "Clear selection")) + '">' + I("x") + '</button></span>';
+        }
+        bar.innerHTML = html;
+
+        var on = function (id, fn) {
+            var b = document.getElementById(id);
+            if (b) {
+                b.addEventListener("click", function (e) { fn(b, e); });
+            }
+        };
+        on("tbNew", function (b) { DriveActions.newMenu({ anchor: b }); });
+        on("tbUpload", function () { DriveActions.upload(); });
+        on("tbShare", function () {
+            var s = selectedItems();
+            if (s.length == 1) {
+                DriveShare.open(s[0]);
+            } else if (state.view.kind == "drive") {
+                DriveShare.open(currentFolderItem());
+            }
+        });
+        on("tbMove", function () { DriveActions.moveTo(selectedItems()); });
+        on("tbDownload", function () { DriveActions.download(selectedItems()); });
+        on("tbMore", function (b) { DriveActions.moreMenu({ anchor: b }); });
+        on("tbRestore", function () { DriveActions.restore(selectedItems()); });
+        on("tbPurge", function () { DriveActions.deleteForever(selectedItems()); });
+        on("tbEmpty", function () { DriveActions.emptyTrash(); });
+        on("tbClear", function () { setSelection([]); });
+    }
+
+    function renderBanner() {
+        var box = document.getElementById("paneBanner");
+        var html = "";
+        var view = state.view;
+        if (!API.caps.fm && view && view.kind == "drive") {
+            html = '<div class="banner">' + I("alert") + '<span>' + esc(T("banner/reduced", "Limited mode: your account cannot use the File Manager service, so deleting is permanent and some actions are unavailable.")) + '</span></div>';
+        } else if (view && view.kind == "drive" && !view.writable) {
+            html = '<div class="banner info">' + I("lock") + '<span>' + esc(T("banner/readonly", "This location is read only. You can open and download files but not change them.")) + '</span></div>';
+        } else if (view && view.truncated) {
+            html = '<div class="banner info">' + I("info") + '<span>' + esc(T("banner/truncated", "Showing the newest results found so far. Your drive is large, so the scan stopped early.")) + '</span></div>';
+        }
+        box.innerHTML = html;
+    }
+
+    function renderFoot() {
+        var foot = document.getElementById("paneFoot");
+        if (!state.view || state.view.kind == "settings") {
+            return;
+        }
+        var total = state.items.length;
+        var n = state.selection.size;
+        var text = state.loading ? T("foot/loading", "Loading...") :
+            (total == 1 ? T("foot/item", "{n} item", { n: total }) : T("foot/items", "{n} items", { n: total }));
+        if (n > 0) {
+            text += " · " + T("sel/count", "{n} selected", { n: n });
+        }
+        var html = '<span>' + esc(text) + '</span>';
+        var pages = DriveList.pageCount();
+        if (pages > 1) {
+            var p = state.page;
+            html += '<div class="pager"><button data-page="' + (p - 1) + '"' + (p <= 1 ? " disabled" : "") + ' aria-label="' + esc(T("pager/prev", "Previous page")) + '">' + I("chevronLeft") + '</button>';
+            var list = [];
+            for (var i = 1; i <= pages; i++) {
+                if (i == 1 || i == pages || Math.abs(i - p) <= 1) {
+                    list.push(i);
+                } else if (list[list.length - 1] != "...") {
+                    list.push("...");
+                }
+            }
+            list.forEach(function (x) {
+                html += x == "..." ? '<span class="muted">...</span>' : '<button data-page="' + x + '" class="' + (x == p ? "active" : "") + '">' + x + '</button>';
+            });
+            html += '<button data-page="' + (p + 1) + '"' + (p >= pages ? " disabled" : "") + ' aria-label="' + esc(T("pager/next", "Next page")) + '">' + I("chevronRight") + '</button></div>';
+        }
+        foot.innerHTML = html;
+        foot.querySelectorAll("[data-page]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                state.page = parseInt(b.getAttribute("data-page"), 10);
+                DriveList.render();
+                document.getElementById("listWrap").scrollTop = 0;
+            });
+        });
+    }
+
+    /* View mode and sorting ----------------------------------------------- */
+    function currentViewMode() {
+        var v = state.view;
+        if (v && v.viewOverride) {
+            return v.viewOverride;
+        }
+        if (v && v.defaultView && !state.settings["viewSet_" + v.kind]) {
+            return v.defaultView;
+        }
+        return state.settings.view;
+    }
+
+    function setViewMode(mode) {
+        if (state.view && state.view.defaultView) {
+            state.view.viewOverride = mode;
+        } else {
+            saveSettings({ view: mode }, true);
+        }
+        renderHead();
+        DriveList.render();
+    }
+
+    function sortState() {
+        var v = state.view;
+        if (v && v.fixedSort) {
+            return v.fixedSort;
+        }
+        if (v && v.sortOverride) {
+            return v.sortOverride;
+        }
+        return { key: state.settings.sortKey, dir: state.settings.sortDir };
+    }
+
+    function sortLabel(key) {
+        var labels = {
+            name: T("col/name", "Name"), mtime: T("col/modified", "Modified"), size: T("col/size", "Size"),
+            type: T("col/type", "Type"), owner: T("col/owner", "Owner"), access: T("col/access", "Who has access"),
+            expire: T("col/expires", "Expires"), location: T("col/location", "Location"), origin: T("col/origin", "Original location"),
+            activity: T("col/activity", "Last activity")
+        };
+        return labels[key] || key;
+    }
+
+    function setSort(key, dir) {
+        var v = state.view;
+        var global = ["name", "mtime", "size", "type"].indexOf(key) >= 0 && v.kind == "drive";
+        if (global) {
+            saveSettings({ sortKey: key, sortDir: dir }, true);
+            v.sortOverride = null;
+        } else {
+            v.sortOverride = { key: key, dir: dir };
+        }
+        renderHead();
+        DriveList.render();
+    }
+
+    function toggleSortColumn(key) {
+        var s = sortState();
+        if (state.view.fixedSort) {
+            return;
+        }
+        setSort(key, s.key == key && s.dir == "asc" ? "desc" : "asc");
+    }
+
+    function openSortMenu(anchor) {
+        var s = sortState();
+        var keys = ["name"].concat(state.view.columns.map(function (c) { return c.key; }));
+        var items = [{ title: T("sort/by", "Sort by") }];
+        keys.forEach(function (k) {
+            items.push({ checkable: true, checked: s.key == k, label: sortLabel(k), action: function () { setSort(k, s.dir); } });
+        });
+        items.push("-", { title: T("sort/order", "Order") });
+        items.push({ checkable: true, checked: s.dir == "asc", label: T("sort/asc", "Ascending"), action: function () { setSort(s.key, "asc"); } });
+        items.push({ checkable: true, checked: s.dir == "desc", label: T("sort/desc", "Descending"), action: function () { setSort(s.key, "desc"); } });
+        items.push("-");
+        items.push({ checkable: true, checked: state.settings.foldersFirst, label: T("sort/foldersFirst", "Folders on top"), action: function () { saveSettings({ foldersFirst: !state.settings.foldersFirst }); DriveList.render(); } });
+        DriveUI.menu({ anchor: anchor, align: "end" }, items);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Selection                                                          */
+    /* ------------------------------------------------------------------ */
+    function itemByPath(path) {
+        for (var i = 0; i < state.items.length; i++) {
+            if (state.items[i].path == path) {
+                return state.items[i];
+            }
+        }
+        return null;
+    }
+
+    function selectedItems() {
+        var out = [];
+        state.sorted.forEach(function (i) {
+            if (state.selection.has(i.path)) {
+                out.push(i);
+            }
+        });
+        if (out.length != state.selection.size) {
+            //Selected on another page
+            state.items.forEach(function (i) {
+                if (state.selection.has(i.path) && out.indexOf(i) < 0) {
+                    out.push(i);
+                }
+            });
+        }
+        return out;
+    }
+
+    function setSelection(paths, opts) {
+        opts = opts || {};
+        state.selection = new Set(paths);
+        if (opts.anchor !== undefined) {
+            state.anchor = opts.anchor;
+        }
+        if (opts.focus !== undefined) {
+            state.focus = opts.focus;
+        }
+        selectionChanged();
+    }
+
+    function selectionChanged() {
+        DriveList.updateSelection();
+        renderToolbar();
+        renderFoot();
+        DriveDetails.refresh();
+    }
+
+    function currentFolderItem() {
+        var v = state.view;
+        if (!v || v.kind != "drive") {
+            return null;
+        }
+        var parts = pathParts(v.path);
+        return { name: parts[parts.length - 1].label, path: v.path.replace(/\/$/, ""), isDir: true, size: -1, mtime: 0, isCurrentFolder: true, isRoot: parts.length == 1 };
+    }
+
+    /* Details panel ---------------------------------------------------- */
+    function toggleDetails(force) {
+        state.detailsOpen = force === undefined ? !state.detailsOpen : !!force;
+        document.documentElement.classList.toggle("details-open", state.detailsOpen);
+        var btn = document.getElementById("detailsBtn");
+        if (btn) {
+            btn.classList.toggle("active", state.detailsOpen);
+        }
+        if (state.detailsOpen) {
+            DriveDetails.open();
+        } else {
+            DriveDetails.close();
+        }
+        if (window.innerWidth > 1100) {
+            saveSettings({ detailsOpen: state.detailsOpen }, true);
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Search                                                             */
+    /* ------------------------------------------------------------------ */
+    function defaultScope() {
+        var v = state.view;
+        if (v && v.kind == "drive") {
+            return v.path;
+        }
+        if (v && v.kind == "search") {
+            return v.scope;
+        }
+        return "user:/";
+    }
+
+    function scopeLabel(path) {
+        var parts = pathParts(path);
+        return parts[parts.length - 1].label;
+    }
+
+    function updateScopeChip() {
+        var chip = document.getElementById("searchScope");
+        var scope = state.searchScope || defaultScope();
+        chip.innerHTML = I("folder") + '<span>' + esc(scopeLabel(scope)) + '</span>' + I("chevronDown");
+        chip.title = T("search/in", "Search in {where}", { where: pathParts(scope).map(function (p) { return p.label; }).join(" / ") });
+    }
+
+    function bindSearch() {
+        var form = document.getElementById("searchBox");
+        var input = document.getElementById("searchInput");
+        var chip = document.getElementById("searchScope");
+        form.addEventListener("submit", function (e) {
+            e.preventDefault();
+            var q = input.value.trim();
+            if (!q) {
+                return;
+            }
+            var scope = state.searchScope || defaultScope();
+            location.hash = "#/search/" + encodeURIComponent(q) + "?in=" + encodeURIComponent(scope);
+            input.blur();
+            document.documentElement.classList.remove("search-open");
+            if (DriveHost.isVisible()) {
+                DriveHost.hide();
+            }
+        });
+        input.addEventListener("focus", function () {
+            if (!state.searchScope) {
+                updateScopeChip();
+            }
+        });
+        input.addEventListener("keydown", function (e) {
+            if (e.key == "Escape") {
+                input.value = "";
+                input.blur();
+                document.documentElement.classList.remove("search-open");
+            }
+        });
+        chip.addEventListener("click", function () {
+            var cur = state.searchScope || defaultScope();
+            var options = [];
+            var here = defaultScope();
+            if (here != "user:/") {
+                options.push({ path: here, label: T("search/thisFolder", "This folder") + " (" + scopeLabel(here) + ")" });
+            }
+            state.roots.forEach(function (r) {
+                options.push({ path: r.path, label: rootLabel(r), icon: rootIcon(r) });
+            });
+            DriveUI.menu({ anchor: chip, align: "end" }, [{ title: T("search/where", "Search in") }].concat(options.map(function (o) {
+                return {
+                    checkable: true, checked: normDir(o.path) == normDir(cur), label: o.label, action: function () {
+                        state.searchScope = normDir(o.path);
+                        updateScopeChip();
+                        input.focus();
+                    }
+                };
+            })));
+        });
+        document.getElementById("searchToggle").addEventListener("click", function () {
+            document.documentElement.classList.add("search-open");
+            updateScopeChip();
+            setTimeout(function () { input.focus(); }, 30);
+        });
+        document.getElementById("searchClose").addEventListener("click", function () {
+            document.documentElement.classList.remove("search-open");
+        });
+        updateScopeChip();
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Global events                                                      */
+    /* ------------------------------------------------------------------ */
+    function isTyping(e) {
+        var t = e.target;
+        return t && (t.tagName == "INPUT" || t.tagName == "TEXTAREA" || t.tagName == "SELECT" || t.isContentEditable);
+    }
+
+    function onKeyDown(e) {
+        if (DriveUI.menuOpen() && DriveUI.menuKeyNav(e)) {
+            e.preventDefault();
+            return;
+        }
+        if (e.key == "Escape" && DriveUI.popoverOpen()) {
+            DriveUI.closePopover();
+            return;
+        }
+        if (DrivePreview.isOpen()) {
+            DrivePreview.onKey(e);
+            return;
+        }
+        var top = DriveUI.topModal();
+        if (top) {
+            if (e.key == "Escape") {
+                top.close();
+            }
+            return;
+        }
+        if (isTyping(e)) {
+            return;
+        }
+        if (DriveHost.isVisible()) {
+            return;
+        }
+        var view = state.view;
+        if (!view || view.kind == "settings") {
+            return;
+        }
+        var ctrl = e.ctrlKey || e.metaKey;
+        var key = e.key;
+        var sel = selectedItems();
+
+        if (key == "/" && !ctrl) {
+            e.preventDefault();
+            if (window.innerWidth <= 760) {
+                document.documentElement.classList.add("search-open");
+            }
+            document.getElementById("searchInput").focus();
+            return;
+        }
+        if (key == "?" && !ctrl) {
+            showShortcuts();
+            return;
+        }
+        if (ctrl && (key == "a" || key == "A")) {
+            e.preventDefault();
+            DriveList.selectAll();
+            return;
+        }
+        if (ctrl && (key == "s" || key == "S")) {
+            e.preventDefault();
+            if (sel.length) {
+                DriveActions.download(sel);
+            }
+            return;
+        }
+        if (ctrl && (key == "c" || key == "C") && sel.length) {
+            DriveActions.clipboard("copy", sel);
+            return;
+        }
+        if (ctrl && (key == "x" || key == "X") && sel.length && view.kind != "trash") {
+            DriveActions.clipboard("cut", sel);
+            return;
+        }
+        if (ctrl && (key == "v" || key == "V")) {
+            DriveActions.paste();
+            return;
+        }
+        if (ctrl) {
+            return;
+        }
+        if (key == "Enter" && sel.length) {
+            e.preventDefault();
+            DriveActions.open(sel[0]);
+            return;
+        }
+        if (key == " " && sel.length) {
+            e.preventDefault();
+            DrivePreview.open(sel[0]);
+            return;
+        }
+        if (key == "Delete" && sel.length) {
+            e.preventDefault();
+            if (e.shiftKey || view.kind == "trash") {
+                DriveActions.deleteForever(sel);
+            } else {
+                DriveActions.trash(sel);
+            }
+            return;
+        }
+        if (key == "F2" && sel.length == 1) {
+            e.preventDefault();
+            DriveActions.rename(sel[0]);
+            return;
+        }
+        if ((key == "Backspace" || (e.altKey && key == "ArrowUp")) && view.kind == "drive") {
+            e.preventDefault();
+            var parts = pathParts(view.path);
+            if (parts.length > 1) {
+                location.hash = folderRoute(parts[parts.length - 2].path);
+            }
+            return;
+        }
+        if (e.shiftKey && (key == "F" || key == "f") && view.kind == "drive") {
+            e.preventDefault();
+            DriveActions.newFolder();
+            return;
+        }
+        if (e.shiftKey && (key == "U" || key == "u") && view.kind == "drive") {
+            e.preventDefault();
+            DriveActions.upload();
+            return;
+        }
+        if (key == "i" || key == "I") {
+            toggleDetails();
+            return;
+        }
+        if (key == "Escape") {
+            if (state.selection.size) {
+                setSelection([]);
+            } else if (state.detailsOpen && window.innerWidth <= 1100) {
+                toggleDetails(false);
+            }
+            return;
+        }
+        if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].indexOf(key) >= 0) {
+            if (DriveList.moveFocus(key, e.shiftKey)) {
+                e.preventDefault();
+            }
+        }
+    }
+
+    function bindGlobalEvents() {
+        window.addEventListener("hashchange", route);
+        document.addEventListener("keydown", onKeyDown);
+        document.getElementById("navToggle").addEventListener("click", openDrawer);
+        document.getElementById("scrim").addEventListener("click", function () {
+            closeDrawer();
+            //On a phone the scrim also sits behind the details sheet
+            if (state.detailsOpen && window.innerWidth <= 600) {
+                toggleDetails(false);
+            }
+        });
+        document.getElementById("userBtn").addEventListener("click", openUserMenu);
+        document.getElementById("bellBtn").addEventListener("click", openNotifications);
+        document.getElementById("helpBtn").addEventListener("click", openHelpMenu);
+        document.getElementById("brand").addEventListener("click", function () {
+            if (DriveHost.isVisible()) {
+                DriveHost.hide();
+            }
+        });
+        bindSearch();
+        DriveList.bindContainer();
+        //Re-render the header once per resize so collapsed labels follow the width
+        var resizeTimer = null;
+        window.addEventListener("resize", function () {
+            clearTimeout(resizeTimer);
+            resizeTimer = setTimeout(function () {
+                if (window.innerWidth > 1024) {
+                    closeDrawer();
+                }
+                if (window.innerWidth > 760) {
+                    document.documentElement.classList.remove("search-open");
+                }
+            }, 150);
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    global.Drive = {
+        state: state,
+        boot: boot,
+        refresh: refresh,
+        route: route,
+        rootById: rootById,
+        rootOf: rootOf,
+        rootIdOf: rootIdOf,
+        rootLabel: rootLabel,
+        rootIcon: rootIcon,
+        pathParts: pathParts,
+        locationLabel: locationLabel,
+        normDir: normDir,
+        folderRoute: folderRoute,
+        href: href,
+        isWritable: isWritable,
+        module: module,
+        effectiveTheme: effectiveTheme,
+        applyTheme: applyTheme,
+        saveSettings: saveSettings,
+        selectedItems: selectedItems,
+        setSelection: setSelection,
+        selectionChanged: selectionChanged,
+        itemByPath: itemByPath,
+        currentFolderItem: currentFolderItem,
+        renderHead: renderHead,
+        renderToolbar: renderToolbar,
+        renderFoot: renderFoot,
+        renderSidebar: renderSidebar,
+        renderStorage: renderStorage,
+        refreshQuota: refreshQuota,
+        sortState: sortState,
+        toggleSortColumn: toggleSortColumn,
+        currentViewMode: currentViewMode,
+        toggleDetails: toggleDetails,
+        updateScopeChip: updateScopeChip,
+        retranslate: function () {
+            DriveI18n.translate(document);
+            document.getElementById("hostBack").innerHTML = I("arrowLeft") + '<span class="lbl">' + esc(T("host/back", "Back to Drive")) + '</span>';
+            renderUser();
+            renderSidebar();
+            renderStorage();
+            updateScopeChip();
+            DriveHost.renderTabs();
+            route();
+        }
+    };
+
+    $(boot);
+})(window);

+ 367 - 0
src/web/Drive/js/apphost.js

@@ -0,0 +1,367 @@
+/*
+    apphost.js
+
+    Opens web apps (and System Settings) inside Drive instead of new browser
+    windows. Each app is a tab over the file area; "Back to Drive" leaves
+    them running and a pill in the top bar brings them back.
+
+    Web apps detect a host through parent.isDesktopMode and then call the
+    float window API of the desktop (newFloatWindow, closeFwProcess,
+    setFloatWindowTitle, ...). Drive implements that API here, with the same
+    .floatWindowWrapper > .floatWindow[windowId] > .fwtab > iframe markup the
+    apps walk to find their own window id, parent window and callback. So
+    file pickers, "open with" dialogs and parent callbacks of the apps keep
+    working, all inside this page.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    var windows = [];   //{id, url, title, icon, parent, callback}
+    var activeId = null;
+    var visible = false;
+    var seq = 0;
+
+    /*
+        Apps pass web root relative paths, and the document base of Drive is
+        the web root, so they are used as they are. Keeping them unprefixed in
+        the iframe src matters: apps compare and rewrite that attribute.
+    */
+    function resolve(url) {
+        return String(url || "");
+    }
+
+    function iconURL(icon) {
+        if (!icon) {
+            return API.ROOT + "img/system/favicon.png";
+        }
+        return resolve(icon);
+    }
+
+    function find(id) {
+        for (var i = 0; i < windows.length; i++) {
+            if (String(windows[i].id) == String(id)) {
+                return windows[i];
+            }
+        }
+        return null;
+    }
+
+    function wrapperOf(id) {
+        return document.querySelector('#fw-body .floatWindowWrapper[windowId="' + String(id).replace(/"/g, "") + '"]');
+    }
+
+    function show() {
+        visible = true;
+        document.getElementById("apphost").hidden = false;
+        DriveUI.closeMenus(0);
+        updatePill();
+    }
+
+    function hide() {
+        visible = false;
+        document.getElementById("apphost").hidden = true;
+        updatePill();
+    }
+
+    function updatePill() {
+        var pill = document.getElementById("appsPill");
+        if (!windows.length || visible) {
+            pill.hidden = true;
+            return;
+        }
+        pill.hidden = false;
+        var w = find(activeId) || windows[windows.length - 1];
+        pill.innerHTML = '<img src="' + esc(iconURL(w.icon)) + '" alt="" style="width:18px;height:18px;object-fit:contain" onerror="this.style.display=\'none\'">' +
+            '<span class="hide-xs">' + esc(windows.length == 1 ? w.title : T("host/openApps", "{n} apps open", { n: windows.length })) + '</span>' +
+            '<span class="only-sm">' + windows.length + '</span>';
+        pill.title = T("host/return", "Return to open apps");
+    }
+
+    function renderTabs() {
+        var tabs = document.getElementById("hostTabs");
+        tabs.innerHTML = "";
+        windows.forEach(function (w) {
+            var tab = DriveUI.el('<div class="app-tab' + (w.id == activeId ? " active" : "") + '" data-id="' + esc(w.id) + '" title="' + esc(w.title) + '">' +
+                '<img src="' + esc(iconURL(w.icon)) + '" alt="" onerror="this.style.visibility=\'hidden\'"><span class="t">' + esc(w.title) + '</span>' +
+                '<span class="x" role="button" aria-label="' + esc(T("common/close", "Close")) + '">' + I("x") + '</span></div>');
+            tab.addEventListener("click", function (e) {
+                if (e.target.closest(".x")) {
+                    closeWindow(w.id);
+                } else {
+                    focus(w.id);
+                }
+            });
+            tab.addEventListener("auxclick", function (e) {
+                if (e.button == 1) {
+                    closeWindow(w.id);
+                }
+            });
+            tabs.appendChild(tab);
+        });
+        var active = tabs.querySelector(".app-tab.active");
+        if (active && active.scrollIntoView) {
+            active.scrollIntoView({ block: "nearest", inline: "nearest" });
+        }
+        updatePill();
+    }
+
+    function focus(id) {
+        var w = find(id);
+        if (!w) {
+            return;
+        }
+        activeId = w.id;
+        document.querySelectorAll("#fw-body .floatWindowWrapper").forEach(function (n) {
+            n.classList.toggle("on", n.getAttribute("windowId") == String(w.id));
+        });
+        renderTabs();
+        show();
+    }
+
+    /*
+        open({url, title, icon, uid, parent, callback}) -> window id
+        url is web root relative, like every ModuleInfo path.
+    */
+    function open(cfg) {
+        cfg = cfg || {};
+        if (cfg.uid && find(cfg.uid)) {
+            var existing = find(cfg.uid);
+            if (cfg.url && existing.url != cfg.url) {
+                existing.url = cfg.url;
+                var frame = wrapperOf(existing.id).querySelector("iframe");
+                frame.src = resolve(cfg.url);
+            }
+            focus(existing.id);
+            return existing.id;
+        }
+        var id = cfg.uid || ("w" + Date.now() + "_" + (++seq));
+        var w = {
+            id: id,
+            url: cfg.url || "",
+            title: cfg.title || T("host/untitled", "App"),
+            icon: cfg.appicon || cfg.icon || "",
+            parent: cfg.parent || "",
+            callback: cfg.callback || ""
+        };
+        windows.push(w);
+        var wrap = DriveUI.el('<div class="floatWindowWrapper" windowId="' + esc(id) + '">' +
+            '<div class="floatWindow" windowId="' + esc(id) + '" parent="' + esc(w.parent) + '" callback="' + esc(w.callback) + '">' +
+            '<div class="fwtab"><iframe allow="fullscreen; clipboard-read; clipboard-write; camera; microphone; autoplay" title="' + esc(w.title) + '"></iframe></div>' +
+            '</div><div class="apphost-loading"><div class="spinner"></div></div></div>');
+        var iframe = wrap.querySelector("iframe");
+        iframe.addEventListener("load", function () {
+            var loading = wrap.querySelector(".apphost-loading");
+            if (loading) {
+                loading.classList.add("done");
+                setTimeout(function () { loading.remove(); }, 250);
+            }
+            try {
+                var cw = iframe.contentWindow;
+                //A standalone style close lands on this page; treat it as closing the tab
+                if (/closeTabInsturction\.html/i.test(cw.location.pathname)) {
+                    closeFwProcess(id);
+                    return;
+                }
+                if (typeof cw.desktopThemeChanged === "function") {
+                    cw.desktopThemeChanged(Drive.effectiveTheme());
+                }
+            } catch (e) {
+                //Cross origin page: nothing to wire up
+            }
+        });
+        document.getElementById("fw-body").appendChild(wrap);
+        iframe.src = resolve(w.url);
+        focus(id);
+        return id;
+    }
+
+    function closeFwProcess(id) {
+        var w = find(id);
+        if (!w) {
+            return;
+        }
+        var wrap = wrapperOf(id);
+        if (wrap) {
+            wrap.remove();
+        }
+        var idx = windows.indexOf(w);
+        windows.splice(idx, 1);
+        if (!windows.length) {
+            activeId = null;
+            renderTabs();
+            hide();
+            return;
+        }
+        if (activeId == w.id) {
+            var next = (w.parent && find(w.parent)) || windows[Math.max(0, idx - 1)];
+            focus(next.id);
+        } else {
+            renderTabs();
+        }
+    }
+
+    //Ask the app to close itself first, so it can warn about unsaved work
+    function closeWindow(id) {
+        var wrap = wrapperOf(id);
+        if (!wrap) {
+            return;
+        }
+        try {
+            var cw = wrap.querySelector("iframe").contentWindow;
+            if (cw && typeof cw.ao_module_close === "function" && cw.ao_module_virtualDesktop) {
+                cw.ao_module_close();
+                return;
+            }
+        } catch (e) { }
+        closeFwProcess(id);
+    }
+
+    function broadcastTheme(theme) {
+        document.querySelectorAll("#fw-body iframe").forEach(function (f) {
+            try {
+                if (typeof f.contentWindow.desktopThemeChanged === "function") {
+                    f.contentWindow.desktopThemeChanged(theme);
+                }
+            } catch (e) { }
+        });
+    }
+
+    function openModule(name) {
+        var m = Drive.module(name);
+        var go = function (info) {
+            var url = info.SupportFW && info.LaunchFWDir ? info.LaunchFWDir : info.StartDir;
+            open({ url: url, title: info.Name, icon: info.IconPath });
+        };
+        if (m) {
+            go(m);
+        } else {
+            API.launchInfo(name).then(go).catch(DriveUI.error);
+        }
+    }
+
+    function openSystemSettings(group, name) {
+        var url = "SystemAO/system_setting/index.html";
+        if (group || name) {
+            url += "#" + encodeURIComponent(JSON.stringify({ group: group || "", name: name || "" }));
+        }
+        open({ url: url, title: T("user/systemSettings", "System Settings"), icon: "SystemAO/system_setting/img/small_icon.png", uid: "drive-system-settings" });
+    }
+
+    //ao_module_startFileOperation() from a hosted app
+    function startFileOperation(cfg) {
+        var opr = cfg.opr == "unzipAndOpen" ? "unzip" : cfg.opr;
+        var items = (cfg.src || []).map(function (p) { return { name: API.baseName(p), path: p, isDir: false }; });
+        var mode = cfg.overwriteMode && cfg.overwriteMode != "ask" ? cfg.overwriteMode : "keep";
+        DriveTransfers.operation(opr, items, cfg.dest, mode).then(function () {
+            if (cfg.opr == "unzipAndOpen") {
+                hide();
+                location.hash = Drive.folderRoute(cfg.dest);
+            }
+            if (cfg.callbackWindowID && cfg.callbackFunction) {
+                var wrap = wrapperOf(cfg.callbackWindowID);
+                try {
+                    wrap.querySelector("iframe").contentWindow.eval(cfg.callbackFunction);
+                } catch (e) { }
+            }
+        }).catch(function () { });
+    }
+
+    function install() {
+        //Read by ao_module.js inside the hosted iframes
+        global.isDesktopMode = true;
+        global.ime = null;
+
+        global.newFloatWindow = function (config, callback) {
+            var id = open(config || {});
+            if (typeof callback === "function") {
+                callback(id);
+            }
+            return id;
+        };
+        global.closeFwProcess = closeFwProcess;
+        global.closeFloatWindow = closeWindow;
+        global.getFloatWindowByID = function (id) {
+            var wrap = wrapperOf(id);
+            return wrap ? $(wrap) : null;
+        };
+        global.MoveFloatWindowToTop = function (fw) {
+            var id = $(fw).attr("windowId") || $(fw).find(".floatWindow").attr("windowId");
+            if (id) {
+                focus(id);
+            }
+        };
+        global.setFloatWindowTitle = function (id, title) {
+            var w = find(id);
+            if (w) {
+                w.title = title;
+                renderTabs();
+            }
+        };
+        global.setFloatWindowSize = function () { };
+        global.setFloatWindowResizePolicy = function () { };
+        global.setFloatWindowMinimizePolicy = function () { };
+        global.setFloatWindowTheme = function () { };
+        global.PinFloatWindowToTopMostMode = function () { };
+        global.UnpinFloatWindowFromTopMostMode = function () { };
+        global.bindObjectToIMEEvents = function () { };
+        global.openModule = openModule;
+        global.openFileWithModule = function (info, files) {
+            var url = DriveActions.launchURL(info);
+            open({ url: url + "#" + encodeURIComponent(JSON.stringify(files || [])), title: info.Name, icon: info.IconPath });
+        };
+        global.startFileOperation = startFileOperation;
+        global.toggleDesktopTheme = function () {
+            Drive.saveSettings({ theme: Drive.effectiveTheme() == "dark" ? "light" : "dark" });
+        };
+        global.showDesktop = hide;
+        global.openSystemSettings = openSystemSettings;
+        global.openContainerAppShortcut = function (s) {
+            window.open(API.ROOT + "app/" + encodeURIComponent(s.ShortcutPath) + "/", "_blank");
+        };
+
+        document.getElementById("hostBack").addEventListener("click", hide);
+        document.getElementById("appsPill").addEventListener("click", function () {
+            focus(activeId || windows[windows.length - 1].id);
+        });
+        document.getElementById("hostClose").addEventListener("click", function () {
+            if (activeId) {
+                closeWindow(activeId);
+            }
+        });
+        document.getElementById("hostNewTab").addEventListener("click", function () {
+            var w = find(activeId);
+            if (!w) {
+                return;
+            }
+            var src = resolve(w.url);
+            try {
+                //Keep where the app navigated to, when it is still on this origin
+                src = wrapperOf(w.id).querySelector("iframe").contentWindow.location.href;
+            } catch (e) { }
+            window.open(src, "_blank");
+        });
+    }
+
+    global.DriveHost = {
+        install: install,
+        open: open,
+        openModule: openModule,
+        openSystemSettings: openSystemSettings,
+        closeWindow: closeWindow,
+        isVisible: function () { return visible; },
+        show: function () {
+            if (windows.length) {
+                focus(activeId || windows[windows.length - 1].id);
+            }
+        },
+        hide: hide,
+        broadcastTheme: broadcastTheme,
+        renderTabs: renderTabs,
+        count: function () { return windows.length; }
+    };
+})(window);

+ 555 - 0
src/web/Drive/js/details.js

@@ -0,0 +1,555 @@
+/*
+    details.js
+
+    The details panel on the right of the list: General (preview, file
+    information, sharing summary, quick actions), Permissions (owner, access
+    and every share link), Activity (a timeline built from the dates the
+    server knows: created, modified, shared, versions, opened in Drive) and
+    Versions (local version history, files only).
+
+    It follows the selection: one item, several items (a summary), or
+    nothing (the current folder).
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    var tab = "general";
+    var token = 0;
+    var lastKey = null;
+    var cache = {};
+
+    function S() {
+        return Drive.state;
+    }
+
+    function panel() {
+        return document.getElementById("details");
+    }
+
+    function target() {
+        var sel = Drive.selectedItems();
+        if (sel.length == 1) {
+            return { kind: "one", item: sel[0] };
+        }
+        if (sel.length > 1) {
+            return { kind: "many", items: sel };
+        }
+        var folder = Drive.currentFolderItem();
+        if (folder) {
+            return { kind: "one", item: folder };
+        }
+        return { kind: "none" };
+    }
+
+    function open(withTab) {
+        if (withTab) {
+            tab = withTab;
+        }
+        var p = panel();
+        p.hidden = false;
+        refresh(true);
+    }
+
+    function close(silent) {
+        panel().hidden = true;
+        document.documentElement.classList.remove("details-open");
+        lastKey = null;
+        if (!silent && S().detailsOpen) {
+            S().detailsOpen = false;
+            var btn = document.getElementById("detailsBtn");
+            if (btn) {
+                btn.classList.remove("active");
+            }
+        }
+    }
+
+    function refresh(force) {
+        var p = panel();
+        if (!S().detailsOpen || !S().view || S().view.kind == "settings") {
+            p.hidden = true;
+            return;
+        }
+        p.hidden = false;
+        var t = target();
+        var key = t.kind + ":" + (t.item ? t.item.path : t.items ? t.items.map(function (i) { return i.path; }).join("|") : "") + ":" + tab;
+        if (!force && key == lastKey) {
+            return;
+        }
+        lastKey = key;
+        if (t.kind == "many") {
+            renderMany(t.items);
+        } else if (t.kind == "one") {
+            renderOne(t.item);
+        } else {
+            p.innerHTML = head(null) + '<div class="details-body"><div class="state">' + I("info", { size: 36 }) +
+                '<p>' + esc(T("details/pick", "Select a file or folder to see its details.")) + '</p></div></div>';
+            bindHead();
+        }
+    }
+
+    function head(item, sub) {
+        var icon = item ? DriveIcons.file(item.name, item.isDir) : I("info", { size: 32 });
+        return '<div class="details-head"><div class="big-icon">' + icon + '</div><div class="meta"><b>' +
+            esc(item ? item.name : T("details/title", "Details")) + '</b><small>' + esc(sub || "") + '</small></div>' +
+            '<button class="icon-btn small" data-close aria-label="' + esc(T("common/close", "Close")) + '">' + I("x") + '</button></div>';
+    }
+
+    function bindHead() {
+        var c = panel().querySelector("[data-close]");
+        if (c) {
+            c.addEventListener("click", function () { Drive.toggleDetails(false); });
+        }
+    }
+
+    function subtitleOf(item) {
+        if (item.isDir) {
+            return item.isCurrentFolder ? T("details/currentFolder", "Current folder") : T("type/folder", "Folder");
+        }
+        return DriveUI.fmtSize(item.size) + " · " + DriveList.typeLabel(item);
+    }
+
+    /* ------------------------------------------------------------------ */
+    function renderMany(items) {
+        var files = items.filter(function (i) { return !i.isDir; });
+        var total = files.reduce(function (s, i) { return s + (i.size > 0 ? i.size : 0); }, 0);
+        var folders = items.length - files.length;
+        var p = panel();
+        p.innerHTML = '<div class="details-head"><div class="big-icon">' + DriveIcons.file("", true, { plain: true }) + '</div><div class="meta"><b>' +
+            esc(T("sel/count", "{n} selected", { n: items.length })) + '</b><small>' +
+            esc(T("details/manySub", "{files} files, {folders} folders", { files: files.length, folders: folders })) + '</small></div>' +
+            '<button class="icon-btn small" data-close>' + I("x") + '</button></div>' +
+            '<div class="details-body scroll-y"><div class="section"><h4>' + I("info") + esc(T("details/summary", "Summary")) + '</h4><dl class="kv">' +
+            '<dt>' + esc(T("details/items", "Items")) + '</dt><dd>' + items.length + '</dd>' +
+            '<dt>' + esc(T("col/size", "Size")) + '</dt><dd>' + esc(DriveUI.fmtSize(total)) + (folders ? '<small>' + esc(T("details/excludesFolders", "Folders not counted")) + '</small>' : "") + '</dd>' +
+            '</dl></div><div class="section"><h4>' + I("sliders") + esc(T("details/quick", "Quick actions")) + '</h4><div class="quick-actions" id="dQuick"></div></div></div>';
+        bindHead();
+        var quick = p.querySelector("#dQuick");
+        var actions = [];
+        if (S().view.kind == "trash") {
+            actions = [
+                ["restore", T("trash/restore", "Restore"), function () { DriveActions.restore(items); }],
+                ["trash", T("trash/deleteForever", "Delete forever"), function () { DriveActions.deleteForever(items); }]
+            ];
+        } else {
+            actions = [
+                ["download", T("toolbar/download", "Download"), function () { DriveActions.download(items); }],
+                ["move", T("menu/moveTo", "Move to..."), function () { DriveActions.moveTo(items); }],
+                ["copy", T("menu/copyTo", "Copy to..."), function () { DriveActions.copyTo(items); }],
+                ["star", T("menu/star", "Add to Starred"), function () { DriveActions.star(items, true); }],
+                ["trash", T("menu/trash", "Move to trash"), function () { DriveActions.trash(items); }]
+            ];
+        }
+        actions.forEach(function (a) {
+            var b = DriveUI.el('<button>' + I(a[0]) + '<span>' + esc(a[1]) + '</span></button>');
+            b.addEventListener("click", a[2]);
+            quick.appendChild(b);
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    function renderOne(item) {
+        var p = panel();
+        var tabs = [["general", T("details/general", "General")]];
+        var canMeta = !item.trashed && !item.shareOnly;
+        if (canMeta && API.caps.fm) {
+            tabs.push(["permissions", T("details/permissions", "Permissions")]);
+        }
+        if (canMeta) {
+            tabs.push(["activity", T("details/activity", "Activity")]);
+        }
+        if (canMeta && !item.isDir && API.caps.fm) {
+            tabs.push(["versions", T("details/versions", "Versions")]);
+        }
+        if (!tabs.some(function (t) { return t[0] == tab; })) {
+            tab = "general";
+        }
+        p.innerHTML = head(item, subtitleOf(item)) +
+            '<div class="tabs">' + tabs.map(function (t) {
+                return '<button class="tab' + (t[0] == tab ? " active" : "") + '" data-tab="' + t[0] + '">' + esc(t[1]) + '</button>';
+            }).join("") + '</div><div class="details-body scroll-y" id="dBody"></div>';
+        bindHead();
+        p.querySelectorAll("[data-tab]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                tab = b.getAttribute("data-tab");
+                refresh(true);
+            });
+        });
+        var body = p.querySelector("#dBody");
+        var my = ++token;
+        if (tab == "general") {
+            general(item, body, my);
+        } else if (tab == "permissions") {
+            permissions(item, body, my);
+        } else if (tab == "activity") {
+            activity(item, body, my);
+        } else if (tab == "versions") {
+            versions(item, body, my);
+        }
+    }
+
+    function propsOf(item) {
+        if (item.trashed || item.shareOnly) {
+            return Promise.resolve(null);
+        }
+        var key = "p:" + item.path + ":" + (item.mtime || "");
+        if (cache[key]) {
+            return Promise.resolve(cache[key]);
+        }
+        return API.props(item.path).then(function (p) {
+            cache[key] = p;
+            return p;
+        }).catch(function () { return null; });
+    }
+
+    function shareInfoOf(item) {
+        if (item.trashed) {
+            return Promise.resolve(null);
+        }
+        if (item.shareOnly) {
+            return Promise.resolve({ Shares: [item.share], CanManage: false });
+        }
+        return API.shareInfo(item.path).catch(function () { return null; });
+    }
+
+    function stillCurrent(my) {
+        return my == token;
+    }
+
+    function previewHTML(item) {
+        var thumb = API.caps.fm && DriveList.wantsThumb(item) && S().settings.thumbnails ?
+            '<img src="' + esc(API.thumbURL(item.path) + "&t=" + (item.mtime || 0)) + '" alt="" onerror="this.remove()">' : "";
+        var canPreview = !item.isDir && DrivePreview.canPreview(item);
+        return '<div class="preview-box"><div class="preview-card">' + DriveIcons.file(item.name, item.isDir) +
+            (thumb ? thumb.replace("<img ", '<img style="position:absolute;inset:0" ') : "") + '</div>' +
+            (item.isDir ? (item.isCurrentFolder ? "" : '<button class="btn" data-act="openFolder">' + I("folderOpen") + esc(T("menu/open", "Open")) + '</button>') :
+                '<button class="btn" data-act="' + (canPreview ? "preview" : "open") + '">' + I(canPreview ? "eye" : "open") + esc(canPreview ? T("menu/preview", "Preview") : T("menu/open", "Open")) + '</button>') +
+            '</div>';
+    }
+
+    function general(item, body, my) {
+        var writable = Drive.isWritable(item.path) && !item.trashed && !item.shareOnly && !item.isRoot && S().view.kind != "shared";
+        var parentPath = item.trashed ? (item.origin || "") : API.parentOf(item.path);
+        var html = previewHTML(item);
+        html += '<div class="section"><h4>' + esc(item.isDir ? T("details/folderInfo", "Folder information") : T("details/fileInfo", "File information")) + '</h4><dl class="kv">' +
+            '<dt>' + esc(T("col/name", "Name")) + '</dt><dd class="with-action"><span>' + esc(item.name) + '</span>' +
+            (writable ? '<button class="icon-btn small" data-act="rename" title="' + esc(T("menu/rename", "Rename")) + '">' + I("pencil") + '</button>' : "") + '</dd>' +
+            '<dt>' + esc(T("col/type", "Type")) + '</dt><dd>' + esc(DriveList.typeLabel(item)) + '</dd>' +
+            '<dt>' + esc(T("col/size", "Size")) + '</dt><dd id="dSize">' + (item.isDir ? '<span class="spinner small"></span>' : esc(DriveUI.fmtSize(item.size)) + '<small>' + esc(DriveUI.fmtExact(item.size)) + '</small>') + '</dd>';
+        if (item.trashed) {
+            html += '<dt>' + esc(T("col/origin", "Original location")) + '</dt><dd><a href="' + esc(Drive.href(Drive.folderRoute(parentPath))) + '">' + I("folder") +
+                esc(Drive.pathParts(Drive.normDir(parentPath)).map(function (x) { return x.label; }).join(" / ")) + '</a></dd>' +
+                '<dt>' + esc(T("col/deleted", "Deleted")) + '</dt><dd>' + esc(DriveUI.fmtDate(item.mtime)) + '</dd>';
+        } else if (item.shareOnly) {
+            html += '<dt>' + esc(T("col/owner", "Owner")) + '</dt><dd>' + esc(item.owner) + '</dd>';
+        } else {
+            if (!item.isRoot) {
+                html += '<dt>' + esc(T("col/location", "Location")) + '</dt><dd><a href="' + esc(Drive.href(Drive.folderRoute(parentPath))) + '">' + I("folder") +
+                    esc(Drive.pathParts(Drive.normDir(parentPath)).map(function (x) { return x.label; }).join(" / ")) + '</a></dd>';
+            }
+            html += '<dt>' + esc(T("col/modified", "Modified")) + '</dt><dd id="dModified">' + esc(item.mtime ? DriveUI.fmtDate(item.mtime) : "-") + '</dd>' +
+                '<dt>' + esc(T("details/created", "Created")) + '</dt><dd id="dCreated">-</dd>';
+        }
+        html += '</dl></div>';
+        if (!item.trashed) {
+            html += '<div class="section"><h4>' + I("users") + esc(T("details/sharing", "Sharing")) + '</h4><div id="dShare"><span class="spinner small"></span></div></div>';
+        }
+        html += '<div class="section"><h4>' + esc(T("details/quick", "Quick actions")) + '</h4><div class="quick-actions" id="dQuick"></div></div>';
+        body.innerHTML = html;
+
+        bindPreviewButtons(item, body);
+        var rn = body.querySelector('[data-act="rename"]');
+        if (rn) {
+            rn.addEventListener("click", function () { DriveActions.rename(item); });
+        }
+
+        //Quick actions
+        var quick = body.querySelector("#dQuick");
+        var list = [];
+        if (item.trashed) {
+            list = [["restore", T("trash/restore", "Restore"), function () { DriveActions.restore([item]); }, false],
+            ["trash", T("trash/deleteForever", "Delete forever"), function () { DriveActions.deleteForever([item]); }, false]];
+        } else {
+            if (!item.isDir && !item.shareOnly) {
+                list.push(["apps", T("menu/openWith", "Open with"), function (b) {
+                    DriveUI.menu({ anchor: b }, DriveActions.openWithItems(item));
+                }, true]);
+            }
+            if (!item.shareOnly) {
+                list.push(["download", item.isDir ? T("menu/downloadFolder", "Download as zip") : T("toolbar/download", "Download"), function () { DriveActions.download([item]); }, false]);
+            }
+            if (!item.isRoot) {
+                list.push(["link", T("details/getLink", "Get link"), function () { DriveActions.copyLink(item); }, false]);
+            }
+            if (writable) {
+                list.push(["move", T("menu/moveTo", "Move to..."), function () { DriveActions.moveTo([item]); }, false]);
+            }
+            if (!item.shareOnly && !item.isRoot) {
+                list.push(["copy", T("menu/copyTo", "Copy to..."), function () { DriveActions.copyTo([item]); }, false]);
+                var starred = S().starred.has(item.path);
+                list.push([starred ? "starFill" : "star", starred ? T("menu/unstar", "Remove from Starred") : T("menu/star", "Add to Starred"), function () { DriveActions.star([item], !starred); }, false]);
+            }
+        }
+        list.forEach(function (a) {
+            var b = DriveUI.el('<button>' + I(a[0]) + '<span>' + esc(a[1]) + '</span>' + (a[3] ? I("chevronRight") : "") + '</button>');
+            b.addEventListener("click", function () { a[2](b); });
+            quick.appendChild(b);
+        });
+
+        //Async: properties and sharing
+        propsOf(item).then(function (pr) {
+            if (!stillCurrent(my) || !pr) {
+                if (stillCurrent(my) && item.isDir) {
+                    body.querySelector("#dSize").textContent = "-";
+                }
+                return;
+            }
+            var size = body.querySelector("#dSize");
+            if (size && item.isDir) {
+                size.innerHTML = pr.Filesize >= 0 ? esc(DriveUI.fmtSize(pr.Filesize)) + '<small>' + esc(DriveUI.fmtExact(pr.Filesize)) + '</small>' : esc(T("details/unknown", "Unknown"));
+            }
+            var by = pr.Owner && pr.Owner != "Unknown" ? '<small>' + esc(T("details/by", "by {name}", { name: pr.Owner })) + '</small>' : "";
+            var mod = body.querySelector("#dModified");
+            if (mod && pr.LastModUnix) {
+                mod.innerHTML = esc(DriveUI.fmtDate(pr.LastModUnix)) + by;
+            }
+            var cr = body.querySelector("#dCreated");
+            if (cr) {
+                cr.innerHTML = pr.CreationUnix ? esc(DriveUI.fmtDate(pr.CreationUnix)) + by : '<span class="muted">' + esc(T("details/notRecorded", "Not recorded by this storage")) + '</span>';
+            }
+        });
+        if (!item.trashed) {
+            shareInfoOf(item).then(function (info) {
+                if (!stillCurrent(my)) {
+                    return;
+                }
+                var box = body.querySelector("#dShare");
+                var shares = ((info && info.Shares) || []).filter(function (s) { return !s.IsExpired; });
+                var canManage = !info || info.CanManage !== false;
+                if (!shares.length) {
+                    box.innerHTML = '<div class="share-summary"><div class="bubble">' + I("lock") + '</div><div class="txt">' + esc(T("share/private", "Not shared")) +
+                        '<small>' + esc(T("details/onlyYou", "Only people with access to this location")) + '</small></div>' +
+                        (item.isRoot || !canManage ? "" : '<button class="btn small" data-act="share">' + esc(T("toolbar/share", "Share")) + '</button>') + '</div>';
+                } else {
+                    var s = shares[0];
+                    box.innerHTML = '<div class="share-summary"><div class="bubble">' + I(DriveShare.audienceIcon(s)) + '</div><div class="txt">' + esc(DriveShare.audienceTitle(s)) +
+                        '<small>' + esc(DriveShare.detailText(s, item.isDir) + (shares.length > 1 ? " · " + T("details/moreLinks", "+{n} more links", { n: shares.length - 1 }) : "")) + '</small></div>' +
+                        (canManage ? '<button class="btn small" data-act="share">' + esc(T("share/manage", "Manage")) + '</button>' : "") + '</div>';
+                }
+                var sb = box.querySelector('[data-act="share"]');
+                if (sb) {
+                    sb.addEventListener("click", function () { DriveShare.open(item); });
+                }
+            });
+        }
+    }
+
+    function bindPreviewButtons(item, body) {
+        var pv = body.querySelector('[data-act="preview"]');
+        if (pv) {
+            pv.addEventListener("click", function () { DrivePreview.open(item); });
+        }
+        var op = body.querySelector('[data-act="open"]');
+        if (op) {
+            op.addEventListener("click", function () { DriveActions.open(item); });
+        }
+        var of = body.querySelector('[data-act="openFolder"]');
+        if (of) {
+            of.addEventListener("click", function () { DriveActions.open(item); });
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    function permissions(item, body, my) {
+        body.innerHTML = '<div class="state" style="padding:30px"><div class="spinner"></div></div>';
+        Promise.all([propsOf(item), shareInfoOf(item)]).then(function (r) {
+            if (!stillCurrent(my)) {
+                return;
+            }
+            var pr = r[0], info = r[1];
+            var root = Drive.rootOf(item.path);
+            var readonly = root && root.readonly;
+            var owner = pr && pr.Owner && pr.Owner != "Unknown" ? pr.Owner : "";
+            var html = '<div class="section" style="padding-top:0"><h4>' + I("user") + esc(T("perm/access", "Access")) + '</h4><div class="list-plain">' +
+                '<div class="item-card"><div class="ic-wrap accent">' + I("user") + '</div><div class="body"><b>' + esc(owner || T("details/unknown", "Unknown")) +
+                (owner == S().user.name ? " (" + esc(T("owner/me", "me")) + ")" : "") + '</b><small>' + esc(T("perm/owner", "Owner")) + '</small></div></div>' +
+                '<div class="item-card"><div class="ic-wrap">' + I(readonly ? "lock" : "pencil") + '</div><div class="body"><b>' + esc(readonly ? T("perm/readOnly", "Read only") : T("perm/readWrite", "Read and write")) +
+                '</b><small>' + esc(T("perm/yourAccess", "Your access through {root}", { root: Drive.rootLabel(root) })) + '</small></div></div>';
+            if (pr && pr.Permission) {
+                html += '<div class="item-card"><div class="ic-wrap">' + I("shield") + '</div><div class="body"><b class="mono">' + esc(pr.Permission) + '</b><small>' +
+                    esc(T("perm/mode", "Storage permission bits")) + '</small></div></div>';
+            }
+            html += '</div></div>';
+
+            var shares = (info && info.Shares) || [];
+            html += '<div class="section"><h4>' + I("link") + esc(T("perm/links", "Share links")) + '</h4>';
+            if (!shares.length) {
+                html += '<p class="muted" style="margin:0 0 12px">' + esc(T("perm/noLinks", "There are no share links for this item.")) + '</p>';
+            } else {
+                html += '<div class="list-plain" style="margin-bottom:12px">' + shares.map(function (s) {
+                    return '<div class="item-card"><div class="ic-wrap' + (s.Permission == "anyone" ? " accent" : "") + '">' + I(DriveShare.audienceIcon(s)) + '</div><div class="body"><b>' +
+                        esc(DriveShare.audienceTitle(s)) + '</b><small>' + esc(DriveShare.detailText(s, item.isDir)) + '</small>' +
+                        ((s.Accessibles || []).length ? '<small>' + esc(s.Accessibles.join(", ")) + '</small>' : "") + '</div>' +
+                        '<div class="actions"><button class="icon-btn small" data-copy="' + esc(s.UUID) + '" title="' + esc(T("menu/copyLink", "Copy link")) + '">' + I("copy") + '</button></div></div>';
+                }).join("") + '</div>';
+            }
+            if (!item.isRoot && (!info || info.CanManage !== false)) {
+                html += '<button class="btn" data-act="share">' + I("users") + esc(T("share/manageSharing", "Manage sharing")) + '</button>';
+            }
+            html += '</div>';
+            body.innerHTML = html;
+            body.querySelectorAll("[data-copy]").forEach(function (b) {
+                b.addEventListener("click", function () {
+                    DriveActions.copyText(API.shareURL(b.getAttribute("data-copy"))).then(function () {
+                        DriveUI.toast(T("share/copied", "Link copied"), { type: "success" });
+                    });
+                });
+            });
+            var sb = body.querySelector('[data-act="share"]');
+            if (sb) {
+                sb.addEventListener("click", function () { DriveShare.open(item); });
+            }
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    function activity(item, body, my) {
+        body.innerHTML = '<div class="state" style="padding:30px"><div class="spinner"></div></div>';
+        var versionsP = (!item.isDir && API.caps.fm) ? API.versions(item.path).catch(function () { return null; }) : Promise.resolve(null);
+        Promise.all([propsOf(item), shareInfoOf(item), versionsP]).then(function (r) {
+            if (!stillCurrent(my)) {
+                return;
+            }
+            var pr = r[0], info = r[1], vers = r[2];
+            var events = [];
+            var owner = pr && pr.Owner && pr.Owner != "Unknown" ? pr.Owner : "";
+            if (pr && pr.CreationUnix) {
+                events.push({ t: pr.CreationUnix, icon: "plus", cls: "success", what: owner ? T("act/createdBy", "{name} created this item", { name: owner }) : T("act/created", "Created") });
+            }
+            var mt = (pr && pr.LastModUnix) || item.mtime;
+            if (mt && (!pr || mt != pr.CreationUnix)) {
+                events.push({ t: mt, icon: "pencil", cls: "accent", what: T("act/modified", "Last modified") });
+            }
+            ((info && info.Shares) || []).forEach(function (s) {
+                if (s.CreatedAt) {
+                    events.push({ t: s.CreatedAt, icon: DriveShare.audienceIcon(s), cls: "accent", what: T("act/shared", "{who} shared it with {audience}", { who: s.Owner || owner || "", audience: DriveShare.audienceTitle(s).toLowerCase() }).trim() });
+                }
+                if (s.ExpireAt > 0) {
+                    events.push({ t: s.ExpireAt, icon: "clock", cls: "warning", what: s.IsExpired ? T("act/expired", "A share link expired") : T("act/willExpire", "A share link expires") });
+                }
+            });
+            ((vers && vers.Versions) || []).forEach(function (v) {
+                events.push({ t: v.ModTime, icon: "history", cls: "", what: T("act/version", "Version saved ({size})", { size: DriveUI.fmtSize(v.Filesize) }) });
+            });
+            var opened = S().recentOpened && S().recentOpened[item.path];
+            if (opened) {
+                events.push({ t: opened, icon: "eye", cls: "", what: T("act/opened", "You opened it in Drive") });
+            }
+            events.sort(function (a, b) { return b.t - a.t; });
+            if (!events.length) {
+                body.innerHTML = '<div class="state">' + I("activity", { size: 36 }) + '<p>' + esc(T("act/none", "No activity recorded yet.")) + '</p></div>';
+                return;
+            }
+            body.innerHTML = '<ul class="timeline">' + events.map(function (e) {
+                return '<li><div class="dot ' + e.cls + '">' + I(e.icon) + '</div><div><div class="what">' + esc(e.what) + '</div><div class="when">' +
+                    esc(DriveUI.fmtDate(e.t) + " · " + DriveUI.fmtRelative(e.t)) + '</div></div></li>';
+            }).join("") + '</ul><p class="muted" style="font-size:12px;margin:4px 0 0">' +
+                esc(T("act/note", "Built from the dates the storage, share links and version history keep.")) + '</p>';
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    function versions(item, body, my) {
+        body.innerHTML = '<div class="state" style="padding:30px"><div class="spinner"></div></div>';
+        API.versions(item.path).then(function (data) {
+            if (!stillCurrent(my)) {
+                return;
+            }
+            var list = (data && data.Versions) || [];
+            var writable = Drive.isWritable(item.path);
+            var parent = API.parentOf(item.path);
+            var html = '<div class="item-card" style="margin-bottom:14px"><div class="ic-wrap accent">' + I("file") + '</div><div class="body"><b>' + esc(T("ver/current", "Current version")) +
+                '</b><small>' + esc(DriveUI.fmtDate(data.LatestModtime || item.mtime) + " · " + DriveUI.fmtSize(item.size)) + '</small></div></div>';
+            if (writable) {
+                html += '<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><button class="btn small" data-act="snap">' + I("plus") + esc(T("ver/save", "Save a version now")) + '</button>' +
+                    (list.length ? '<button class="btn small ghost" data-act="purge">' + I("trash") + esc(T("ver/deleteAll", "Delete all versions")) + '</button>' : "") + '</div>';
+            }
+            if (!list.length) {
+                html += '<p class="muted" style="margin:0">' + esc(T("ver/none", "No earlier versions. Versions are kept when a file is overwritten, or when you save one here.")) + '</p>';
+            } else {
+                html += '<div class="list-plain">' + list.map(function (v) {
+                    return '<div class="item-card"><div class="ic-wrap">' + I("history") + '</div><div class="body"><b>' + esc(v.OverwriteTime) + '</b><small>' +
+                        esc(DriveUI.fmtSize(v.Filesize)) + '</small></div><div class="actions">' +
+                        '<a class="icon-btn small" href="' + esc(API.downloadURL(parent + v.Relpath)) + '" download="' + esc(v.Filename) + '" title="' + esc(T("toolbar/download", "Download")) + '">' + I("download") + '</a>' +
+                        (writable ? '<button class="icon-btn small" data-restore="' + esc(v.HistoryID) + '" title="' + esc(T("ver/restore", "Restore this version")) + '">' + I("restore") + '</button>' +
+                            '<button class="icon-btn small" data-del="' + esc(v.HistoryID) + '" title="' + esc(T("ver/delete", "Delete this version")) + '">' + I("trash") + '</button>' : "") +
+                        '</div></div>';
+                }).join("") + '</div>';
+            }
+            body.innerHTML = html;
+            var again = function () {
+                if (stillCurrent(my)) {
+                    versions(item, body, my);
+                }
+            };
+            var snap = body.querySelector('[data-act="snap"]');
+            if (snap) {
+                snap.addEventListener("click", function () {
+                    API.versionOp(item.path, "new").then(function () {
+                        DriveUI.toast(T("ver/saved", "Version saved"), { type: "success" });
+                        again();
+                    }).catch(DriveUI.error);
+                });
+            }
+            var purge = body.querySelector('[data-act="purge"]');
+            if (purge) {
+                purge.addEventListener("click", function () {
+                    DriveUI.confirm({ title: T("ver/deleteAll", "Delete all versions"), message: T("ver/deleteAllText", "Every earlier version of this file will be deleted."), ok: T("common/delete", "Delete"), danger: true }).then(function (ok) {
+                        if (ok) {
+                            API.versionOp(item.path, "deleteAll").then(again).catch(DriveUI.error);
+                        }
+                    });
+                });
+            }
+            body.querySelectorAll("[data-restore]").forEach(function (b) {
+                b.addEventListener("click", function () {
+                    DriveUI.confirm({ title: T("ver/restore", "Restore this version"), message: T("ver/restoreText", "The current content is replaced by this version."), ok: T("ver/restoreOk", "Restore") }).then(function (ok) {
+                        if (!ok) {
+                            return;
+                        }
+                        API.versionOp(item.path, "restore", b.getAttribute("data-restore")).then(function () {
+                            DriveUI.toast(T("ver/restored", "Version restored"), { type: "success" });
+                            Drive.refresh();
+                            again();
+                        }).catch(DriveUI.error);
+                    });
+                });
+            });
+            body.querySelectorAll("[data-del]").forEach(function (b) {
+                b.addEventListener("click", function () {
+                    API.versionOp(item.path, "delete", b.getAttribute("data-del")).then(again).catch(DriveUI.error);
+                });
+            });
+        }).catch(function (err) {
+            if (stillCurrent(my)) {
+                body.innerHTML = '<div class="banner">' + I("alert") + '<span>' + esc(err.message) + '</span></div>';
+            }
+        });
+    }
+
+    global.DriveDetails = {
+        open: open,
+        close: close,
+        refresh: function (force) {
+            refresh(force);
+        },
+        invalidate: function () {
+            cache = {};
+            lastKey = null;
+        }
+    };
+})(window);

+ 150 - 0
src/web/Drive/js/i18n.js

@@ -0,0 +1,150 @@
+/*
+    i18n.js
+
+    Thin wrapper over the shared script/applocale.js.
+
+        T("toolbar/new", "New")                       -> localised or fallback
+        T("msg/moved", "Moved {n} items", {n: 3})     -> placeholders
+        DriveI18n.translate(rootElement)              -> fill [data-i18n] /
+                                                         [data-i18n-title] /
+                                                         [data-i18n-placeholder]
+
+    English lives in the code as the fallback, so the page works even when
+    locale/drive.json cannot be loaded.
+*/
+
+(function (global) {
+    "use strict";
+
+    var data = null;
+    var lang = "en-us";
+
+    function resolveLang() {
+        var stored = null;
+        try {
+            stored = localStorage.getItem("global_language");
+        } catch (e) {
+            stored = null;
+        }
+        var l = (stored == null || stored == "default") ? navigator.language : stored;
+        return String(l || "en-us").toLowerCase();
+    }
+
+    function table() {
+        if (!data || !data.keys) {
+            return null;
+        }
+        if (data.keys[lang]) {
+            return data.keys[lang];
+        }
+        //zh-hant-tw style or bare language codes
+        var base = lang.split("-")[0];
+        if (base == "zh") {
+            return data.keys[lang.indexOf("hans") >= 0 || lang.indexOf("cn") >= 0 || lang.indexOf("sg") >= 0 ? "zh-cn" : "zh-tw"] || null;
+        }
+        for (var k in data.keys) {
+            if (k.split("-")[0] == base) {
+                return data.keys[k];
+            }
+        }
+        return null;
+    }
+
+    function T(key, fallback, vars) {
+        var t = table();
+        var text = (t && t.strings && t.strings[key] !== undefined) ? t.strings[key] : (fallback !== undefined ? fallback : key);
+        if (vars) {
+            Object.keys(vars).forEach(function (k) {
+                text = text.split("{" + k + "}").join(vars[k]);
+            });
+        }
+        return text;
+    }
+
+    function translate(root) {
+        root = root || document;
+        root.querySelectorAll("[data-i18n]").forEach(function (el) {
+            if (!el.hasAttribute("data-i18n-fallback")) {
+                el.setAttribute("data-i18n-fallback", el.textContent);
+            }
+            el.textContent = T(el.getAttribute("data-i18n"), el.getAttribute("data-i18n-fallback"));
+        });
+        root.querySelectorAll("[data-i18n-title]").forEach(function (el) {
+            var key = el.getAttribute("data-i18n-title");
+            var fb = el.getAttribute("data-i18n-title-fallback") || el.getAttribute("title") || "";
+            el.setAttribute("data-i18n-title-fallback", fb);
+            el.setAttribute("title", T(key, fb));
+            el.setAttribute("aria-label", T(key, fb));
+        });
+        root.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) {
+            var key = el.getAttribute("data-i18n-placeholder");
+            var fb = el.getAttribute("data-i18n-placeholder-fallback") || el.getAttribute("placeholder") || "";
+            el.setAttribute("data-i18n-placeholder-fallback", fb);
+            el.setAttribute("placeholder", T(key, fb));
+        });
+    }
+
+    function load(callback) {
+        lang = resolveLang();
+        document.documentElement.setAttribute("lang", lang);
+        var done = false;
+        var finish = function () {
+            if (!done) {
+                done = true;
+                callback();
+            }
+        };
+        $.ajax({
+            url: "Drive/locale/drive.json?v=1.0",
+            dataType: "json",
+            cache: true,
+            success: function (json) {
+                data = json;
+                finish();
+            },
+            error: finish
+        });
+        //Never hold the page hostage to a slow locale file
+        setTimeout(finish, 4000);
+    }
+
+    function setLanguage(code, callback) {
+        try {
+            localStorage.setItem("global_language", code);
+        } catch (e) { }
+        lang = resolveLang();
+        document.documentElement.setAttribute("lang", lang);
+        if (callback) {
+            callback();
+        }
+    }
+
+    function languages() {
+        var list = [];
+        if (data && data.keys) {
+            Object.keys(data.keys).forEach(function (k) {
+                list.push({ code: k, name: data.keys[k].name || k });
+            });
+        }
+        return list;
+    }
+
+    //Dates and numbers follow the interface language
+    function locale() {
+        return lang;
+    }
+
+    global.T = T;
+    global.DriveI18n = {
+        load: load,
+        translate: translate,
+        setLanguage: setLanguage,
+        languages: languages,
+        locale: locale,
+        current: function () {
+            var stored = null;
+            try { stored = localStorage.getItem("global_language"); } catch (e) { }
+            return stored || "default";
+        }
+    };
+})(window);

+ 338 - 0
src/web/Drive/js/icons.js

@@ -0,0 +1,338 @@
+/*
+    icons.js
+
+    Every glyph Drive draws, as inline SVG: the stroked interface icons and
+    the filled file type icons used in the listings. Nothing here is an
+    emoji or an icon font (CLAUDE.md rule 6).
+
+        DriveIcons.ui("star")                  -> <svg> markup, 1em, currentColor
+        DriveIcons.ui("star", {size: 20})      -> fixed pixel size
+        DriveIcons.file("report.pdf", false)   -> file type tile
+        DriveIcons.file("Music", true)         -> folder, with an emblem for
+                                                  well known folder names
+        DriveIcons.category("report.pdf")      -> "pdf"
+*/
+
+(function (global) {
+    "use strict";
+
+    /* ------------------------------------------------------------------ */
+    /*  Interface icons (24 x 24, stroked)                                 */
+    /* ------------------------------------------------------------------ */
+
+    //An 8 tooth cog, built from polar coordinates rather than drawn by hand
+    function cogPath() {
+        var cx = 12, cy = 12, outer = 9.2, inner = 7.1, teeth = 8;
+        var pts = [];
+        for (var i = 0; i < teeth; i++) {
+            var base = (Math.PI * 2 / teeth) * i;
+            var half = Math.PI / teeth;
+            var a1 = base - half * 0.62, a2 = base - half * 0.32;
+            var a3 = base + half * 0.32, a4 = base + half * 0.62;
+            pts.push([cx + inner * Math.cos(a1), cy + inner * Math.sin(a1)]);
+            pts.push([cx + outer * Math.cos(a2), cy + outer * Math.sin(a2)]);
+            pts.push([cx + outer * Math.cos(a3), cy + outer * Math.sin(a3)]);
+            pts.push([cx + inner * Math.cos(a4), cy + inner * Math.sin(a4)]);
+        }
+        var d = "M" + pts.map(function (p) { return p[0].toFixed(2) + " " + p[1].toFixed(2); }).join("L") + "Z";
+        return '<path d="' + d + '"/><circle cx="12" cy="12" r="2.9"/>';
+    }
+
+    function dots(points, r) {
+        return points.map(function (p) {
+            return '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (r || 1.35) + '" fill="currentColor" stroke="none"/>';
+        }).join("");
+    }
+
+    var FOLDER = '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
+    var PAGE = '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>';
+
+    var UI = {
+        home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>',
+        users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8"/><path d="M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
+        user: '<circle cx="12" cy="8" r="4"/><path d="M4.5 20.5a7.5 7.5 0 0 1 15 0"/>',
+        userPlus: '<circle cx="10" cy="8" r="4"/><path d="M2.5 20.5a7.5 7.5 0 0 1 15 0"/><path d="M19 7v6M16 10h6"/>',
+        clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
+        star: '<path d="M12 3.2l2.7 5.6 6.1.8-4.5 4.2 1.1 6-5.4-3-5.4 3 1.1-6-4.5-4.2 6.1-.8z"/>',
+        starFill: '<path d="M12 3.2l2.7 5.6 6.1.8-4.5 4.2 1.1 6-5.4-3-5.4 3 1.1-6-4.5-4.2 6.1-.8z" fill="currentColor"/>',
+        trash: '<path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M10 11v6M14 11v6"/>',
+        drive: '<rect x="3" y="13" width="18" height="7" rx="2"/><path d="M5.5 13 8 5h8l2.5 8"/><path d="M7 16.5h.01M10 16.5h.01"/>',
+        globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/>',
+        cloud: '<path d="M7 18a4.5 4.5 0 0 1-.6-9 6 6 0 0 1 11.4 1.5A3.75 3.75 0 0 1 17.5 18z"/>',
+        hourglass: '<path d="M7 3h10M7 21h10"/><path d="M8 3v3.5a4 4 0 0 0 1.6 3.2L12 12l2.4-2.3A4 4 0 0 0 16 6.5V3"/><path d="M8 21v-3.5a4 4 0 0 1 1.6-3.2L12 12l2.4 2.3a4 4 0 0 1 1.6 3.2V21"/>',
+        cluster: '<rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="8.5" y="14" width="7" height="6" rx="1.5"/><path d="M6.5 10v2h11v-2M12 12v2"/>',
+        share: '<path d="M14 5l6 6-6 6"/><path d="M20 11h-9a7 7 0 0 0-7 7v1"/>',
+        link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1 1"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1-1"/>',
+        upload: '<path d="M12 15V4"/><path d="M7.5 8.5 12 4l4.5 4.5"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>',
+        download: '<path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>',
+        move: FOLDER + '<path d="M9.5 13.5h6.5"/><path d="M13.5 11l2.5 2.5-2.5 2.5"/>',
+        copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
+        cut: '<circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M7.9 16.4 18 4M16.1 16.4 6 4"/>',
+        paste: '<rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 5V3.5h6V5"/><path d="M9 11h6M9 15h4"/>',
+        pencil: '<path d="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17z"/><path d="M14.5 7.5l3 3"/>',
+        plus: '<path d="M12 5v14M5 12h14"/>',
+        folder: FOLDER,
+        folderPlus: FOLDER + '<path d="M12 10.5v6M9 13.5h6"/>',
+        folderUp: FOLDER + '<path d="M12 16.5v-6M9.5 13 12 10.5l2.5 2.5"/>',
+        folderOpen: '<path d="M3 18V7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v1"/><path d="M3 18l2.5-6.5A1.5 1.5 0 0 1 7 10.5h13.2a1 1 0 0 1 .95 1.3L19 18.5a2 2 0 0 1-1.9 1.5H5a2 2 0 0 1-2-2z"/>',
+        file: PAGE,
+        filePlus: PAGE + '<path d="M12 11v6M9 14h6"/>',
+        fileUp: PAGE + '<path d="M12 17.5v-6M9.5 14 12 11.5l2.5 2.5"/>',
+        doc: PAGE + '<path d="M8.5 12.5h7M8.5 16h7"/>',
+        moreH: dots([[5, 12], [12, 12], [19, 12]], 1.5),
+        moreV: dots([[12, 5], [12, 12], [12, 19]], 1.5),
+        chevronDown: '<path d="M6 9l6 6 6-6"/>',
+        chevronUp: '<path d="M6 15l6-6 6 6"/>',
+        chevronRight: '<path d="M9 6l6 6-6 6"/>',
+        chevronLeft: '<path d="M15 6l-6 6 6 6"/>',
+        arrowUp: '<path d="M12 19V5M6 11l6-6 6 6"/>',
+        arrowDown: '<path d="M12 5v14M6 13l6 6 6-6"/>',
+        arrowLeft: '<path d="M19 12H5M11 6l-6 6 6 6"/>',
+        arrowRight: '<path d="M5 12h14M13 6l6 6-6 6"/>',
+        list: '<path d="M9 6h11M9 12h11M9 18h11"/>' + dots([[4.5, 6], [4.5, 12], [4.5, 18]], 1.3),
+        grid: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>',
+        sortAsc: '<path d="M7 20V4M3.5 7.5 7 4l3.5 3.5"/><path d="M13 7h8M13 12h6M13 17h4"/>',
+        sortDesc: '<path d="M7 4v16M3.5 16.5 7 20l3.5-3.5"/><path d="M13 7h4M13 12h6M13 17h8"/>',
+        panel: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/><path d="M17.5 8.5h1M17.5 11.5h1"/>',
+        info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><path d="M12 7.6h.01"/>',
+        x: '<path d="M6 6l12 12M18 6 6 18"/>',
+        check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
+        search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.3-4.3"/>',
+        bell: '<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
+        help: '<circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6"/><path d="M12 17h.01"/>',
+        cog: cogPath(),
+        logout: '<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/><path d="M10 16l-4-4 4-4"/><path d="M6 12h10"/>',
+        sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4"/>',
+        moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/>',
+        monitor: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
+        external: '<path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>',
+        open: '<rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M10 14l6-6M11 8h5v5"/>',
+        newTab: '<rect x="3" y="7" width="14" height="14" rx="2"/><path d="M7 3h12a2 2 0 0 1 2 2v12"/><path d="M10 11v6M7 14h6"/>',
+        eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/>',
+        eyeOff: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
+        lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
+        qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM18 14h2M14 18v2"/>',
+        calendar: '<rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/>',
+        history: '<path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.5"/><path d="M4 4v4.5h4.5"/><path d="M12 8v4.5l3 2"/>',
+        activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
+        refresh: '<path d="M20 12a8 8 0 1 1-2.4-5.7L20 8.5"/><path d="M20 4v4.5h-4.5"/>',
+        checkSquare: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12.5l3 3 5-6"/>',
+        image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="9.5" r="1.6"/><path d="M4 17l5-5 4 4 2.5-2.5L20 18"/>',
+        video: '<rect x="3.5" y="5" width="17" height="14" rx="2.5"/><path d="M10.5 9.5v5l4.5-2.5z"/>',
+        music: '<path d="M9 18V6l11-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/>',
+        archive: '<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M12 4v2.5M12 8.5v2M12 12.5v1.5"/><rect x="10.5" y="14.5" width="3" height="3.5" rx=".8"/>',
+        extract: '<path d="M4 9V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3"/><path d="M12 8v11M8 15l4 4 4-4"/>',
+        menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
+        layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 12.5l9 5 9-5"/><path d="M3 16.5l9 5 9-5"/>',
+        pause: '<path d="M9 6v12M15 6v12"/>',
+        play: '<path d="M8 5.5v13l10-6.5z"/>',
+        alert: '<path d="M12 3.5 2.5 20h19z"/><path d="M12 10v4.5M12 17.4h.01"/>',
+        translate: '<path d="M4 5h9M8.5 3v2M6 5c.8 3 3 5.5 6 7M11 5c-.8 3.2-3.2 6-6.5 7.5"/><path d="M12.5 21l4-9 4 9M14 17.5h5"/>',
+        keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M7 14.5h10"/>',
+        sliders: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
+        minimize: '<path d="M5 12h14"/>',
+        apps: dots([[6, 6], [12, 6], [18, 6], [6, 12], [12, 12], [18, 12], [6, 18], [12, 18], [18, 18]], 1.6),
+        zoomIn: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.3-4.3M11 8.5v5M8.5 11h5"/>',
+        zoomOut: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.3-4.3M8.5 11h5"/>',
+        rotate: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/>',
+        restore: '<path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.5"/><path d="M4 4v4.5h4.5"/>',
+        shield: '<path d="M12 3l8 3v6c0 4.5-3.4 8-8 9-4.6-1-8-4.5-8-9V6z"/>',
+        mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5 12 13l8.5-6.5"/>',
+        code: '<path d="M8.5 7 3.5 12l5 5M15.5 7l5 5-5 5"/>',
+        hide: '<path d="M4 4l16 16"/><path d="M9.9 5.7A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a17 17 0 0 1-2.6 3.4M6.4 6.9C3.9 8.6 2.5 12 2.5 12S6 18.5 12 18.5c1.7 0 3.2-.5 4.5-1.2"/>',
+        dot: dots([[12, 12]], 3),
+        logo: ''
+    };
+
+    function ui(name, opts) {
+        opts = opts || {};
+        var body = UI[name] || UI.file;
+        var size = opts.size ? ' width="' + opts.size + '" height="' + opts.size + '"' : "";
+        var cls = "ic" + (opts.cls ? " " + opts.cls : "");
+        return '<svg class="' + cls + '"' + size + ' viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
+            'stroke-width="' + (opts.stroke || 1.8) + '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+            body + '</svg>';
+    }
+
+    //The ArozOS mark shown in the top bar
+    function logo(size) {
+        size = size || 32;
+        return '<svg class="logo" width="' + size + '" height="' + size + '" viewBox="0 0 40 40" aria-hidden="true">' +
+            '<defs><linearGradient id="drvLogoBg" x1="0" y1="0" x2="1" y2="1">' +
+            '<stop offset="0" stop-color="#4f8ff8"/><stop offset="1" stop-color="#1d5fe0"/></linearGradient></defs>' +
+            '<rect width="40" height="40" rx="10" fill="url(#drvLogoBg)"/>' +
+            '<path d="M20 8.5 30.5 30h-6.2L20 20.6 15.7 30H9.5z" fill="#fff"/>' +
+            '<path d="M20 24.4l2.3 5.6h-4.6z" fill="#cfe0ff"/>' +
+            '</svg>';
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  File type classification                                           */
+    /* ------------------------------------------------------------------ */
+    var EXT = {};
+    function map(cat, list) {
+        list.split(" ").forEach(function (e) { EXT[e] = cat; });
+    }
+    map("image", "jpg jpeg png gif bmp webp svg tif tiff ico heic heif avif jxl raw cr2 nef arw dng");
+    map("video", "mp4 webm mkv avi mov flv wmv m4v 3gp ts mts m2ts vob ogv");
+    map("audio", "mp3 flac wav aac ogg oga m4a opus wma aiff alac mid midi");
+    map("pdf", "pdf");
+    map("doc", "doc docx odt rtf pages wpd fodt docm dot dotx");
+    map("sheet", "xls xlsx ods csv tsv numbers xlsm xlsb fods");
+    map("slide", "ppt pptx odp key pptm fodp");
+    map("text", "txt md markdown log ini cfg conf nfo srt ass vtt");
+    map("code", "js mjs ts tsx jsx html htm css scss less json xml yaml yml toml go py java c cpp cc h hpp cs php rb rs swift kt sh bat ps1 lua sql agi vue svelte dart");
+    map("archive", "zip 7z rar tar gz tgz bz2 xz zst lz4 cab");
+    map("design", "psd ai xd sketch fig indd eps cdr afdesign afphoto kra xcf");
+    map("font", "ttf otf woff woff2 eot fon");
+    map("ebook", "epub mobi azw azw3 djvu fb2 cbz cbr");
+    map("exe", "exe msi apk ipa deb rpm app run com dmg");
+    map("model", "stl obj step stp fbx glb gltf blend 3ds dae iges igs 3mf ply");
+    map("disk", "iso img vhd vhdx vmdk qcow2 vdi");
+    map("data", "db sqlite sqlite3 mdb accdb parquet");
+    map("shortcut", "shortcut lnk url");
+
+    function getExt(filename) {
+        var name = String(filename || "");
+        var dot = name.lastIndexOf(".");
+        if (dot <= 0 || dot == name.length - 1) {
+            return "";
+        }
+        return name.substring(dot + 1).toLowerCase();
+    }
+
+    function category(filename, isDir) {
+        if (isDir) {
+            return "folder";
+        }
+        return EXT[getExt(filename)] || "unknown";
+    }
+
+    //Sidebar categories group the fine grained file types
+    var GROUPS = {
+        documents: ["pdf", "doc", "sheet", "slide", "text", "ebook"],
+        photos: ["image"],
+        videos: ["video"],
+        music: ["audio"]
+    };
+
+    function group(filename) {
+        var cat = category(filename, false);
+        for (var g in GROUPS) {
+            if (GROUPS[g].indexOf(cat) >= 0) {
+                return g;
+            }
+        }
+        return "others";
+    }
+
+    function extensionsOf(groupName) {
+        var cats = GROUPS[groupName] || [];
+        return Object.keys(EXT).filter(function (e) { return cats.indexOf(EXT[e]) >= 0; });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  File type icons (32 x 32, filled)                                  */
+    /* ------------------------------------------------------------------ */
+    var COLOR = {
+        image: "#2f8be6", video: "#8b5cf6", audio: "#ec4899", pdf: "#ef4444",
+        doc: "#2563eb", sheet: "#16a34a", slide: "#f97316", text: "#94a3b8",
+        code: "#0d9488", archive: "#38a3f1", design: "#a855f7", font: "#64748b",
+        ebook: "#c2410c", exe: "#475569", model: "#7c3aed", disk: "#6366f1",
+        data: "#0891b2", shortcut: "#64748b", unknown: "#9aa3b2"
+    };
+
+    var PAGE_SHAPE = "M9 2.5h10.6L26.5 9.4V27a2.5 2.5 0 0 1-2.5 2.5H9A2.5 2.5 0 0 1 6.5 27V5A2.5 2.5 0 0 1 9 2.5z";
+    var PAGE_FOLD = "M19.6 2.5 26.5 9.4h-5A1.9 1.9 0 0 1 19.6 7.5z";
+
+    function label(text, size) {
+        return '<text x="16.5" y="23.4" text-anchor="middle" font-family="Segoe UI, Roboto, Helvetica, Arial, sans-serif" ' +
+            'font-size="' + size + '" font-weight="700" fill="#fff" letter-spacing=".2">' + text + '</text>';
+    }
+
+    var GLYPH = {
+        image: '<circle cx="12.8" cy="15.2" r="1.9" fill="#fff"/><path d="M9.3 25.2l4.6-5.4 2.9 3.1 2.4-2.7 3.9 5z" fill="#fff"/>',
+        video: '<rect x="9.6" y="14" width="13.8" height="10.4" rx="2.2" fill="#fff" opacity=".28"/><path d="M14.6 15.9v6.6l5.4-3.3z" fill="#fff"/>',
+        audio: '<circle cx="13.9" cy="23" r="2.3" fill="#fff"/><path d="M15.3 23v-9.2l5.6 1.7v2.8l-3.8-1.1V23z" fill="#fff"/>',
+        pdf: label("PDF", 6.6),
+        doc: '<rect x="10.4" y="13.6" width="12.4" height="1.7" rx=".85" fill="#fff"/><rect x="10.4" y="17.2" width="12.4" height="1.7" rx=".85" fill="#fff"/><rect x="10.4" y="20.8" width="12.4" height="1.7" rx=".85" fill="#fff"/><rect x="10.4" y="24.4" width="7.6" height="1.7" rx=".85" fill="#fff"/>',
+        sheet: '<rect x="10" y="13.4" width="13.2" height="12" rx="1.4" fill="#fff"/><path d="M10 17.4h13.2M10 21.4h13.2M14.6 13.4v12" stroke="#16a34a" stroke-width="1.3"/>',
+        slide: '<rect x="9.8" y="13.6" width="13.6" height="9" rx="1.2" fill="#fff"/><path d="M16.6 22.6v3M13.8 26.2h5.6" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/><rect x="12.4" y="17.6" width="2" height="3" fill="#f97316"/><rect x="15.6" y="16" width="2" height="4.6" fill="#f97316"/><rect x="18.8" y="18.6" width="2" height="2" fill="#f97316"/>',
+        text: '<rect x="10.4" y="14.2" width="12.4" height="1.6" rx=".8" fill="#fff"/><rect x="10.4" y="18" width="12.4" height="1.6" rx=".8" fill="#fff"/><rect x="10.4" y="21.8" width="8.6" height="1.6" rx=".8" fill="#fff"/>',
+        code: '<path d="M13.8 15.4 10.6 19.3l3.2 3.9M19.4 15.4l3.2 3.9-3.2 3.9" fill="none" stroke="#fff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>',
+        archive: '<rect x="15.1" y="4.2" width="2.8" height="2" rx=".5" fill="#fff"/><rect x="15.1" y="7.8" width="2.8" height="2" rx=".5" fill="#fff"/><rect x="15.1" y="11.4" width="2.8" height="2" rx=".5" fill="#fff"/><rect x="14.2" y="15.2" width="4.6" height="6.6" rx="1.3" fill="#fff"/><rect x="15.4" y="18.4" width="2.2" height="1.8" rx=".4" fill="#38a3f1"/>',
+        design: '<path d="M16.5 12.6l5 6.2-5 7.4-5-7.4z" fill="#fff"/><circle cx="16.5" cy="19.3" r="1.5" fill="#a855f7"/><path d="M16.5 12.6v5.2" stroke="#a855f7" stroke-width="1"/>',
+        font: label("Aa", 7.6),
+        ebook: '<path d="M9.8 15a1 1 0 0 1 1-1h3.4a2.3 2.3 0 0 1 2.3 2.3v9.2a1.9 1.9 0 0 0-1.9-1.9H9.8zM23.2 15a1 1 0 0 0-1-1h-3.4a2.3 2.3 0 0 0-2.3 2.3v9.2a1.9 1.9 0 0 1 1.9-1.9h4.8z" fill="#fff"/>',
+        exe: '<rect x="9.8" y="14" width="13.4" height="11" rx="1.6" fill="#fff"/><rect x="9.8" y="14" width="13.4" height="2.8" rx="1.4" fill="#fff" opacity=".7"/><path d="M9.8 16.8h13.4" stroke="#475569" stroke-width="1"/><path d="M13 20l2 1.6-2 1.6M16.6 23.2h3" fill="none" stroke="#475569" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
+        model: '<path d="M16.5 12.8l5.8 3.2v6.6l-5.8 3.2-5.8-3.2V16z" fill="#fff"/><path d="M10.7 16l5.8 3.2 5.8-3.2M16.5 19.2v6.6" fill="none" stroke="#7c3aed" stroke-width="1.1"/>',
+        disk: '<circle cx="16.5" cy="19.6" r="6.2" fill="#fff"/><circle cx="16.5" cy="19.6" r="1.8" fill="#6366f1"/>',
+        data: '<ellipse cx="16.5" cy="15" rx="5.6" ry="2" fill="#fff"/><path d="M10.9 15v9.2c0 1.1 2.5 2 5.6 2s5.6-.9 5.6-2V15c0 1.1-2.5 2-5.6 2s-5.6-.9-5.6-2z" fill="#fff" opacity=".85"/><path d="M10.9 19.6c0 1.1 2.5 2 5.6 2s5.6-.9 5.6-2" fill="none" stroke="#0891b2" stroke-width="1"/>',
+        shortcut: '<path d="M12 24.5v-2.6a5 5 0 0 1 5-5h3.6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M18.6 14.2l3.2 2.7-3.2 2.7" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
+    };
+
+    //Emblems drawn on the front of well known folders (centred near 16,19)
+    var EMBLEM = {
+        desktop: '<rect x="11.2" y="14.4" width="9.6" height="6.6" rx="1" fill="none" stroke="#fff" stroke-width="1.5"/><path d="M14 23.4h4M16 21v2.4" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>',
+        document: '<path d="M12.6 14.6h6.8M12.6 17.8h6.8M12.6 21h4.4" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>',
+        music: '<circle cx="14.3" cy="21.7" r="1.7" fill="#fff"/><path d="M15.4 21.7v-6.8l4.2 1.3v2l-2.8-.8v4.3" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/>',
+        photo: '<circle cx="13.8" cy="15.6" r="1.4" fill="#fff"/><path d="M11 22.6l3.4-3.9 2.2 2.3 1.8-2 2.6 3.6z" fill="#fff"/>',
+        video: '<path d="M14.4 14.8v7.2l5.8-3.6z" fill="#fff"/>',
+        download: '<path d="M16 14v7M13 18.3l3 3 3-3M12.4 23.4h7.2" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
+        web: '<circle cx="16" cy="18.6" r="4.6" fill="none" stroke="#fff" stroke-width="1.3"/><path d="M11.4 18.6h9.2M16 14c1.3 1.3 1.9 2.8 1.9 4.6s-.6 3.3-1.9 4.6c-1.3-1.3-1.9-2.8-1.9-4.6s.6-3.3 1.9-4.6z" fill="none" stroke="#fff" stroke-width="1.1"/>',
+        code: '<path d="M13.6 15.6 11.2 18.6l2.4 3M18.4 15.6l2.4 3-2.4 3" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
+        shared: '<circle cx="14.2" cy="16.4" r="1.9" fill="#fff"/><path d="M10.8 22.6a3.4 3.4 0 0 1 6.8 0z" fill="#fff"/><circle cx="18.8" cy="16.9" r="1.5" fill="#fff" opacity=".85"/><path d="M18.4 19.4a3 3 0 0 1 3.4 3.2h-3.2" fill="#fff" opacity=".85"/>',
+        model: '<path d="M16 13.8l4.4 2.4v5l-4.4 2.4-4.4-2.4v-5z" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/><path d="M11.6 16.2l4.4 2.4 4.4-2.4M16 18.6v5" fill="none" stroke="#fff" stroke-width="1.1"/>',
+        trash: '<path d="M12 15.2h8M14.6 15.2v-1.2h2.8v1.2M13 15.2l.6 7.4h4.8l.6-7.4" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>'
+    };
+
+    var FOLDER_ALIAS = {
+        desktop: "desktop", document: "document", documents: "document", music: "music", audio: "music",
+        photo: "photo", photos: "photo", picture: "photo", pictures: "photo", image: "photo", images: "photo",
+        video: "video", videos: "video", film: "video", movies: "video", download: "download", downloads: "download",
+        www: "web", web: "web", mysite: "web", appdata: "code", code: "code", model: "model", models: "model",
+        shared: "shared", share: "shared", trash: "trash"
+    };
+
+    function folderSVG(emblem) {
+        return '<svg class="ft ft-folder" viewBox="0 0 32 32" aria-hidden="true">' +
+            '<path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h6.4a2.5 2.5 0 0 1 1.9.9L15.6 9h10.9A2.5 2.5 0 0 1 29 11.5V14H3z" fill="#3b7fef"/>' +
+            '<path d="M3 12.4A2.4 2.4 0 0 1 5.4 10h21.2A2.4 2.4 0 0 1 29 12.4v12.1a2.5 2.5 0 0 1-2.5 2.5h-21A2.5 2.5 0 0 1 3 24.5z" fill="#5c9cf8"/>' +
+            '<path d="M3 12.4A2.4 2.4 0 0 1 5.4 10h21.2A2.4 2.4 0 0 1 29 12.4v.8H3z" fill="#fff" opacity=".18"/>' +
+            (emblem ? '<g opacity=".95">' + emblem + '</g>' : "") +
+            '</svg>';
+    }
+
+    function file(filename, isDir, opts) {
+        opts = opts || {};
+        if (isDir) {
+            var key = FOLDER_ALIAS[String(filename).toLowerCase()];
+            if (opts.shared) {
+                key = "shared";
+            }
+            return folderSVG(opts.plain ? "" : (EMBLEM[key] || ""));
+        }
+        var cat = category(filename, false);
+        var color = COLOR[cat] || COLOR.unknown;
+        var glyph = GLYPH[cat];
+        if (!glyph) {
+            var ext = getExt(filename).substring(0, 4).toUpperCase();
+            glyph = ext == "" ? "" : label(ext, ext.length >= 4 ? 5 : (ext.length == 3 ? 6.2 : 7.4));
+        }
+        return '<svg class="ft ft-' + cat + '" viewBox="0 0 32 32" aria-hidden="true">' +
+            '<path d="' + PAGE_SHAPE + '" fill="' + color + '"/>' +
+            '<path d="' + PAGE_FOLD + '" fill="#fff" opacity=".42"/>' +
+            glyph + '</svg>';
+    }
+
+    global.DriveIcons = {
+        ui: ui,
+        logo: logo,
+        file: file,
+        getExt: getExt,
+        category: category,
+        group: group,
+        extensionsOf: extensionsOf,
+        color: function (cat) { return COLOR[cat] || COLOR.unknown; }
+    };
+})(window);

+ 1011 - 0
src/web/Drive/js/listing.js

@@ -0,0 +1,1011 @@
+/*
+    listing.js
+
+    The file list itself: sorting, paging, list and grid markup, selection
+    (click, Ctrl / Shift click, rubber band, keyboard, touch long press),
+    drag and drop (moving items onto folders, the sidebar and the path, and
+    files dropped in from the computer) and lazily loaded thumbnails.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var coarse = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
+    var thumbObserver = null;
+    var DRAG_TYPE = "application/x-arozos-drive";
+
+    function S() {
+        return Drive.state;
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Labels                                                             */
+    /* ------------------------------------------------------------------ */
+    var TYPE_LABEL = {
+        folder: ["type/folder", "Folder"], image: ["type/image", "Image"], video: ["type/video", "Video"],
+        audio: ["type/audio", "Audio"], pdf: ["type/pdf", "PDF"], doc: ["type/doc", "Document"],
+        sheet: ["type/sheet", "Spreadsheet"], slide: ["type/slide", "Presentation"], text: ["type/text", "Text"],
+        code: ["type/code", "Code"], archive: ["type/archive", "Archive"], design: ["type/design", "Design"],
+        font: ["type/font", "Font"], ebook: ["type/ebook", "E-book"], exe: ["type/exe", "Application"],
+        model: ["type/model", "3D model"], disk: ["type/disk", "Disk image"], data: ["type/data", "Database"],
+        shortcut: ["type/shortcut", "Shortcut"]
+    };
+
+    function typeLabel(item) {
+        var cat = DriveIcons.category(item.name, item.isDir);
+        if (TYPE_LABEL[cat]) {
+            return T(TYPE_LABEL[cat][0], TYPE_LABEL[cat][1]);
+        }
+        var ext = DriveIcons.getExt(item.name);
+        return ext ? T("type/extFile", "{ext} file", { ext: ext.toUpperCase() }) : T("type/file", "File");
+    }
+
+    function audienceText(share) {
+        if (!share) {
+            return "-";
+        }
+        var p = String(share.Permission || "");
+        if (p.indexOf("anyone") == 0) {
+            return share.HasPassword ? T("audience/anyonePw", "Anyone with the link and password") : T("audience/anyone", "Anyone with the link");
+        }
+        if (p.indexOf("signedin") == 0) {
+            return T("audience/signedin", "Signed-in users");
+        }
+        if (p.indexOf("samegroup") == 0) {
+            return T("audience/samegroup", "People in the same group");
+        }
+        var m = /\((.*)\)/.exec(p);
+        var names = m ? m[1] : (share.Accessibles || []).join(", ");
+        if (p.indexOf("groups") == 0) {
+            return T("audience/groups", "Groups: {names}", { names: names });
+        }
+        if (p.indexOf("users") == 0) {
+            return names;
+        }
+        return p;
+    }
+
+    function columnText(item, key) {
+        switch (key) {
+            case "mtime":
+                return item.mtime ? DriveUI.fmtDate(item.mtime) : "-";
+            case "size":
+                return item.isDir ? "-" : DriveUI.fmtSize(item.size);
+            case "type":
+                return typeLabel(item);
+            case "location":
+                return Drive.locationLabel(item.path);
+            case "origin":
+                return item.origin ? Drive.pathParts(Drive.normDir(item.origin)).map(function (p) { return p.label; }).join(" / ") : "-";
+            case "owner":
+                return item.owner == S().user.name ? T("owner/me", "me") : (item.owner || "-");
+            case "access":
+                return audienceText(item.share);
+            case "expire":
+                if (!item.share) {
+                    return "-";
+                }
+                if (item.share.IsExpired) {
+                    return T("share/expired", "Expired");
+                }
+                return item.share.ExpireAt > 0 ? DriveUI.fmtDay(item.share.ExpireAt) : T("share/never", "Never");
+            case "activity":
+                return DriveUI.fmtRelative(item.activity) + " · " + (item.openedLast ? T("recent/opened", "Opened") : T("recent/modified", "Modified"));
+        }
+        return "";
+    }
+
+    function sortValue(item, key) {
+        switch (key) {
+            case "name": return item.name.toLowerCase();
+            case "mtime": return item.mtime || 0;
+            case "size": return item.isDir ? -1 : (item.size || 0);
+            case "activity": return item.activity || 0;
+            case "expire": return item.share && item.share.ExpireAt > 0 ? item.share.ExpireAt : Number.MAX_SAFE_INTEGER;
+            default: return columnText(item, key).toLowerCase();
+        }
+    }
+
+    var collator = (typeof Intl !== "undefined" && Intl.Collator) ? new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }) : null;
+
+    function sortItems(items) {
+        var s = Drive.sortState();
+        var dir = s.dir == "desc" ? -1 : 1;
+        var foldersFirst = S().settings.foldersFirst && s.key != "activity";
+        var out = items.slice();
+        out.sort(function (a, b) {
+            if (foldersFirst && a.isDir != b.isDir) {
+                return a.isDir ? -1 : 1;
+            }
+            var va = sortValue(a, s.key), vb = sortValue(b, s.key);
+            var r;
+            if (typeof va === "number" && typeof vb === "number") {
+                r = va - vb;
+            } else {
+                r = collator ? collator.compare(String(va), String(vb)) : String(va).localeCompare(String(vb));
+            }
+            if (r == 0 && s.key != "name") {
+                r = collator ? collator.compare(a.name, b.name) : a.name.localeCompare(b.name);
+            }
+            return r * dir;
+        });
+        return out;
+    }
+
+    function pageSize() {
+        var n = parseInt(S().settings.pageSize, 10);
+        return n > 0 ? n : 0;
+    }
+
+    function pageCount() {
+        var n = pageSize();
+        if (!n) {
+            return 1;
+        }
+        return Math.max(1, Math.ceil(S().items.length / n));
+    }
+
+    function visibleItems() {
+        var n = pageSize();
+        if (!n) {
+            return S().sorted;
+        }
+        var start = (S().page - 1) * n;
+        return S().sorted.slice(start, start + n);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Markup                                                             */
+    /* ------------------------------------------------------------------ */
+    function iconHTML(item) {
+        return DriveIcons.file(item.name, item.isDir, { shared: item.isDir && item.shared && false });
+    }
+
+    function nameExtras(item) {
+        var html = "";
+        if (item.shared || (item.share && S().view.kind != "shared")) {
+            html += '<span title="' + esc(T("badge/shared", "Shared")) + '">' + I("users") + '</span>';
+        }
+        if (item.shareOnly) {
+            html += '<span title="' + esc(T("badge/linkOnly", "Opens through the share link")) + '">' + I("link") + '</span>';
+        }
+        return html;
+    }
+
+    function subLines(item, view) {
+        var desktop = "";
+        if (view.showLocation) {
+            desktop = Drive.locationLabel(item.path);
+        } else if (view.kind == "trash" && item.origin) {
+            desktop = "";
+        } else if (view.kind == "shared" && item.share) {
+            desktop = view.key == "shared:byme" ? Drive.locationLabel(item.path) : T("shared/by", "Shared by {name}", { name: item.owner });
+        }
+        var mobileBits = [];
+        var cols = view.columns || [];
+        if (cols[0]) {
+            mobileBits.push(columnText(item, cols[0].key));
+        }
+        if (!item.isDir && !view.noSize) {
+            mobileBits.push(DriveUI.fmtSize(item.size));
+        }
+        return (desktop ? '<div class="sub sub-desktop" title="' + esc(desktop) + '">' + esc(desktop) + '</div>' : "") +
+            '<div class="sub sub-mobile">' + esc(mobileBits.filter(Boolean).join(" · ")) + '</div>';
+    }
+
+    function rowHTML(item, view) {
+        var sel = S().selection.has(item.path);
+        var starred = S().starred.has(item.path);
+        var cut = S().clipboard && S().clipboard.mode == "cut" && S().clipboard.paths.indexOf(item.path) >= 0;
+        var cols = view.columns || [];
+        var html = '<div class="row' + (sel ? " selected" : "") + (cut ? " cut" : "") + (S().focus == item.path ? " focused" : "") +
+            '" data-path="' + esc(item.path) + '" draggable="' + (view.kind == "trash" || view.kind == "shared" ? "false" : "true") + '" role="row">' +
+            '<div><input type="checkbox" class="check" tabindex="-1"' + (sel ? " checked" : "") + ' aria-label="' + esc(T("sel/select", "Select")) + '"></div>' +
+            '<div class="name-cell"><div class="thumb">' + iconHTML(item) +
+            (starred ? I("starFill", { cls: "star-mark" }) : "") + '</div>' +
+            '<div class="name-text"><div class="name"><span title="' + esc(item.name) + '">' + esc(item.name) + '</span>' + nameExtras(item) + '</div>' +
+            subLines(item, view) + '</div></div>';
+        cols.forEach(function (c) {
+            var text = columnText(item, c.key);
+            html += '<div class="cell ' + c.cls + '" title="' + esc(text) + '">' + esc(text) + '</div>';
+        });
+        html += '<div><button class="icon-btn small more-btn" tabindex="-1" aria-label="' + esc(T("menu/actions", "Actions")) + '">' + I("moreV") + '</button></div></div>';
+        return html;
+    }
+
+    function tileHTML(item, view) {
+        var sel = S().selection.has(item.path);
+        var starred = S().starred.has(item.path);
+        var cut = S().clipboard && S().clipboard.mode == "cut" && S().clipboard.paths.indexOf(item.path) >= 0;
+        var meta = item.isDir ? (view.showLocation ? Drive.locationLabel(item.path) : T("type/folder", "Folder")) :
+            DriveUI.fmtSize(item.size) + (item.mtime ? " · " + DriveUI.fmtDay(item.mtime) : "");
+        return '<div class="tile' + (sel ? " selected" : "") + (cut ? " cut" : "") + (S().focus == item.path ? " focused" : "") +
+            '" data-path="' + esc(item.path) + '" draggable="' + (view.kind == "trash" || view.kind == "shared" ? "false" : "true") + '">' +
+            '<input type="checkbox" class="check tile-check" tabindex="-1"' + (sel ? " checked" : "") + ' aria-label="' + esc(T("sel/select", "Select")) + '">' +
+            (starred ? I("starFill", { cls: "tile-star" }) : "") +
+            '<div class="tile-preview"><div class="tile-icon thumb-holder">' + iconHTML(item) + '</div></div>' +
+            '<div class="tile-body"><div class="mini">' + iconHTML(item) + '</div><div class="name-text"><div class="name"><span title="' + esc(item.name) + '">' +
+            esc(item.name) + '</span>' + nameExtras(item) + '</div><div class="sub">' + esc(meta) + '</div></div>' +
+            '<button class="icon-btn small more-btn" tabindex="-1" aria-label="' + esc(T("menu/actions", "Actions")) + '">' + I("moreV") + '</button></div></div>';
+    }
+
+    var EMPTY_ART = '<svg class="art" viewBox="0 0 140 110" aria-hidden="true">' +
+        '<ellipse cx="70" cy="100" rx="52" ry="6" fill="currentColor" opacity=".08"/>' +
+        '<path d="M22 30a8 8 0 0 1 8-8h22l8 8h50a8 8 0 0 1 8 8v48a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8z" fill="#5c9cf8" opacity=".25"/>' +
+        '<path d="M22 42a8 8 0 0 1 8-8h80a8 8 0 0 1 8 8v44a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8z" fill="#5c9cf8" opacity=".45"/>' +
+        '</svg>';
+
+    function emptyHTML(view) {
+        var e = view.empty || {};
+        return '<div class="state">' + EMPTY_ART + '<h3>' + esc(e.title || "") + '</h3><p>' + esc(e.text || "") + '</p>' +
+            (e.action ? '<button class="btn primary" id="emptyAction">' + I(e.action.icon || "plus") + esc(e.action.label) + '</button>' : "") + '</div>';
+    }
+
+    function render() {
+        var st = S();
+        var view = st.view;
+        var wrap = document.getElementById("listWrap");
+        if (!view || view.kind == "settings") {
+            return;
+        }
+        st.sorted = sortItems(st.items);
+        var pages = pageCount();
+        if (st.page > pages) {
+            st.page = pages;
+        }
+        if (st.page < 1) {
+            st.page = 1;
+        }
+        var items = visibleItems();
+        var scroll = wrap.scrollTop;
+
+        if (st.items.length == 0) {
+            wrap.innerHTML = emptyHTML(view);
+            var act = document.getElementById("emptyAction");
+            if (act) {
+                act.addEventListener("click", view.empty.action.fn);
+            }
+        } else if (Drive.currentViewMode() == "grid") {
+            wrap.innerHTML = '<div class="grid' + (st.selection.size ? " has-selection" : "") + '" role="grid">' +
+                items.map(function (i) { return tileHTML(i, view); }).join("") + '</div>';
+        } else {
+            var s = Drive.sortState();
+            var headCol = function (key, label, cls) {
+                var sorted = s.key == key;
+                return '<div class="' + (cls || "") + '"><span class="col' + (sorted ? " sorted" : "") + '" data-sort="' + key + '">' + esc(label) +
+                    I(sorted && s.dir == "desc" ? "arrowDown" : "arrowUp") + '</span></div>';
+            };
+            var allSel = items.length > 0 && items.every(function (i) { return st.selection.has(i.path); });
+            var someSel = !allSel && items.some(function (i) { return st.selection.has(i.path); });
+            var head = '<div class="list-head" role="row"><div><input type="checkbox" class="check" id="selAll" tabindex="-1"' + (allSel ? " checked" : "") +
+                ' aria-label="' + esc(T("sel/all", "Select all")) + '"></div>' + headCol("name", T("col/name", "Name"));
+            (view.columns || []).forEach(function (c) {
+                head += headCol(c.key, c.label, c.cls);
+            });
+            head += '<div></div></div>';
+            wrap.innerHTML = head + '<div class="rows" role="rowgroup">' + items.map(function (i) { return rowHTML(i, view); }).join("") + '</div>';
+            var selAll = document.getElementById("selAll");
+            selAll.indeterminate = someSel;
+            selAll.addEventListener("click", function (e) {
+                e.stopPropagation();
+                if (allSel) {
+                    Drive.setSelection([]);
+                } else {
+                    selectAll();
+                }
+            });
+            wrap.querySelectorAll("[data-sort]").forEach(function (h) {
+                h.addEventListener("click", function () {
+                    Drive.toggleSortColumn(h.getAttribute("data-sort"));
+                });
+            });
+        }
+        wrap.scrollTop = scroll;
+        loadThumbs();
+        Drive.renderFoot();
+        Drive.renderToolbar();
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Thumbnails                                                         */
+    /* ------------------------------------------------------------------ */
+    var THUMB_CATS = { image: true, video: true, audio: true, design: true, model: true };
+    var failedThumbs = {};
+
+    function wantsThumb(item) {
+        if (item.isDir || item.trashed || item.shareOnly) {
+            return false;
+        }
+        var cat = DriveIcons.category(item.name, false);
+        if (!THUMB_CATS[cat]) {
+            return false;
+        }
+        //Audio and models only have a preview when the server can extract one
+        if (cat == "design" && DriveIcons.getExt(item.name) != "psd") {
+            return false;
+        }
+        return !failedThumbs[item.path];
+    }
+
+    function applyThumb(node, src) {
+        var holder = node.querySelector(".tile-preview") || node.querySelector(".thumb");
+        if (!holder || holder.querySelector("img.thumb-img")) {
+            return;
+        }
+        var img = new Image();
+        img.className = "thumb-img";
+        img.alt = "";
+        img.decoding = "async";
+        img.onload = function () {
+            if (!node.isConnected) {
+                return;
+            }
+            holder.appendChild(img);
+            var icon = holder.querySelector(".tile-icon") || holder.querySelector("svg.ft");
+            if (icon) {
+                icon.style.display = "none";
+            }
+        };
+        img.onerror = function () {
+            failedThumbs[node.getAttribute("data-path")] = true;
+        };
+        img.src = src;
+    }
+
+    function loadThumbFor(node) {
+        var item = Drive.itemByPath(node.getAttribute("data-path"));
+        if (!item || !wantsThumb(item)) {
+            return;
+        }
+        if (DriveAPI.caps.fm) {
+            applyThumb(node, DriveAPI.thumbURL(item.path) + "&t=" + (item.mtime || 0));
+        } else {
+            DriveAPI.thumbFallback(item.path).then(function (data) {
+                if (data) {
+                    applyThumb(node, data);
+                } else {
+                    failedThumbs[item.path] = true;
+                }
+            }).catch(function () {
+                failedThumbs[item.path] = true;
+            });
+        }
+    }
+
+    function loadThumbs() {
+        if (thumbObserver) {
+            thumbObserver.disconnect();
+        }
+        if (!S().settings.thumbnails) {
+            return;
+        }
+        var nodes = document.querySelectorAll("#listWrap .row, #listWrap .tile");
+        if (!("IntersectionObserver" in window)) {
+            nodes.forEach(loadThumbFor);
+            return;
+        }
+        thumbObserver = new IntersectionObserver(function (entries) {
+            entries.forEach(function (en) {
+                if (en.isIntersecting) {
+                    thumbObserver.unobserve(en.target);
+                    loadThumbFor(en.target);
+                }
+            });
+        }, { root: document.getElementById("listWrap"), rootMargin: "200px" });
+        nodes.forEach(function (n) { thumbObserver.observe(n); });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Selection                                                          */
+    /* ------------------------------------------------------------------ */
+    function nodes() {
+        return Array.prototype.slice.call(document.querySelectorAll("#listWrap .row, #listWrap .tile"));
+    }
+
+    function nodeFor(path) {
+        var list = nodes();
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].getAttribute("data-path") == path) {
+                return list[i];
+            }
+        }
+        return null;
+    }
+
+    function updateSelection() {
+        var st = S();
+        nodes().forEach(function (n) {
+            var p = n.getAttribute("data-path");
+            var on = st.selection.has(p);
+            n.classList.toggle("selected", on);
+            n.classList.toggle("focused", st.focus == p);
+            var cb = n.querySelector(".check");
+            if (cb) {
+                cb.checked = on;
+            }
+        });
+        var grid = document.querySelector("#listWrap .grid");
+        if (grid) {
+            grid.classList.toggle("has-selection", st.selection.size > 0);
+        }
+        var selAll = document.getElementById("selAll");
+        if (selAll) {
+            var items = visibleItems();
+            var all = items.length > 0 && items.every(function (i) { return st.selection.has(i.path); });
+            selAll.checked = all;
+            selAll.indeterminate = !all && items.some(function (i) { return st.selection.has(i.path); });
+        }
+    }
+
+    function selectAll() {
+        var paths = visibleItems().map(function (i) { return i.path; });
+        Drive.setSelection(paths, { anchor: paths[0] || null });
+    }
+
+    function rangeSelect(toPath, additive) {
+        var list = visibleItems().map(function (i) { return i.path; });
+        var a = list.indexOf(S().anchor);
+        var b = list.indexOf(toPath);
+        if (a < 0) {
+            a = b;
+        }
+        var lo = Math.min(a, b), hi = Math.max(a, b);
+        var set = additive ? Array.from(S().selection) : [];
+        for (var i = lo; i <= hi; i++) {
+            if (set.indexOf(list[i]) < 0) {
+                set.push(list[i]);
+            }
+        }
+        Drive.setSelection(set, { focus: toPath });
+    }
+
+    function toggle(path) {
+        var set = new Set(S().selection);
+        if (set.has(path)) {
+            set.delete(path);
+        } else {
+            set.add(path);
+        }
+        Drive.setSelection(Array.from(set), { anchor: path, focus: path });
+    }
+
+    function gridColumns() {
+        var grid = document.querySelector("#listWrap .grid");
+        if (!grid) {
+            return 1;
+        }
+        var tiles = grid.children;
+        if (tiles.length < 2) {
+            return 1;
+        }
+        var top = tiles[0].offsetTop;
+        var cols = 0;
+        for (var i = 0; i < tiles.length; i++) {
+            if (tiles[i].offsetTop != top) {
+                break;
+            }
+            cols++;
+        }
+        return cols || 1;
+    }
+
+    function moveFocus(key, extend) {
+        var list = visibleItems();
+        if (list.length == 0) {
+            return false;
+        }
+        var paths = list.map(function (i) { return i.path; });
+        var idx = paths.indexOf(S().focus);
+        var grid = Drive.currentViewMode() == "grid";
+        var step = grid ? gridColumns() : 1;
+        var next = idx;
+        if (idx < 0) {
+            next = 0;
+        } else if (key == "ArrowDown") {
+            next = Math.min(paths.length - 1, idx + step);
+        } else if (key == "ArrowUp") {
+            next = Math.max(0, idx - step);
+        } else if (key == "ArrowRight" && grid) {
+            next = Math.min(paths.length - 1, idx + 1);
+        } else if (key == "ArrowLeft" && grid) {
+            next = Math.max(0, idx - 1);
+        } else if (key == "Home") {
+            next = 0;
+        } else if (key == "End") {
+            next = paths.length - 1;
+        } else {
+            return false;
+        }
+        var target = paths[next];
+        if (extend) {
+            if (!S().anchor) {
+                S().anchor = paths[Math.max(0, idx)];
+            }
+            rangeSelect(target, false);
+        } else {
+            Drive.setSelection([target], { anchor: target, focus: target });
+        }
+        var n = nodeFor(target);
+        if (n) {
+            n.scrollIntoView({ block: "nearest" });
+        }
+        return true;
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Container events                                                   */
+    /* ------------------------------------------------------------------ */
+    var longPress = null;
+
+    function itemFromNode(n) {
+        return n ? Drive.itemByPath(n.getAttribute("data-path")) : null;
+    }
+
+    function bindContainer() {
+        var wrap = document.getElementById("listWrap");
+
+        wrap.addEventListener("click", function (e) {
+            if (S().view && S().view.kind == "settings") {
+                return;
+            }
+            var n = e.target.closest(".row, .tile");
+            if (!n) {
+                if (!e.target.closest(".list-head, .state, button, input") && !marqueeMoved) {
+                    if (S().selection.size) {
+                        Drive.setSelection([]);
+                    }
+                }
+                return;
+            }
+            var path = n.getAttribute("data-path");
+            var item = itemFromNode(n);
+            if (e.target.closest(".more-btn")) {
+                e.stopPropagation();
+                if (!S().selection.has(path)) {
+                    Drive.setSelection([path], { anchor: path, focus: path });
+                }
+                DriveActions.contextMenu({ anchor: e.target.closest(".more-btn"), align: "end" }, item);
+                return;
+            }
+            if (e.target.closest(".check")) {
+                e.stopPropagation();
+                toggle(path);
+                return;
+            }
+            if (e.target.closest(".rename-input")) {
+                return;
+            }
+            if (e.shiftKey) {
+                rangeSelect(path, e.ctrlKey || e.metaKey);
+            } else if (e.ctrlKey || e.metaKey) {
+                toggle(path);
+            } else if (coarse) {
+                //Touch: a tap opens, unless the user is picking items
+                if (S().selection.size > 0) {
+                    toggle(path);
+                } else {
+                    DriveActions.open(item);
+                }
+            } else {
+                Drive.setSelection([path], { anchor: path, focus: path });
+            }
+        });
+
+        wrap.addEventListener("dblclick", function (e) {
+            var n = e.target.closest(".row, .tile");
+            if (!n || coarse || e.target.closest(".check, .more-btn, .rename-input")) {
+                return;
+            }
+            DriveActions.open(itemFromNode(n));
+        });
+
+        wrap.addEventListener("contextmenu", function (e) {
+            if (S().view && S().view.kind == "settings") {
+                return;
+            }
+            if (e.target.closest(".rename-input")) {
+                return;
+            }
+            e.preventDefault();
+            var n = e.target.closest(".row, .tile");
+            if (!n) {
+                if (S().selection.size) {
+                    Drive.setSelection([]);
+                }
+                DriveActions.backgroundMenu({ x: e.clientX, y: e.clientY });
+                return;
+            }
+            var path = n.getAttribute("data-path");
+            if (!S().selection.has(path)) {
+                Drive.setSelection([path], { anchor: path, focus: path });
+            }
+            DriveActions.contextMenu({ x: e.clientX, y: e.clientY }, itemFromNode(n));
+        });
+
+        //Touch long press selects
+        wrap.addEventListener("touchstart", function (e) {
+            var n = e.target.closest(".row, .tile");
+            if (!n || e.touches.length > 1) {
+                return;
+            }
+            var startX = e.touches[0].clientX, startY = e.touches[0].clientY;
+            longPress = {
+                x: startX, y: startY, timer: setTimeout(function () {
+                    longPress.fired = true;
+                    var path = n.getAttribute("data-path");
+                    if (!S().selection.has(path)) {
+                        toggle(path);
+                    }
+                    if (navigator.vibrate) {
+                        navigator.vibrate(15);
+                    }
+                }, 480)
+            };
+        }, { passive: true });
+        wrap.addEventListener("touchmove", function (e) {
+            if (longPress && (Math.abs(e.touches[0].clientX - longPress.x) > 8 || Math.abs(e.touches[0].clientY - longPress.y) > 8)) {
+                clearTimeout(longPress.timer);
+                longPress = null;
+            }
+        }, { passive: true });
+        wrap.addEventListener("touchend", function (e) {
+            if (longPress) {
+                clearTimeout(longPress.timer);
+                if (longPress.fired) {
+                    e.preventDefault();
+                }
+                longPress = null;
+            }
+        });
+
+        bindMarquee(wrap);
+        bindDrag(wrap);
+        bindExternalDrop();
+    }
+
+    /* Rubber band ------------------------------------------------------ */
+    var marqueeMoved = false;
+
+    function bindMarquee(wrap) {
+        wrap.addEventListener("mousedown", function (e) {
+            if (e.button != 0 || coarse || !S().view || S().view.kind == "settings") {
+                return;
+            }
+            if (e.target.closest(".row, .tile, button, input, a, .list-head, .state")) {
+                return;
+            }
+            //Ignore presses on the scrollbar
+            if (e.offsetX > wrap.clientWidth) {
+                return;
+            }
+            var rect = wrap.getBoundingClientRect();
+            var startX = e.clientX - rect.left + wrap.scrollLeft;
+            var startY = e.clientY - rect.top + wrap.scrollTop;
+            var base = (e.ctrlKey || e.metaKey) ? Array.from(S().selection) : [];
+            var box = null;
+            marqueeMoved = false;
+            var last = null;
+
+            function move(ev) {
+                var r = wrap.getBoundingClientRect();
+                var x = ev.clientX - r.left + wrap.scrollLeft;
+                var y = ev.clientY - r.top + wrap.scrollTop;
+                if (!marqueeMoved && Math.abs(x - startX) < 5 && Math.abs(y - startY) < 5) {
+                    return;
+                }
+                marqueeMoved = true;
+                if (!box) {
+                    box = document.createElement("div");
+                    box.className = "marquee";
+                    wrap.appendChild(box);
+                }
+                var left = Math.min(x, startX), top = Math.min(y, startY);
+                var w = Math.abs(x - startX), h = Math.abs(y - startY);
+                box.style.left = left + "px";
+                box.style.top = top + "px";
+                box.style.width = w + "px";
+                box.style.height = h + "px";
+                var sel = base.slice();
+                nodes().forEach(function (n) {
+                    var nr = n.getBoundingClientRect();
+                    var nl = nr.left - r.left + wrap.scrollLeft, nt = nr.top - r.top + wrap.scrollTop;
+                    if (nl < left + w && nl + nr.width > left && nt < top + h && nt + nr.height > top) {
+                        var p = n.getAttribute("data-path");
+                        if (sel.indexOf(p) < 0) {
+                            sel.push(p);
+                        }
+                    }
+                });
+                var key = sel.join("\n");
+                if (key != last) {
+                    last = key;
+                    Drive.setSelection(sel, { anchor: sel[0] || null });
+                }
+                //Scroll while dragging near the edges
+                if (ev.clientY > r.bottom - 20) {
+                    wrap.scrollTop += 12;
+                } else if (ev.clientY < r.top + 20) {
+                    wrap.scrollTop -= 12;
+                }
+            }
+
+            function up() {
+                document.removeEventListener("mousemove", move);
+                document.removeEventListener("mouseup", up);
+                if (box) {
+                    box.remove();
+                }
+                setTimeout(function () { marqueeMoved = false; }, 0);
+            }
+            document.addEventListener("mousemove", move);
+            document.addEventListener("mouseup", up);
+        });
+    }
+
+    /* Internal drag and drop ----------------------------------------------- */
+    var dragPaths = null;
+
+    function bindDrag(wrap) {
+        wrap.addEventListener("dragstart", function (e) {
+            var n = e.target.closest(".row, .tile");
+            if (!n || n.getAttribute("draggable") != "true") {
+                return;
+            }
+            var path = n.getAttribute("data-path");
+            if (!S().selection.has(path)) {
+                Drive.setSelection([path], { anchor: path, focus: path });
+            }
+            var items = Drive.selectedItems();
+            dragPaths = items.map(function (i) { return i.path; });
+            e.dataTransfer.effectAllowed = "copyMove";
+            e.dataTransfer.setData(DRAG_TYPE, JSON.stringify(dragPaths));
+            //Same shape the desktop and the File Manager use, for drops into other apps
+            e.dataTransfer.setData("filedata", JSON.stringify(items.map(function (i) {
+                return { filename: i.name, filepath: i.path };
+            })));
+            var ghost = document.getElementById("dragGhost");
+            ghost.innerHTML = I(items.length == 1 && items[0].isDir ? "folder" : "file") + '<span>' +
+                esc(items.length == 1 ? items[0].name : T("drag/items", "{n} items", { n: items.length })) + '</span>';
+            if (e.dataTransfer.setDragImage) {
+                e.dataTransfer.setDragImage(ghost, 14, 14);
+            }
+        });
+        wrap.addEventListener("dragend", function () {
+            dragPaths = null;
+            clearDropTargets();
+        });
+        bindDropTargets(wrap);
+    }
+
+    function clearDropTargets() {
+        document.querySelectorAll(".drop-target").forEach(function (n) { n.classList.remove("drop-target"); });
+    }
+
+    function isInternal(e) {
+        return dragPaths != null || (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], DRAG_TYPE) >= 0);
+    }
+
+    function hasFiles(e) {
+        return e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], "Files") >= 0;
+    }
+
+    //Folder rows, tiles and anything carrying data-drop accept drops
+    function dropTargetOf(e) {
+        var t = e.target.closest("[data-drop], .row, .tile");
+        if (!t) {
+            return null;
+        }
+        if (t.hasAttribute("data-drop")) {
+            var d = t.getAttribute("data-drop");
+            return d ? { node: t, path: Drive.normDir(d) } : null;
+        }
+        var item = itemFromNode(t);
+        if (item && item.isDir && !item.trashed && !item.shareOnly && S().view.kind != "trash") {
+            return { node: t, path: Drive.normDir(item.path) };
+        }
+        return null;
+    }
+
+    function bindDropTargets(root) {
+        if (root.__driveDrop) {
+            return;
+        }
+        root.__driveDrop = true;
+        root.addEventListener("dragover", function (e) {
+            var internal = isInternal(e);
+            if (!internal && !hasFiles(e)) {
+                return;
+            }
+            var target = dropTargetOf(e);
+            if (!target || !Drive.isWritable(target.path)) {
+                return;
+            }
+            if (internal && dragPaths && dragPaths.some(function (p) { return (target.path).indexOf(Drive.normDir(p)) == 0; })) {
+                //A folder cannot be dropped into itself
+                e.dataTransfer.dropEffect = "none";
+                return;
+            }
+            e.preventDefault();
+            e.stopPropagation();
+            e.dataTransfer.dropEffect = internal ? ((e.ctrlKey || e.altKey) ? "copy" : "move") : "copy";
+            if (!target.node.classList.contains("drop-target")) {
+                clearDropTargets();
+                target.node.classList.add("drop-target");
+            }
+            hideDropOverlay();
+        });
+        root.addEventListener("dragleave", function (e) {
+            var t = e.target.closest("[data-drop], .row, .tile");
+            if (t && !t.contains(e.relatedTarget)) {
+                t.classList.remove("drop-target");
+            }
+        });
+        root.addEventListener("drop", function (e) {
+            var target = dropTargetOf(e);
+            if (!target) {
+                return;
+            }
+            e.preventDefault();
+            e.stopPropagation();
+            clearDropTargets();
+            hideDropOverlay();
+            if (isInternal(e)) {
+                var paths = dragPaths;
+                if (!paths) {
+                    try {
+                        paths = JSON.parse(e.dataTransfer.getData(DRAG_TYPE));
+                    } catch (ex) {
+                        paths = [];
+                    }
+                }
+                dragPaths = null;
+                var items = paths.map(function (p) { return Drive.itemByPath(p) || { name: DriveAPI.baseName(p), path: p }; });
+                DriveActions.transfer(items, target.path, (e.ctrlKey || e.altKey) ? "copy" : "move");
+            } else if (hasFiles(e)) {
+                DriveTransfers.fromDataTransfer(e.dataTransfer, target.path);
+            }
+        });
+    }
+
+    /* Files dragged in from the computer ----------------------------------- */
+    var overlay = null;
+    var dragDepth = 0;
+
+    function showDropOverlay() {
+        var view = S().view;
+        if (!view || view.kind != "drive" || !view.writable) {
+            return;
+        }
+        if (!overlay) {
+            overlay = document.createElement("div");
+            overlay.className = "drop-overlay";
+            document.getElementById("card").appendChild(overlay);
+        }
+        var label = Drive.pathParts(view.path);
+        overlay.innerHTML = '<div>' + I("upload") + '<span>' + esc(T("drop/upload", "Drop to upload to {name}", { name: label[label.length - 1].label })) + '</span></div>';
+        overlay.hidden = false;
+    }
+
+    function hideDropOverlay() {
+        if (overlay) {
+            overlay.hidden = true;
+        }
+    }
+
+    function bindExternalDrop() {
+        var card = document.getElementById("card");
+        card.addEventListener("dragenter", function (e) {
+            if (hasFiles(e) && !isInternal(e)) {
+                dragDepth++;
+                showDropOverlay();
+            }
+        });
+        card.addEventListener("dragleave", function (e) {
+            if (hasFiles(e) && !isInternal(e)) {
+                dragDepth = Math.max(0, dragDepth - 1);
+                if (dragDepth == 0) {
+                    hideDropOverlay();
+                }
+            }
+        });
+        card.addEventListener("dragover", function (e) {
+            var view = S().view;
+            if (hasFiles(e) && !isInternal(e) && view && view.kind == "drive" && view.writable) {
+                e.preventDefault();
+                e.dataTransfer.dropEffect = "copy";
+                if (!e.target.closest(".row, .tile") || !dropTargetOf(e)) {
+                    clearDropTargets();
+                    showDropOverlay();
+                }
+            }
+        });
+        card.addEventListener("drop", function (e) {
+            dragDepth = 0;
+            hideDropOverlay();
+            var view = S().view;
+            if (hasFiles(e) && !isInternal(e) && view && view.kind == "drive" && view.writable) {
+                e.preventDefault();
+                DriveTransfers.fromDataTransfer(e.dataTransfer, view.path);
+            }
+        });
+        //Never let a stray drop navigate the page away
+        window.addEventListener("dragover", function (e) {
+            if (hasFiles(e)) {
+                e.preventDefault();
+            }
+        });
+        window.addEventListener("drop", function (e) {
+            if (hasFiles(e)) {
+                e.preventDefault();
+                dragDepth = 0;
+                hideDropOverlay();
+            }
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Inline rename                                                      */
+    /* ------------------------------------------------------------------ */
+    function startRename(item, onCommit) {
+        var n = nodeFor(item.path);
+        if (!n) {
+            return false;
+        }
+        var nameEl = n.querySelector(".name > span");
+        if (!nameEl) {
+            return false;
+        }
+        var input = document.createElement("input");
+        input.className = "rename-input";
+        input.value = item.name;
+        input.spellcheck = false;
+        nameEl.replaceWith(input);
+        n.setAttribute("draggable", "false");
+        input.focus();
+        var dot = item.isDir ? -1 : item.name.lastIndexOf(".");
+        input.setSelectionRange(0, dot > 0 ? dot : item.name.length);
+        var done = false;
+        function finish(commit) {
+            if (done) {
+                return;
+            }
+            done = true;
+            var value = input.value.trim();
+            if (commit && value && value != item.name) {
+                onCommit(value);
+            } else {
+                render();
+            }
+        }
+        input.addEventListener("keydown", function (e) {
+            e.stopPropagation();
+            if (e.key == "Enter") {
+                finish(true);
+            } else if (e.key == "Escape") {
+                finish(false);
+            }
+        });
+        input.addEventListener("blur", function () { finish(true); });
+        input.addEventListener("click", function (e) { e.stopPropagation(); });
+        input.addEventListener("dblclick", function (e) { e.stopPropagation(); });
+        return true;
+    }
+
+    global.DriveList = {
+        render: render,
+        updateSelection: updateSelection,
+        selectAll: selectAll,
+        moveFocus: moveFocus,
+        pageCount: pageCount,
+        visibleItems: visibleItems,
+        bindContainer: bindContainer,
+        bindDropTargets: bindDropTargets,
+        startRename: startRename,
+        typeLabel: typeLabel,
+        audienceText: audienceText,
+        nodeFor: nodeFor,
+        wantsThumb: wantsThumb
+    };
+})(window);

+ 315 - 0
src/web/Drive/js/preview.js

@@ -0,0 +1,315 @@
+/*
+    preview.js
+
+    A full window previewer for the files a browser can show by itself:
+    images (zoom, rotate), video, audio, PDF and text / code. Arrow keys and
+    swipes step through the other previewable files of the current list.
+    Anything else gets "open with" and download buttons.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    var TEXT_LIMIT = 2 * 1024 * 1024;
+    var IMAGE = "jpg jpeg png gif bmp webp svg ico avif jfif cr2 nef arw dng raw".split(" ");
+    var VIDEO = "mp4 webm ogv mov m4v mkv".split(" ");
+    var AUDIO = "mp3 wav ogg oga m4a aac flac opus".split(" ");
+    var TEXT_EXTRA = "csv tsv srt vtt ass".split(" ");
+
+    var node = null;
+    var current = null;
+    var list = [];
+    var zoom = 1;
+    var rotation = 0;
+
+    function kind(item) {
+        if (!item || item.isDir) {
+            return null;
+        }
+        var ext = DriveIcons.getExt(item.name);
+        if (IMAGE.indexOf(ext) >= 0) {
+            return "image";
+        }
+        if (VIDEO.indexOf(ext) >= 0) {
+            return "video";
+        }
+        if (AUDIO.indexOf(ext) >= 0) {
+            return "audio";
+        }
+        if (ext == "pdf") {
+            return "pdf";
+        }
+        var cat = DriveIcons.category(item.name, false);
+        if ((cat == "text" || cat == "code" || TEXT_EXTRA.indexOf(ext) >= 0) && (item.size == undefined || item.size <= TEXT_LIMIT)) {
+            return "text";
+        }
+        return null;
+    }
+
+    function canPreview(item) {
+        return kind(item) != null && !item.shareOnly;
+    }
+
+    function isOpen() {
+        return node != null;
+    }
+
+    function close() {
+        if (!node) {
+            return;
+        }
+        node.querySelectorAll("video, audio").forEach(function (m) {
+            try { m.pause(); } catch (e) { }
+        });
+        node.remove();
+        node = null;
+        current = null;
+    }
+
+    function siblings(item) {
+        var src = Drive.state.sorted || [];
+        var found = src.some(function (i) { return i.path == item.path; });
+        if (!found) {
+            return [item];
+        }
+        return src.filter(function (i) { return !i.isDir && !i.shareOnly; });
+    }
+
+    function open(item) {
+        if (!item || item.isDir) {
+            return;
+        }
+        list = siblings(item);
+        if (!node) {
+            node = DriveUI.el('<div class="preview" role="dialog" aria-modal="true"></div>');
+            document.body.appendChild(node);
+            bindSwipe(node);
+        }
+        show(item);
+    }
+
+    function step(delta) {
+        if (!current || list.length < 2) {
+            return;
+        }
+        var idx = -1;
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].path == current.path) {
+                idx = i;
+                break;
+            }
+        }
+        var next = list[(idx + delta + list.length) % list.length];
+        if (next) {
+            show(next);
+        }
+    }
+
+    function applyTransform() {
+        var img = node && node.querySelector(".preview-stage img");
+        if (img) {
+            img.style.transform = "scale(" + zoom + ") rotate(" + rotation + "deg)";
+        }
+    }
+
+    function show(item) {
+        current = item;
+        zoom = 1;
+        rotation = 0;
+        var k = kind(item);
+        var url = API.mediaURL(item.path);
+        var hasNav = list.length > 1;
+        var canOpenWith = !item.trashed && DriveActions.compatibleModules(item).length > 0;
+        node.innerHTML = '<div class="preview-bar"><span class="ft-box">' + DriveIcons.file(item.name, false) + '</span>' +
+            '<div class="title"><b title="' + esc(item.name) + '">' + esc(item.name) + '</b><small>' + esc(DriveUI.fmtSize(item.size) + " · " + DriveList.typeLabel(item)) + '</small></div>' +
+            (k == "image" ? '<button class="icon-btn hide-xs" data-act="zoomOut" title="' + esc(T("pv/zoomOut", "Zoom out")) + '">' + I("zoomOut") + '</button>' +
+                '<button class="icon-btn hide-xs" data-act="zoomIn" title="' + esc(T("pv/zoomIn", "Zoom in")) + '">' + I("zoomIn") + '</button>' +
+                '<button class="icon-btn" data-act="rotate" title="' + esc(T("pv/rotate", "Rotate")) + '">' + I("rotate") + '</button>' : "") +
+            (canOpenWith ? '<button class="btn small hide-xs" data-act="openWith">' + I("apps") + '<span>' + esc(T("menu/openWith", "Open with")) + '</span>' + I("chevronDown", { cls: "caret" }) + '</button>' : "") +
+            '<button class="icon-btn" data-act="download" title="' + esc(T("toolbar/download", "Download")) + '">' + I("download") + '</button>' +
+            (!item.trashed ? '<button class="icon-btn hide-xs" data-act="share" title="' + esc(T("toolbar/share", "Share")) + '">' + I("link") + '</button>' : "") +
+            '<button class="icon-btn" data-act="close" title="' + esc(T("common/close", "Close")) + '">' + I("x") + '</button></div>' +
+            '<div class="preview-stage" data-stage></div>';
+        var stage = node.querySelector("[data-stage]");
+
+        if (k == "image") {
+            stage.innerHTML = '<div class="spinner"></div>';
+            var img = new Image();
+            img.alt = item.name;
+            img.onload = function () {
+                if (current !== item) {
+                    return;
+                }
+                stage.innerHTML = "";
+                stage.appendChild(img);
+                addNav(stage, hasNav);
+            };
+            img.onerror = function () {
+                if (current === item) {
+                    noPreview(stage, item, T("pv/cannotShow", "This image cannot be shown in the browser."));
+                    addNav(stage, hasNav);
+                }
+            };
+            img.src = url;
+        } else if (k == "video") {
+            stage.innerHTML = '<video src="' + esc(url) + '" controls autoplay playsinline></video>';
+            stage.querySelector("video").addEventListener("error", function () {
+                noPreview(stage, item, T("pv/cannotPlay", "This video format cannot be played in the browser."));
+                addNav(stage, hasNav);
+            });
+            addNav(stage, hasNav);
+        } else if (k == "audio") {
+            stage.innerHTML = '<div class="preview-none"><div class="big">' + DriveIcons.file(item.name, false) + '</div><audio src="' + esc(url) + '" controls autoplay></audio></div>';
+            addNav(stage, hasNav);
+        } else if (k == "pdf") {
+            stage.innerHTML = '<iframe src="' + esc(url) + '" title="' + esc(item.name) + '"></iframe>';
+            addNav(stage, hasNav);
+        } else if (k == "text") {
+            stage.innerHTML = '<div class="spinner"></div>';
+            API.readText(item.path).then(function (text) {
+                if (current !== item) {
+                    return;
+                }
+                var pre = document.createElement("pre");
+                pre.textContent = text;
+                stage.innerHTML = "";
+                stage.appendChild(pre);
+                addNav(stage, hasNav);
+            }).catch(function (err) {
+                if (current === item) {
+                    noPreview(stage, item, err.message);
+                }
+            });
+        } else {
+            noPreview(stage, item, T("pv/none", "No preview available for this file type."));
+            addNav(stage, hasNav);
+        }
+
+        node.querySelectorAll("[data-act]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var act = b.getAttribute("data-act");
+                if (act == "close") {
+                    close();
+                } else if (act == "download") {
+                    DriveActions.download([item]);
+                } else if (act == "share") {
+                    DriveShare.open(item);
+                } else if (act == "openWith") {
+                    DriveUI.menu({ anchor: b, align: "end" }, DriveActions.openWithItems(item).filter(function (x) {
+                        return !(x && x.icon == "eye");
+                    }).map(function (x) {
+                        if (x && x.action) {
+                            var fn = x.action;
+                            x.action = function () { close(); fn(); };
+                        }
+                        return x;
+                    }));
+                } else if (act == "zoomIn") {
+                    zoom = Math.min(6, zoom * 1.25);
+                    applyTransform();
+                } else if (act == "zoomOut") {
+                    zoom = Math.max(0.2, zoom / 1.25);
+                    applyTransform();
+                } else if (act == "rotate") {
+                    rotation = (rotation + 90) % 360;
+                    applyTransform();
+                }
+            });
+        });
+    }
+
+    function noPreview(stage, item, message) {
+        stage.innerHTML = '<div class="preview-none"><div class="big">' + DriveIcons.file(item.name, false) + '</div><div>' + esc(message) + '</div>' +
+            '<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">' +
+            (!item.trashed ? '<button class="btn" data-np="open">' + I("apps") + esc(T("open/choose", "Choose another app...")) + '</button>' : "") +
+            '<button class="btn" data-np="download">' + I("download") + esc(T("toolbar/download", "Download")) + '</button></div></div>';
+        var o = stage.querySelector('[data-np="open"]');
+        if (o) {
+            o.addEventListener("click", function () {
+                close();
+                DriveActions.openWithChooser(item);
+            });
+        }
+        stage.querySelector('[data-np="download"]').addEventListener("click", function () {
+            DriveActions.download([item]);
+        });
+    }
+
+    function addNav(stage, hasNav) {
+        if (!hasNav || stage.querySelector(".preview-nav")) {
+            return;
+        }
+        var prev = DriveUI.el('<button class="preview-nav prev" aria-label="' + esc(T("pv/prev", "Previous")) + '">' + I("chevronLeft") + '</button>');
+        var next = DriveUI.el('<button class="preview-nav next" aria-label="' + esc(T("pv/next", "Next")) + '">' + I("chevronRight") + '</button>');
+        prev.addEventListener("click", function () { step(-1); });
+        next.addEventListener("click", function () { step(1); });
+        stage.appendChild(prev);
+        stage.appendChild(next);
+    }
+
+    function bindSwipe(el) {
+        var sx = 0, sy = 0, tracking = false;
+        el.addEventListener("touchstart", function (e) {
+            if (e.touches.length != 1 || zoom != 1) {
+                tracking = false;
+                return;
+            }
+            tracking = true;
+            sx = e.touches[0].clientX;
+            sy = e.touches[0].clientY;
+        }, { passive: true });
+        el.addEventListener("touchend", function (e) {
+            if (!tracking) {
+                return;
+            }
+            tracking = false;
+            var dx = e.changedTouches[0].clientX - sx;
+            var dy = e.changedTouches[0].clientY - sy;
+            if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
+                step(dx < 0 ? 1 : -1);
+            }
+        });
+    }
+
+    function onKey(e) {
+        if (DriveUI.topModal()) {
+            if (e.key == "Escape") {
+                DriveUI.topModal().close();
+            }
+            return;
+        }
+        var typing = e.target && (e.target.tagName == "INPUT" || e.target.tagName == "TEXTAREA");
+        if (typing) {
+            return;
+        }
+        if (e.key == "Escape") {
+            close();
+        } else if (e.key == "ArrowRight") {
+            step(1);
+        } else if (e.key == "ArrowLeft") {
+            step(-1);
+        } else if ((e.key == "+" || e.key == "=") && kind(current) == "image") {
+            zoom = Math.min(6, zoom * 1.25);
+            applyTransform();
+        } else if (e.key == "-" && kind(current) == "image") {
+            zoom = Math.max(0.2, zoom / 1.25);
+            applyTransform();
+        } else if (e.key == " " && kind(current) != "video" && kind(current) != "audio") {
+            e.preventDefault();
+            close();
+        }
+    }
+
+    global.DrivePreview = {
+        open: open,
+        close: close,
+        isOpen: isOpen,
+        onKey: onKey,
+        canPreview: canPreview,
+        kind: kind
+    };
+})(window);

+ 138 - 0
src/web/Drive/js/settings.js

@@ -0,0 +1,138 @@
+/*
+    settings.js
+
+    Drive's own settings page (#/settings). Like the Dashboard, Drive keeps
+    its preferences apart from System Settings: they are stored per user by
+    backend/prefs.agi and only change how Drive looks and behaves. System
+    wide options stay in System Settings, which opens in the app host.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+
+    function S() {
+        return Drive.state;
+    }
+
+    function group(icon, title, rows) {
+        return '<div class="set-group"><div class="set-group-head">' + I(icon) + '<b>' + esc(title) + '</b></div>' + rows.join("") + '</div>';
+    }
+
+    function row(title, desc, control) {
+        return '<div class="set-row"><div class="txt"><b>' + esc(title) + '</b>' + (desc ? '<small>' + esc(desc) + '</small>' : "") + '</div>' + control + '</div>';
+    }
+
+    function toggle(key) {
+        return '<label class="switch"><input type="checkbox" data-set="' + key + '"' + (S().settings[key] ? " checked" : "") + '><span></span></label>';
+    }
+
+    function select(key, options, value) {
+        return '<select class="select" data-set="' + key + '">' + options.map(function (o) {
+            return '<option value="' + esc(o[0]) + '"' + (String(value) == String(o[0]) ? " selected" : "") + '>' + esc(o[1]) + '</option>';
+        }).join("") + '</select>';
+    }
+
+    function render(container) {
+        var st = S().settings;
+        var themeCard = function (id, label) {
+            return '<button class="theme-card' + (st.theme == id ? " active" : "") + '" data-theme="' + id + '"><div class="swatch ' + id + '"><i></i><i></i></div>' + esc(label) + '</button>';
+        };
+        var langs = [["default", T("set/langAuto", "Follow the browser")]].concat(DriveI18n.languages().map(function (l) { return [l.code, l.name]; }));
+        var q = S().quota;
+
+        var html = '<div class="settings"><div class="settings-inner">';
+        html += group("sun", T("set/appearance", "Appearance"), [
+            '<div class="set-row" style="flex-wrap:wrap"><div class="txt"><b>' + esc(T("set/theme", "Theme")) + '</b><small>' +
+            esc(T("set/themeDesc", "Apps opened inside Drive follow this theme when they support it")) + '</small></div><div class="theme-cards">' +
+            themeCard("auto", T("set/themeAuto", "Match device")) + themeCard("light", T("set/themeLight", "Light")) + themeCard("dark", T("set/themeDark", "Dark")) + '</div></div>',
+            row(T("set/density", "Density"), T("set/densityDesc", "How much space each row takes"),
+                select("density", [["comfortable", T("set/comfortable", "Comfortable")], ["compact", T("set/compact", "Compact")]], st.density)),
+            row(T("set/language", "Language"), T("set/languageDesc", "Also used by other ArozOS apps in this browser"),
+                select("language", langs, DriveI18n.current()))
+        ]);
+        html += group("folder", T("set/files", "Files and folders"), [
+            row(T("set/view", "Default layout"), "", select("view", [["list", T("view/list", "List view")], ["grid", T("view/grid", "Grid view")]], st.view)),
+            row(T("set/sort", "Sort by"), "", select("sortKey", [["name", T("col/name", "Name")], ["mtime", T("col/modified", "Modified")], ["size", T("col/size", "Size")], ["type", T("col/type", "Type")]], st.sortKey)),
+            row(T("set/order", "Order"), "", select("sortDir", [["asc", T("sort/asc", "Ascending")], ["desc", T("sort/desc", "Descending")]], st.sortDir)),
+            row(T("sort/foldersFirst", "Folders on top"), T("set/foldersFirstDesc", "List folders before files"), toggle("foldersFirst")),
+            row(T("menu/showHidden", "Show hidden files"), T("set/hiddenDesc", "Files and folders whose name starts with a dot"), toggle("showHidden")),
+            row(T("set/thumbs", "Thumbnails"), T("set/thumbsDesc", "Show previews of photos and videos in the list"), toggle("thumbnails")),
+            row(T("set/pageSize", "Items per page"), T("set/pageSizeDesc", "Very large folders stay quick with paging"),
+                select("pageSize", [["50", "50"], ["100", "100"], ["200", "200"], ["0", T("set/all", "All")]], st.pageSize))
+        ]);
+        html += group("open", T("set/behaviour", "Opening and deleting"), [
+            row(T("set/openIn", "Open files"), T("set/openInDesc", "Where an app opens when you open a file"),
+                select("openIn", [["drive", T("set/inDrive", "Inside Drive")], ["tab", T("set/inTab", "In a new browser tab")]], st.openIn)),
+            row(T("set/confirmTrash", "Ask before moving to trash"), T("set/confirmTrashDesc", "Items in the trash can always be restored"), toggle("confirmTrash"))
+        ]);
+        html += group("cloud", T("storage/title", "Storage"), [
+            row(T("set/used", "Used space"), q.total > 0 ? T("storage/usedOf", "{used} of {total} used", { used: DriveUI.fmtSize(q.used), total: DriveUI.fmtSize(q.total) }) :
+                T("storage/usedUnlimited", "{used} used · no quota", { used: DriveUI.fmtSize(q.used || 0) }),
+                DriveAPI.caps.settings ? '<button class="btn small" data-go="quota">' + esc(T("set/details", "Details")) + '</button>' : ""),
+            row(T("set/fileAccess", "File access"), DriveAPI.caps.fm ? T("about/full", "Full (File Manager API)") : T("set/reducedDesc", "Limited: ask your administrator for the File Manager permission to use the trash, version history and large uploads"), "")
+        ]);
+        var accountRows = [
+            row(S().user.name, S().user.groups.join(", "), DriveAPI.caps.settings ? '<button class="btn small" data-go="account">' + I("user") + esc(T("set/account", "My account")) + '</button>' : "")
+        ];
+        if (DriveAPI.caps.settings) {
+            accountRows.push(row(T("user/systemSettings", "System Settings"), T("set/systemDesc", "Password, language coverage, network, users and everything system wide"),
+                '<button class="btn small" data-go="system">' + I("cog") + esc(T("set/open", "Open")) + '</button>'));
+        }
+        if (S().interfaces.length > 1) {
+            accountRows.push(row(T("user/switchInterface", "Switch workspace"), T("set/switchDesc", "Your account can also use another interface such as the desktop"),
+                '<button class="btn small" data-go="switch">' + I("apps") + esc(T("set/switch", "Switch")) + '</button>'));
+        }
+        html += group("user", T("set/accountGroup", "Account"), accountRows);
+        html += '</div></div>';
+        container.innerHTML = html;
+
+        container.querySelectorAll(".theme-card").forEach(function (b) {
+            b.addEventListener("click", function () {
+                Drive.saveSettings({ theme: b.getAttribute("data-theme") });
+                container.querySelectorAll(".theme-card").forEach(function (x) { x.classList.toggle("active", x === b); });
+            });
+        });
+        container.querySelectorAll("[data-set]").forEach(function (c) {
+            c.addEventListener("change", function () {
+                var key = c.getAttribute("data-set");
+                if (key == "language") {
+                    DriveI18n.setLanguage(c.value, function () {
+                        Drive.retranslate();
+                    });
+                    return;
+                }
+                var patch = {};
+                if (c.type == "checkbox") {
+                    patch[key] = c.checked;
+                } else if (key == "pageSize") {
+                    patch[key] = parseInt(c.value, 10);
+                } else {
+                    patch[key] = c.value;
+                }
+                Drive.saveSettings(patch);
+                DriveUI.toast(T("set/saved", "Settings saved"), { type: "success", timeout: 1600 });
+            });
+        });
+        container.querySelectorAll("[data-go]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var go = b.getAttribute("data-go");
+                if (go == "system") {
+                    DriveHost.openSystemSettings();
+                } else if (go == "quota") {
+                    DriveHost.openSystemSettings("Disk", "Storage Quota");
+                } else if (go == "account") {
+                    DriveHost.openSystemSettings("Users", "My Account");
+                } else if (go == "switch") {
+                    window.location.href = DriveAPI.ROOT + "SystemAO/boot/interface_selector.html";
+                }
+            });
+        });
+    }
+
+    global.DriveSettings = {
+        render: render
+    };
+})(window);

+ 394 - 0
src/web/Drive/js/share.js

@@ -0,0 +1,394 @@
+/*
+    share.js
+
+    The share dialog, inside the page. It reads the state through
+    share/info and only changes it through share/create, share/update and
+    share/delete, the same contract as the File Manager's share dialog, so
+    shares made in either place look the same in both.
+
+    A path can carry several links, each with its own audience (anyone,
+    signed-in users, the same group, invited users or groups), access
+    level, password, download option and expiry.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+    var DAY = 86400;
+
+    var recipientsCache = null;
+
+    function recipients() {
+        if (recipientsCache) {
+            return Promise.resolve(recipientsCache);
+        }
+        return API.shareRecipients().then(function (r) {
+            recipientsCache = r || { Users: [], Groups: [] };
+            return recipientsCache;
+        }).catch(function () {
+            return { Users: [], Groups: [] };
+        });
+    }
+
+    function audienceTitle(share) {
+        switch (share.Permission) {
+            case "anyone": return share.HasPassword ? T("audience/anyonePw", "Anyone with the link and password") : T("audience/anyone", "Anyone with the link");
+            case "signedin": return T("audience/signedin", "Signed-in users");
+            case "samegroup": return T("audience/samegroup", "People in the same group");
+            case "users": return T("audience/users", "Invited people");
+            case "groups": return T("audience/groupsTitle", "Invited groups");
+        }
+        return share.Permission;
+    }
+
+    function audienceIcon(share) {
+        switch (share.Permission) {
+            case "anyone": return share.HasPassword ? "lock" : "globe";
+            case "signedin": return "shield";
+            case "samegroup": return "users";
+            case "users": return "user";
+            case "groups": return "users";
+        }
+        return "link";
+    }
+
+    function detailText(share, isFolder) {
+        var bits = [];
+        bits.push(share.AccessLevel == "edit" ? T("level/edit", "Can edit") : T("level/view", "Can view"));
+        if (share.ExpireAt > 0) {
+            bits.push(share.IsExpired ? T("share/expired", "Expired") : T("share/expires", "Expires {date}", { date: DriveUI.fmtDay(share.ExpireAt) }));
+        }
+        if (share.HasPassword) {
+            bits.push(T("share/hasPassword", "Password"));
+        }
+        if (!share.AllowDownload) {
+            bits.push(T("share/noDownload", "No download"));
+        }
+        if (isFolder && !share.ShowFileList) {
+            bits.push(T("share/noList", "File list hidden"));
+        }
+        return bits.join(" · ");
+    }
+
+    function showQR(share) {
+        var url = API.shareURL(share.UUID);
+        var ctx = DriveUI.modal({
+            title: T("share/qr", "QR code"),
+            icon: "qr",
+            body: '<div class="qr-box"><div id="qrTarget"></div></div><div class="share-link-row"><input class="input" readonly value="' + esc(url) + '"></div>',
+            buttons: [{ label: T("common/close", "Close"), cls: "primary" }]
+        });
+        var draw = function () {
+            try {
+                new QRCode(ctx.body.querySelector("#qrTarget"), { text: url, width: 200, height: 200 });
+            } catch (e) {
+                ctx.body.querySelector("#qrTarget").textContent = url;
+            }
+        };
+        if (global.QRCode) {
+            draw();
+        } else {
+            var s = document.createElement("script");
+            s.src = API.ROOT + "script/qrcode.min.js";
+            s.onload = draw;
+            document.head.appendChild(s);
+        }
+    }
+
+    /* ------------------------------------------------------------------ */
+    function open(item) {
+        if (!item) {
+            return;
+        }
+        var state = { info: null, editing: null, formOpen: false };
+        var body = DriveUI.el('<div><div id="shLinks"><div class="state" style="padding:30px"><div class="spinner"></div></div></div><div id="shForm"></div></div>');
+        var ctx = DriveUI.modal({
+            title: T("share/title", "Share \"{name}\"", { name: item.name }),
+            iconHTML: DriveIcons.file(item.name, item.isDir),
+            size: "wide",
+            body: body,
+            footLeft: '<button class="btn" id="shCopy">' + I("link") + esc(T("menu/copyLink", "Copy link")) + '</button>',
+            buttons: [{ label: T("common/done", "Done"), cls: "primary" }],
+            onClose: function () {
+                Drive.refresh();
+            }
+        });
+        var copyBtn = ctx.node.querySelector("#shCopy");
+
+        function shares() {
+            return (state.info && state.info.Shares) || [];
+        }
+
+        function reload() {
+            return API.shareInfo(item.path).then(function (info) {
+                state.info = info;
+                if (!shares().length && info.CanManage !== false) {
+                    state.formOpen = true;
+                }
+                render();
+            }).catch(function (err) {
+                body.querySelector("#shLinks").innerHTML = '<div class="banner">' + I("alert") + '<span>' + esc(err.message) + '</span></div>';
+            });
+        }
+
+        function render() {
+            var info = state.info;
+            var links = body.querySelector("#shLinks");
+            var isFolder = !!(info.IsFolder || item.isDir);
+            var html = "";
+            if (info.CanManage === false) {
+                html += '<div class="banner info">' + I("info") + '<span>' + esc(T("share/notOwner", "This item is shared by someone else. Only they can change its links.")) + '</span></div>';
+            }
+            if (!shares().length) {
+                html += '<div class="audience"><div class="bubble private">' + I("lock") + '</div><div class="body"><b>' + esc(T("share/private", "Not shared")) +
+                    '</b><small>' + esc(T("share/privateText", "Only you and people with access to this location can open it.")) + '</small></div></div>';
+            }
+            shares().forEach(function (s) {
+                var chips = (s.Accessibles || []).map(function (n) { return '<span class="chip">' + esc(n) + '</span>'; }).join("");
+                html += '<div class="audience" data-uuid="' + esc(s.UUID) + '"><div class="bubble' + (s.Permission == "anyone" && !s.HasPassword ? "" : " private") + '">' + I(audienceIcon(s)) + '</div>' +
+                    '<div class="body"><b>' + esc(audienceTitle(s)) + (s.IsExpired ? ' <span class="chip danger">' + esc(T("share/expired", "Expired")) + '</span>' : "") + '</b>' +
+                    '<small>' + esc(detailText(s, isFolder)) + '</small>' + (chips ? '<div class="chips">' + chips + '</div>' : "") + '</div>' +
+                    '<button class="icon-btn small" data-act="copy" title="' + esc(T("menu/copyLink", "Copy link")) + '">' + I("copy") + '</button>' +
+                    '<button class="icon-btn small hide-xs" data-act="qr" title="' + esc(T("share/qr", "QR code")) + '">' + I("qr") + '</button>' +
+                    (info.reduced || info.CanManage === false ? "" : '<button class="icon-btn small" data-act="edit" title="' + esc(T("share/edit", "Edit link")) + '">' + I("pencil") + '</button>') +
+                    (info.CanManage === false ? "" : '<button class="icon-btn small" data-act="remove" title="' + esc(T("share/remove", "Remove link")) + '">' + I("trash") + '</button>') +
+                    '</div>';
+            });
+            if (info.CanManage !== false && !state.formOpen) {
+                if (!info.reduced || !shares().length) {
+                    html += '<button class="btn" id="shNew">' + I("plus") + esc(T("share/newLink", "Create a link")) + '</button>';
+                }
+            }
+            links.innerHTML = html;
+            copyBtn.disabled = !shares().some(function (s) { return !s.IsExpired; }) && info.CanManage === false;
+
+            links.querySelectorAll(".audience[data-uuid]").forEach(function (row) {
+                var s = shares().filter(function (x) { return x.UUID == row.getAttribute("data-uuid"); })[0];
+                row.querySelectorAll("[data-act]").forEach(function (b) {
+                    b.addEventListener("click", function () {
+                        var act = b.getAttribute("data-act");
+                        if (act == "copy") {
+                            DriveActions.copyText(API.shareURL(s.UUID)).then(function () {
+                                DriveUI.toast(T("share/copied", "Link copied"), { type: "success" });
+                            });
+                        } else if (act == "qr") {
+                            showQR(s);
+                        } else if (act == "edit") {
+                            state.editing = s;
+                            state.formOpen = true;
+                            render();
+                        } else if (act == "remove") {
+                            API.shareDelete(s.UUID, item.path).then(function () {
+                                DriveUI.toast(T("share/removed", "Link removed"), { type: "success" });
+                                reload();
+                            }).catch(DriveUI.error);
+                        }
+                    });
+                });
+            });
+            var nb = links.querySelector("#shNew");
+            if (nb) {
+                nb.addEventListener("click", function () {
+                    state.editing = null;
+                    state.formOpen = true;
+                    render();
+                });
+            }
+            renderForm(isFolder);
+        }
+
+        function renderForm(isFolder) {
+            var holder = body.querySelector("#shForm");
+            if (!state.formOpen || state.info.CanManage === false) {
+                holder.innerHTML = "";
+                return;
+            }
+            if (state.info.reduced) {
+                holder.innerHTML = '<div class="share-form"><p>' + esc(T("share/reducedText", "In limited mode Drive can create one public link per item.")) + '</p>' +
+                    '<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px"><button class="btn" data-f="cancel">' + esc(T("common/cancel", "Cancel")) + '</button>' +
+                    '<button class="btn primary" data-f="save">' + esc(T("share/create", "Create link")) + '</button></div></div>';
+                holder.querySelector('[data-f="cancel"]').addEventListener("click", function () { state.formOpen = false; render(); });
+                holder.querySelector('[data-f="save"]').addEventListener("click", function () {
+                    API.shareCreate(item.path, {}).then(function () {
+                        state.formOpen = false;
+                        reload();
+                    }).catch(DriveUI.error);
+                });
+                return;
+            }
+            var e = state.editing || { Permission: "anyone", Accessibles: [], AccessLevel: "view", AllowDownload: true, ShowFileList: true, HasPassword: false, ExpireAt: 0 };
+            var expiry = "never";
+            if (e.ExpireAt > 0) {
+                expiry = "custom";
+            }
+            var dateVal = e.ExpireAt > 0 ? new Date(e.ExpireAt * 1000) : new Date(Date.now() + 7 * DAY * 1000);
+            var pad = function (n) { return (n < 10 ? "0" : "") + n; };
+            var dateStr = dateVal.getFullYear() + "-" + pad(dateVal.getMonth() + 1) + "-" + pad(dateVal.getDate());
+
+            holder.innerHTML = '<div class="share-form">' +
+                '<div class="field-label" style="margin-bottom:10px;font-size:13.5px;color:var(--text)">' + esc(state.editing ? T("share/editTitle", "Edit link") : T("share/newLink", "Create a link")) + '</div>' +
+                '<div class="form-grid">' +
+                '<div class="field"><label>' + esc(T("share/who", "Who can open it")) + '</label><select class="select" data-f="perm">' +
+                ['anyone', 'signedin', 'samegroup', 'users', 'groups'].map(function (p) {
+                    var labels = {
+                        anyone: T("audience/anyone", "Anyone with the link"), signedin: T("audience/signedin", "Signed-in users"),
+                        samegroup: T("audience/samegroup", "People in the same group"), users: T("audience/pickUsers", "Specific people"), groups: T("audience/pickGroups", "Specific groups")
+                    };
+                    return '<option value="' + p + '"' + (e.Permission == p ? " selected" : "") + '>' + esc(labels[p]) + '</option>';
+                }).join("") + '</select></div>' +
+                '<div class="field"><label>' + esc(T("share/level", "Access")) + '</label><select class="select" data-f="level"' + (isFolder ? "" : " disabled") + '>' +
+                '<option value="view"' + (e.AccessLevel != "edit" ? " selected" : "") + '>' + esc(T("level/view", "Can view")) + '</option>' +
+                '<option value="edit"' + (e.AccessLevel == "edit" ? " selected" : "") + '>' + esc(T("level/editFolder", "Can edit (upload and change files)")) + '</option></select></div>' +
+                '</div>' +
+                '<div class="field" data-f="pickWrap" hidden><label data-f="pickLabel"></label><div class="picklist" data-f="pick"><span class="muted" style="padding:4px 6px">' + esc(T("common/loading", "Loading...")) + '</span></div></div>' +
+                '<div class="form-grid">' +
+                '<div class="field"><label>' + esc(T("share/expiry", "Link expires")) + '</label><select class="select" data-f="expiry">' +
+                '<option value="never"' + (expiry == "never" ? " selected" : "") + '>' + esc(T("share/never", "Never")) + '</option>' +
+                '<option value="1">' + esc(T("share/in1", "In 1 day")) + '</option>' +
+                '<option value="7">' + esc(T("share/in7", "In 7 days")) + '</option>' +
+                '<option value="30">' + esc(T("share/in30", "In 30 days")) + '</option>' +
+                '<option value="custom"' + (expiry == "custom" ? " selected" : "") + '>' + esc(T("share/onDate", "On a date...")) + '</option></select></div>' +
+                '<div class="field" data-f="dateWrap"' + (expiry == "custom" ? "" : " hidden") + '><label>' + esc(T("share/date", "Expiry date")) + '</label><input class="input" type="date" data-f="date" value="' + dateStr + '"></div>' +
+                '</div>' +
+                '<label class="toggle-row"><span>' + esc(T("share/password", "Require a password")) + '<small>' + esc(e.HasPassword ? T("share/passwordSet", "A password is set. Type a new one to change it.") : T("share/passwordHint", "Only people with the password can open the link")) + '</small></span>' +
+                '<span class="switch"><input type="checkbox" data-f="pwOn"' + (e.HasPassword ? " checked" : "") + '><span></span></span></label>' +
+                '<div class="field" data-f="pwWrap"' + (e.HasPassword ? "" : " hidden") + '><input class="input" type="password" data-f="pw" autocomplete="new-password" placeholder="' + esc(e.HasPassword ? T("share/keepPassword", "Leave empty to keep the current password") : T("share/newPassword", "Password")) + '"></div>' +
+                '<label class="toggle-row"><span>' + esc(T("share/allowDownload", "Allow download")) + '</span><span class="switch"><input type="checkbox" data-f="dl"' + (e.AllowDownload ? " checked" : "") + '><span></span></span></label>' +
+                (isFolder ? '<label class="toggle-row"><span>' + esc(T("share/showList", "Show the file list")) + '</span><span class="switch"><input type="checkbox" data-f="list"' + (e.ShowFileList ? " checked" : "") + '><span></span></span></label>' : "") +
+                '<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:14px"><button class="btn" data-f="cancel">' + esc(T("common/cancel", "Cancel")) + '</button>' +
+                '<button class="btn primary" data-f="save">' + esc(state.editing ? T("common/save", "Save") : T("share/create", "Create link")) + '</button></div></div>';
+
+            var q = function (k) { return holder.querySelector('[data-f="' + k + '"]'); };
+            var chosen = (e.Accessibles || []).slice();
+
+            function syncPick() {
+                var perm = q("perm").value;
+                var wrap = q("pickWrap");
+                if (perm != "users" && perm != "groups") {
+                    wrap.hidden = true;
+                    return;
+                }
+                wrap.hidden = false;
+                q("pickLabel").textContent = perm == "users" ? T("share/pickUsers", "Choose people") : T("share/pickGroups", "Choose groups");
+                recipients().then(function (r) {
+                    var names = perm == "users" ? r.Users.filter(function (u) { return !u.IsSelf; }).map(function (u) { return u.Username; }) : r.Groups;
+                    if (!names.length) {
+                        q("pick").innerHTML = '<span class="muted" style="padding:4px 6px">' + esc(T("share/nobody", "Nobody to choose from")) + '</span>';
+                        return;
+                    }
+                    q("pick").innerHTML = names.map(function (n) {
+                        var on = chosen.indexOf(n) >= 0;
+                        return '<label class="' + (on ? "on" : "") + '"><input type="checkbox" value="' + esc(n) + '"' + (on ? " checked" : "") + '>' + I(perm == "users" ? "user" : "users") + esc(n) + '</label>';
+                    }).join("");
+                    q("pick").querySelectorAll("input").forEach(function (cb) {
+                        cb.addEventListener("change", function () {
+                            cb.parentNode.classList.toggle("on", cb.checked);
+                            var v = cb.value;
+                            var at = chosen.indexOf(v);
+                            if (cb.checked && at < 0) {
+                                chosen.push(v);
+                            } else if (!cb.checked && at >= 0) {
+                                chosen.splice(at, 1);
+                            }
+                        });
+                    });
+                });
+            }
+
+            q("perm").addEventListener("change", function () {
+                chosen = [];
+                syncPick();
+            });
+            q("expiry").addEventListener("change", function () {
+                q("dateWrap").hidden = q("expiry").value != "custom";
+            });
+            q("pwOn").addEventListener("change", function () {
+                q("pwWrap").hidden = !q("pwOn").checked;
+                if (q("pwOn").checked) {
+                    q("pw").focus();
+                }
+            });
+            q("cancel").addEventListener("click", function () {
+                state.formOpen = false;
+                state.editing = null;
+                render();
+            });
+            q("save").addEventListener("click", function () {
+                var perm = q("perm").value;
+                if ((perm == "users" || perm == "groups") && !chosen.length) {
+                    DriveUI.toast(perm == "users" ? T("share/needUsers", "Choose at least one person") : T("share/needGroups", "Choose at least one group"), { type: "error" });
+                    return;
+                }
+                var settings = {
+                    permission: perm,
+                    accessibles: chosen.join(","),
+                    accessLevel: isFolder ? q("level").value : "view",
+                    allowDownload: q("dl").checked ? "true" : "false"
+                };
+                if (isFolder) {
+                    settings.showFileList = q("list").checked ? "true" : "false";
+                }
+                var exp = q("expiry").value;
+                if (exp == "never") {
+                    settings.expireAt = "0";
+                } else if (exp == "custom") {
+                    var d = new Date(q("date").value + "T23:59:59");
+                    if (isNaN(d.getTime()) || d.getTime() < Date.now()) {
+                        DriveUI.toast(T("share/badDate", "Choose a date in the future"), { type: "error" });
+                        return;
+                    }
+                    settings.expireAt = String(Math.floor(d.getTime() / 1000));
+                } else {
+                    settings.expireIn = String(parseInt(exp, 10) * DAY);
+                }
+                if (!q("pwOn").checked) {
+                    if (e.HasPassword) {
+                        settings.clearPassword = "true";
+                    }
+                } else if (q("pw").value) {
+                    settings.password = q("pw").value;
+                } else if (!e.HasPassword) {
+                    DriveUI.toast(T("share/needPassword", "Type a password or turn the option off"), { type: "error" });
+                    return;
+                }
+                var req = state.editing ? API.shareUpdate(state.editing.UUID, settings) : API.shareCreate(item.path, settings);
+                q("save").disabled = true;
+                req.then(function (saved) {
+                    var wasNew = !state.editing;
+                    state.formOpen = false;
+                    state.editing = null;
+                    if (wasNew && saved && saved.UUID) {
+                        DriveActions.copyText(API.shareURL(saved.UUID)).then(function () {
+                            DriveUI.toast(T("share/createdCopied", "Link created and copied"), { type: "success" });
+                        }).catch(function () {
+                            DriveUI.toast(T("share/created", "Link created"), { type: "success" });
+                        });
+                    } else {
+                        DriveUI.toast(T("share/saved", "Link updated"), { type: "success" });
+                    }
+                    reload();
+                }).catch(function (err) {
+                    q("save").disabled = false;
+                    DriveUI.error(err);
+                });
+            });
+            syncPick();
+        }
+
+        copyBtn.addEventListener("click", function () {
+            DriveActions.copyLink(item);
+            setTimeout(reload, 600);
+        });
+        reload();
+    }
+
+    global.DriveShare = {
+        open: open,
+        audienceTitle: audienceTitle,
+        audienceIcon: audienceIcon,
+        detailText: detailText
+    };
+})(window);

+ 787 - 0
src/web/Drive/js/transfers.js

@@ -0,0 +1,787 @@
+/*
+    transfers.js
+
+    Uploads and background file operations, with one progress panel in the
+    corner of the page (no separate window like the File Manager's
+    standalone file operation dialog).
+
+      upload      small files go through the XHR POST endpoint, large ones
+                  through the chunked WebSocket channel (script/chunkupload.js,
+                  the same client the File Manager and the desktop use);
+                  without File Manager they go to fs.agi in hex chunks
+      operation   move / copy / zip / unzip tasks started on the server,
+                  followed by polling /system/file_system/ongoing
+      zip         "download several items" packs them in tmp:/ first and
+                  hands the archive to the browser once it is ready
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = DriveUI.I;
+    var esc = DriveUI.esc;
+    var API = DriveAPI;
+
+    var POST_CUTOFF = 25 * 1024 * 1024;
+    var MAX_PARALLEL = 3;
+    var AGI_CHUNK = 2 * 1024 * 1024;
+    var AGI_LIMIT = 24 * 1024 * 1024;
+
+    var tasks = [];
+    var queue = [];
+    var running = 0;
+    var collapsed = false;
+    var pollTimer = null;
+    var seq = 0;
+    var refreshTimer = null;
+
+    function newTask(props) {
+        var t = $.extend({
+            id: "t" + (++seq),
+            kind: "upload",
+            name: "",
+            status: "queued",
+            loaded: 0,
+            total: 0,
+            percent: -1,
+            error: "",
+            abort: null,
+            started: Date.now()
+        }, props);
+        tasks.unshift(t);
+        show();
+        return t;
+    }
+
+    function finished(t) {
+        return t.status == "done" || t.status == "error" || t.status == "cancelled";
+    }
+
+    //Refresh the listing once when a batch of changes lands
+    function softRefresh() {
+        clearTimeout(refreshTimer);
+        refreshTimer = setTimeout(function () {
+            Drive.refresh();
+            Drive.refreshQuota();
+        }, 400);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Panel                                                              */
+    /* ------------------------------------------------------------------ */
+    function show() {
+        var panel = document.getElementById("transfers");
+        panel.hidden = false;
+        render();
+    }
+
+    function hide() {
+        var active = tasks.filter(function (t) { return !finished(t); });
+        if (active.length) {
+            DriveUI.confirm({
+                title: T("xfer/cancelAllTitle", "Cancel transfers?"),
+                message: T("xfer/cancelAllText", "{n} transfers are still running. Cancel them?", { n: active.length }),
+                ok: T("xfer/cancelAll", "Cancel transfers"), danger: true
+            }).then(function (ok) {
+                if (!ok) {
+                    return;
+                }
+                active.forEach(cancel);
+                tasks = [];
+                document.getElementById("transfers").hidden = true;
+            });
+            return;
+        }
+        tasks = [];
+        document.getElementById("transfers").hidden = true;
+    }
+
+    function cancel(t) {
+        if (finished(t)) {
+            return;
+        }
+        if (t.status == "queued") {
+            queue = queue.filter(function (q) { return q.task !== t; });
+        }
+        if (t.abort) {
+            try { t.abort(); } catch (e) { }
+        }
+        t.status = "cancelled";
+        render();
+    }
+
+    function headline() {
+        var active = tasks.filter(function (t) { return !finished(t); });
+        var failed = tasks.filter(function (t) { return t.status == "error"; });
+        var uploads = active.filter(function (t) { return t.kind == "upload"; }).length;
+        if (active.length) {
+            if (uploads == active.length) {
+                return T("xfer/uploadingN", "Uploading {n} items", { n: active.length });
+            }
+            return T("xfer/workingN", "{n} transfers in progress", { n: active.length });
+        }
+        if (failed.length) {
+            return T("xfer/failedN", "{n} items failed", { n: failed.length });
+        }
+        return T("xfer/doneN", "{n} transfers complete", { n: tasks.length });
+    }
+
+    function remainingText() {
+        var active = tasks.filter(function (t) { return t.status == "running" && t.kind == "upload" && t.total > 0; });
+        if (!active.length) {
+            return "";
+        }
+        var loaded = 0, total = 0, elapsed = 0;
+        active.forEach(function (t) {
+            loaded += t.loaded;
+            total += t.total;
+            elapsed = Math.max(elapsed, (Date.now() - t.started) / 1000);
+        });
+        if (loaded <= 0 || elapsed < 2) {
+            return "";
+        }
+        var rate = loaded / elapsed;
+        var left = (total - loaded) / rate;
+        if (!isFinite(left) || left > 86400) {
+            return "";
+        }
+        if (left < 60) {
+            return T("xfer/secondsLeft", "{n} s left", { n: Math.max(1, Math.round(left)) });
+        }
+        return T("xfer/minutesLeft", "{n} min left", { n: Math.round(left / 60) });
+    }
+
+    function statusText(t) {
+        switch (t.status) {
+            case "queued": return T("xfer/queued", "Waiting");
+            case "processing": return t.note || T("xfer/processing", "Finishing up");
+            case "done": return t.doneText || T("xfer/done", "Done");
+            case "cancelled": return T("xfer/cancelled", "Cancelled");
+            case "error": return t.error || T("xfer/failed", "Failed");
+        }
+        if (t.total > 0) {
+            return DriveUI.fmtSize(t.loaded) + " / " + DriveUI.fmtSize(t.total);
+        }
+        if (t.percent >= 0) {
+            return Math.round(t.percent) + "%";
+        }
+        return t.note || T("xfer/working", "Working");
+    }
+
+    function percentOf(t) {
+        if (t.total > 0) {
+            return Math.min(100, t.loaded / t.total * 100);
+        }
+        return t.percent;
+    }
+
+    function render() {
+        var panel = document.getElementById("transfers");
+        if (panel.hidden) {
+            return;
+        }
+        panel.classList.toggle("collapsed", collapsed);
+        var rem = remainingText();
+        var html = '<div class="transfers-head"><b>' + esc(headline()) + (rem ? '<small>' + esc(rem) + '</small>' : "") + '</b>' +
+            '<button class="icon-btn small" data-act="collapse" title="' + esc(collapsed ? T("xfer/expand", "Expand") : T("xfer/collapse", "Collapse")) + '">' +
+            I(collapsed ? "chevronUp" : "chevronDown") + '</button>' +
+            '<button class="icon-btn small" data-act="close" title="' + esc(T("common/close", "Close")) + '">' + I("x") + '</button></div>' +
+            '<div class="transfers-list scroll-y">';
+        tasks.forEach(function (t) {
+            var pct = percentOf(t);
+            var stateIcon = "";
+            if (t.status == "done") {
+                stateIcon = '<span class="state-ic ok">' + I("check") + '</span>';
+            } else if (t.status == "error") {
+                stateIcon = '<span class="state-ic bad" title="' + esc(t.error) + '">' + I("alert") + '</span>';
+            } else if (t.status == "cancelled") {
+                stateIcon = '<span class="state-ic">' + I("x") + '</span>';
+            }
+            var bar = "";
+            if (t.status == "running" || t.status == "processing") {
+                bar = '<div class="bar' + (pct < 0 || t.status == "processing" ? " indeterminate" : "") + '"><i style="width:' + Math.max(0, pct) + '%"></i></div>';
+            }
+            var actions = "";
+            if (!finished(t)) {
+                actions = '<button class="icon-btn small" data-cancel="' + t.id + '" title="' + esc(T("common/cancel", "Cancel")) + '">' + I("x") + '</button>';
+            } else if (t.status == "done" && t.link) {
+                actions = '<a class="icon-btn small" href="' + esc(t.link) + '" download="' + esc(t.linkName || "") + '" title="' + esc(T("xfer/downloadAgain", "Download again")) + '">' + I("download") + '</a>';
+            } else if (t.status == "done" && t.folder) {
+                actions = '<button class="icon-btn small" data-open="' + t.id + '" title="' + esc(T("xfer/showFolder", "Show in folder")) + '">' + I("folderOpen") + '</button>';
+            } else if (t.status == "error" && t.retry) {
+                actions = '<button class="icon-btn small" data-retry="' + t.id + '" title="' + esc(T("common/retry", "Try again")) + '">' + I("refresh") + '</button>';
+            }
+            var icon = t.icon ? '<span class="ft-box"><span class="ic-wrap" style="color:var(--accent-text)">' + I(t.icon, { size: 24 }) + '</span></span>' :
+                '<span class="ft-box">' + DriveIcons.file(t.name, false) + '</span>';
+            html += '<div class="xfer">' + icon + '<div class="body"><b title="' + esc(t.name) + '">' + esc(t.name) + '</b>' +
+                '<small class="' + (t.status == "error" ? "err" : "") + '">' + esc(statusText(t)) + '</small>' + bar + '</div>' +
+                stateIcon + actions + '</div>';
+        });
+        html += '</div>';
+        panel.innerHTML = html;
+        panel.querySelector('[data-act="collapse"]').addEventListener("click", function () {
+            collapsed = !collapsed;
+            render();
+        });
+        panel.querySelector('[data-act="close"]').addEventListener("click", hide);
+        panel.querySelectorAll("[data-cancel]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var t = tasks.filter(function (x) { return x.id == b.getAttribute("data-cancel"); })[0];
+                if (t) {
+                    cancel(t);
+                }
+            });
+        });
+        panel.querySelectorAll("[data-retry]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var t = tasks.filter(function (x) { return x.id == b.getAttribute("data-retry"); })[0];
+                if (t && t.retry) {
+                    tasks.splice(tasks.indexOf(t), 1);
+                    t.retry();
+                }
+            });
+        });
+        panel.querySelectorAll("[data-open]").forEach(function (b) {
+            b.addEventListener("click", function () {
+                var t = tasks.filter(function (x) { return x.id == b.getAttribute("data-open"); })[0];
+                if (t && t.folder) {
+                    location.hash = Drive.folderRoute(t.folder);
+                }
+            });
+        });
+    }
+
+    //A timer rather than requestAnimationFrame: rAF stops in a background
+    //tab, which left rows frozen at 0% until the tab was shown again
+    var renderPending = false;
+    function renderSoon() {
+        if (renderPending) {
+            return;
+        }
+        renderPending = true;
+        setTimeout(function () {
+            renderPending = false;
+            render();
+        }, 60);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Uploads                                                            */
+    /* ------------------------------------------------------------------ */
+    //entries: [{file, dir}] where dir is the target folder of that file
+    function uploadEntries(entries) {
+        if (!entries.length) {
+            return;
+        }
+        if (!API.caps.fm) {
+            var tooBig = entries.filter(function (e) { return e.file.size > AGI_LIMIT; });
+            if (tooBig.length) {
+                DriveUI.toast(T("xfer/tooBigReduced", "{n} files are larger than 24 MB and need the File Manager permission to upload", { n: tooBig.length }), { type: "error" });
+                entries = entries.filter(function (e) { return e.file.size <= AGI_LIMIT; });
+            }
+        }
+        entries.forEach(function (e) {
+            var task = newTask({ kind: "upload", name: e.file.name, total: e.file.size, folder: e.dir });
+            queue.push({ task: task, file: e.file, dir: e.dir });
+        });
+        pump();
+        render();
+    }
+
+    function pump() {
+        while (running < MAX_PARALLEL && queue.length) {
+            var job = queue.shift();
+            if (job.task.status == "cancelled") {
+                continue;
+            }
+            running++;
+            startUpload(job);
+        }
+    }
+
+    function jobEnded() {
+        running = Math.max(0, running - 1);
+        pump();
+        renderSoon();
+        softRefresh();
+    }
+
+    function startUpload(job) {
+        var t = job.task;
+        t.status = "running";
+        t.started = Date.now();
+        t.retry = function () {
+            uploadEntries([{ file: job.file, dir: job.dir }]);
+        };
+        DriveAPI.recentAdd(DriveAPI.joinPath(job.dir, job.file.name));
+        if (!API.caps.fm) {
+            uploadViaAGI(job);
+        } else if (job.file.size > POST_CUTOFF && global.ChunkUpload) {
+            uploadViaSocket(job);
+        } else {
+            uploadViaPost(job);
+        }
+        renderSoon();
+    }
+
+    function uploadViaPost(job) {
+        var t = job.task;
+        var xhr = new XMLHttpRequest();
+        var form = new FormData();
+        form.append("file", job.file, job.file.name);
+        form.append("path", job.dir);
+        var ended = false;
+        function end() {
+            if (!ended) {
+                ended = true;
+                jobEnded();
+            }
+        }
+        t.abort = function () {
+            xhr.abort();
+            end();
+        };
+        xhr.upload.addEventListener("progress", function (e) {
+            if (e.lengthComputable) {
+                t.loaded = e.loaded;
+                t.total = e.total;
+                if (e.loaded >= e.total) {
+                    t.status = "processing";
+                }
+                renderSoon();
+            }
+        });
+        xhr.addEventListener("load", function () {
+            if (t.status == "cancelled") {
+                return end();
+            }
+            var resp = null;
+            try {
+                resp = JSON.parse(xhr.responseText);
+            } catch (e) {
+                resp = null;
+            }
+            if (xhr.status == 200 && !(resp && resp.error)) {
+                t.status = "done";
+                t.loaded = t.total;
+            } else {
+                t.status = "error";
+                t.error = resp && resp.error ? T("srv/" + resp.error, resp.error) : T("xfer/uploadFailed", "Upload failed (the file may be too big or the disk full)");
+            }
+            end();
+        });
+        xhr.addEventListener("error", function () {
+            if (t.status != "cancelled") {
+                t.status = "error";
+                t.error = T("err/network", "Cannot reach the server");
+            }
+            end();
+        });
+        xhr.open("POST", API.ROOT + "system/file_system/upload?path=" + encodeURIComponent(job.dir));
+        xhr.send(form);
+    }
+
+    function socketBase() {
+        var secure = location.protocol == "https:";
+        var port = location.port || (secure ? "443" : "80");
+        return (secure ? "wss://" : "ws://") + location.hostname + ":" + port;
+    }
+
+    function uploadViaSocket(job) {
+        var t = job.task;
+        var ended = false;
+        function end() {
+            if (!ended) {
+                ended = true;
+                jobEnded();
+            }
+        }
+        var url = socketBase() + "/system/file_system/lowmemUpload?filename=" +
+            encodeURIComponent(encodeURIComponent(job.file.name)) + "&path=" + encodeURIComponent(job.dir);
+        var ctl = ChunkUpload.start({
+            url: url,
+            file: job.file,
+            chunkSize: 1024 * 1024,
+            windowSize: 8 * 1024 * 1024,
+            ackTimeout: 30000,
+            maxRetries: 3,
+            pingInterval: 20000,
+            onProgress: function (loaded, total) {
+                t.loaded = loaded;
+                t.total = total;
+                renderSoon();
+            },
+            onProcessing: function () {
+                t.status = "processing";
+                renderSoon();
+            },
+            onMove: function (status) {
+                t.status = "processing";
+                t.note = typeof status === "string" ? status : "";
+                renderSoon();
+            },
+            onDone: function () {
+                t.status = "done";
+                t.loaded = t.total;
+                end();
+            },
+            onError: function (message) {
+                if (t.status != "cancelled") {
+                    t.status = "error";
+                    t.error = message || T("xfer/failed", "Failed");
+                }
+                end();
+            },
+            onClose: function () {
+                if (!finished(t)) {
+                    t.status = "error";
+                    t.error = T("xfer/connectionLost", "Connection lost");
+                }
+                end();
+            },
+            onSocketError: function () {
+                if (!finished(t)) {
+                    t.status = "error";
+                    t.error = T("xfer/connectionLost", "Connection lost");
+                }
+                end();
+            }
+        });
+        t.abort = function () {
+            ctl.abort();
+            end();
+        };
+    }
+
+    function toHex(buffer) {
+        var bytes = new Uint8Array(buffer);
+        var hex = new Array(bytes.length);
+        for (var i = 0; i < bytes.length; i++) {
+            hex[i] = (bytes[i] < 16 ? "0" : "") + bytes[i].toString(16);
+        }
+        return hex.join("");
+    }
+
+    function uploadViaAGI(job) {
+        var t = job.task;
+        var file = job.file;
+        var chunks = Math.max(1, Math.ceil(file.size / AGI_CHUNK));
+        var index = 0;
+        var target = null;
+        var cancelled = false;
+        t.abort = function () {
+            cancelled = true;
+            jobEnded();
+        };
+        function next() {
+            if (cancelled) {
+                return;
+            }
+            if (index >= chunks) {
+                t.status = "done";
+                t.loaded = t.total;
+                jobEnded();
+                return;
+            }
+            var blob = file.slice(index * AGI_CHUNK, Math.min(file.size, (index + 1) * AGI_CHUNK));
+            blob.arrayBuffer().then(function (buf) {
+                var data = { opr: "upload", dir: job.dir, name: file.name, index: index, total: file.size, chunk: toHex(buf) };
+                if (target) {
+                    data.target = target;
+                }
+                return API.agi("fs.agi", data);
+            }).then(function (r) {
+                if (r && r.path) {
+                    target = r.path;
+                }
+                index++;
+                t.loaded = Math.min(file.size, index * AGI_CHUNK);
+                renderSoon();
+                next();
+            }).catch(function (err) {
+                if (!cancelled) {
+                    t.status = "error";
+                    t.error = err.message;
+                    jobEnded();
+                }
+            });
+        }
+        next();
+    }
+
+    /* Picking and dropping ------------------------------------------------- */
+    function pick(targetDir, folder) {
+        var input = document.createElement("input");
+        input.type = "file";
+        input.multiple = true;
+        if (folder) {
+            input.webkitdirectory = true;
+            input.setAttribute("webkitdirectory", "");
+            input.setAttribute("directory", "");
+        }
+        input.style.display = "none";
+        input.addEventListener("change", function () {
+            var files = Array.prototype.slice.call(input.files || []);
+            input.remove();
+            if (!files.length) {
+                return;
+            }
+            var entries = files.map(function (f) {
+                var rel = f.webkitRelativePath || "";
+                var dir = targetDir;
+                if (rel && rel.indexOf("/") > 0) {
+                    dir = Drive.normDir(targetDir + rel.substring(0, rel.lastIndexOf("/")));
+                }
+                return { file: f, dir: dir };
+            });
+            uploadEntries(entries);
+        });
+        document.body.appendChild(input);
+        input.click();
+    }
+
+    function readAllEntries(reader) {
+        return new Promise(function (resolve) {
+            var all = [];
+            function more() {
+                reader.readEntries(function (batch) {
+                    if (!batch.length) {
+                        resolve(all);
+                        return;
+                    }
+                    all = all.concat(Array.prototype.slice.call(batch));
+                    more();
+                }, function () { resolve(all); });
+            }
+            more();
+        });
+    }
+
+    function walkEntry(entry, dir, out) {
+        if (entry.isFile) {
+            return new Promise(function (resolve) {
+                entry.file(function (f) {
+                    out.push({ file: f, dir: dir });
+                    resolve();
+                }, function () { resolve(); });
+            });
+        }
+        if (entry.isDirectory) {
+            var sub = Drive.normDir(dir + entry.name);
+            return readAllEntries(entry.createReader()).then(function (children) {
+                if (!children.length) {
+                    //Keep empty folders too
+                    return API.mkdir(dir, entry.name).catch(function () { });
+                }
+                return children.reduce(function (p, child) {
+                    return p.then(function () { return walkEntry(child, sub, out); });
+                }, Promise.resolve());
+            });
+        }
+        return Promise.resolve();
+    }
+
+    function fromDataTransfer(dt, targetDir) {
+        if (!Drive.isWritable(targetDir)) {
+            DriveUI.toast(T("err/readonly", "This location is read only"), { type: "error" });
+            return;
+        }
+        var items = dt.items ? Array.prototype.slice.call(dt.items) : [];
+        var entries = items.map(function (i) {
+            return i.kind == "file" && i.webkitGetAsEntry ? i.webkitGetAsEntry() : null;
+        }).filter(Boolean);
+        if (!entries.length) {
+            uploadEntries(Array.prototype.slice.call(dt.files || []).map(function (f) { return { file: f, dir: targetDir }; }));
+            return;
+        }
+        var out = [];
+        Promise.all(entries.map(function (en) { return walkEntry(en, targetDir, out); })).then(function () {
+            uploadEntries(out);
+            if (!out.length) {
+                softRefresh();
+            }
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Server side operations                                             */
+    /* ------------------------------------------------------------------ */
+    var OP_LABEL = {
+        move: ["xfer/moving", "Moving {name}"], copy: ["xfer/copying", "Copying {name}"],
+        zip: ["xfer/zipping", "Compressing {name}"], unzip: ["xfer/unzipping", "Extracting {name}"]
+    };
+    var OP_ICON = { move: "move", copy: "copy", zip: "archive", unzip: "extract" };
+
+    function describe(opr, items) {
+        var name = items.length == 1 ? items[0].name : T("drag/items", "{n} items", { n: items.length });
+        var l = OP_LABEL[opr] || ["xfer/working", "Working"];
+        return T(l[0], l[1], { name: name });
+    }
+
+    /*
+        operation(opr, items, dest, existsresp, opts) -> Promise resolved when
+        the server finishes. opts.doneText replaces "Done" on the row.
+    */
+    function operation(opr, items, dest, existsresp, opts) {
+        opts = opts || {};
+        var task = newTask({
+            kind: "op", name: describe(opr, items), icon: OP_ICON[opr] || "file", status: "running",
+            percent: 0, folder: opts.folder || dest, doneText: opts.doneText
+        });
+        render();
+        return API.startOperation(opr, items.map(function (i) { return i.path; }), dest, existsresp).then(function (r) {
+            if (r && r.sync) {
+                task.status = "done";
+                renderSoon();
+                softRefresh();
+                return;
+            }
+            return follow(task, r.oprid);
+        }).catch(function (err) {
+            task.status = "error";
+            task.error = err.message;
+            renderSoon();
+            throw err;
+        });
+    }
+
+    var followers = {};
+
+    function follow(task, oprid) {
+        return new Promise(function (resolve, reject) {
+            task.oprid = oprid;
+            task.misses = 0;
+            task.abort = function () {
+                API.controlOperation(oprid, "cancel").catch(function () { });
+            };
+            followers[oprid] = { task: task, resolve: resolve, reject: reject };
+            startPolling();
+        });
+    }
+
+    function startPolling() {
+        if (pollTimer) {
+            return;
+        }
+        poll();
+        pollTimer = setInterval(poll, 1000);
+    }
+
+    function poll() {
+        var ids = Object.keys(followers);
+        if (!ids.length) {
+            clearInterval(pollTimer);
+            pollTimer = null;
+            return;
+        }
+        API.ongoing().then(function (list) {
+            if (!Array.isArray(list)) {
+                return;
+            }
+            var byId = {};
+            list.forEach(function (x) { byId[x.ID] = x; });
+            ids.forEach(function (id) {
+                var f = followers[id];
+                var t = f.task;
+                var rec = byId[id];
+                if (t.status == "cancelled") {
+                    delete followers[id];
+                    f.reject(new Error(T("xfer/cancelled", "Cancelled")));
+                    return;
+                }
+                if (!rec) {
+                    //Finished records drop out after a few seconds
+                    t.misses++;
+                    if (t.misses >= 3) {
+                        t.status = "done";
+                        delete followers[id];
+                        f.resolve();
+                        softRefresh();
+                    }
+                    return;
+                }
+                t.misses = 0;
+                if (rec.Status == "completed") {
+                    t.status = "done";
+                    t.percent = 100;
+                    delete followers[id];
+                    API.controlOperation(id, "remove").catch(function () { });
+                    f.resolve(rec);
+                    softRefresh();
+                } else if (rec.Status == "error") {
+                    t.status = "error";
+                    t.error = rec.Error || T("xfer/failed", "Failed");
+                    delete followers[id];
+                    f.reject(new Error(t.error));
+                    softRefresh();
+                } else if (rec.Status == "cancelled") {
+                    t.status = "cancelled";
+                    delete followers[id];
+                    f.reject(new Error(T("xfer/cancelled", "Cancelled")));
+                    softRefresh();
+                } else {
+                    t.percent = rec.Progress || 0;
+                    if (rec.TotalSize > 0) {
+                        t.total = rec.TotalSize;
+                        t.loaded = rec.DoneSize;
+                    }
+                    t.note = rec.LatestFile ? DriveAPI.baseName(rec.LatestFile) : "";
+                }
+            });
+            renderSoon();
+        }).catch(function () { });
+    }
+
+    /* Zip and download ------------------------------------------------------ */
+    function zipDownload(items) {
+        var name = items.length == 1 ? items[0].name + ".zip" : "download.zip";
+        var task = newTask({ kind: "op", name: T("xfer/preparing", "Preparing {name}", { name: name }), icon: "archive", status: "running", percent: 0 });
+        render();
+        API.zipForDownload(items.map(function (i) { return i.path; })).then(function (r) {
+            return follow(task, r.oprid).then(function () {
+                var url = API.downloadURL(r.dest);
+                task.status = "done";
+                task.name = name;
+                task.link = url;
+                task.linkName = name;
+                renderSoon();
+                saveURL(url, name);
+            });
+        }).catch(function (err) {
+            if (task.status != "cancelled") {
+                task.status = "error";
+                task.error = err.message;
+                renderSoon();
+            }
+        });
+    }
+
+    function saveURL(url, name) {
+        var a = document.createElement("a");
+        a.href = url;
+        a.download = name || "";
+        a.style.display = "none";
+        document.body.appendChild(a);
+        a.click();
+        setTimeout(function () { a.remove(); }, 1000);
+    }
+
+    global.DriveTransfers = {
+        pick: pick,
+        uploadEntries: uploadEntries,
+        fromDataTransfer: fromDataTransfer,
+        operation: operation,
+        zipDownload: zipDownload,
+        saveURL: saveURL,
+        hasActive: function () {
+            return tasks.some(function (t) { return !finished(t); });
+        }
+    };
+
+    window.addEventListener("beforeunload", function (e) {
+        if (tasks.some(function (t) { return t.kind == "upload" && !finished(t); })) {
+            e.preventDefault();
+            e.returnValue = "";
+        }
+    });
+})(window);

+ 724 - 0
src/web/Drive/js/ui.js

@@ -0,0 +1,724 @@
+/*
+    ui.js
+
+    Generic interface pieces shared by every view: formatting helpers,
+    toasts, menus (with submenus), popovers, modals, confirm / prompt, and
+    the in-page folder picker used by Move to and Copy to. None of them
+    open a browser window.
+*/
+
+(function (global) {
+    "use strict";
+
+    var I = function (name, opts) { return DriveIcons.ui(name, opts); };
+
+    function esc(str) {
+        return String(str == null ? "" : str)
+            .replace(/&/g, "&amp;").replace(/</g, "&lt;")
+            .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
+    }
+
+    function el(html) {
+        var t = document.createElement("template");
+        t.innerHTML = html.trim();
+        return t.content.firstElementChild;
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Formatting                                                         */
+    /* ------------------------------------------------------------------ */
+    function fmtSize(bytes) {
+        if (bytes == null || bytes < 0 || isNaN(bytes)) {
+            return "-";
+        }
+        if (bytes < 1024) {
+            return bytes + " B";
+        }
+        var units = ["KB", "MB", "GB", "TB", "PB"];
+        var v = bytes / 1024;
+        var i = 0;
+        while (v >= 1024 && i < units.length - 1) {
+            v /= 1024;
+            i++;
+        }
+        return (v < 10 ? v.toFixed(1) : v < 100 ? v.toFixed(1) : Math.round(v)) + " " + units[i];
+    }
+
+    function fmtExact(bytes) {
+        try {
+            return Number(bytes).toLocaleString(DriveI18n.locale()) + " " + T("unit/bytes", "bytes");
+        } catch (e) {
+            return bytes + " bytes";
+        }
+    }
+
+    function pad(n) {
+        return (n < 10 ? "0" : "") + n;
+    }
+
+    function fmtDate(unix) {
+        if (!unix) {
+            return "-";
+        }
+        var d = new Date(unix * 1000);
+        return d.getFullYear() + "/" + pad(d.getMonth() + 1) + "/" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
+    }
+
+    function fmtDay(unix) {
+        var d = new Date(unix * 1000);
+        return d.getFullYear() + "/" + pad(d.getMonth() + 1) + "/" + pad(d.getDate());
+    }
+
+    function fmtRelative(unix) {
+        if (!unix) {
+            return "-";
+        }
+        var diff = Math.floor(Date.now() / 1000) - unix;
+        if (diff < -60) {
+            var ahead = -diff;
+            if (ahead < 3600) {
+                return T("time/inMinutes", "In {n} min", { n: Math.ceil(ahead / 60) });
+            }
+            if (ahead < 86400) {
+                return T("time/inHours", "In {n} h", { n: Math.round(ahead / 3600) });
+            }
+            return T("time/inDays", "In {n} d", { n: Math.round(ahead / 86400) });
+        }
+        if (diff < 60) {
+            return T("time/justNow", "Just now");
+        }
+        if (diff < 3600) {
+            return T("time/minutes", "{n} min ago", { n: Math.floor(diff / 60) });
+        }
+        if (diff < 86400) {
+            return T("time/hours", "{n} h ago", { n: Math.floor(diff / 3600) });
+        }
+        if (diff < 86400 * 7) {
+            return T("time/days", "{n} d ago", { n: Math.floor(diff / 86400) });
+        }
+        return fmtDay(unix);
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Toasts                                                             */
+    /* ------------------------------------------------------------------ */
+    function toast(message, opts) {
+        opts = opts || {};
+        var layer = document.getElementById("toastLayer");
+        var type = opts.type || "info";
+        var icon = type == "error" ? "alert" : type == "success" ? "check" : "info";
+        var node = el('<div class="toast ' + type + '" role="status">' + I(icon) +
+            '<div class="msg">' + esc(message) + '</div></div>');
+        if (opts.action) {
+            var btn = el('<button>' + esc(opts.action.label) + '</button>');
+            btn.addEventListener("click", function () {
+                opts.action.fn();
+                dismiss();
+            });
+            node.appendChild(btn);
+        }
+        var close = el('<button aria-label="Close">' + I("x") + '</button>');
+        close.addEventListener("click", dismiss);
+        node.appendChild(close);
+        layer.appendChild(node);
+        while (layer.children.length > 3) {
+            layer.removeChild(layer.firstChild);
+        }
+        var timer = setTimeout(dismiss, opts.timeout || (type == "error" ? 6000 : 3500));
+        function dismiss() {
+            clearTimeout(timer);
+            node.classList.add("leaving");
+            setTimeout(function () { node.remove(); }, 220);
+        }
+        return dismiss;
+    }
+
+    function error(err) {
+        var msg = err && err.message ? err.message : String(err || T("err/unknown", "Something went wrong"));
+        toast(T("srv/" + msg, msg), { type: "error" });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Menus                                                              */
+    /* ------------------------------------------------------------------ */
+    var openMenus = [];
+    var menuCloseHook = null;
+
+    function closeMenus(depth) {
+        depth = depth || 0;
+        while (openMenus.length > depth) {
+            var m = openMenus.pop();
+            m.remove();
+        }
+        if (depth == 0 && menuCloseHook) {
+            var hook = menuCloseHook;
+            menuCloseHook = null;
+            hook();
+        }
+    }
+
+    function placeAt(node, x, y, anchorRect) {
+        document.getElementById("menuLayer").appendChild(node);
+        var w = node.offsetWidth, h = node.offsetHeight;
+        var vw = window.innerWidth, vh = window.innerHeight;
+        if (anchorRect) {
+            //Submenu or dropdown: try right / below first, flip if needed
+            x = anchorRect.right + 2;
+            y = anchorRect.top - 6;
+            if (x + w > vw - 8) {
+                x = anchorRect.left - w - 2;
+            }
+        }
+        if (x + w > vw - 8) {
+            x = vw - w - 8;
+        }
+        if (y + h > vh - 8) {
+            y = Math.max(8, vh - h - 8);
+        }
+        node.style.left = Math.max(8, x) + "px";
+        node.style.top = Math.max(8, y) + "px";
+    }
+
+    function buildMenu(items, depth) {
+        var node = el('<div class="menu" role="menu"></div>');
+        items.forEach(function (item) {
+            if (!item) {
+                return;
+            }
+            if (item === "-") {
+                if (node.lastElementChild && !node.lastElementChild.classList.contains("menu-sep")) {
+                    node.appendChild(el('<div class="menu-sep"></div>'));
+                }
+                return;
+            }
+            if (item.title) {
+                node.appendChild(el('<div class="menu-title">' + esc(item.title) + '</div>'));
+                return;
+            }
+            var iconHTML = item.img ? '<img src="' + esc(item.img) + '" alt="">' :
+                (item.checkable ? I("check", { cls: "check-mark" }) : (item.icon ? I(item.icon) : '<span style="width:18px"></span>'));
+            var btn = el('<button class="menu-item' + (item.danger ? " danger" : "") + (item.checked ? " checked" : "") + '" role="menuitem">' +
+                iconHTML + '<span class="label">' + esc(item.label) + '</span>' +
+                (item.hint ? '<span class="hint">' + esc(item.hint) + '</span>' : "") +
+                (item.submenu ? I("chevronRight", { cls: "sub-caret" }) : "") + '</button>');
+            if (item.disabled) {
+                btn.disabled = true;
+            }
+            if (item.submenu) {
+                var openSub = function () {
+                    closeMenus(depth + 1);
+                    node.querySelectorAll(".menu-item.hover").forEach(function (b) { b.classList.remove("hover"); });
+                    btn.classList.add("hover");
+                    Promise.resolve(typeof item.submenu === "function" ? item.submenu() : item.submenu).then(function (sub) {
+                        if (openMenus[depth] !== node) {
+                            return;
+                        }
+                        if (!sub || sub.length == 0) {
+                            sub = [{ label: T("menu/nothing", "Nothing here"), disabled: true }];
+                        }
+                        var subNode = buildMenu(sub, depth + 1);
+                        openMenus.push(subNode);
+                        placeAt(subNode, 0, 0, btn.getBoundingClientRect());
+                    });
+                };
+                var hoverTimer = null;
+                btn.addEventListener("mouseenter", function () {
+                    hoverTimer = setTimeout(openSub, 120);
+                });
+                btn.addEventListener("mouseleave", function () {
+                    clearTimeout(hoverTimer);
+                });
+                btn.addEventListener("click", function (e) {
+                    e.stopPropagation();
+                    openSub();
+                });
+            } else {
+                btn.addEventListener("mouseenter", function () {
+                    closeMenus(depth + 1);
+                    node.querySelectorAll(".menu-item.hover").forEach(function (b) { b.classList.remove("hover"); });
+                });
+                btn.addEventListener("click", function (e) {
+                    e.stopPropagation();
+                    closeMenus(0);
+                    if (item.action) {
+                        item.action();
+                    }
+                });
+            }
+            node.appendChild(btn);
+        });
+        if (node.lastElementChild && node.lastElementChild.classList.contains("menu-sep")) {
+            node.lastElementChild.remove();
+        }
+        node.addEventListener("contextmenu", function (e) { e.preventDefault(); });
+        return node;
+    }
+
+    /*
+        menu(position, items, opts)
+        position: {x, y} or {anchor: element, align: "start" | "end"}
+    */
+    function menu(position, items, opts) {
+        closeMenus(0);
+        closePopover();
+        opts = opts || {};
+        var node = buildMenu(items, 0);
+        openMenus.push(node);
+        if (position.anchor) {
+            var r = position.anchor.getBoundingClientRect();
+            document.getElementById("menuLayer").appendChild(node);
+            var x = position.align == "end" ? r.right - node.offsetWidth : r.left;
+            placeAt(node, x, r.bottom + 6);
+            position.anchor.setAttribute("aria-expanded", "true");
+            menuCloseHook = function () {
+                position.anchor.removeAttribute("aria-expanded");
+                if (opts.onClose) {
+                    opts.onClose();
+                }
+            };
+        } else {
+            placeAt(node, position.x, position.y);
+            menuCloseHook = opts.onClose || null;
+        }
+        var first = node.querySelector(".menu-item:not(:disabled)");
+        if (first && opts.focus) {
+            first.focus();
+        }
+        return node;
+    }
+
+    function menuKeyNav(e) {
+        if (openMenus.length == 0) {
+            return false;
+        }
+        var node = openMenus[openMenus.length - 1];
+        var items = Array.prototype.slice.call(node.querySelectorAll(".menu-item:not(:disabled)"));
+        var idx = items.indexOf(document.activeElement);
+        if (e.key == "Escape") {
+            if (openMenus.length > 1) {
+                closeMenus(openMenus.length - 1);
+            } else {
+                closeMenus(0);
+            }
+            return true;
+        }
+        if (e.key == "ArrowDown" || e.key == "ArrowUp") {
+            var next = e.key == "ArrowDown" ? idx + 1 : idx - 1;
+            if (next < 0) { next = items.length - 1; }
+            if (next >= items.length) { next = 0; }
+            if (items[next]) { items[next].focus(); }
+            return true;
+        }
+        if (e.key == "ArrowRight" && idx >= 0 && items[idx].querySelector(".sub-caret")) {
+            items[idx].click();
+            setTimeout(function () {
+                var sub = openMenus[openMenus.length - 1];
+                var f = sub && sub.querySelector(".menu-item:not(:disabled)");
+                if (f) { f.focus(); }
+            }, 30);
+            return true;
+        }
+        if (e.key == "ArrowLeft" && openMenus.length > 1) {
+            closeMenus(openMenus.length - 1);
+            return true;
+        }
+        return false;
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Popovers (notifications, user menu)                                */
+    /* ------------------------------------------------------------------ */
+    var popover = null;
+
+    function closePopover() {
+        if (popover) {
+            var p = popover;
+            popover = null;
+            if (p.anchor) {
+                p.anchor.classList.remove("active");
+            }
+            p.node.remove();
+            if (p.onClose) {
+                p.onClose();
+            }
+        }
+    }
+
+    function openPopover(anchor, node, opts) {
+        opts = opts || {};
+        if (popover && popover.anchor === anchor) {
+            closePopover();
+            return null;
+        }
+        closeMenus(0);
+        closePopover();
+        document.getElementById("menuLayer").appendChild(node);
+        var r = anchor.getBoundingClientRect();
+        var w = node.offsetWidth;
+        var x = Math.min(window.innerWidth - w - 10, r.right - w + (opts.offsetX || 0));
+        node.style.left = Math.max(10, x) + "px";
+        node.style.top = (r.bottom + 8) + "px";
+        anchor.classList.add("active");
+        popover = { node: node, anchor: anchor, onClose: opts.onClose };
+        return node;
+    }
+
+    document.addEventListener("mousedown", function (e) {
+        if (openMenus.length > 0 && !e.target.closest(".menu")) {
+            closeMenus(0);
+        }
+        if (popover && !e.target.closest(".popover") && !popover.anchor.contains(e.target)) {
+            closePopover();
+        }
+    }, true);
+
+    window.addEventListener("resize", function () {
+        closeMenus(0);
+        closePopover();
+    });
+
+    window.addEventListener("blur", function () {
+        closeMenus(0);
+    });
+
+    /* ------------------------------------------------------------------ */
+    /*  Modals                                                             */
+    /* ------------------------------------------------------------------ */
+    var modalStack = [];
+
+    /*
+        modal({title, icon, iconHTML, iconClass, body, size, buttons, footLeft,
+               onOpen(node), onClose, dismissable})
+        buttons: [{label, cls, action(ctx) -> false keeps it open, id}]
+        Returns {node, close, body, setBusy(bool)}
+    */
+    function modal(opts) {
+        closeMenus(0);
+        closePopover();
+        var backdrop = el('<div class="modal-backdrop"></div>');
+        var box = el('<div class="modal ' + (opts.size || "") + '" role="dialog" aria-modal="true"></div>');
+        var head = '<div class="modal-head">' +
+            (opts.iconHTML ? '<div class="head-icon file">' + opts.iconHTML + '</div>' :
+                (opts.icon ? '<div class="head-icon ' + (opts.iconClass || "") + '">' + I(opts.icon) + '</div>' : "")) +
+            '<h3>' + esc(opts.title || "") + '</h3>' +
+            '<button class="icon-btn small" data-close aria-label="' + esc(T("common/close", "Close")) + '">' + I("x") + '</button></div>';
+        box.innerHTML = head + '<div class="modal-body scroll-y"></div>';
+        var body = box.querySelector(".modal-body");
+        if (typeof opts.body === "string") {
+            body.innerHTML = opts.body;
+        } else if (opts.body) {
+            body.appendChild(opts.body);
+        }
+        var ctx = { node: box, body: body };
+        if (opts.buttons && opts.buttons.length) {
+            var foot = el('<div class="modal-foot"></div>');
+            if (opts.footLeft) {
+                var left = el('<div class="left"></div>');
+                if (typeof opts.footLeft === "string") {
+                    left.innerHTML = opts.footLeft;
+                } else {
+                    left.appendChild(opts.footLeft);
+                }
+                foot.appendChild(left);
+            }
+            opts.buttons.forEach(function (b) {
+                var btn = el('<button class="btn ' + (b.cls || "") + '"' + (b.id ? ' id="' + b.id + '"' : "") + '>' + esc(b.label) + '</button>');
+                btn.addEventListener("click", function () {
+                    var r = b.action ? b.action(ctx) : undefined;
+                    if (r === false) {
+                        return;
+                    }
+                    Promise.resolve(r).then(function (keep) {
+                        if (keep !== false) {
+                            close();
+                        }
+                    });
+                });
+                foot.appendChild(btn);
+            });
+            box.appendChild(foot);
+        }
+        backdrop.appendChild(box);
+        document.getElementById("modalLayer").appendChild(backdrop);
+
+        var closed = false;
+        function close() {
+            if (closed) {
+                return;
+            }
+            closed = true;
+            backdrop.remove();
+            var i = modalStack.indexOf(entry);
+            if (i >= 0) {
+                modalStack.splice(i, 1);
+            }
+            if (opts.onClose) {
+                opts.onClose();
+            }
+        }
+        ctx.close = close;
+        ctx.setBusy = function (busy) {
+            box.querySelectorAll(".modal-foot .btn").forEach(function (b) { b.disabled = !!busy; });
+        };
+        box.querySelector("[data-close]").addEventListener("click", close);
+        backdrop.addEventListener("mousedown", function (e) {
+            if (e.target === backdrop && opts.dismissable !== false) {
+                close();
+            }
+        });
+        var entry = { close: close, opts: opts, ctx: ctx };
+        modalStack.push(entry);
+        if (opts.onOpen) {
+            opts.onOpen(ctx);
+        }
+        setTimeout(function () {
+            var f = box.querySelector("[autofocus]") || box.querySelector(".modal-foot .btn.primary, .modal-foot .btn.danger");
+            if (f) {
+                f.focus();
+                if (f.select && f.hasAttribute("data-select")) {
+                    var range = f.getAttribute("data-select").split(",");
+                    f.setSelectionRange(parseInt(range[0], 10), parseInt(range[1], 10));
+                }
+            }
+        }, 30);
+        return ctx;
+    }
+
+    function topModal() {
+        return modalStack.length ? modalStack[modalStack.length - 1] : null;
+    }
+
+    function confirm(opts) {
+        return new Promise(function (resolve) {
+            var answered = false;
+            modal({
+                title: opts.title,
+                icon: opts.icon || (opts.danger ? "alert" : "info"),
+                iconClass: opts.danger ? "danger" : "",
+                body: (opts.message ? '<p>' + esc(opts.message) + '</p>' : "") + (opts.html || ""),
+                buttons: [
+                    { label: opts.cancel || T("common/cancel", "Cancel"), action: function () { answered = true; resolve(false); } },
+                    { label: opts.ok || T("common/ok", "OK"), cls: opts.danger ? "danger" : "primary", action: function () { answered = true; resolve(true); } }
+                ],
+                onClose: function () {
+                    if (!answered) {
+                        resolve(false);
+                    }
+                }
+            });
+        });
+    }
+
+    function prompt(opts) {
+        return new Promise(function (resolve) {
+            var answered = false;
+            var value = opts.value || "";
+            var selEnd = value.length;
+            if (opts.selectStem) {
+                var dot = value.lastIndexOf(".");
+                if (dot > 0) {
+                    selEnd = dot;
+                }
+            }
+            var ctxRef = modal({
+                title: opts.title,
+                icon: opts.icon || "pencil",
+                body: '<div class="field"><label>' + esc(opts.label || "") + '</label>' +
+                    '<input class="input" autofocus data-select="0,' + selEnd + '" value="' + esc(value) + '" spellcheck="false" autocomplete="off"></div>',
+                buttons: [
+                    { label: T("common/cancel", "Cancel") },
+                    {
+                        label: opts.ok || T("common/ok", "OK"), cls: "primary", action: function (ctx) {
+                            var v = ctx.body.querySelector("input").value.trim();
+                            if (v == "") {
+                                return false;
+                            }
+                            answered = true;
+                            resolve(v);
+                        }
+                    }
+                ],
+                onOpen: function (ctx) {
+                    ctx.body.querySelector("input").addEventListener("keydown", function (e) {
+                        if (e.key == "Enter") {
+                            e.preventDefault();
+                            ctx.node.querySelector(".modal-foot .btn.primary").click();
+                        }
+                    });
+                },
+                onClose: function () {
+                    if (!answered) {
+                        resolve(null);
+                    }
+                }
+            });
+            return ctxRef;
+        });
+    }
+
+    /* ------------------------------------------------------------------ */
+    /*  Folder picker                                                      */
+    /* ------------------------------------------------------------------ */
+    /*
+        pickFolder({title, okLabel, start, sources}) -> Promise<vpath | null>
+        sources: paths being moved, so the picker refuses to drop a folder
+        into itself.
+    */
+    function pickFolder(opts) {
+        return new Promise(function (resolve) {
+            var current = null; //null = list of roots
+            var answered = false;
+            var sources = opts.sources || [];
+            var body = el('<div><div class="picker">' +
+                '<div class="picker-bar"><button class="icon-btn small" data-up title="' + esc(T("picker/up", "Up one level")) + '">' + I("arrowLeft") + '</button>' +
+                '<div class="picker-crumbs"></div>' +
+                '<button class="icon-btn small" data-mkdir title="' + esc(T("toolbar/newFolder", "New folder")) + '">' + I("folderPlus") + '</button></div>' +
+                '<div class="picker-list scroll-y"></div></div></div>');
+            var list = body.querySelector(".picker-list");
+            var crumbs = body.querySelector(".picker-crumbs");
+
+            function insideSources(path) {
+                for (var i = 0; i < sources.length; i++) {
+                    var s = sources[i].replace(/\/+$/, "") + "/";
+                    if ((path.replace(/\/+$/, "") + "/").indexOf(s) == 0) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+
+            function renderCrumbs() {
+                crumbs.innerHTML = "";
+                var rootBtn = el('<button>' + esc(T("picker/locations", "Locations")) + '</button>');
+                rootBtn.addEventListener("click", function () { go(null); });
+                crumbs.appendChild(rootBtn);
+                if (current == null) {
+                    return;
+                }
+                var parts = Drive.pathParts(current);
+                parts.forEach(function (p) {
+                    crumbs.insertAdjacentHTML("beforeend", I("chevronRight"));
+                    var b = el('<button title="' + esc(p.path) + '">' + esc(p.label) + '</button>');
+                    b.addEventListener("click", function () { go(p.path); });
+                    crumbs.appendChild(b);
+                });
+                crumbs.scrollLeft = crumbs.scrollWidth;
+            }
+
+            function go(path) {
+                current = path;
+                renderCrumbs();
+                body.querySelector("[data-up]").disabled = current == null;
+                body.querySelector("[data-mkdir]").disabled = current == null;
+                var okBtn = ctx.node.querySelector(".modal-foot .btn.primary");
+                if (okBtn) {
+                    okBtn.disabled = current == null || insideSources(current);
+                }
+                list.innerHTML = '<div class="state"><div class="spinner"></div></div>';
+                if (current == null) {
+                    list.innerHTML = "";
+                    Drive.state.roots.forEach(function (r) {
+                        var item = el('<button class="picker-item"><span class="ic-box">' + I(Drive.rootIcon(r)) + '</span><span>' +
+                            esc(Drive.rootLabel(r)) + '</span>' + I("chevronRight", { cls: "go" }) + '</button>');
+                        item.addEventListener("click", function () { go(r.path); });
+                        list.appendChild(item);
+                    });
+                    return;
+                }
+                var requested = current;
+                DriveAPI.list(current, false).then(function (items) {
+                    if (requested !== current) {
+                        return;
+                    }
+                    list.innerHTML = "";
+                    var folders = items.filter(function (i) { return i.isDir; });
+                    folders.sort(function (a, b) { return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); });
+                    if (folders.length == 0) {
+                        list.innerHTML = '<div class="state"><p>' + esc(T("picker/noFolders", "No folders here. You can still choose this folder.")) + '</p></div>';
+                    }
+                    folders.forEach(function (f) {
+                        var blocked = insideSources(f.path);
+                        var item = el('<button class="picker-item' + (blocked ? " disabled" : "") + '">' + DriveIcons.file(f.name, true) +
+                            '<span>' + esc(f.name) + '</span>' + I("chevronRight", { cls: "go" }) + '</button>');
+                        item.addEventListener("click", function () { go(f.path); });
+                        list.appendChild(item);
+                    });
+                }).catch(function (err) {
+                    list.innerHTML = '<div class="state"><p>' + esc(err.message) + '</p></div>';
+                });
+            }
+
+            var ctx = modal({
+                title: opts.title,
+                icon: opts.icon || "move",
+                size: "wide",
+                body: body,
+                buttons: [
+                    { label: T("common/cancel", "Cancel") },
+                    {
+                        label: opts.okLabel || T("common/select", "Select"), cls: "primary", action: function () {
+                            if (current == null) {
+                                return false;
+                            }
+                            answered = true;
+                            resolve(current);
+                        }
+                    }
+                ],
+                onClose: function () {
+                    if (!answered) {
+                        resolve(null);
+                    }
+                }
+            });
+
+            body.querySelector("[data-up]").addEventListener("click", function () {
+                if (current == null) {
+                    return;
+                }
+                var parts = Drive.pathParts(current);
+                go(parts.length > 1 ? parts[parts.length - 2].path : null);
+            });
+            body.querySelector("[data-mkdir]").addEventListener("click", function () {
+                if (current == null) {
+                    return;
+                }
+                var target = current;
+                prompt({ title: T("toolbar/newFolder", "New folder"), label: T("prompt/folderName", "Folder name"), value: T("prompt/untitledFolder", "Untitled folder"), icon: "folderPlus", ok: T("common/create", "Create") }).then(function (name) {
+                    if (!name) {
+                        return;
+                    }
+                    DriveAPI.mkdir(target, name).then(function () {
+                        go(DriveAPI.joinPath(target, name));
+                    }).catch(error);
+                });
+            });
+            go(opts.start || null);
+        });
+    }
+
+    global.DriveUI = {
+        I: I,
+        esc: esc,
+        el: el,
+        fmtSize: fmtSize,
+        fmtExact: fmtExact,
+        fmtDate: fmtDate,
+        fmtDay: fmtDay,
+        fmtRelative: fmtRelative,
+        toast: toast,
+        error: error,
+        menu: menu,
+        closeMenus: closeMenus,
+        menuOpen: function () { return openMenus.length > 0; },
+        menuKeyNav: menuKeyNav,
+        openPopover: openPopover,
+        closePopover: closePopover,
+        popoverOpen: function () { return popover != null; },
+        modal: modal,
+        topModal: topModal,
+        confirm: confirm,
+        prompt: prompt,
+        pickFolder: pickFolder
+    };
+})(window);

+ 3090 - 0
src/web/Drive/locale/drive.json

@@ -0,0 +1,3090 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "en-us": {
+            "name": "English",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "A cloud drive workspace for your ArozOS files",
+                "about/full": "Full (File Manager API)",
+                "about/host": "Host",
+                "about/mode": "File access",
+                "about/reduced": "Limited (AGI fallback)",
+                "about/version": "ArozOS",
+                "act/created": "Created",
+                "act/createdBy": "{name} created this item",
+                "act/expired": "A share link expired",
+                "act/modified": "Last modified",
+                "act/none": "No activity recorded yet.",
+                "act/note": "Built from the dates the storage, share links and version history keep.",
+                "act/opened": "You opened it in Drive",
+                "act/shared": "{who} shared it with {audience}",
+                "act/version": "Version saved ({size})",
+                "act/willExpire": "A share link expires",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "Anyone with the link",
+                "audience/anyonePw": "Anyone with the link and password",
+                "audience/groups": "Groups: {names}",
+                "audience/groupsTitle": "Invited groups",
+                "audience/pickGroups": "Specific groups",
+                "audience/pickUsers": "Specific people",
+                "audience/samegroup": "People in the same group",
+                "audience/signedin": "Signed-in users",
+                "audience/users": "Invited people",
+                "badge/linkOnly": "Opens through the share link",
+                "badge/shared": "Shared",
+                "banner/readonly": "This location is read only. You can open and download files but not change them.",
+                "banner/reduced": "Limited mode: your account cannot use the File Manager service, so deleting is permanent and some actions are unavailable.",
+                "banner/truncated": "Showing the newest results found so far. Your drive is large, so the scan stopped early.",
+                "cat/documents": "Documents",
+                "cat/music": "Music",
+                "cat/others": "Others",
+                "cat/photos": "Photos",
+                "cat/videos": "Videos",
+                "col/access": "Who has access",
+                "col/activity": "Last activity",
+                "col/deleted": "Deleted",
+                "col/expires": "Expires",
+                "col/location": "Location",
+                "col/modified": "Modified",
+                "col/name": "Name",
+                "col/origin": "Original location",
+                "col/owner": "Owner",
+                "col/size": "Size",
+                "col/type": "Type",
+                "common/cancel": "Cancel",
+                "common/close": "Close",
+                "common/create": "Create",
+                "common/delete": "Delete",
+                "common/done": "Done",
+                "common/loading": "Loading...",
+                "common/ok": "OK",
+                "common/retry": "Try again",
+                "common/save": "Save",
+                "common/select": "Select",
+                "conflict/keep": "Keep both",
+                "conflict/replace": "Replace",
+                "conflict/skip": "Skip",
+                "conflict/text": "{n} of {total} items have the same name as items in the destination.",
+                "conflict/title": "Some items already exist",
+                "copy/here": "Copy here",
+                "copy/title": "Copy {n} items",
+                "copy/titleOne": "Copy \"{name}\"",
+                "delete/ok": "Delete forever",
+                "delete/reducedText": "Deleted items cannot be restored in limited mode.",
+                "delete/text": "{n} items will be deleted forever. This cannot be undone.",
+                "delete/text1": "\"{name}\" will be deleted forever. This cannot be undone.",
+                "delete/title": "Delete forever?",
+                "details/activity": "Activity",
+                "details/by": "by {name}",
+                "details/created": "Created",
+                "details/currentFolder": "Current folder",
+                "details/excludesFolders": "Folders not counted",
+                "details/fileInfo": "File information",
+                "details/folderInfo": "Folder information",
+                "details/general": "General",
+                "details/getLink": "Get link",
+                "details/items": "Items",
+                "details/manySub": "{files} files, {folders} folders",
+                "details/moreLinks": "+{n} more links",
+                "details/notRecorded": "Not recorded by this storage",
+                "details/onlyYou": "Only people with access to this location",
+                "details/permissions": "Permissions",
+                "details/pick": "Select a file or folder to see its details.",
+                "details/quick": "Quick actions",
+                "details/sharing": "Sharing",
+                "details/summary": "Summary",
+                "details/title": "Details",
+                "details/unknown": "Unknown",
+                "details/versions": "Versions",
+                "drag/items": "{n} items",
+                "drop/upload": "Drop to upload to {name}",
+                "empty/category": "Nothing here yet",
+                "empty/categoryText": "Files of this kind in My Drive appear here.",
+                "empty/folder": "This folder is empty",
+                "empty/folderText": "Drop files here or use the New button to add something.",
+                "empty/needFM": "Not available in limited mode",
+                "empty/needFMText": "Your account does not have the File Manager permission this view needs.",
+                "empty/readonlyText": "This location is read only.",
+                "empty/recent": "No recent files",
+                "empty/recentText": "Files you open or edit show up here.",
+                "empty/search": "No matching files",
+                "empty/searchText": "Try another keyword, or search a different location.",
+                "empty/shared": "Nothing shared with you yet",
+                "empty/sharedByMe": "You have not shared anything yet",
+                "empty/sharedByMeText": "Select a file and press Share to create a link.",
+                "empty/sharedText": "Files and folders other people share with you appear here.",
+                "empty/starred": "No starred items",
+                "empty/starredText": "Star files and folders you use often to find them here.",
+                "empty/trash": "Trash is empty",
+                "empty/trashNeedFM": "Without the File Manager permission deleted files are removed immediately.",
+                "empty/trashText": "Items you delete are kept here until the trash is emptied.",
+                "err/badName": "Names cannot contain / or \\",
+                "err/copyFolder": "Use Copy to... for folders",
+                "err/intoItself": "A folder cannot be moved into itself",
+                "err/loadTitle": "This location cannot be opened",
+                "err/needFM": "This action needs the File Manager permission",
+                "err/network": "Cannot reach the server",
+                "err/noModule": "You do not have access to {name}",
+                "err/readonly": "This location is read only",
+                "err/unknown": "Something went wrong",
+                "err/zipNeedFM": "Folders can only be downloaded as a zip with the File Manager permission",
+                "foot/item": "{n} item",
+                "foot/items": "{n} items",
+                "foot/loading": "Loading...",
+                "help/about": "About Drive",
+                "help/shortcuts": "Keyboard shortcuts",
+                "host/back": "Back to Drive",
+                "host/close": "Close this app",
+                "host/newTab": "Open in new browser tab",
+                "host/openApps": "{n} apps open",
+                "host/return": "Return to open apps",
+                "host/untitled": "App",
+                "level/edit": "Can edit",
+                "level/editFolder": "Can edit (upload and change files)",
+                "level/view": "Can view",
+                "menu/actions": "Actions",
+                "menu/compress": "Compress to ZIP",
+                "menu/copy": "Copy",
+                "menu/copyLink": "Copy link",
+                "menu/copyPath": "Copy path",
+                "menu/copyTo": "Copy to...",
+                "menu/cut": "Cut",
+                "menu/delete": "Delete",
+                "menu/details": "View details",
+                "menu/downloadFolder": "Download as zip",
+                "menu/duplicate": "Make a copy",
+                "menu/extract": "Extract here",
+                "menu/folderDetails": "Folder details",
+                "menu/moveTo": "Move to...",
+                "menu/nothing": "Nothing here",
+                "menu/open": "Open",
+                "menu/openNewTab": "Open in new tab",
+                "menu/openWith": "Open with",
+                "menu/paste": "Paste",
+                "menu/pasteInto": "Paste into folder",
+                "menu/preview": "Preview",
+                "menu/refresh": "Refresh",
+                "menu/rename": "Rename",
+                "menu/selectAll": "Select all",
+                "menu/showHidden": "Show hidden files",
+                "menu/showLocation": "Show file location",
+                "menu/star": "Add to Starred",
+                "menu/trash": "Move to trash",
+                "menu/unstar": "Remove from Starred",
+                "menu/versions": "Version history",
+                "misc/pathCopied": "Path copied",
+                "move/here": "Move here",
+                "move/title": "Move {n} items",
+                "move/titleOne": "Move \"{name}\"",
+                "nav/categories": "Categories",
+                "nav/drives": "Storage",
+                "nav/myDrive": "My Drive",
+                "nav/readonly": "Read only",
+                "nav/recent": "Recent",
+                "nav/shared": "Shared with me",
+                "nav/starred": "Starred",
+                "nav/tmp": "Temporary",
+                "nav/trash": "Trash",
+                "new/fileTitle": "New {type}",
+                "new/untitled": "Untitled",
+                "notif/clear": "Clear all",
+                "notif/markRead": "Mark all read",
+                "notif/none": "You are all caught up",
+                "notif/open": "Open",
+                "notif/title": "Notification",
+                "open/always": "Always open .{ext} files with the selected app",
+                "open/choose": "Choose another app...",
+                "open/noApp": "No installed app registered .{ext} files. You can still preview, download, or try another app.",
+                "open/otherApps": "Other apps",
+                "open/preview": "Drive preview",
+                "open/suggested": "Suggested apps",
+                "open/withTitle": "Open \"{name}\" with",
+                "owner/me": "me",
+                "pager/next": "Next page",
+                "pager/prev": "Previous page",
+                "perm/access": "Access",
+                "perm/links": "Share links",
+                "perm/mode": "Storage permission bits",
+                "perm/noLinks": "There are no share links for this item.",
+                "perm/owner": "Owner",
+                "perm/readOnly": "Read only",
+                "perm/readWrite": "Read and write",
+                "perm/yourAccess": "Your access through {root}",
+                "picker/locations": "Locations",
+                "picker/noFolders": "No folders here. You can still choose this folder.",
+                "picker/up": "Up one level",
+                "prompt/fileName": "File name",
+                "prompt/folderName": "Folder name",
+                "prompt/newName": "New name",
+                "prompt/untitledFolder": "Untitled folder",
+                "pv/cannotPlay": "This video format cannot be played in the browser.",
+                "pv/cannotShow": "This image cannot be shown in the browser.",
+                "pv/next": "Next",
+                "pv/none": "No preview available for this file type.",
+                "pv/prev": "Previous",
+                "pv/rotate": "Rotate",
+                "pv/zoomIn": "Zoom in",
+                "pv/zoomOut": "Zoom out",
+                "recent/clear": "Clear open history",
+                "recent/modified": "Modified",
+                "recent/opened": "Opened",
+                "search/in": "Search in {where}",
+                "search/sub": "\"{q}\" in {where}",
+                "search/thisFolder": "This folder",
+                "search/title": "Search results",
+                "search/where": "Search in",
+                "sel/all": "Select all",
+                "sel/clear": "Clear selection",
+                "sel/count": "{n} selected",
+                "sel/select": "Select",
+                "set/account": "My account",
+                "set/accountGroup": "Account",
+                "set/all": "All",
+                "set/appearance": "Appearance",
+                "set/behaviour": "Opening and deleting",
+                "set/comfortable": "Comfortable",
+                "set/compact": "Compact",
+                "set/confirmTrash": "Ask before moving to trash",
+                "set/confirmTrashDesc": "Items in the trash can always be restored",
+                "set/density": "Density",
+                "set/densityDesc": "How much space each row takes",
+                "set/details": "Details",
+                "set/fileAccess": "File access",
+                "set/files": "Files and folders",
+                "set/foldersFirstDesc": "List folders before files",
+                "set/hiddenDesc": "Files and folders whose name starts with a dot",
+                "set/inDrive": "Inside Drive",
+                "set/inTab": "In a new browser tab",
+                "set/langAuto": "Follow the browser",
+                "set/language": "Language",
+                "set/languageDesc": "Also used by other ArozOS apps in this browser",
+                "set/open": "Open",
+                "set/openIn": "Open files",
+                "set/openInDesc": "Where an app opens when you open a file",
+                "set/order": "Order",
+                "set/pageSize": "Items per page",
+                "set/pageSizeDesc": "Very large folders stay quick with paging",
+                "set/reducedDesc": "Limited: ask your administrator for the File Manager permission to use the trash, version history and large uploads",
+                "set/saved": "Settings saved",
+                "set/sort": "Sort by",
+                "set/switch": "Switch",
+                "set/switchDesc": "Your account can also use another interface such as the desktop",
+                "set/systemDesc": "Password, language coverage, network, users and everything system wide",
+                "set/theme": "Theme",
+                "set/themeAuto": "Match device",
+                "set/themeDark": "Dark",
+                "set/themeDesc": "Apps opened inside Drive follow this theme when they support it",
+                "set/themeLight": "Light",
+                "set/thumbs": "Thumbnails",
+                "set/thumbsDesc": "Show previews of photos and videos in the list",
+                "set/used": "Used space",
+                "set/view": "Default layout",
+                "share/allowDownload": "Allow download",
+                "share/badDate": "Choose a date in the future",
+                "share/copied": "Link copied",
+                "share/create": "Create link",
+                "share/created": "Link created",
+                "share/createdCopied": "Link created and copied",
+                "share/date": "Expiry date",
+                "share/edit": "Edit link",
+                "share/editTitle": "Edit link",
+                "share/expired": "Expired",
+                "share/expires": "Expires {date}",
+                "share/expiry": "Link expires",
+                "share/hasPassword": "Password",
+                "share/in1": "In 1 day",
+                "share/in30": "In 30 days",
+                "share/in7": "In 7 days",
+                "share/keepPassword": "Leave empty to keep the current password",
+                "share/level": "Access",
+                "share/linkCopied": "Link copied. Anyone with the link can view.",
+                "share/manage": "Manage",
+                "share/manageSharing": "Manage sharing",
+                "share/needGroups": "Choose at least one group",
+                "share/needPassword": "Type a password or turn the option off",
+                "share/needUsers": "Choose at least one person",
+                "share/never": "Never",
+                "share/newLink": "Create a link",
+                "share/newPassword": "Password",
+                "share/noDownload": "No download",
+                "share/noList": "File list hidden",
+                "share/nobody": "Nobody to choose from",
+                "share/notOwner": "This item is shared by someone else. Only they can change its links.",
+                "share/notOwnerShort": "This item is shared by someone else",
+                "share/onDate": "On a date...",
+                "share/password": "Require a password",
+                "share/passwordHint": "Only people with the password can open the link",
+                "share/passwordSet": "A password is set. Type a new one to change it.",
+                "share/pickGroups": "Choose groups",
+                "share/pickUsers": "Choose people",
+                "share/private": "Not shared",
+                "share/privateText": "Only you and people with access to this location can open it.",
+                "share/qr": "QR code",
+                "share/reducedText": "In limited mode Drive can create one public link per item.",
+                "share/remove": "Remove link",
+                "share/removed": "Link removed",
+                "share/saved": "Link updated",
+                "share/showList": "Show the file list",
+                "share/stop": "Stop sharing",
+                "share/stopped": "Sharing stopped",
+                "share/title": "Share \"{name}\"",
+                "share/who": "Who can open it",
+                "shared/by": "Shared by {name}",
+                "shared/byMe": "Shared by me",
+                "shared/withMe": "Shared with me",
+                "shortcut/copy": "Copy / Cut / Paste",
+                "shortcut/delete": "Delete forever",
+                "shortcut/details": "Show or hide details",
+                "shortcut/download": "Download",
+                "shortcut/move": "Move the focus",
+                "shortcut/newFolder": "New folder",
+                "shortcut/open": "Open",
+                "shortcut/preview": "Preview",
+                "shortcut/rename": "Rename",
+                "shortcut/search": "Search",
+                "shortcut/select": "Select all",
+                "shortcut/trash": "Move to trash",
+                "shortcut/up": "Parent folder",
+                "shortcut/upload": "Upload files",
+                "sort/asc": "Ascending",
+                "sort/by": "Sort by",
+                "sort/desc": "Descending",
+                "sort/foldersFirst": "Folders on top",
+                "sort/order": "Order",
+                "srv/Access Denied": "Access Denied",
+                "srv/File already exists": "File already exists",
+                "srv/File not exists": "File not exists",
+                "srv/Filename contains illegal characters": "Filename contains illegal characters",
+                "srv/Folder not exists": "Folder not exists",
+                "srv/Given filename already exists": "Given filename already exists",
+                "srv/Given folder already exists": "Given folder already exists",
+                "srv/Permission Denied": "Permission Denied",
+                "srv/Storage Quota Full": "Storage Quota Full",
+                "srv/TRASH_QUOTA_EXCEEDED": "The trash is full",
+                "srv/This directory is Read Only": "This directory is Read Only",
+                "srv/User Storage Quota Exceeded": "User Storage Quota Exceeded",
+                "storage/title": "Storage",
+                "storage/usedOf": "{used} of {total} used",
+                "storage/usedUnlimited": "{used} used · no quota",
+                "sub/category": "Newest {what} in My Drive",
+                "sub/myDrive": "Personal storage space",
+                "sub/readonlyRoot": "{name} · read only",
+                "sub/recent": "Files you opened or changed in the last 30 days",
+                "sub/settings": "These settings only apply to Drive. System wide options live in System Settings.",
+                "sub/sharedByMe": "Links you have created",
+                "sub/sharedWithMe": "Files other people shared with you",
+                "sub/starred": "Quick access to the items you starred",
+                "sub/trash": "Items in the trash are removed for good after the retention period",
+                "time/days": "{n} d ago",
+                "time/hours": "{n} h ago",
+                "time/inDays": "In {n} d",
+                "time/inHours": "In {n} h",
+                "time/inMinutes": "In {n} min",
+                "time/justNow": "Just now",
+                "time/minutes": "{n} min ago",
+                "toast/clip": "{n} items copied to the clipboard",
+                "toast/clip1": "1 item copied to the clipboard",
+                "toast/copied": "Copied {n} items to {name}",
+                "toast/copied1": "Copied 1 item to {name}",
+                "toast/created": "Created {name}",
+                "toast/cut": "{n} items cut. Paste them in another folder.",
+                "toast/cut1": "1 item cut. Paste it in another folder.",
+                "toast/deleted": "Deleted {n} items",
+                "toast/deleted1": "Deleted 1 item",
+                "toast/duplicated": "Made {n} copies",
+                "toast/duplicated1": "Made a copy",
+                "toast/moved": "Moved {n} items to {name}",
+                "toast/moved1": "Moved 1 item to {name}",
+                "toast/open": "Open",
+                "toast/renamed": "Renamed to {name}",
+                "toast/restored": "Restored",
+                "toast/restoredN": "Restored {n} items",
+                "toast/restoredN1": "Restored 1 item",
+                "toast/show": "Show",
+                "toast/starred": "Added {n} items to Starred",
+                "toast/starred1": "Added to Starred",
+                "toast/trashEmptied": "Trash emptied",
+                "toast/trashed": "Moved {n} items to the trash",
+                "toast/trashed1": "Moved 1 item to the trash",
+                "toast/undo": "Undo",
+                "toast/unstarred": "Removed {n} items from Starred",
+                "toast/unstarred1": "Removed from Starred",
+                "toolbar/download": "Download",
+                "toolbar/more": "More",
+                "toolbar/move": "Move",
+                "toolbar/new": "New",
+                "toolbar/newFile": "New file",
+                "toolbar/newFolder": "New folder",
+                "toolbar/share": "Share",
+                "toolbar/upload": "Upload",
+                "toolbar/uploadFiles": "File upload",
+                "toolbar/uploadFolder": "Folder upload",
+                "top/help": "Help",
+                "top/menu": "Menu",
+                "top/notifications": "Notifications",
+                "top/search": "Search files, folders, or anything...",
+                "top/searchBtn": "Search",
+                "trash/confirmText": "{n} items will be moved to the trash.",
+                "trash/confirmText1": "\"{name}\" will be moved to the trash.",
+                "trash/confirmTitle": "Move to trash?",
+                "trash/deleteForever": "Delete forever",
+                "trash/empty": "Empty trash",
+                "trash/emptyText": "All items in the trash will be deleted forever. This cannot be undone.",
+                "trash/emptyTitle": "Empty trash?",
+                "trash/fullText": "The trash is full. Delete these items forever instead?",
+                "trash/fullTitle": "Cannot move to trash",
+                "trash/move": "Move to trash",
+                "trash/restore": "Restore",
+                "trash/unsupported": "This storage has no trash. Delete these items forever instead?",
+                "type/archive": "Archive",
+                "type/audio": "Audio",
+                "type/code": "Code",
+                "type/data": "Database",
+                "type/design": "Design",
+                "type/disk": "Disk image",
+                "type/doc": "Document",
+                "type/ebook": "E-book",
+                "type/exe": "Application",
+                "type/extFile": "{ext} file",
+                "type/file": "File",
+                "type/folder": "Folder",
+                "type/font": "Font",
+                "type/image": "Image",
+                "type/model": "3D model",
+                "type/pdf": "PDF",
+                "type/sheet": "Spreadsheet",
+                "type/shortcut": "Shortcut",
+                "type/slide": "Presentation",
+                "type/text": "Text",
+                "type/video": "Video",
+                "unit/bytes": "bytes",
+                "user/darkMode": "Switch to dark theme",
+                "user/driveSettings": "Drive settings",
+                "user/lightMode": "Switch to light theme",
+                "user/logout": "Sign out",
+                "user/logoutConfirm": "Sign out of ArozOS on this browser?",
+                "user/switchInterface": "Switch workspace",
+                "user/systemSettings": "System Settings",
+                "ver/current": "Current version",
+                "ver/delete": "Delete this version",
+                "ver/deleteAll": "Delete all versions",
+                "ver/deleteAllText": "Every earlier version of this file will be deleted.",
+                "ver/none": "No earlier versions. Versions are kept when a file is overwritten, or when you save one here.",
+                "ver/restore": "Restore this version",
+                "ver/restoreOk": "Restore",
+                "ver/restoreText": "The current content is replaced by this version.",
+                "ver/restored": "Version restored",
+                "ver/save": "Save a version now",
+                "ver/saved": "Version saved",
+                "view/details": "Show details",
+                "view/grid": "Grid view",
+                "view/list": "List view",
+                "xfer/cancelAll": "Cancel transfers",
+                "xfer/cancelAllText": "{n} transfers are still running. Cancel them?",
+                "xfer/cancelAllTitle": "Cancel transfers?",
+                "xfer/cancelled": "Cancelled",
+                "xfer/collapse": "Collapse",
+                "xfer/connectionLost": "Connection lost",
+                "xfer/copiedTo": "Copied to {name}",
+                "xfer/copyMade": "Copy created",
+                "xfer/copying": "Copying {name}",
+                "xfer/done": "Done",
+                "xfer/doneN": "{n} transfers complete",
+                "xfer/downloadAgain": "Download again",
+                "xfer/expand": "Expand",
+                "xfer/extracted": "Extracted",
+                "xfer/failed": "Failed",
+                "xfer/failedN": "{n} items failed",
+                "xfer/minutesLeft": "{n} min left",
+                "xfer/movedTo": "Moved to {name}",
+                "xfer/moving": "Moving {name}",
+                "xfer/preparing": "Preparing {name}",
+                "xfer/processing": "Finishing up",
+                "xfer/queued": "Waiting",
+                "xfer/secondsLeft": "{n} s left",
+                "xfer/showFolder": "Show in folder",
+                "xfer/tooBigReduced": "{n} files are larger than 24 MB and need the File Manager permission to upload",
+                "xfer/unzipping": "Extracting {name}",
+                "xfer/uploadFailed": "Upload failed (the file may be too big or the disk full)",
+                "xfer/uploadingN": "Uploading {n} items",
+                "xfer/working": "Working",
+                "xfer/workingN": "{n} transfers in progress",
+                "xfer/zipped": "Archive created",
+                "xfer/zipping": "Compressing {name}"
+            }
+        },
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "用來管理 ArozOS 檔案的雲端硬碟工作區",
+                "about/full": "完整(檔案管理員 API)",
+                "about/host": "主機",
+                "about/mode": "檔案存取",
+                "about/reduced": "受限(AGI 備援)",
+                "about/version": "ArozOS",
+                "act/created": "已建立",
+                "act/createdBy": "{name} 建立了此項目",
+                "act/expired": "分享連結已到期",
+                "act/modified": "最後修改",
+                "act/none": "目前沒有活動記錄。",
+                "act/note": "根據儲存空間、分享連結及版本記錄所保存的日期整理。",
+                "act/opened": "你在 Drive 中開啟了它",
+                "act/shared": "{who} 將它分享給{audience}",
+                "act/version": "已儲存版本({size})",
+                "act/willExpire": "分享連結將到期",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "任何擁有連結的人",
+                "audience/anyonePw": "任何擁有連結和密碼的人",
+                "audience/groups": "群組:{names}",
+                "audience/groupsTitle": "受邀的群組",
+                "audience/pickGroups": "特定群組",
+                "audience/pickUsers": "特定使用者",
+                "audience/samegroup": "同一群組的使用者",
+                "audience/signedin": "已登入的使用者",
+                "audience/users": "受邀的使用者",
+                "badge/linkOnly": "透過分享連結開啟",
+                "badge/shared": "已分享",
+                "banner/readonly": "此位置為唯讀,你可以開啟及下載檔案,但無法修改。",
+                "banner/reduced": "受限模式:你的帳戶無法使用檔案管理員服務,因此刪除會是永久的,部分功能也無法使用。",
+                "banner/truncated": "目前顯示到目前為止找到的最新結果。你的雲端硬碟很大,因此掃描提早結束。",
+                "cat/documents": "文件",
+                "cat/music": "音樂",
+                "cat/others": "其他",
+                "cat/photos": "相片",
+                "cat/videos": "影片",
+                "col/access": "存取對象",
+                "col/activity": "最近活動",
+                "col/deleted": "刪除時間",
+                "col/expires": "到期日",
+                "col/location": "位置",
+                "col/modified": "修改時間",
+                "col/name": "名稱",
+                "col/origin": "原始位置",
+                "col/owner": "擁有者",
+                "col/size": "大小",
+                "col/type": "類型",
+                "common/cancel": "取消",
+                "common/close": "關閉",
+                "common/create": "建立",
+                "common/delete": "刪除",
+                "common/done": "完成",
+                "common/loading": "載入中...",
+                "common/ok": "確定",
+                "common/retry": "再試一次",
+                "common/save": "儲存",
+                "common/select": "選擇",
+                "conflict/keep": "兩者都保留",
+                "conflict/replace": "取代",
+                "conflict/skip": "略過",
+                "conflict/text": "{total} 個項目中有 {n} 個與目的地的項目同名。",
+                "conflict/title": "部分項目已存在",
+                "copy/here": "複製到這裡",
+                "copy/title": "複製 {n} 個項目",
+                "copy/titleOne": "複製「{name}」",
+                "delete/ok": "永久刪除",
+                "delete/reducedText": "在受限模式下,刪除的項目將無法還原。",
+                "delete/text": "{n} 個項目將被永久刪除,此動作無法復原。",
+                "delete/text1": "「{name}」將被永久刪除,此動作無法復原。",
+                "delete/title": "要永久刪除嗎?",
+                "details/activity": "活動",
+                "details/by": "由 {name}",
+                "details/created": "建立時間",
+                "details/currentFolder": "目前的資料夾",
+                "details/excludesFolders": "不含資料夾",
+                "details/fileInfo": "檔案資訊",
+                "details/folderInfo": "資料夾資訊",
+                "details/general": "一般",
+                "details/getLink": "取得連結",
+                "details/items": "項目",
+                "details/manySub": "{files} 個檔案,{folders} 個資料夾",
+                "details/moreLinks": "另有 {n} 個連結",
+                "details/notRecorded": "此儲存空間未記錄",
+                "details/onlyYou": "只有可存取此位置的人",
+                "details/permissions": "權限",
+                "details/pick": "選取檔案或資料夾即可查看詳細資料。",
+                "details/quick": "快速動作",
+                "details/sharing": "分享",
+                "details/summary": "摘要",
+                "details/title": "詳細資料",
+                "details/unknown": "未知",
+                "details/versions": "版本",
+                "drag/items": "{n} 個項目",
+                "drop/upload": "放開即可上傳到 {name}",
+                "empty/category": "這裡還沒有項目",
+                "empty/categoryText": "我的雲端硬碟中此類型的檔案會顯示在這裡。",
+                "empty/folder": "此資料夾是空的",
+                "empty/folderText": "將檔案拖放到這裡,或使用「新增」按鈕加入內容。",
+                "empty/needFM": "受限模式下無法使用",
+                "empty/needFMText": "你的帳戶沒有此檢視所需的「檔案管理員」權限。",
+                "empty/readonlyText": "此位置為唯讀。",
+                "empty/recent": "沒有最近使用的檔案",
+                "empty/recentText": "你開啟或編輯的檔案會顯示在這裡。",
+                "empty/search": "沒有符合的檔案",
+                "empty/searchText": "請嘗試其他關鍵字,或搜尋其他位置。",
+                "empty/shared": "還沒有人與你共用項目",
+                "empty/sharedByMe": "你還沒有分享任何項目",
+                "empty/sharedByMeText": "選取檔案並按下「分享」即可建立連結。",
+                "empty/sharedText": "其他人與你共用的檔案和資料夾會顯示在這裡。",
+                "empty/starred": "沒有已加星號的項目",
+                "empty/starredText": "為常用的檔案和資料夾加上星號,就能在這裡找到它們。",
+                "empty/trash": "垃圾桶是空的",
+                "empty/trashNeedFM": "沒有「檔案管理員」權限時,刪除的檔案會立即移除。",
+                "empty/trashText": "刪除的項目會保留在這裡,直到垃圾桶被清空。",
+                "err/badName": "名稱不可包含 / 或 \\",
+                "err/copyFolder": "資料夾請使用「複製到...」",
+                "err/intoItself": "資料夾無法移動到它自己裡面",
+                "err/loadTitle": "無法開啟此位置",
+                "err/needFM": "此動作需要「檔案管理員」權限",
+                "err/network": "無法連線到伺服器",
+                "err/noModule": "你沒有使用 {name} 的權限",
+                "err/readonly": "此位置為唯讀",
+                "err/unknown": "發生錯誤",
+                "err/zipNeedFM": "需要「檔案管理員」權限才能將資料夾打包成 ZIP 下載",
+                "foot/item": "{n} 個項目",
+                "foot/items": "{n} 個項目",
+                "foot/loading": "載入中...",
+                "help/about": "關於 Drive",
+                "help/shortcuts": "鍵盤快速鍵",
+                "host/back": "返回 Drive",
+                "host/close": "關閉此應用程式",
+                "host/newTab": "在新的瀏覽器分頁中開啟",
+                "host/openApps": "已開啟 {n} 個應用程式",
+                "host/return": "返回已開啟的應用程式",
+                "host/untitled": "應用程式",
+                "level/edit": "可編輯",
+                "level/editFolder": "可編輯(上傳及修改檔案)",
+                "level/view": "可檢視",
+                "menu/actions": "動作",
+                "menu/compress": "壓縮成 ZIP",
+                "menu/copy": "複製",
+                "menu/copyLink": "複製連結",
+                "menu/copyPath": "複製路徑",
+                "menu/copyTo": "複製到...",
+                "menu/cut": "剪下",
+                "menu/delete": "刪除",
+                "menu/details": "檢視詳細資料",
+                "menu/downloadFolder": "下載為 ZIP",
+                "menu/duplicate": "建立副本",
+                "menu/extract": "解壓縮到這裡",
+                "menu/folderDetails": "資料夾詳細資料",
+                "menu/moveTo": "移動到...",
+                "menu/nothing": "沒有內容",
+                "menu/open": "開啟",
+                "menu/openNewTab": "在新分頁中開啟",
+                "menu/openWith": "開啟方式",
+                "menu/paste": "貼上",
+                "menu/pasteInto": "貼到資料夾中",
+                "menu/preview": "預覽",
+                "menu/refresh": "重新整理",
+                "menu/rename": "重新命名",
+                "menu/selectAll": "全選",
+                "menu/showHidden": "顯示隱藏檔案",
+                "menu/showLocation": "顯示檔案位置",
+                "menu/star": "加上星號",
+                "menu/trash": "移至垃圾桶",
+                "menu/unstar": "從已加星號中移除",
+                "menu/versions": "版本記錄",
+                "misc/pathCopied": "已複製路徑",
+                "move/here": "移到這裡",
+                "move/title": "移動 {n} 個項目",
+                "move/titleOne": "移動「{name}」",
+                "nav/categories": "分類",
+                "nav/drives": "儲存空間",
+                "nav/myDrive": "我的雲端硬碟",
+                "nav/readonly": "唯讀",
+                "nav/recent": "最近使用",
+                "nav/shared": "與我共用",
+                "nav/starred": "已加星號",
+                "nav/tmp": "暫存空間",
+                "nav/trash": "垃圾桶",
+                "new/fileTitle": "新增{type}",
+                "new/untitled": "未命名",
+                "notif/clear": "全部清除",
+                "notif/markRead": "全部標為已讀",
+                "notif/none": "目前沒有新通知",
+                "notif/open": "開啟",
+                "notif/title": "通知",
+                "open/always": "一律使用所選應用程式開啟 .{ext} 檔案",
+                "open/choose": "選擇其他應用程式...",
+                "open/noApp": "沒有已安裝的應用程式登記開啟 .{ext} 檔案。你仍然可以預覽、下載,或嘗試其他應用程式。",
+                "open/otherApps": "其他應用程式",
+                "open/preview": "Drive 預覽",
+                "open/suggested": "建議的應用程式",
+                "open/withTitle": "選擇開啟「{name}」的方式",
+                "owner/me": "我",
+                "pager/next": "下一頁",
+                "pager/prev": "上一頁",
+                "perm/access": "存取權",
+                "perm/links": "分享連結",
+                "perm/mode": "儲存空間權限位元",
+                "perm/noLinks": "此項目沒有分享連結。",
+                "perm/owner": "擁有者",
+                "perm/readOnly": "唯讀",
+                "perm/readWrite": "可讀寫",
+                "perm/yourAccess": "你透過{root}的存取權",
+                "picker/locations": "位置",
+                "picker/noFolders": "這裡沒有資料夾,你仍然可以選擇此資料夾。",
+                "picker/up": "上一層",
+                "prompt/fileName": "檔案名稱",
+                "prompt/folderName": "資料夾名稱",
+                "prompt/newName": "新名稱",
+                "prompt/untitledFolder": "未命名資料夾",
+                "pv/cannotPlay": "瀏覽器無法播放此影片格式。",
+                "pv/cannotShow": "瀏覽器無法顯示此圖片。",
+                "pv/next": "下一個",
+                "pv/none": "此檔案類型無法預覽。",
+                "pv/prev": "上一個",
+                "pv/rotate": "旋轉",
+                "pv/zoomIn": "放大",
+                "pv/zoomOut": "縮小",
+                "recent/clear": "清除開啟記錄",
+                "recent/modified": "已修改",
+                "recent/opened": "已開啟",
+                "search/in": "在{where}中搜尋",
+                "search/sub": "在{where}中搜尋「{q}」",
+                "search/thisFolder": "此資料夾",
+                "search/title": "搜尋結果",
+                "search/where": "搜尋範圍",
+                "sel/all": "全選",
+                "sel/clear": "清除選取",
+                "sel/count": "已選取 {n} 個",
+                "sel/select": "選取",
+                "set/account": "我的帳戶",
+                "set/accountGroup": "帳戶",
+                "set/all": "全部",
+                "set/appearance": "外觀",
+                "set/behaviour": "開啟與刪除",
+                "set/comfortable": "舒適",
+                "set/compact": "緊湊",
+                "set/confirmTrash": "移至垃圾桶前先詢問",
+                "set/confirmTrashDesc": "垃圾桶中的項目隨時都可以還原",
+                "set/density": "顯示密度",
+                "set/densityDesc": "每一列所佔的空間",
+                "set/details": "詳細資料",
+                "set/fileAccess": "檔案存取",
+                "set/files": "檔案與資料夾",
+                "set/foldersFirstDesc": "先列出資料夾,再列出檔案",
+                "set/hiddenDesc": "名稱以點開頭的檔案和資料夾",
+                "set/inDrive": "在 Drive 內",
+                "set/inTab": "在新的瀏覽器分頁",
+                "set/langAuto": "跟隨瀏覽器",
+                "set/language": "語言",
+                "set/languageDesc": "此瀏覽器中的其他 ArozOS 應用程式也會使用",
+                "set/open": "開啟",
+                "set/openIn": "開啟檔案",
+                "set/openInDesc": "開啟檔案時應用程式出現的位置",
+                "set/order": "順序",
+                "set/pageSize": "每頁項目數",
+                "set/pageSizeDesc": "分頁可讓大型資料夾維持流暢",
+                "set/reducedDesc": "受限:請向管理員申請「檔案管理員」權限,以使用垃圾桶、版本記錄和大型檔案上傳",
+                "set/saved": "已儲存設定",
+                "set/sort": "排序依據",
+                "set/switch": "切換",
+                "set/switchDesc": "你的帳戶也可以使用其他介面,例如桌面",
+                "set/systemDesc": "密碼、網路、使用者以及所有系統層級的設定",
+                "set/theme": "主題",
+                "set/themeAuto": "跟隨裝置",
+                "set/themeDark": "深色",
+                "set/themeDesc": "在 Drive 中開啟的應用程式若有支援,也會跟隨此主題",
+                "set/themeLight": "淺色",
+                "set/thumbs": "縮圖",
+                "set/thumbsDesc": "在清單中顯示相片和影片的預覽",
+                "set/used": "已使用空間",
+                "set/view": "預設版面",
+                "share/allowDownload": "允許下載",
+                "share/badDate": "請選擇未來的日期",
+                "share/copied": "已複製連結",
+                "share/create": "建立連結",
+                "share/created": "已建立連結",
+                "share/createdCopied": "已建立並複製連結",
+                "share/date": "到期日",
+                "share/edit": "編輯連結",
+                "share/editTitle": "編輯連結",
+                "share/expired": "已到期",
+                "share/expires": "{date} 到期",
+                "share/expiry": "連結到期",
+                "share/hasPassword": "密碼",
+                "share/in1": "1 天後",
+                "share/in30": "30 天後",
+                "share/in7": "7 天後",
+                "share/keepPassword": "留空即保留目前的密碼",
+                "share/level": "存取權",
+                "share/linkCopied": "已複製連結,任何擁有連結的人都可以檢視。",
+                "share/manage": "管理",
+                "share/manageSharing": "管理分享",
+                "share/needGroups": "請至少選擇一個群組",
+                "share/needPassword": "請輸入密碼,或關閉此選項",
+                "share/needUsers": "請至少選擇一位使用者",
+                "share/never": "永不",
+                "share/newLink": "建立連結",
+                "share/newPassword": "密碼",
+                "share/noDownload": "禁止下載",
+                "share/noList": "隱藏檔案清單",
+                "share/nobody": "沒有可選擇的對象",
+                "share/notOwner": "此項目由其他人分享,只有對方能修改連結。",
+                "share/notOwnerShort": "此項目由其他人分享",
+                "share/onDate": "指定日期...",
+                "share/password": "需要密碼",
+                "share/passwordHint": "只有知道密碼的人才能開啟連結",
+                "share/passwordSet": "已設定密碼,輸入新密碼即可變更。",
+                "share/pickGroups": "選擇群組",
+                "share/pickUsers": "選擇使用者",
+                "share/private": "未分享",
+                "share/privateText": "只有你和可存取此位置的人能開啟。",
+                "share/qr": "QR 碼",
+                "share/reducedText": "在受限模式下,每個項目只能建立一個公開連結。",
+                "share/remove": "移除連結",
+                "share/removed": "已移除連結",
+                "share/saved": "已更新連結",
+                "share/showList": "顯示檔案清單",
+                "share/stop": "停止分享",
+                "share/stopped": "已停止分享",
+                "share/title": "分享「{name}」",
+                "share/who": "誰可以開啟",
+                "shared/by": "由 {name} 分享",
+                "shared/byMe": "我分享的",
+                "shared/withMe": "與我共用",
+                "shortcut/copy": "複製 / 剪下 / 貼上",
+                "shortcut/delete": "永久刪除",
+                "shortcut/details": "顯示或隱藏詳細資料",
+                "shortcut/download": "下載",
+                "shortcut/move": "移動焦點",
+                "shortcut/newFolder": "新增資料夾",
+                "shortcut/open": "開啟",
+                "shortcut/preview": "預覽",
+                "shortcut/rename": "重新命名",
+                "shortcut/search": "搜尋",
+                "shortcut/select": "全選",
+                "shortcut/trash": "移至垃圾桶",
+                "shortcut/up": "上一層資料夾",
+                "shortcut/upload": "上傳檔案",
+                "sort/asc": "遞增",
+                "sort/by": "排序依據",
+                "sort/desc": "遞減",
+                "sort/foldersFirst": "資料夾置頂",
+                "sort/order": "順序",
+                "srv/Access Denied": "存取被拒",
+                "srv/File already exists": "檔案已存在",
+                "srv/File not exists": "檔案不存在",
+                "srv/Filename contains illegal characters": "檔名包含不允許的字元",
+                "srv/Folder not exists": "資料夾不存在",
+                "srv/Given filename already exists": "檔名已存在",
+                "srv/Given folder already exists": "資料夾已存在",
+                "srv/Permission Denied": "權限不足",
+                "srv/Storage Quota Full": "儲存配額已滿",
+                "srv/TRASH_QUOTA_EXCEEDED": "垃圾桶已滿",
+                "srv/This directory is Read Only": "此目錄為唯讀",
+                "srv/User Storage Quota Exceeded": "已超過使用者儲存配額",
+                "storage/title": "儲存空間",
+                "storage/usedOf": "已使用 {used},共 {total}",
+                "storage/usedUnlimited": "已使用 {used} · 無配額限制",
+                "sub/category": "我的雲端硬碟中最新的{what}",
+                "sub/myDrive": "個人儲存空間",
+                "sub/readonlyRoot": "{name} · 唯讀",
+                "sub/recent": "你在過去 30 天內開啟或修改的檔案",
+                "sub/settings": "這些設定只適用於 Drive。系統層級的選項請到系統設定中調整。",
+                "sub/sharedByMe": "你建立的分享連結",
+                "sub/sharedWithMe": "其他人分享給你的檔案",
+                "sub/starred": "快速存取已加星號的項目",
+                "sub/trash": "垃圾桶中的項目會在保留期限後永久移除",
+                "time/days": "{n} 天前",
+                "time/hours": "{n} 小時前",
+                "time/inDays": "{n} 天後",
+                "time/inHours": "{n} 小時後",
+                "time/inMinutes": "{n} 分鐘後",
+                "time/justNow": "剛剛",
+                "time/minutes": "{n} 分鐘前",
+                "toast/clip": "已將 {n} 個項目複製到剪貼簿",
+                "toast/clip1": "已將 1 個項目複製到剪貼簿",
+                "toast/copied": "已將 {n} 個項目複製到 {name}",
+                "toast/copied1": "已將 1 個項目複製到 {name}",
+                "toast/created": "已建立 {name}",
+                "toast/cut": "已剪下 {n} 個項目,請到其他資料夾貼上。",
+                "toast/cut1": "已剪下 1 個項目,請到其他資料夾貼上。",
+                "toast/deleted": "已刪除 {n} 個項目",
+                "toast/deleted1": "已刪除 1 個項目",
+                "toast/duplicated": "已建立 {n} 個副本",
+                "toast/duplicated1": "已建立副本",
+                "toast/moved": "已將 {n} 個項目移至 {name}",
+                "toast/moved1": "已將 1 個項目移至 {name}",
+                "toast/open": "開啟",
+                "toast/renamed": "已重新命名為 {name}",
+                "toast/restored": "已還原",
+                "toast/restoredN": "已還原 {n} 個項目",
+                "toast/restoredN1": "已還原 1 個項目",
+                "toast/show": "顯示",
+                "toast/starred": "已為 {n} 個項目加上星號",
+                "toast/starred1": "已加上星號",
+                "toast/trashEmptied": "已清空垃圾桶",
+                "toast/trashed": "已將 {n} 個項目移至垃圾桶",
+                "toast/trashed1": "已將 1 個項目移至垃圾桶",
+                "toast/undo": "復原",
+                "toast/unstarred": "已移除 {n} 個項目的星號",
+                "toast/unstarred1": "已移除星號",
+                "toolbar/download": "下載",
+                "toolbar/more": "更多",
+                "toolbar/move": "移動",
+                "toolbar/new": "新增",
+                "toolbar/newFile": "新增檔案",
+                "toolbar/newFolder": "新增資料夾",
+                "toolbar/share": "分享",
+                "toolbar/upload": "上傳",
+                "toolbar/uploadFiles": "上傳檔案",
+                "toolbar/uploadFolder": "上傳資料夾",
+                "top/help": "說明",
+                "top/menu": "選單",
+                "top/notifications": "通知",
+                "top/search": "搜尋檔案、資料夾或任何內容...",
+                "top/searchBtn": "搜尋",
+                "trash/confirmText": "{n} 個項目將會移至垃圾桶。",
+                "trash/confirmText1": "「{name}」將會移至垃圾桶。",
+                "trash/confirmTitle": "要移至垃圾桶嗎?",
+                "trash/deleteForever": "永久刪除",
+                "trash/empty": "清空垃圾桶",
+                "trash/emptyText": "垃圾桶中的所有項目都會被永久刪除,此動作無法復原。",
+                "trash/emptyTitle": "要清空垃圾桶嗎?",
+                "trash/fullText": "垃圾桶已滿。要改為永久刪除這些項目嗎?",
+                "trash/fullTitle": "無法移至垃圾桶",
+                "trash/move": "移至垃圾桶",
+                "trash/restore": "還原",
+                "trash/unsupported": "此儲存空間沒有垃圾桶。要改為永久刪除這些項目嗎?",
+                "type/archive": "壓縮檔",
+                "type/audio": "音訊",
+                "type/code": "程式碼",
+                "type/data": "資料庫",
+                "type/design": "設計檔",
+                "type/disk": "磁碟映像",
+                "type/doc": "文件",
+                "type/ebook": "電子書",
+                "type/exe": "應用程式",
+                "type/extFile": "{ext} 檔案",
+                "type/file": "檔案",
+                "type/folder": "資料夾",
+                "type/font": "字型",
+                "type/image": "圖片",
+                "type/model": "3D 模型",
+                "type/pdf": "PDF",
+                "type/sheet": "試算表",
+                "type/shortcut": "捷徑",
+                "type/slide": "簡報",
+                "type/text": "文字",
+                "type/video": "影片",
+                "unit/bytes": "位元組",
+                "user/darkMode": "切換為深色主題",
+                "user/driveSettings": "Drive 設定",
+                "user/lightMode": "切換為淺色主題",
+                "user/logout": "登出",
+                "user/logoutConfirm": "要在此瀏覽器登出 ArozOS 嗎?",
+                "user/switchInterface": "切換工作區",
+                "user/systemSettings": "系統設定",
+                "ver/current": "目前版本",
+                "ver/delete": "刪除此版本",
+                "ver/deleteAll": "刪除所有版本",
+                "ver/deleteAllText": "此檔案所有較早的版本都將被刪除。",
+                "ver/none": "沒有較早的版本。檔案被覆寫時,或你在這裡儲存版本時,都會保留版本。",
+                "ver/restore": "還原此版本",
+                "ver/restoreOk": "還原",
+                "ver/restoreText": "目前的內容將被此版本取代。",
+                "ver/restored": "已還原版本",
+                "ver/save": "立即儲存一個版本",
+                "ver/saved": "已儲存版本",
+                "view/details": "顯示詳細資料",
+                "view/grid": "格狀檢視",
+                "view/list": "清單檢視",
+                "xfer/cancelAll": "取消傳輸",
+                "xfer/cancelAllText": "仍有 {n} 個傳輸正在進行,要取消嗎?",
+                "xfer/cancelAllTitle": "要取消傳輸嗎?",
+                "xfer/cancelled": "已取消",
+                "xfer/collapse": "收合",
+                "xfer/connectionLost": "連線中斷",
+                "xfer/copiedTo": "已複製到 {name}",
+                "xfer/copyMade": "已建立副本",
+                "xfer/copying": "正在複製 {name}",
+                "xfer/done": "完成",
+                "xfer/doneN": "已完成 {n} 個傳輸",
+                "xfer/downloadAgain": "再次下載",
+                "xfer/expand": "展開",
+                "xfer/extracted": "已解壓縮",
+                "xfer/failed": "失敗",
+                "xfer/failedN": "{n} 個項目失敗",
+                "xfer/minutesLeft": "剩餘 {n} 分鐘",
+                "xfer/movedTo": "已移至 {name}",
+                "xfer/moving": "正在移動 {name}",
+                "xfer/preparing": "正在準備 {name}",
+                "xfer/processing": "即將完成",
+                "xfer/queued": "等待中",
+                "xfer/secondsLeft": "剩餘 {n} 秒",
+                "xfer/showFolder": "在資料夾中顯示",
+                "xfer/tooBigReduced": "有 {n} 個檔案超過 24 MB,需要「檔案管理員」權限才能上傳",
+                "xfer/unzipping": "正在解壓縮 {name}",
+                "xfer/uploadFailed": "上傳失敗(檔案可能太大或磁碟已滿)",
+                "xfer/uploadingN": "正在上傳 {n} 個項目",
+                "xfer/working": "處理中",
+                "xfer/workingN": "{n} 個傳輸進行中",
+                "xfer/zipped": "已建立壓縮檔",
+                "xfer/zipping": "正在壓縮 {name}"
+            }
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "用來管理 ArozOS 檔案的雲端硬碟工作區",
+                "about/full": "完整(檔案管理員 API)",
+                "about/host": "主機",
+                "about/mode": "檔案存取",
+                "about/reduced": "受限(AGI 備援)",
+                "about/version": "ArozOS",
+                "act/created": "已建立",
+                "act/createdBy": "{name} 建立了此項目",
+                "act/expired": "分享連結已到期",
+                "act/modified": "最後修改",
+                "act/none": "目前沒有活動記錄。",
+                "act/note": "根據儲存空間、分享連結及版本記錄所保存的日期整理。",
+                "act/opened": "你在 Drive 中開啟了它",
+                "act/shared": "{who} 將它分享給{audience}",
+                "act/version": "已儲存版本({size})",
+                "act/willExpire": "分享連結將到期",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "任何擁有連結的人",
+                "audience/anyonePw": "任何擁有連結和密碼的人",
+                "audience/groups": "群組:{names}",
+                "audience/groupsTitle": "受邀的群組",
+                "audience/pickGroups": "特定群組",
+                "audience/pickUsers": "特定使用者",
+                "audience/samegroup": "同一群組的使用者",
+                "audience/signedin": "已登入的使用者",
+                "audience/users": "受邀的使用者",
+                "badge/linkOnly": "透過分享連結開啟",
+                "badge/shared": "已分享",
+                "banner/readonly": "此位置為唯讀,你可以開啟及下載檔案,但無法修改。",
+                "banner/reduced": "受限模式:你的帳戶無法使用檔案管理員服務,因此刪除會是永久的,部分功能也無法使用。",
+                "banner/truncated": "目前顯示到目前為止找到的最新結果。你的雲端硬碟很大,因此掃描提早結束。",
+                "cat/documents": "文件",
+                "cat/music": "音樂",
+                "cat/others": "其他",
+                "cat/photos": "相片",
+                "cat/videos": "影片",
+                "col/access": "存取對象",
+                "col/activity": "最近活動",
+                "col/deleted": "刪除時間",
+                "col/expires": "到期日",
+                "col/location": "位置",
+                "col/modified": "修改時間",
+                "col/name": "名稱",
+                "col/origin": "原始位置",
+                "col/owner": "擁有者",
+                "col/size": "大小",
+                "col/type": "類型",
+                "common/cancel": "取消",
+                "common/close": "關閉",
+                "common/create": "建立",
+                "common/delete": "刪除",
+                "common/done": "完成",
+                "common/loading": "載入中...",
+                "common/ok": "確定",
+                "common/retry": "再試一次",
+                "common/save": "儲存",
+                "common/select": "選擇",
+                "conflict/keep": "兩者都保留",
+                "conflict/replace": "取代",
+                "conflict/skip": "略過",
+                "conflict/text": "{total} 個項目中有 {n} 個與目的地的項目同名。",
+                "conflict/title": "部分項目已存在",
+                "copy/here": "複製到這裡",
+                "copy/title": "複製 {n} 個項目",
+                "copy/titleOne": "複製「{name}」",
+                "delete/ok": "永久刪除",
+                "delete/reducedText": "在受限模式下,刪除的項目將無法還原。",
+                "delete/text": "{n} 個項目將被永久刪除,此動作無法復原。",
+                "delete/text1": "「{name}」將被永久刪除,此動作無法復原。",
+                "delete/title": "要永久刪除嗎?",
+                "details/activity": "活動",
+                "details/by": "由 {name}",
+                "details/created": "建立時間",
+                "details/currentFolder": "目前的資料夾",
+                "details/excludesFolders": "不含資料夾",
+                "details/fileInfo": "檔案資訊",
+                "details/folderInfo": "資料夾資訊",
+                "details/general": "一般",
+                "details/getLink": "取得連結",
+                "details/items": "項目",
+                "details/manySub": "{files} 個檔案,{folders} 個資料夾",
+                "details/moreLinks": "另有 {n} 個連結",
+                "details/notRecorded": "此儲存空間未記錄",
+                "details/onlyYou": "只有可存取此位置的人",
+                "details/permissions": "權限",
+                "details/pick": "選取檔案或資料夾即可查看詳細資料。",
+                "details/quick": "快速動作",
+                "details/sharing": "分享",
+                "details/summary": "摘要",
+                "details/title": "詳細資料",
+                "details/unknown": "未知",
+                "details/versions": "版本",
+                "drag/items": "{n} 個項目",
+                "drop/upload": "放開即可上傳到 {name}",
+                "empty/category": "這裡還沒有項目",
+                "empty/categoryText": "我的雲端硬碟中此類型的檔案會顯示在這裡。",
+                "empty/folder": "此資料夾是空的",
+                "empty/folderText": "將檔案拖放到這裡,或使用「新增」按鈕加入內容。",
+                "empty/needFM": "受限模式下無法使用",
+                "empty/needFMText": "你的帳戶沒有此檢視所需的「檔案管理員」權限。",
+                "empty/readonlyText": "此位置為唯讀。",
+                "empty/recent": "沒有最近使用的檔案",
+                "empty/recentText": "你開啟或編輯的檔案會顯示在這裡。",
+                "empty/search": "沒有符合的檔案",
+                "empty/searchText": "請嘗試其他關鍵字,或搜尋其他位置。",
+                "empty/shared": "還沒有人與你共用項目",
+                "empty/sharedByMe": "你還沒有分享任何項目",
+                "empty/sharedByMeText": "選取檔案並按下「分享」即可建立連結。",
+                "empty/sharedText": "其他人與你共用的檔案和資料夾會顯示在這裡。",
+                "empty/starred": "沒有已加星號的項目",
+                "empty/starredText": "為常用的檔案和資料夾加上星號,就能在這裡找到它們。",
+                "empty/trash": "垃圾桶是空的",
+                "empty/trashNeedFM": "沒有「檔案管理員」權限時,刪除的檔案會立即移除。",
+                "empty/trashText": "刪除的項目會保留在這裡,直到垃圾桶被清空。",
+                "err/badName": "名稱不可包含 / 或 \\",
+                "err/copyFolder": "資料夾請使用「複製到...」",
+                "err/intoItself": "資料夾無法移動到它自己裡面",
+                "err/loadTitle": "無法開啟此位置",
+                "err/needFM": "此動作需要「檔案管理員」權限",
+                "err/network": "無法連線到伺服器",
+                "err/noModule": "你沒有使用 {name} 的權限",
+                "err/readonly": "此位置為唯讀",
+                "err/unknown": "發生錯誤",
+                "err/zipNeedFM": "需要「檔案管理員」權限才能將資料夾打包成 ZIP 下載",
+                "foot/item": "{n} 個項目",
+                "foot/items": "{n} 個項目",
+                "foot/loading": "載入中...",
+                "help/about": "關於 Drive",
+                "help/shortcuts": "鍵盤快速鍵",
+                "host/back": "返回 Drive",
+                "host/close": "關閉此應用程式",
+                "host/newTab": "在新的瀏覽器分頁中開啟",
+                "host/openApps": "已開啟 {n} 個應用程式",
+                "host/return": "返回已開啟的應用程式",
+                "host/untitled": "應用程式",
+                "level/edit": "可編輯",
+                "level/editFolder": "可編輯(上傳及修改檔案)",
+                "level/view": "可檢視",
+                "menu/actions": "動作",
+                "menu/compress": "壓縮成 ZIP",
+                "menu/copy": "複製",
+                "menu/copyLink": "複製連結",
+                "menu/copyPath": "複製路徑",
+                "menu/copyTo": "複製到...",
+                "menu/cut": "剪下",
+                "menu/delete": "刪除",
+                "menu/details": "檢視詳細資料",
+                "menu/downloadFolder": "下載為 ZIP",
+                "menu/duplicate": "建立副本",
+                "menu/extract": "解壓縮到這裡",
+                "menu/folderDetails": "資料夾詳細資料",
+                "menu/moveTo": "移動到...",
+                "menu/nothing": "沒有內容",
+                "menu/open": "開啟",
+                "menu/openNewTab": "在新分頁中開啟",
+                "menu/openWith": "開啟方式",
+                "menu/paste": "貼上",
+                "menu/pasteInto": "貼到資料夾中",
+                "menu/preview": "預覽",
+                "menu/refresh": "重新整理",
+                "menu/rename": "重新命名",
+                "menu/selectAll": "全選",
+                "menu/showHidden": "顯示隱藏檔案",
+                "menu/showLocation": "顯示檔案位置",
+                "menu/star": "加上星號",
+                "menu/trash": "移至垃圾桶",
+                "menu/unstar": "從已加星號中移除",
+                "menu/versions": "版本記錄",
+                "misc/pathCopied": "已複製路徑",
+                "move/here": "移到這裡",
+                "move/title": "移動 {n} 個項目",
+                "move/titleOne": "移動「{name}」",
+                "nav/categories": "分類",
+                "nav/drives": "儲存空間",
+                "nav/myDrive": "我的雲端硬碟",
+                "nav/readonly": "唯讀",
+                "nav/recent": "最近使用",
+                "nav/shared": "與我共用",
+                "nav/starred": "已加星號",
+                "nav/tmp": "暫存空間",
+                "nav/trash": "垃圾桶",
+                "new/fileTitle": "新增{type}",
+                "new/untitled": "未命名",
+                "notif/clear": "全部清除",
+                "notif/markRead": "全部標為已讀",
+                "notif/none": "目前沒有新通知",
+                "notif/open": "開啟",
+                "notif/title": "通知",
+                "open/always": "一律使用所選應用程式開啟 .{ext} 檔案",
+                "open/choose": "選擇其他應用程式...",
+                "open/noApp": "沒有已安裝的應用程式登記開啟 .{ext} 檔案。你仍然可以預覽、下載,或嘗試其他應用程式。",
+                "open/otherApps": "其他應用程式",
+                "open/preview": "Drive 預覽",
+                "open/suggested": "建議的應用程式",
+                "open/withTitle": "選擇開啟「{name}」的方式",
+                "owner/me": "我",
+                "pager/next": "下一頁",
+                "pager/prev": "上一頁",
+                "perm/access": "存取權",
+                "perm/links": "分享連結",
+                "perm/mode": "儲存空間權限位元",
+                "perm/noLinks": "此項目沒有分享連結。",
+                "perm/owner": "擁有者",
+                "perm/readOnly": "唯讀",
+                "perm/readWrite": "可讀寫",
+                "perm/yourAccess": "你透過{root}的存取權",
+                "picker/locations": "位置",
+                "picker/noFolders": "這裡沒有資料夾,你仍然可以選擇此資料夾。",
+                "picker/up": "上一層",
+                "prompt/fileName": "檔案名稱",
+                "prompt/folderName": "資料夾名稱",
+                "prompt/newName": "新名稱",
+                "prompt/untitledFolder": "未命名資料夾",
+                "pv/cannotPlay": "瀏覽器無法播放此影片格式。",
+                "pv/cannotShow": "瀏覽器無法顯示此圖片。",
+                "pv/next": "下一個",
+                "pv/none": "此檔案類型無法預覽。",
+                "pv/prev": "上一個",
+                "pv/rotate": "旋轉",
+                "pv/zoomIn": "放大",
+                "pv/zoomOut": "縮小",
+                "recent/clear": "清除開啟記錄",
+                "recent/modified": "已修改",
+                "recent/opened": "已開啟",
+                "search/in": "在{where}中搜尋",
+                "search/sub": "在{where}中搜尋「{q}」",
+                "search/thisFolder": "此資料夾",
+                "search/title": "搜尋結果",
+                "search/where": "搜尋範圍",
+                "sel/all": "全選",
+                "sel/clear": "清除選取",
+                "sel/count": "已選取 {n} 個",
+                "sel/select": "選取",
+                "set/account": "我的帳戶",
+                "set/accountGroup": "帳戶",
+                "set/all": "全部",
+                "set/appearance": "外觀",
+                "set/behaviour": "開啟與刪除",
+                "set/comfortable": "舒適",
+                "set/compact": "緊湊",
+                "set/confirmTrash": "移至垃圾桶前先詢問",
+                "set/confirmTrashDesc": "垃圾桶中的項目隨時都可以還原",
+                "set/density": "顯示密度",
+                "set/densityDesc": "每一列所佔的空間",
+                "set/details": "詳細資料",
+                "set/fileAccess": "檔案存取",
+                "set/files": "檔案與資料夾",
+                "set/foldersFirstDesc": "先列出資料夾,再列出檔案",
+                "set/hiddenDesc": "名稱以點開頭的檔案和資料夾",
+                "set/inDrive": "在 Drive 內",
+                "set/inTab": "在新的瀏覽器分頁",
+                "set/langAuto": "跟隨瀏覽器",
+                "set/language": "語言",
+                "set/languageDesc": "此瀏覽器中的其他 ArozOS 應用程式也會使用",
+                "set/open": "開啟",
+                "set/openIn": "開啟檔案",
+                "set/openInDesc": "開啟檔案時應用程式出現的位置",
+                "set/order": "順序",
+                "set/pageSize": "每頁項目數",
+                "set/pageSizeDesc": "分頁可讓大型資料夾維持流暢",
+                "set/reducedDesc": "受限:請向管理員申請「檔案管理員」權限,以使用垃圾桶、版本記錄和大型檔案上傳",
+                "set/saved": "已儲存設定",
+                "set/sort": "排序依據",
+                "set/switch": "切換",
+                "set/switchDesc": "你的帳戶也可以使用其他介面,例如桌面",
+                "set/systemDesc": "密碼、網路、使用者以及所有系統層級的設定",
+                "set/theme": "主題",
+                "set/themeAuto": "跟隨裝置",
+                "set/themeDark": "深色",
+                "set/themeDesc": "在 Drive 中開啟的應用程式若有支援,也會跟隨此主題",
+                "set/themeLight": "淺色",
+                "set/thumbs": "縮圖",
+                "set/thumbsDesc": "在清單中顯示相片和影片的預覽",
+                "set/used": "已使用空間",
+                "set/view": "預設版面",
+                "share/allowDownload": "允許下載",
+                "share/badDate": "請選擇未來的日期",
+                "share/copied": "已複製連結",
+                "share/create": "建立連結",
+                "share/created": "已建立連結",
+                "share/createdCopied": "已建立並複製連結",
+                "share/date": "到期日",
+                "share/edit": "編輯連結",
+                "share/editTitle": "編輯連結",
+                "share/expired": "已到期",
+                "share/expires": "{date} 到期",
+                "share/expiry": "連結到期",
+                "share/hasPassword": "密碼",
+                "share/in1": "1 天後",
+                "share/in30": "30 天後",
+                "share/in7": "7 天後",
+                "share/keepPassword": "留空即保留目前的密碼",
+                "share/level": "存取權",
+                "share/linkCopied": "已複製連結,任何擁有連結的人都可以檢視。",
+                "share/manage": "管理",
+                "share/manageSharing": "管理分享",
+                "share/needGroups": "請至少選擇一個群組",
+                "share/needPassword": "請輸入密碼,或關閉此選項",
+                "share/needUsers": "請至少選擇一位使用者",
+                "share/never": "永不",
+                "share/newLink": "建立連結",
+                "share/newPassword": "密碼",
+                "share/noDownload": "禁止下載",
+                "share/noList": "隱藏檔案清單",
+                "share/nobody": "沒有可選擇的對象",
+                "share/notOwner": "此項目由其他人分享,只有對方能修改連結。",
+                "share/notOwnerShort": "此項目由其他人分享",
+                "share/onDate": "指定日期...",
+                "share/password": "需要密碼",
+                "share/passwordHint": "只有知道密碼的人才能開啟連結",
+                "share/passwordSet": "已設定密碼,輸入新密碼即可變更。",
+                "share/pickGroups": "選擇群組",
+                "share/pickUsers": "選擇使用者",
+                "share/private": "未分享",
+                "share/privateText": "只有你和可存取此位置的人能開啟。",
+                "share/qr": "QR 碼",
+                "share/reducedText": "在受限模式下,每個項目只能建立一個公開連結。",
+                "share/remove": "移除連結",
+                "share/removed": "已移除連結",
+                "share/saved": "已更新連結",
+                "share/showList": "顯示檔案清單",
+                "share/stop": "停止分享",
+                "share/stopped": "已停止分享",
+                "share/title": "分享「{name}」",
+                "share/who": "誰可以開啟",
+                "shared/by": "由 {name} 分享",
+                "shared/byMe": "我分享的",
+                "shared/withMe": "與我共用",
+                "shortcut/copy": "複製 / 剪下 / 貼上",
+                "shortcut/delete": "永久刪除",
+                "shortcut/details": "顯示或隱藏詳細資料",
+                "shortcut/download": "下載",
+                "shortcut/move": "移動焦點",
+                "shortcut/newFolder": "新增資料夾",
+                "shortcut/open": "開啟",
+                "shortcut/preview": "預覽",
+                "shortcut/rename": "重新命名",
+                "shortcut/search": "搜尋",
+                "shortcut/select": "全選",
+                "shortcut/trash": "移至垃圾桶",
+                "shortcut/up": "上一層資料夾",
+                "shortcut/upload": "上傳檔案",
+                "sort/asc": "遞增",
+                "sort/by": "排序依據",
+                "sort/desc": "遞減",
+                "sort/foldersFirst": "資料夾置頂",
+                "sort/order": "順序",
+                "srv/Access Denied": "存取被拒",
+                "srv/File already exists": "檔案已存在",
+                "srv/File not exists": "檔案不存在",
+                "srv/Filename contains illegal characters": "檔名包含不允許的字元",
+                "srv/Folder not exists": "資料夾不存在",
+                "srv/Given filename already exists": "檔名已存在",
+                "srv/Given folder already exists": "資料夾已存在",
+                "srv/Permission Denied": "權限不足",
+                "srv/Storage Quota Full": "儲存配額已滿",
+                "srv/TRASH_QUOTA_EXCEEDED": "垃圾桶已滿",
+                "srv/This directory is Read Only": "此目錄為唯讀",
+                "srv/User Storage Quota Exceeded": "已超過使用者儲存配額",
+                "storage/title": "儲存空間",
+                "storage/usedOf": "已使用 {used},共 {total}",
+                "storage/usedUnlimited": "已使用 {used} · 無配額限制",
+                "sub/category": "我的雲端硬碟中最新的{what}",
+                "sub/myDrive": "個人儲存空間",
+                "sub/readonlyRoot": "{name} · 唯讀",
+                "sub/recent": "你在過去 30 天內開啟或修改的檔案",
+                "sub/settings": "這些設定只適用於 Drive。系統層級的選項請到系統設定中調整。",
+                "sub/sharedByMe": "你建立的分享連結",
+                "sub/sharedWithMe": "其他人分享給你的檔案",
+                "sub/starred": "快速存取已加星號的項目",
+                "sub/trash": "垃圾桶中的項目會在保留期限後永久移除",
+                "time/days": "{n} 天前",
+                "time/hours": "{n} 小時前",
+                "time/inDays": "{n} 天後",
+                "time/inHours": "{n} 小時後",
+                "time/inMinutes": "{n} 分鐘後",
+                "time/justNow": "剛剛",
+                "time/minutes": "{n} 分鐘前",
+                "toast/clip": "已將 {n} 個項目複製到剪貼簿",
+                "toast/clip1": "已將 1 個項目複製到剪貼簿",
+                "toast/copied": "已將 {n} 個項目複製到 {name}",
+                "toast/copied1": "已將 1 個項目複製到 {name}",
+                "toast/created": "已建立 {name}",
+                "toast/cut": "已剪下 {n} 個項目,請到其他資料夾貼上。",
+                "toast/cut1": "已剪下 1 個項目,請到其他資料夾貼上。",
+                "toast/deleted": "已刪除 {n} 個項目",
+                "toast/deleted1": "已刪除 1 個項目",
+                "toast/duplicated": "已建立 {n} 個副本",
+                "toast/duplicated1": "已建立副本",
+                "toast/moved": "已將 {n} 個項目移至 {name}",
+                "toast/moved1": "已將 1 個項目移至 {name}",
+                "toast/open": "開啟",
+                "toast/renamed": "已重新命名為 {name}",
+                "toast/restored": "已還原",
+                "toast/restoredN": "已還原 {n} 個項目",
+                "toast/restoredN1": "已還原 1 個項目",
+                "toast/show": "顯示",
+                "toast/starred": "已為 {n} 個項目加上星號",
+                "toast/starred1": "已加上星號",
+                "toast/trashEmptied": "已清空垃圾桶",
+                "toast/trashed": "已將 {n} 個項目移至垃圾桶",
+                "toast/trashed1": "已將 1 個項目移至垃圾桶",
+                "toast/undo": "復原",
+                "toast/unstarred": "已移除 {n} 個項目的星號",
+                "toast/unstarred1": "已移除星號",
+                "toolbar/download": "下載",
+                "toolbar/more": "更多",
+                "toolbar/move": "移動",
+                "toolbar/new": "新增",
+                "toolbar/newFile": "新增檔案",
+                "toolbar/newFolder": "新增資料夾",
+                "toolbar/share": "分享",
+                "toolbar/upload": "上傳",
+                "toolbar/uploadFiles": "上傳檔案",
+                "toolbar/uploadFolder": "上傳資料夾",
+                "top/help": "說明",
+                "top/menu": "選單",
+                "top/notifications": "通知",
+                "top/search": "搜尋檔案、資料夾或任何內容...",
+                "top/searchBtn": "搜尋",
+                "trash/confirmText": "{n} 個項目將會移至垃圾桶。",
+                "trash/confirmText1": "「{name}」將會移至垃圾桶。",
+                "trash/confirmTitle": "要移至垃圾桶嗎?",
+                "trash/deleteForever": "永久刪除",
+                "trash/empty": "清空垃圾桶",
+                "trash/emptyText": "垃圾桶中的所有項目都會被永久刪除,此動作無法復原。",
+                "trash/emptyTitle": "要清空垃圾桶嗎?",
+                "trash/fullText": "垃圾桶已滿。要改為永久刪除這些項目嗎?",
+                "trash/fullTitle": "無法移至垃圾桶",
+                "trash/move": "移至垃圾桶",
+                "trash/restore": "還原",
+                "trash/unsupported": "此儲存空間沒有垃圾桶。要改為永久刪除這些項目嗎?",
+                "type/archive": "壓縮檔",
+                "type/audio": "音訊",
+                "type/code": "程式碼",
+                "type/data": "資料庫",
+                "type/design": "設計檔",
+                "type/disk": "磁碟映像",
+                "type/doc": "文件",
+                "type/ebook": "電子書",
+                "type/exe": "應用程式",
+                "type/extFile": "{ext} 檔案",
+                "type/file": "檔案",
+                "type/folder": "資料夾",
+                "type/font": "字型",
+                "type/image": "圖片",
+                "type/model": "3D 模型",
+                "type/pdf": "PDF",
+                "type/sheet": "試算表",
+                "type/shortcut": "捷徑",
+                "type/slide": "簡報",
+                "type/text": "文字",
+                "type/video": "影片",
+                "unit/bytes": "位元組",
+                "user/darkMode": "切換為深色主題",
+                "user/driveSettings": "Drive 設定",
+                "user/lightMode": "切換為淺色主題",
+                "user/logout": "登出",
+                "user/logoutConfirm": "要在此瀏覽器登出 ArozOS 嗎?",
+                "user/switchInterface": "切換工作區",
+                "user/systemSettings": "系統設定",
+                "ver/current": "目前版本",
+                "ver/delete": "刪除此版本",
+                "ver/deleteAll": "刪除所有版本",
+                "ver/deleteAllText": "此檔案所有較早的版本都將被刪除。",
+                "ver/none": "沒有較早的版本。檔案被覆寫時,或你在這裡儲存版本時,都會保留版本。",
+                "ver/restore": "還原此版本",
+                "ver/restoreOk": "還原",
+                "ver/restoreText": "目前的內容將被此版本取代。",
+                "ver/restored": "已還原版本",
+                "ver/save": "立即儲存一個版本",
+                "ver/saved": "已儲存版本",
+                "view/details": "顯示詳細資料",
+                "view/grid": "格狀檢視",
+                "view/list": "清單檢視",
+                "xfer/cancelAll": "取消傳輸",
+                "xfer/cancelAllText": "仍有 {n} 個傳輸正在進行,要取消嗎?",
+                "xfer/cancelAllTitle": "要取消傳輸嗎?",
+                "xfer/cancelled": "已取消",
+                "xfer/collapse": "收合",
+                "xfer/connectionLost": "連線中斷",
+                "xfer/copiedTo": "已複製到 {name}",
+                "xfer/copyMade": "已建立副本",
+                "xfer/copying": "正在複製 {name}",
+                "xfer/done": "完成",
+                "xfer/doneN": "已完成 {n} 個傳輸",
+                "xfer/downloadAgain": "再次下載",
+                "xfer/expand": "展開",
+                "xfer/extracted": "已解壓縮",
+                "xfer/failed": "失敗",
+                "xfer/failedN": "{n} 個項目失敗",
+                "xfer/minutesLeft": "剩餘 {n} 分鐘",
+                "xfer/movedTo": "已移至 {name}",
+                "xfer/moving": "正在移動 {name}",
+                "xfer/preparing": "正在準備 {name}",
+                "xfer/processing": "即將完成",
+                "xfer/queued": "等待中",
+                "xfer/secondsLeft": "剩餘 {n} 秒",
+                "xfer/showFolder": "在資料夾中顯示",
+                "xfer/tooBigReduced": "有 {n} 個檔案超過 24 MB,需要「檔案管理員」權限才能上傳",
+                "xfer/unzipping": "正在解壓縮 {name}",
+                "xfer/uploadFailed": "上傳失敗(檔案可能太大或磁碟已滿)",
+                "xfer/uploadingN": "正在上傳 {n} 個項目",
+                "xfer/working": "處理中",
+                "xfer/workingN": "{n} 個傳輸進行中",
+                "xfer/zipped": "已建立壓縮檔",
+                "xfer/zipping": "正在壓縮 {name}"
+            }
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "用来管理 ArozOS 文件的云盘工作区",
+                "about/full": "完整(文件管理器 API)",
+                "about/host": "主机",
+                "about/mode": "文件访问",
+                "about/reduced": "受限(AGI 备用)",
+                "about/version": "ArozOS",
+                "act/created": "已创建",
+                "act/createdBy": "{name} 创建了此项目",
+                "act/expired": "分享链接已过期",
+                "act/modified": "最后修改",
+                "act/none": "暂无活动记录。",
+                "act/note": "根据存储空间、分享链接和版本历史保存的日期整理。",
+                "act/opened": "你在 Drive 中打开了它",
+                "act/shared": "{who} 将它分享给{audience}",
+                "act/version": "已保存版本({size})",
+                "act/willExpire": "分享链接即将过期",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "任何拥有链接的人",
+                "audience/anyonePw": "任何拥有链接和密码的人",
+                "audience/groups": "用户组:{names}",
+                "audience/groupsTitle": "受邀用户组",
+                "audience/pickGroups": "指定用户组",
+                "audience/pickUsers": "指定用户",
+                "audience/samegroup": "同一用户组的用户",
+                "audience/signedin": "已登录的用户",
+                "audience/users": "受邀用户",
+                "badge/linkOnly": "通过分享链接打开",
+                "badge/shared": "已分享",
+                "banner/readonly": "此位置为只读,你可以打开和下载文件,但不能修改。",
+                "banner/reduced": "受限模式:你的账户无法使用文件管理器服务,因此删除是永久性的,部分功能也不可用。",
+                "banner/truncated": "当前显示的是目前找到的最新结果。你的云盘很大,因此扫描提前结束。",
+                "cat/documents": "文档",
+                "cat/music": "音乐",
+                "cat/others": "其他",
+                "cat/photos": "照片",
+                "cat/videos": "视频",
+                "col/access": "访问对象",
+                "col/activity": "最近活动",
+                "col/deleted": "删除时间",
+                "col/expires": "到期时间",
+                "col/location": "位置",
+                "col/modified": "修改时间",
+                "col/name": "名称",
+                "col/origin": "原始位置",
+                "col/owner": "所有者",
+                "col/size": "大小",
+                "col/type": "类型",
+                "common/cancel": "取消",
+                "common/close": "关闭",
+                "common/create": "创建",
+                "common/delete": "删除",
+                "common/done": "完成",
+                "common/loading": "加载中...",
+                "common/ok": "确定",
+                "common/retry": "重试",
+                "common/save": "保存",
+                "common/select": "选择",
+                "conflict/keep": "两者都保留",
+                "conflict/replace": "替换",
+                "conflict/skip": "跳过",
+                "conflict/text": "{total} 个项目中有 {n} 个与目标位置的项目同名。",
+                "conflict/title": "部分项目已存在",
+                "copy/here": "复制到这里",
+                "copy/title": "复制 {n} 个项目",
+                "copy/titleOne": "复制“{name}”",
+                "delete/ok": "永久删除",
+                "delete/reducedText": "在受限模式下,删除的项目将无法恢复。",
+                "delete/text": "{n} 个项目将被永久删除,此操作无法撤销。",
+                "delete/text1": "“{name}”将被永久删除,此操作无法撤销。",
+                "delete/title": "要永久删除吗?",
+                "details/activity": "活动",
+                "details/by": "由 {name}",
+                "details/created": "创建时间",
+                "details/currentFolder": "当前文件夹",
+                "details/excludesFolders": "不含文件夹",
+                "details/fileInfo": "文件信息",
+                "details/folderInfo": "文件夹信息",
+                "details/general": "常规",
+                "details/getLink": "获取链接",
+                "details/items": "项目",
+                "details/manySub": "{files} 个文件,{folders} 个文件夹",
+                "details/moreLinks": "另有 {n} 个链接",
+                "details/notRecorded": "此存储空间未记录",
+                "details/onlyYou": "只有可访问此位置的人",
+                "details/permissions": "权限",
+                "details/pick": "选择文件或文件夹即可查看详情。",
+                "details/quick": "快捷操作",
+                "details/sharing": "分享",
+                "details/summary": "摘要",
+                "details/title": "详情",
+                "details/unknown": "未知",
+                "details/versions": "版本",
+                "drag/items": "{n} 个项目",
+                "drop/upload": "松开即可上传到 {name}",
+                "empty/category": "这里还没有内容",
+                "empty/categoryText": "我的云盘中此类文件会显示在这里。",
+                "empty/folder": "此文件夹是空的",
+                "empty/folderText": "将文件拖放到这里,或使用“新建”按钮添加内容。",
+                "empty/needFM": "受限模式下不可用",
+                "empty/needFMText": "你的账户没有此视图所需的“文件管理器”权限。",
+                "empty/readonlyText": "此位置为只读。",
+                "empty/recent": "没有最近使用的文件",
+                "empty/recentText": "你打开或编辑的文件会显示在这里。",
+                "empty/search": "没有匹配的文件",
+                "empty/searchText": "请尝试其他关键字,或搜索其他位置。",
+                "empty/shared": "还没有人与你共享内容",
+                "empty/sharedByMe": "你还没有分享任何内容",
+                "empty/sharedByMeText": "选择文件并点击“分享”即可创建链接。",
+                "empty/sharedText": "其他人与你共享的文件和文件夹会显示在这里。",
+                "empty/starred": "没有已加星标的项目",
+                "empty/starredText": "为常用的文件和文件夹添加星标,就能在这里找到它们。",
+                "empty/trash": "回收站是空的",
+                "empty/trashNeedFM": "没有“文件管理器”权限时,删除的文件会被立即移除。",
+                "empty/trashText": "删除的项目会保留在这里,直到回收站被清空。",
+                "err/badName": "名称不能包含 / 或 \\",
+                "err/copyFolder": "文件夹请使用“复制到...”",
+                "err/intoItself": "文件夹不能移动到它自己里面",
+                "err/loadTitle": "无法打开此位置",
+                "err/needFM": "此操作需要“文件管理器”权限",
+                "err/network": "无法连接到服务器",
+                "err/noModule": "你没有使用 {name} 的权限",
+                "err/readonly": "此位置为只读",
+                "err/unknown": "出现错误",
+                "err/zipNeedFM": "需要“文件管理器”权限才能将文件夹打包成 ZIP 下载",
+                "foot/item": "{n} 个项目",
+                "foot/items": "{n} 个项目",
+                "foot/loading": "加载中...",
+                "help/about": "关于 Drive",
+                "help/shortcuts": "键盘快捷键",
+                "host/back": "返回 Drive",
+                "host/close": "关闭此应用",
+                "host/newTab": "在新的浏览器标签页中打开",
+                "host/openApps": "已打开 {n} 个应用",
+                "host/return": "返回已打开的应用",
+                "host/untitled": "应用",
+                "level/edit": "可编辑",
+                "level/editFolder": "可编辑(上传和修改文件)",
+                "level/view": "可查看",
+                "menu/actions": "操作",
+                "menu/compress": "压缩为 ZIP",
+                "menu/copy": "复制",
+                "menu/copyLink": "复制链接",
+                "menu/copyPath": "复制路径",
+                "menu/copyTo": "复制到...",
+                "menu/cut": "剪切",
+                "menu/delete": "删除",
+                "menu/details": "查看详情",
+                "menu/downloadFolder": "下载为 ZIP",
+                "menu/duplicate": "创建副本",
+                "menu/extract": "解压到这里",
+                "menu/folderDetails": "文件夹详情",
+                "menu/moveTo": "移动到...",
+                "menu/nothing": "没有内容",
+                "menu/open": "打开",
+                "menu/openNewTab": "在新标签页中打开",
+                "menu/openWith": "打开方式",
+                "menu/paste": "粘贴",
+                "menu/pasteInto": "粘贴到文件夹中",
+                "menu/preview": "预览",
+                "menu/refresh": "刷新",
+                "menu/rename": "重命名",
+                "menu/selectAll": "全选",
+                "menu/showHidden": "显示隐藏文件",
+                "menu/showLocation": "显示文件位置",
+                "menu/star": "添加星标",
+                "menu/trash": "移至回收站",
+                "menu/unstar": "取消星标",
+                "menu/versions": "版本历史",
+                "misc/pathCopied": "路径已复制",
+                "move/here": "移到这里",
+                "move/title": "移动 {n} 个项目",
+                "move/titleOne": "移动“{name}”",
+                "nav/categories": "分类",
+                "nav/drives": "存储空间",
+                "nav/myDrive": "我的云盘",
+                "nav/readonly": "只读",
+                "nav/recent": "最近使用",
+                "nav/shared": "与我共享",
+                "nav/starred": "已加星标",
+                "nav/tmp": "临时空间",
+                "nav/trash": "回收站",
+                "new/fileTitle": "新建{type}",
+                "new/untitled": "未命名",
+                "notif/clear": "全部清除",
+                "notif/markRead": "全部标为已读",
+                "notif/none": "暂时没有新通知",
+                "notif/open": "打开",
+                "notif/title": "通知",
+                "open/always": "始终使用所选应用打开 .{ext} 文件",
+                "open/choose": "选择其他应用...",
+                "open/noApp": "没有已安装的应用登记打开 .{ext} 文件。你仍然可以预览、下载,或尝试其他应用。",
+                "open/otherApps": "其他应用",
+                "open/preview": "Drive 预览",
+                "open/suggested": "推荐的应用",
+                "open/withTitle": "选择打开“{name}”的方式",
+                "owner/me": "我",
+                "pager/next": "下一页",
+                "pager/prev": "上一页",
+                "perm/access": "访问权限",
+                "perm/links": "分享链接",
+                "perm/mode": "存储权限位",
+                "perm/noLinks": "此项目没有分享链接。",
+                "perm/owner": "所有者",
+                "perm/readOnly": "只读",
+                "perm/readWrite": "可读写",
+                "perm/yourAccess": "你通过{root}获得的访问权限",
+                "picker/locations": "位置",
+                "picker/noFolders": "这里没有文件夹,你仍然可以选择此文件夹。",
+                "picker/up": "上一级",
+                "prompt/fileName": "文件名",
+                "prompt/folderName": "文件夹名称",
+                "prompt/newName": "新名称",
+                "prompt/untitledFolder": "未命名文件夹",
+                "pv/cannotPlay": "浏览器无法播放此视频格式。",
+                "pv/cannotShow": "浏览器无法显示此图片。",
+                "pv/next": "下一个",
+                "pv/none": "此文件类型无法预览。",
+                "pv/prev": "上一个",
+                "pv/rotate": "旋转",
+                "pv/zoomIn": "放大",
+                "pv/zoomOut": "缩小",
+                "recent/clear": "清除打开记录",
+                "recent/modified": "已修改",
+                "recent/opened": "已打开",
+                "search/in": "在{where}中搜索",
+                "search/sub": "在{where}中搜索“{q}”",
+                "search/thisFolder": "此文件夹",
+                "search/title": "搜索结果",
+                "search/where": "搜索范围",
+                "sel/all": "全选",
+                "sel/clear": "清除选择",
+                "sel/count": "已选择 {n} 项",
+                "sel/select": "选择",
+                "set/account": "我的账户",
+                "set/accountGroup": "账户",
+                "set/all": "全部",
+                "set/appearance": "外观",
+                "set/behaviour": "打开与删除",
+                "set/comfortable": "舒适",
+                "set/compact": "紧凑",
+                "set/confirmTrash": "移至回收站前先确认",
+                "set/confirmTrashDesc": "回收站中的项目随时都可以恢复",
+                "set/density": "显示密度",
+                "set/densityDesc": "每一行所占的空间",
+                "set/details": "详情",
+                "set/fileAccess": "文件访问",
+                "set/files": "文件和文件夹",
+                "set/foldersFirstDesc": "先列出文件夹,再列出文件",
+                "set/hiddenDesc": "名称以点开头的文件和文件夹",
+                "set/inDrive": "在 Drive 内",
+                "set/inTab": "在新的浏览器标签页",
+                "set/langAuto": "跟随浏览器",
+                "set/language": "语言",
+                "set/languageDesc": "此浏览器中的其他 ArozOS 应用也会使用",
+                "set/open": "打开",
+                "set/openIn": "打开文件",
+                "set/openInDesc": "打开文件时应用出现的位置",
+                "set/order": "顺序",
+                "set/pageSize": "每页项目数",
+                "set/pageSizeDesc": "分页可以让大型文件夹保持流畅",
+                "set/reducedDesc": "受限:请向管理员申请“文件管理器”权限,以使用回收站、版本历史和大文件上传",
+                "set/saved": "设置已保存",
+                "set/sort": "排序方式",
+                "set/switch": "切换",
+                "set/switchDesc": "你的账户也可以使用其他界面,例如桌面",
+                "set/systemDesc": "密码、网络、用户以及所有系统级设置",
+                "set/theme": "主题",
+                "set/themeAuto": "跟随设备",
+                "set/themeDark": "深色",
+                "set/themeDesc": "在 Drive 中打开的应用如果支持,也会跟随此主题",
+                "set/themeLight": "浅色",
+                "set/thumbs": "缩略图",
+                "set/thumbsDesc": "在列表中显示照片和视频的预览",
+                "set/used": "已用空间",
+                "set/view": "默认布局",
+                "share/allowDownload": "允许下载",
+                "share/badDate": "请选择未来的日期",
+                "share/copied": "链接已复制",
+                "share/create": "创建链接",
+                "share/created": "链接已创建",
+                "share/createdCopied": "链接已创建并复制",
+                "share/date": "到期日期",
+                "share/edit": "编辑链接",
+                "share/editTitle": "编辑链接",
+                "share/expired": "已过期",
+                "share/expires": "{date} 到期",
+                "share/expiry": "链接有效期",
+                "share/hasPassword": "密码",
+                "share/in1": "1 天后",
+                "share/in30": "30 天后",
+                "share/in7": "7 天后",
+                "share/keepPassword": "留空则保留当前密码",
+                "share/level": "访问权限",
+                "share/linkCopied": "链接已复制,任何拥有链接的人都可以查看。",
+                "share/manage": "管理",
+                "share/manageSharing": "管理分享",
+                "share/needGroups": "请至少选择一个用户组",
+                "share/needPassword": "请输入密码,或关闭此选项",
+                "share/needUsers": "请至少选择一个用户",
+                "share/never": "永不",
+                "share/newLink": "创建链接",
+                "share/newPassword": "密码",
+                "share/noDownload": "禁止下载",
+                "share/noList": "隐藏文件列表",
+                "share/nobody": "没有可选择的对象",
+                "share/notOwner": "此项目由其他人分享,只有对方能修改链接。",
+                "share/notOwnerShort": "此项目由其他人分享",
+                "share/onDate": "指定日期...",
+                "share/password": "需要密码",
+                "share/passwordHint": "只有知道密码的人才能打开链接",
+                "share/passwordSet": "已设置密码,输入新密码即可更改。",
+                "share/pickGroups": "选择用户组",
+                "share/pickUsers": "选择用户",
+                "share/private": "未分享",
+                "share/privateText": "只有你和可访问此位置的人能打开。",
+                "share/qr": "二维码",
+                "share/reducedText": "在受限模式下,每个项目只能创建一个公开链接。",
+                "share/remove": "移除链接",
+                "share/removed": "链接已移除",
+                "share/saved": "链接已更新",
+                "share/showList": "显示文件列表",
+                "share/stop": "停止分享",
+                "share/stopped": "已停止分享",
+                "share/title": "分享“{name}”",
+                "share/who": "谁可以打开",
+                "shared/by": "由 {name} 分享",
+                "shared/byMe": "我的分享",
+                "shared/withMe": "与我共享",
+                "shortcut/copy": "复制 / 剪切 / 粘贴",
+                "shortcut/delete": "永久删除",
+                "shortcut/details": "显示或隐藏详情",
+                "shortcut/download": "下载",
+                "shortcut/move": "移动焦点",
+                "shortcut/newFolder": "新建文件夹",
+                "shortcut/open": "打开",
+                "shortcut/preview": "预览",
+                "shortcut/rename": "重命名",
+                "shortcut/search": "搜索",
+                "shortcut/select": "全选",
+                "shortcut/trash": "移至回收站",
+                "shortcut/up": "上级文件夹",
+                "shortcut/upload": "上传文件",
+                "sort/asc": "升序",
+                "sort/by": "排序方式",
+                "sort/desc": "降序",
+                "sort/foldersFirst": "文件夹置顶",
+                "sort/order": "顺序",
+                "srv/Access Denied": "拒绝访问",
+                "srv/File already exists": "文件已存在",
+                "srv/File not exists": "文件不存在",
+                "srv/Filename contains illegal characters": "文件名包含不允许的字符",
+                "srv/Folder not exists": "文件夹不存在",
+                "srv/Given filename already exists": "文件名已存在",
+                "srv/Given folder already exists": "文件夹已存在",
+                "srv/Permission Denied": "权限不足",
+                "srv/Storage Quota Full": "存储配额已满",
+                "srv/TRASH_QUOTA_EXCEEDED": "回收站已满",
+                "srv/This directory is Read Only": "此目录为只读",
+                "srv/User Storage Quota Exceeded": "已超过用户存储配额",
+                "storage/title": "存储空间",
+                "storage/usedOf": "已使用 {used},共 {total}",
+                "storage/usedUnlimited": "已使用 {used} · 无配额限制",
+                "sub/category": "我的云盘中最新的{what}",
+                "sub/myDrive": "个人存储空间",
+                "sub/readonlyRoot": "{name} · 只读",
+                "sub/recent": "你在过去 30 天内打开或修改过的文件",
+                "sub/settings": "这些设置只适用于 Drive。系统级选项请在系统设置中调整。",
+                "sub/sharedByMe": "你创建的分享链接",
+                "sub/sharedWithMe": "其他人分享给你的文件",
+                "sub/starred": "快速访问已加星标的项目",
+                "sub/trash": "回收站中的项目会在保留期结束后被永久删除",
+                "time/days": "{n} 天前",
+                "time/hours": "{n} 小时前",
+                "time/inDays": "{n} 天后",
+                "time/inHours": "{n} 小时后",
+                "time/inMinutes": "{n} 分钟后",
+                "time/justNow": "刚刚",
+                "time/minutes": "{n} 分钟前",
+                "toast/clip": "已将 {n} 个项目复制到剪贴板",
+                "toast/clip1": "已将 1 个项目复制到剪贴板",
+                "toast/copied": "已将 {n} 个项目复制到 {name}",
+                "toast/copied1": "已将 1 个项目复制到 {name}",
+                "toast/created": "已创建 {name}",
+                "toast/cut": "已剪切 {n} 个项目,请到其他文件夹粘贴。",
+                "toast/cut1": "已剪切 1 个项目,请到其他文件夹粘贴。",
+                "toast/deleted": "已删除 {n} 个项目",
+                "toast/deleted1": "已删除 1 个项目",
+                "toast/duplicated": "已创建 {n} 个副本",
+                "toast/duplicated1": "已创建副本",
+                "toast/moved": "已将 {n} 个项目移至 {name}",
+                "toast/moved1": "已将 1 个项目移至 {name}",
+                "toast/open": "打开",
+                "toast/renamed": "已重命名为 {name}",
+                "toast/restored": "已恢复",
+                "toast/restoredN": "已恢复 {n} 个项目",
+                "toast/restoredN1": "已恢复 1 个项目",
+                "toast/show": "显示",
+                "toast/starred": "已为 {n} 个项目添加星标",
+                "toast/starred1": "已添加星标",
+                "toast/trashEmptied": "回收站已清空",
+                "toast/trashed": "已将 {n} 个项目移至回收站",
+                "toast/trashed1": "已将 1 个项目移至回收站",
+                "toast/undo": "撤销",
+                "toast/unstarred": "已取消 {n} 个项目的星标",
+                "toast/unstarred1": "已取消星标",
+                "toolbar/download": "下载",
+                "toolbar/more": "更多",
+                "toolbar/move": "移动",
+                "toolbar/new": "新建",
+                "toolbar/newFile": "新建文件",
+                "toolbar/newFolder": "新建文件夹",
+                "toolbar/share": "分享",
+                "toolbar/upload": "上传",
+                "toolbar/uploadFiles": "上传文件",
+                "toolbar/uploadFolder": "上传文件夹",
+                "top/help": "帮助",
+                "top/menu": "菜单",
+                "top/notifications": "通知",
+                "top/search": "搜索文件、文件夹或任何内容...",
+                "top/searchBtn": "搜索",
+                "trash/confirmText": "{n} 个项目将被移至回收站。",
+                "trash/confirmText1": "“{name}”将被移至回收站。",
+                "trash/confirmTitle": "要移至回收站吗?",
+                "trash/deleteForever": "永久删除",
+                "trash/empty": "清空回收站",
+                "trash/emptyText": "回收站中的所有项目都会被永久删除,此操作无法撤销。",
+                "trash/emptyTitle": "要清空回收站吗?",
+                "trash/fullText": "回收站已满。要改为永久删除这些项目吗?",
+                "trash/fullTitle": "无法移至回收站",
+                "trash/move": "移至回收站",
+                "trash/restore": "恢复",
+                "trash/unsupported": "此存储空间没有回收站。要改为永久删除这些项目吗?",
+                "type/archive": "压缩包",
+                "type/audio": "音频",
+                "type/code": "代码",
+                "type/data": "数据库",
+                "type/design": "设计文件",
+                "type/disk": "磁盘映像",
+                "type/doc": "文档",
+                "type/ebook": "电子书",
+                "type/exe": "应用程序",
+                "type/extFile": "{ext} 文件",
+                "type/file": "文件",
+                "type/folder": "文件夹",
+                "type/font": "字体",
+                "type/image": "图片",
+                "type/model": "3D 模型",
+                "type/pdf": "PDF",
+                "type/sheet": "电子表格",
+                "type/shortcut": "快捷方式",
+                "type/slide": "演示文稿",
+                "type/text": "文本",
+                "type/video": "视频",
+                "unit/bytes": "字节",
+                "user/darkMode": "切换为深色主题",
+                "user/driveSettings": "Drive 设置",
+                "user/lightMode": "切换为浅色主题",
+                "user/logout": "退出登录",
+                "user/logoutConfirm": "要在此浏览器退出 ArozOS 吗?",
+                "user/switchInterface": "切换工作区",
+                "user/systemSettings": "系统设置",
+                "ver/current": "当前版本",
+                "ver/delete": "删除此版本",
+                "ver/deleteAll": "删除所有版本",
+                "ver/deleteAllText": "此文件的所有早期版本都将被删除。",
+                "ver/none": "没有早期版本。文件被覆盖时,或你在这里保存版本时,都会保留版本。",
+                "ver/restore": "恢复此版本",
+                "ver/restoreOk": "恢复",
+                "ver/restoreText": "当前内容将被此版本替换。",
+                "ver/restored": "版本已恢复",
+                "ver/save": "立即保存一个版本",
+                "ver/saved": "版本已保存",
+                "view/details": "显示详情",
+                "view/grid": "网格视图",
+                "view/list": "列表视图",
+                "xfer/cancelAll": "取消传输",
+                "xfer/cancelAllText": "仍有 {n} 个传输正在进行,要取消吗?",
+                "xfer/cancelAllTitle": "要取消传输吗?",
+                "xfer/cancelled": "已取消",
+                "xfer/collapse": "收起",
+                "xfer/connectionLost": "连接已断开",
+                "xfer/copiedTo": "已复制到 {name}",
+                "xfer/copyMade": "已创建副本",
+                "xfer/copying": "正在复制 {name}",
+                "xfer/done": "完成",
+                "xfer/doneN": "已完成 {n} 个传输",
+                "xfer/downloadAgain": "再次下载",
+                "xfer/expand": "展开",
+                "xfer/extracted": "已解压",
+                "xfer/failed": "失败",
+                "xfer/failedN": "{n} 个项目失败",
+                "xfer/minutesLeft": "剩余 {n} 分钟",
+                "xfer/movedTo": "已移至 {name}",
+                "xfer/moving": "正在移动 {name}",
+                "xfer/preparing": "正在准备 {name}",
+                "xfer/processing": "即将完成",
+                "xfer/queued": "等待中",
+                "xfer/secondsLeft": "剩余 {n} 秒",
+                "xfer/showFolder": "在文件夹中显示",
+                "xfer/tooBigReduced": "有 {n} 个文件超过 24 MB,需要“文件管理器”权限才能上传",
+                "xfer/unzipping": "正在解压 {name}",
+                "xfer/uploadFailed": "上传失败(文件可能太大或磁盘已满)",
+                "xfer/uploadingN": "正在上传 {n} 个项目",
+                "xfer/working": "处理中",
+                "xfer/workingN": "{n} 个传输进行中",
+                "xfer/zipped": "已创建压缩包",
+                "xfer/zipping": "正在压缩 {name}"
+            }
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "ArozOS のファイルを扱うクラウドドライブのワークスペース",
+                "about/full": "フル(ファイルマネージャー API)",
+                "about/host": "ホスト",
+                "about/mode": "ファイルアクセス",
+                "about/reduced": "制限付き(AGI フォールバック)",
+                "about/version": "ArozOS",
+                "act/created": "作成",
+                "act/createdBy": "{name} がこのアイテムを作成しました",
+                "act/expired": "共有リンクの有効期限が切れました",
+                "act/modified": "最終更新",
+                "act/none": "アクティビティの記録はまだありません。",
+                "act/note": "ストレージ、共有リンク、版の履歴に記録された日時から作成しています。",
+                "act/opened": "Drive で開きました",
+                "act/shared": "{who} が{audience}と共有しました",
+                "act/version": "版を保存しました({size})",
+                "act/willExpire": "共有リンクの有効期限",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "リンクを知っている全員",
+                "audience/anyonePw": "リンクとパスワードを知っている全員",
+                "audience/groups": "グループ:{names}",
+                "audience/groupsTitle": "招待したグループ",
+                "audience/pickGroups": "特定のグループ",
+                "audience/pickUsers": "特定のユーザー",
+                "audience/samegroup": "同じグループのユーザー",
+                "audience/signedin": "ログイン中のユーザー",
+                "audience/users": "招待したユーザー",
+                "badge/linkOnly": "共有リンクから開きます",
+                "badge/shared": "共有中",
+                "banner/readonly": "この場所は読み取り専用です。ファイルを開いたりダウンロードしたりできますが、変更はできません。",
+                "banner/reduced": "制限モード:アカウントがファイルマネージャーのサービスを利用できないため、削除は完全削除となり、一部の機能は使用できません。",
+                "banner/truncated": "これまでに見つかった最新の結果を表示しています。ドライブが大きいため、スキャンを途中で終了しました。",
+                "cat/documents": "ドキュメント",
+                "cat/music": "音楽",
+                "cat/others": "その他",
+                "cat/photos": "写真",
+                "cat/videos": "動画",
+                "col/access": "アクセスできるユーザー",
+                "col/activity": "最近のアクティビティ",
+                "col/deleted": "削除日時",
+                "col/expires": "有効期限",
+                "col/location": "場所",
+                "col/modified": "更新日時",
+                "col/name": "名前",
+                "col/origin": "元の場所",
+                "col/owner": "オーナー",
+                "col/size": "サイズ",
+                "col/type": "種類",
+                "common/cancel": "キャンセル",
+                "common/close": "閉じる",
+                "common/create": "作成",
+                "common/delete": "削除",
+                "common/done": "完了",
+                "common/loading": "読み込み中...",
+                "common/ok": "OK",
+                "common/retry": "再試行",
+                "common/save": "保存",
+                "common/select": "選択",
+                "conflict/keep": "両方残す",
+                "conflict/replace": "置き換え",
+                "conflict/skip": "スキップ",
+                "conflict/text": "{total} 件中 {n} 件が移動先のアイテムと同じ名前です。",
+                "conflict/title": "一部のアイテムは既に存在します",
+                "copy/here": "ここにコピー",
+                "copy/title": "{n} 件のアイテムをコピー",
+                "copy/titleOne": "「{name}」をコピー",
+                "delete/ok": "完全に削除",
+                "delete/reducedText": "制限モードでは、削除したアイテムを復元できません。",
+                "delete/text": "{n} 件のアイテムを完全に削除します。この操作は元に戻せません。",
+                "delete/text1": "「{name}」を完全に削除します。この操作は元に戻せません。",
+                "delete/title": "完全に削除しますか?",
+                "details/activity": "アクティビティ",
+                "details/by": "{name} による",
+                "details/created": "作成日時",
+                "details/currentFolder": "現在のフォルダ",
+                "details/excludesFolders": "フォルダは含みません",
+                "details/fileInfo": "ファイル情報",
+                "details/folderInfo": "フォルダ情報",
+                "details/general": "全般",
+                "details/getLink": "リンクを取得",
+                "details/items": "アイテム",
+                "details/manySub": "ファイル {files} 件、フォルダ {folders} 件",
+                "details/moreLinks": "ほか {n} 件のリンク",
+                "details/notRecorded": "このストレージでは記録されていません",
+                "details/onlyYou": "この場所にアクセスできるユーザーのみ",
+                "details/permissions": "権限",
+                "details/pick": "ファイルまたはフォルダを選択すると詳細が表示されます。",
+                "details/quick": "クイック操作",
+                "details/sharing": "共有",
+                "details/summary": "概要",
+                "details/title": "詳細",
+                "details/unknown": "不明",
+                "details/versions": "版",
+                "drag/items": "{n} 件のアイテム",
+                "drop/upload": "ドロップして {name} にアップロード",
+                "empty/category": "まだ何もありません",
+                "empty/categoryText": "マイドライブ内のこの種類のファイルがここに表示されます。",
+                "empty/folder": "このフォルダは空です",
+                "empty/folderText": "ここにファイルをドロップするか、「新規」ボタンで追加してください。",
+                "empty/needFM": "制限モードでは利用できません",
+                "empty/needFMText": "このビューに必要な「ファイルマネージャー」の権限がアカウントにありません。",
+                "empty/readonlyText": "この場所は読み取り専用です。",
+                "empty/recent": "最近使用したファイルはありません",
+                "empty/recentText": "開いたり編集したりしたファイルがここに表示されます。",
+                "empty/search": "一致するファイルはありません",
+                "empty/searchText": "別のキーワードを試すか、別の場所を検索してください。",
+                "empty/shared": "共有されたアイテムはまだありません",
+                "empty/sharedByMe": "まだ何も共有していません",
+                "empty/sharedByMeText": "ファイルを選択して「共有」を押すとリンクを作成できます。",
+                "empty/sharedText": "他のユーザーから共有されたファイルやフォルダがここに表示されます。",
+                "empty/starred": "スター付きのアイテムはありません",
+                "empty/starredText": "よく使うファイルやフォルダにスターを付けると、ここに表示されます。",
+                "empty/trash": "ゴミ箱は空です",
+                "empty/trashNeedFM": "「ファイルマネージャー」の権限がない場合、削除したファイルはすぐに消去されます。",
+                "empty/trashText": "削除したアイテムは、ゴミ箱を空にするまでここに保存されます。",
+                "err/badName": "名前に / や \\ は使用できません",
+                "err/copyFolder": "フォルダには「コピー先...」を使用してください",
+                "err/intoItself": "フォルダをそれ自身の中に移動することはできません",
+                "err/loadTitle": "この場所を開けません",
+                "err/needFM": "この操作には「ファイルマネージャー」の権限が必要です",
+                "err/network": "サーバーに接続できません",
+                "err/noModule": "{name} を使用する権限がありません",
+                "err/readonly": "この場所は読み取り専用です",
+                "err/unknown": "問題が発生しました",
+                "err/zipNeedFM": "フォルダを ZIP でダウンロードするには「ファイルマネージャー」の権限が必要です",
+                "foot/item": "{n} 件のアイテム",
+                "foot/items": "{n} 件のアイテム",
+                "foot/loading": "読み込み中...",
+                "help/about": "Drive について",
+                "help/shortcuts": "キーボードショートカット",
+                "host/back": "Drive に戻る",
+                "host/close": "このアプリを閉じる",
+                "host/newTab": "新しいブラウザタブで開く",
+                "host/openApps": "{n} 個のアプリを使用中",
+                "host/return": "開いているアプリに戻る",
+                "host/untitled": "アプリ",
+                "level/edit": "編集可能",
+                "level/editFolder": "編集可能(ファイルのアップロードと変更)",
+                "level/view": "閲覧のみ",
+                "menu/actions": "操作",
+                "menu/compress": "ZIP に圧縮",
+                "menu/copy": "コピー",
+                "menu/copyLink": "リンクをコピー",
+                "menu/copyPath": "パスをコピー",
+                "menu/copyTo": "コピー先...",
+                "menu/cut": "切り取り",
+                "menu/delete": "削除",
+                "menu/details": "詳細を表示",
+                "menu/downloadFolder": "ZIP でダウンロード",
+                "menu/duplicate": "コピーを作成",
+                "menu/extract": "ここに展開",
+                "menu/folderDetails": "フォルダの詳細",
+                "menu/moveTo": "移動先...",
+                "menu/nothing": "何もありません",
+                "menu/open": "開く",
+                "menu/openNewTab": "新しいタブで開く",
+                "menu/openWith": "アプリで開く",
+                "menu/paste": "貼り付け",
+                "menu/pasteInto": "フォルダに貼り付け",
+                "menu/preview": "プレビュー",
+                "menu/refresh": "更新",
+                "menu/rename": "名前を変更",
+                "menu/selectAll": "すべて選択",
+                "menu/showHidden": "隠しファイルを表示",
+                "menu/showLocation": "ファイルの場所を表示",
+                "menu/star": "スターを付ける",
+                "menu/trash": "ゴミ箱に移動",
+                "menu/unstar": "スターを外す",
+                "menu/versions": "版の履歴",
+                "misc/pathCopied": "パスをコピーしました",
+                "move/here": "ここに移動",
+                "move/title": "{n} 件のアイテムを移動",
+                "move/titleOne": "「{name}」を移動",
+                "nav/categories": "カテゴリ",
+                "nav/drives": "ストレージ",
+                "nav/myDrive": "マイドライブ",
+                "nav/readonly": "読み取り専用",
+                "nav/recent": "最近使用したアイテム",
+                "nav/shared": "共有アイテム",
+                "nav/starred": "スター付き",
+                "nav/tmp": "一時領域",
+                "nav/trash": "ゴミ箱",
+                "new/fileTitle": "新しい{type}",
+                "new/untitled": "無題",
+                "notif/clear": "すべて消去",
+                "notif/markRead": "すべて既読にする",
+                "notif/none": "新しい通知はありません",
+                "notif/open": "開く",
+                "notif/title": "通知",
+                "open/always": ".{ext} ファイルは常に選択したアプリで開く",
+                "open/choose": "別のアプリを選択...",
+                "open/noApp": ".{ext} ファイルに対応するアプリがインストールされていません。プレビュー、ダウンロード、または別のアプリを試すことができます。",
+                "open/otherApps": "その他のアプリ",
+                "open/preview": "Drive プレビュー",
+                "open/suggested": "おすすめのアプリ",
+                "open/withTitle": "「{name}」を開くアプリ",
+                "owner/me": "自分",
+                "pager/next": "次のページ",
+                "pager/prev": "前のページ",
+                "perm/access": "アクセス権",
+                "perm/links": "共有リンク",
+                "perm/mode": "ストレージの権限ビット",
+                "perm/noLinks": "このアイテムには共有リンクがありません。",
+                "perm/owner": "オーナー",
+                "perm/readOnly": "読み取り専用",
+                "perm/readWrite": "読み取りと書き込み",
+                "perm/yourAccess": "{root} を通じたアクセス権",
+                "picker/locations": "場所",
+                "picker/noFolders": "ここにフォルダはありません。このフォルダを選択することもできます。",
+                "picker/up": "1 つ上へ",
+                "prompt/fileName": "ファイル名",
+                "prompt/folderName": "フォルダ名",
+                "prompt/newName": "新しい名前",
+                "prompt/untitledFolder": "無題のフォルダ",
+                "pv/cannotPlay": "この動画形式はブラウザで再生できません。",
+                "pv/cannotShow": "この画像はブラウザで表示できません。",
+                "pv/next": "次へ",
+                "pv/none": "この種類のファイルはプレビューできません。",
+                "pv/prev": "前へ",
+                "pv/rotate": "回転",
+                "pv/zoomIn": "拡大",
+                "pv/zoomOut": "縮小",
+                "recent/clear": "開いた履歴を消去",
+                "recent/modified": "更新",
+                "recent/opened": "開いた",
+                "search/in": "{where} を検索",
+                "search/sub": "{where} で「{q}」を検索",
+                "search/thisFolder": "このフォルダ",
+                "search/title": "検索結果",
+                "search/where": "検索する場所",
+                "sel/all": "すべて選択",
+                "sel/clear": "選択を解除",
+                "sel/count": "{n} 件選択",
+                "sel/select": "選択",
+                "set/account": "マイアカウント",
+                "set/accountGroup": "アカウント",
+                "set/all": "すべて",
+                "set/appearance": "外観",
+                "set/behaviour": "開く・削除",
+                "set/comfortable": "標準",
+                "set/compact": "コンパクト",
+                "set/confirmTrash": "ゴミ箱に移動する前に確認",
+                "set/confirmTrashDesc": "ゴミ箱のアイテムはいつでも復元できます",
+                "set/density": "表示密度",
+                "set/densityDesc": "各行が占めるスペース",
+                "set/details": "詳細",
+                "set/fileAccess": "ファイルアクセス",
+                "set/files": "ファイルとフォルダ",
+                "set/foldersFirstDesc": "ファイルより先にフォルダを表示",
+                "set/hiddenDesc": "名前がドットで始まるファイルとフォルダ",
+                "set/inDrive": "Drive 内",
+                "set/inTab": "新しいブラウザタブ",
+                "set/langAuto": "ブラウザに合わせる",
+                "set/language": "言語",
+                "set/languageDesc": "このブラウザのほかの ArozOS アプリにも適用されます",
+                "set/open": "開く",
+                "set/openIn": "ファイルを開く場所",
+                "set/openInDesc": "ファイルを開いたときにアプリを表示する場所",
+                "set/order": "順序",
+                "set/pageSize": "1 ページの表示件数",
+                "set/pageSizeDesc": "ページ分割で大きなフォルダも軽快に表示",
+                "set/reducedDesc": "制限付き:ゴミ箱、版の履歴、大きなファイルのアップロードを使うには、管理者に「ファイルマネージャー」の権限を依頼してください",
+                "set/saved": "設定を保存しました",
+                "set/sort": "並べ替え",
+                "set/switch": "切り替え",
+                "set/switchDesc": "このアカウントはデスクトップなど別のインターフェースも利用できます",
+                "set/systemDesc": "パスワード、ネットワーク、ユーザーなどシステム全体の設定",
+                "set/theme": "テーマ",
+                "set/themeAuto": "デバイスに合わせる",
+                "set/themeDark": "ダーク",
+                "set/themeDesc": "Drive で開いたアプリも、対応していればこのテーマに合わせます",
+                "set/themeLight": "ライト",
+                "set/thumbs": "サムネイル",
+                "set/thumbsDesc": "リストに写真や動画のプレビューを表示",
+                "set/used": "使用容量",
+                "set/view": "既定のレイアウト",
+                "share/allowDownload": "ダウンロードを許可",
+                "share/badDate": "未来の日付を選択してください",
+                "share/copied": "リンクをコピーしました",
+                "share/create": "リンクを作成",
+                "share/created": "リンクを作成しました",
+                "share/createdCopied": "リンクを作成してコピーしました",
+                "share/date": "有効期限",
+                "share/edit": "リンクを編集",
+                "share/editTitle": "リンクを編集",
+                "share/expired": "期限切れ",
+                "share/expires": "{date} に期限切れ",
+                "share/expiry": "リンクの有効期限",
+                "share/hasPassword": "パスワード",
+                "share/in1": "1 日後",
+                "share/in30": "30 日後",
+                "share/in7": "7 日後",
+                "share/keepPassword": "空欄のままにすると現在のパスワードを維持します",
+                "share/level": "アクセス権",
+                "share/linkCopied": "リンクをコピーしました。リンクを知っている全員が閲覧できます。",
+                "share/manage": "管理",
+                "share/manageSharing": "共有を管理",
+                "share/needGroups": "グループを 1 つ以上選択してください",
+                "share/needPassword": "パスワードを入力するか、このオプションをオフにしてください",
+                "share/needUsers": "ユーザーを 1 人以上選択してください",
+                "share/never": "なし",
+                "share/newLink": "リンクを作成",
+                "share/newPassword": "パスワード",
+                "share/noDownload": "ダウンロード不可",
+                "share/noList": "ファイル一覧を非表示",
+                "share/nobody": "選択できる対象がありません",
+                "share/notOwner": "このアイテムは他のユーザーが共有しています。リンクを変更できるのはそのユーザーだけです。",
+                "share/notOwnerShort": "このアイテムは他のユーザーが共有しています",
+                "share/onDate": "日付を指定...",
+                "share/password": "パスワードを要求",
+                "share/passwordHint": "パスワードを知っているユーザーだけがリンクを開けます",
+                "share/passwordSet": "パスワードが設定されています。新しいパスワードを入力すると変更できます。",
+                "share/pickGroups": "グループを選択",
+                "share/pickUsers": "ユーザーを選択",
+                "share/private": "共有されていません",
+                "share/privateText": "自分と、この場所にアクセスできるユーザーだけが開けます。",
+                "share/qr": "QR コード",
+                "share/reducedText": "制限モードでは、各アイテムに公開リンクを 1 つだけ作成できます。",
+                "share/remove": "リンクを削除",
+                "share/removed": "リンクを削除しました",
+                "share/saved": "リンクを更新しました",
+                "share/showList": "ファイル一覧を表示",
+                "share/stop": "共有を停止",
+                "share/stopped": "共有を停止しました",
+                "share/title": "「{name}」を共有",
+                "share/who": "開けるユーザー",
+                "shared/by": "{name} が共有",
+                "shared/byMe": "自分が共有",
+                "shared/withMe": "自分と共有",
+                "shortcut/copy": "コピー / 切り取り / 貼り付け",
+                "shortcut/delete": "完全に削除",
+                "shortcut/details": "詳細の表示 / 非表示",
+                "shortcut/download": "ダウンロード",
+                "shortcut/move": "フォーカスを移動",
+                "shortcut/newFolder": "新しいフォルダ",
+                "shortcut/open": "開く",
+                "shortcut/preview": "プレビュー",
+                "shortcut/rename": "名前を変更",
+                "shortcut/search": "検索",
+                "shortcut/select": "すべて選択",
+                "shortcut/trash": "ゴミ箱に移動",
+                "shortcut/up": "親フォルダ",
+                "shortcut/upload": "ファイルをアップロード",
+                "sort/asc": "昇順",
+                "sort/by": "並べ替え",
+                "sort/desc": "降順",
+                "sort/foldersFirst": "フォルダを先頭に表示",
+                "sort/order": "順序",
+                "srv/Access Denied": "アクセスが拒否されました",
+                "srv/File already exists": "ファイルは既に存在します",
+                "srv/File not exists": "ファイルが存在しません",
+                "srv/Filename contains illegal characters": "ファイル名に使用できない文字が含まれています",
+                "srv/Folder not exists": "フォルダが存在しません",
+                "srv/Given filename already exists": "このファイル名は既に存在します",
+                "srv/Given folder already exists": "フォルダは既に存在します",
+                "srv/Permission Denied": "権限がありません",
+                "srv/Storage Quota Full": "ストレージの容量制限に達しました",
+                "srv/TRASH_QUOTA_EXCEEDED": "ゴミ箱がいっぱいです",
+                "srv/This directory is Read Only": "このディレクトリは読み取り専用です",
+                "srv/User Storage Quota Exceeded": "ユーザーのストレージ容量を超えました",
+                "storage/title": "ストレージ",
+                "storage/usedOf": "{total} 中 {used} を使用",
+                "storage/usedUnlimited": "{used} を使用 · 容量制限なし",
+                "sub/category": "マイドライブ内の最新の{what}",
+                "sub/myDrive": "個人用ストレージ",
+                "sub/readonlyRoot": "{name} · 読み取り専用",
+                "sub/recent": "過去 30 日間に開いた、または変更したファイル",
+                "sub/settings": "これらの設定は Drive にのみ適用されます。システム全体のオプションはシステム設定にあります。",
+                "sub/sharedByMe": "作成した共有リンク",
+                "sub/sharedWithMe": "他のユーザーから共有されたファイル",
+                "sub/starred": "スターを付けたアイテムにすばやくアクセス",
+                "sub/trash": "ゴミ箱のアイテムは保存期間を過ぎると完全に削除されます",
+                "time/days": "{n} 日前",
+                "time/hours": "{n} 時間前",
+                "time/inDays": "{n} 日後",
+                "time/inHours": "{n} 時間後",
+                "time/inMinutes": "{n} 分後",
+                "time/justNow": "たった今",
+                "time/minutes": "{n} 分前",
+                "toast/clip": "{n} 件のアイテムをクリップボードにコピーしました",
+                "toast/clip1": "1 件のアイテムをクリップボードにコピーしました",
+                "toast/copied": "{n} 件のアイテムを {name} にコピーしました",
+                "toast/copied1": "1 件のアイテムを {name} にコピーしました",
+                "toast/created": "{name} を作成しました",
+                "toast/cut": "{n} 件のアイテムを切り取りました。別のフォルダに貼り付けてください。",
+                "toast/cut1": "1 件のアイテムを切り取りました。別のフォルダに貼り付けてください。",
+                "toast/deleted": "{n} 件のアイテムを削除しました",
+                "toast/deleted1": "1 件のアイテムを削除しました",
+                "toast/duplicated": "{n} 件のコピーを作成しました",
+                "toast/duplicated1": "コピーを作成しました",
+                "toast/moved": "{n} 件のアイテムを {name} に移動しました",
+                "toast/moved1": "1 件のアイテムを {name} に移動しました",
+                "toast/open": "開く",
+                "toast/renamed": "{name} に名前を変更しました",
+                "toast/restored": "復元しました",
+                "toast/restoredN": "{n} 件のアイテムを復元しました",
+                "toast/restoredN1": "1 件のアイテムを復元しました",
+                "toast/show": "表示",
+                "toast/starred": "{n} 件のアイテムにスターを付けました",
+                "toast/starred1": "スターを付けました",
+                "toast/trashEmptied": "ゴミ箱を空にしました",
+                "toast/trashed": "{n} 件のアイテムをゴミ箱に移動しました",
+                "toast/trashed1": "1 件のアイテムをゴミ箱に移動しました",
+                "toast/undo": "元に戻す",
+                "toast/unstarred": "{n} 件のアイテムのスターを外しました",
+                "toast/unstarred1": "スターを外しました",
+                "toolbar/download": "ダウンロード",
+                "toolbar/more": "その他",
+                "toolbar/move": "移動",
+                "toolbar/new": "新規",
+                "toolbar/newFile": "新しいファイル",
+                "toolbar/newFolder": "新しいフォルダ",
+                "toolbar/share": "共有",
+                "toolbar/upload": "アップロード",
+                "toolbar/uploadFiles": "ファイルをアップロード",
+                "toolbar/uploadFolder": "フォルダをアップロード",
+                "top/help": "ヘルプ",
+                "top/menu": "メニュー",
+                "top/notifications": "通知",
+                "top/search": "ファイル、フォルダなどを検索...",
+                "top/searchBtn": "検索",
+                "trash/confirmText": "{n} 件のアイテムをゴミ箱に移動します。",
+                "trash/confirmText1": "「{name}」をゴミ箱に移動します。",
+                "trash/confirmTitle": "ゴミ箱に移動しますか?",
+                "trash/deleteForever": "完全に削除",
+                "trash/empty": "ゴミ箱を空にする",
+                "trash/emptyText": "ゴミ箱内のすべてのアイテムが完全に削除されます。この操作は元に戻せません。",
+                "trash/emptyTitle": "ゴミ箱を空にしますか?",
+                "trash/fullText": "ゴミ箱がいっぱいです。代わりにこれらのアイテムを完全に削除しますか?",
+                "trash/fullTitle": "ゴミ箱に移動できません",
+                "trash/move": "ゴミ箱に移動",
+                "trash/restore": "復元",
+                "trash/unsupported": "このストレージにはゴミ箱がありません。代わりにこれらのアイテムを完全に削除しますか?",
+                "type/archive": "アーカイブ",
+                "type/audio": "音声",
+                "type/code": "コード",
+                "type/data": "データベース",
+                "type/design": "デザイン",
+                "type/disk": "ディスクイメージ",
+                "type/doc": "ドキュメント",
+                "type/ebook": "電子書籍",
+                "type/exe": "アプリケーション",
+                "type/extFile": "{ext} ファイル",
+                "type/file": "ファイル",
+                "type/folder": "フォルダ",
+                "type/font": "フォント",
+                "type/image": "画像",
+                "type/model": "3D モデル",
+                "type/pdf": "PDF",
+                "type/sheet": "スプレッドシート",
+                "type/shortcut": "ショートカット",
+                "type/slide": "プレゼンテーション",
+                "type/text": "テキスト",
+                "type/video": "動画",
+                "unit/bytes": "バイト",
+                "user/darkMode": "ダークテーマに切り替え",
+                "user/driveSettings": "Drive の設定",
+                "user/lightMode": "ライトテーマに切り替え",
+                "user/logout": "ログアウト",
+                "user/logoutConfirm": "このブラウザで ArozOS からログアウトしますか?",
+                "user/switchInterface": "ワークスペースを切り替え",
+                "user/systemSettings": "システム設定",
+                "ver/current": "現在の版",
+                "ver/delete": "この版を削除",
+                "ver/deleteAll": "すべての版を削除",
+                "ver/deleteAllText": "このファイルの以前の版がすべて削除されます。",
+                "ver/none": "以前の版はありません。ファイルが上書きされたとき、またはここで保存したときに版が残ります。",
+                "ver/restore": "この版を復元",
+                "ver/restoreOk": "復元",
+                "ver/restoreText": "現在の内容がこの版に置き換えられます。",
+                "ver/restored": "版を復元しました",
+                "ver/save": "今すぐ版を保存",
+                "ver/saved": "版を保存しました",
+                "view/details": "詳細を表示",
+                "view/grid": "グリッド表示",
+                "view/list": "リスト表示",
+                "xfer/cancelAll": "転送をキャンセル",
+                "xfer/cancelAllText": "{n} 件の転送が進行中です。キャンセルしますか?",
+                "xfer/cancelAllTitle": "転送をキャンセルしますか?",
+                "xfer/cancelled": "キャンセル済み",
+                "xfer/collapse": "折りたたむ",
+                "xfer/connectionLost": "接続が切れました",
+                "xfer/copiedTo": "{name} にコピーしました",
+                "xfer/copyMade": "コピーを作成しました",
+                "xfer/copying": "{name} をコピー中",
+                "xfer/done": "完了",
+                "xfer/doneN": "{n} 件の転送が完了しました",
+                "xfer/downloadAgain": "もう一度ダウンロード",
+                "xfer/expand": "展開",
+                "xfer/extracted": "展開しました",
+                "xfer/failed": "失敗",
+                "xfer/failedN": "{n} 件が失敗しました",
+                "xfer/minutesLeft": "残り {n} 分",
+                "xfer/movedTo": "{name} に移動しました",
+                "xfer/moving": "{name} を移動中",
+                "xfer/preparing": "{name} を準備中",
+                "xfer/processing": "仕上げ中",
+                "xfer/queued": "待機中",
+                "xfer/secondsLeft": "残り {n} 秒",
+                "xfer/showFolder": "フォルダで表示",
+                "xfer/tooBigReduced": "{n} 件のファイルが 24 MB を超えています。アップロードには「ファイルマネージャー」の権限が必要です",
+                "xfer/unzipping": "{name} を展開中",
+                "xfer/uploadFailed": "アップロードに失敗しました(ファイルが大きすぎるか、ディスクがいっぱいの可能性があります)",
+                "xfer/uploadingN": "{n} 件をアップロード中",
+                "xfer/working": "処理中",
+                "xfer/workingN": "{n} 件の転送を処理中",
+                "xfer/zipped": "アーカイブを作成しました",
+                "xfer/zipping": "{name} を圧縮中"
+            }
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fwtitle": "Drive",
+            "strings": {
+                "about/desc": "ArozOS 파일을 위한 클라우드 드라이브 작업 공간",
+                "about/full": "전체 (파일 관리자 API)",
+                "about/host": "호스트",
+                "about/mode": "파일 접근",
+                "about/reduced": "제한됨 (AGI 대체 경로)",
+                "about/version": "ArozOS",
+                "act/created": "만듦",
+                "act/createdBy": "{name}님이 이 항목을 만들었습니다",
+                "act/expired": "공유 링크가 만료되었습니다",
+                "act/modified": "마지막 수정",
+                "act/none": "아직 기록된 활동이 없습니다.",
+                "act/note": "저장소, 공유 링크, 버전 기록에 남은 날짜를 바탕으로 만들었습니다.",
+                "act/opened": "Drive에서 열었습니다",
+                "act/shared": "{who}님이 {audience}에게 공유했습니다",
+                "act/version": "버전 저장됨 ({size})",
+                "act/willExpire": "공유 링크 만료 예정",
+                "app/name": "Drive",
+                "app/title": "Drive",
+                "audience/anyone": "링크가 있는 모든 사람",
+                "audience/anyonePw": "링크와 비밀번호가 있는 모든 사람",
+                "audience/groups": "그룹: {names}",
+                "audience/groupsTitle": "초대한 그룹",
+                "audience/pickGroups": "특정 그룹",
+                "audience/pickUsers": "특정 사용자",
+                "audience/samegroup": "같은 그룹의 사용자",
+                "audience/signedin": "로그인한 사용자",
+                "audience/users": "초대한 사용자",
+                "badge/linkOnly": "공유 링크로 열립니다",
+                "badge/shared": "공유됨",
+                "banner/readonly": "이 위치는 읽기 전용입니다. 파일을 열고 다운로드할 수 있지만 변경할 수는 없습니다.",
+                "banner/reduced": "제한 모드: 계정이 파일 관리자 서비스를 사용할 수 없어 삭제는 영구적이며 일부 기능을 사용할 수 없습니다.",
+                "banner/truncated": "지금까지 찾은 최신 결과를 표시합니다. 드라이브가 커서 검색을 일찍 종료했습니다.",
+                "cat/documents": "문서",
+                "cat/music": "음악",
+                "cat/others": "기타",
+                "cat/photos": "사진",
+                "cat/videos": "동영상",
+                "col/access": "접근 가능한 사용자",
+                "col/activity": "최근 활동",
+                "col/deleted": "삭제한 날짜",
+                "col/expires": "만료일",
+                "col/location": "위치",
+                "col/modified": "수정한 날짜",
+                "col/name": "이름",
+                "col/origin": "원래 위치",
+                "col/owner": "소유자",
+                "col/size": "크기",
+                "col/type": "유형",
+                "common/cancel": "취소",
+                "common/close": "닫기",
+                "common/create": "만들기",
+                "common/delete": "삭제",
+                "common/done": "완료",
+                "common/loading": "불러오는 중...",
+                "common/ok": "확인",
+                "common/retry": "다시 시도",
+                "common/save": "저장",
+                "common/select": "선택",
+                "conflict/keep": "모두 유지",
+                "conflict/replace": "바꾸기",
+                "conflict/skip": "건너뛰기",
+                "conflict/text": "{total}개 중 {n}개 항목이 대상 위치의 항목과 이름이 같습니다.",
+                "conflict/title": "일부 항목이 이미 있습니다",
+                "copy/here": "여기에 복사",
+                "copy/title": "항목 {n}개 복사",
+                "copy/titleOne": "\"{name}\" 복사",
+                "delete/ok": "영구 삭제",
+                "delete/reducedText": "제한 모드에서는 삭제한 항목을 복원할 수 없습니다.",
+                "delete/text": "항목 {n}개가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
+                "delete/text1": "\"{name}\"이(가) 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
+                "delete/title": "영구적으로 삭제할까요?",
+                "details/activity": "활동",
+                "details/by": "{name}",
+                "details/created": "만든 날짜",
+                "details/currentFolder": "현재 폴더",
+                "details/excludesFolders": "폴더는 포함되지 않음",
+                "details/fileInfo": "파일 정보",
+                "details/folderInfo": "폴더 정보",
+                "details/general": "일반",
+                "details/getLink": "링크 가져오기",
+                "details/items": "항목",
+                "details/manySub": "파일 {files}개, 폴더 {folders}개",
+                "details/moreLinks": "링크 {n}개 더 있음",
+                "details/notRecorded": "이 저장소에는 기록되지 않음",
+                "details/onlyYou": "이 위치에 접근할 수 있는 사용자만",
+                "details/permissions": "권한",
+                "details/pick": "파일이나 폴더를 선택하면 세부정보가 표시됩니다.",
+                "details/quick": "빠른 작업",
+                "details/sharing": "공유",
+                "details/summary": "요약",
+                "details/title": "세부정보",
+                "details/unknown": "알 수 없음",
+                "details/versions": "버전",
+                "drag/items": "항목 {n}개",
+                "drop/upload": "놓아서 {name}에 업로드",
+                "empty/category": "아직 항목이 없습니다",
+                "empty/categoryText": "내 드라이브에 있는 이 유형의 파일이 여기에 표시됩니다.",
+                "empty/folder": "이 폴더는 비어 있습니다",
+                "empty/folderText": "여기에 파일을 끌어다 놓거나 \"새로 만들기\" 버튼으로 추가하세요.",
+                "empty/needFM": "제한 모드에서는 사용할 수 없습니다",
+                "empty/needFMText": "계정에 이 보기에 필요한 \"파일 관리자\" 권한이 없습니다.",
+                "empty/readonlyText": "이 위치는 읽기 전용입니다.",
+                "empty/recent": "최근 파일이 없습니다",
+                "empty/recentText": "열거나 편집한 파일이 여기에 표시됩니다.",
+                "empty/search": "일치하는 파일이 없습니다",
+                "empty/searchText": "다른 키워드를 사용하거나 다른 위치를 검색해 보세요.",
+                "empty/shared": "아직 나와 공유된 항목이 없습니다",
+                "empty/sharedByMe": "아직 공유한 항목이 없습니다",
+                "empty/sharedByMeText": "파일을 선택하고 \"공유\"를 누르면 링크를 만들 수 있습니다.",
+                "empty/sharedText": "다른 사용자가 나와 공유한 파일과 폴더가 여기에 표시됩니다.",
+                "empty/starred": "별표한 항목이 없습니다",
+                "empty/starredText": "자주 사용하는 파일과 폴더에 별표를 추가하면 여기에서 찾을 수 있습니다.",
+                "empty/trash": "휴지통이 비어 있습니다",
+                "empty/trashNeedFM": "\"파일 관리자\" 권한이 없으면 삭제한 파일이 즉시 제거됩니다.",
+                "empty/trashText": "삭제한 항목은 휴지통을 비울 때까지 여기에 보관됩니다.",
+                "err/badName": "이름에 / 또는 \\를 사용할 수 없습니다",
+                "err/copyFolder": "폴더는 \"복사 위치...\"를 사용하세요",
+                "err/intoItself": "폴더를 자기 자신 안으로 옮길 수 없습니다",
+                "err/loadTitle": "이 위치를 열 수 없습니다",
+                "err/needFM": "이 작업에는 \"파일 관리자\" 권한이 필요합니다",
+                "err/network": "서버에 연결할 수 없습니다",
+                "err/noModule": "{name}에 대한 접근 권한이 없습니다",
+                "err/readonly": "이 위치는 읽기 전용입니다",
+                "err/unknown": "문제가 발생했습니다",
+                "err/zipNeedFM": "폴더를 ZIP으로 다운로드하려면 \"파일 관리자\" 권한이 필요합니다",
+                "foot/item": "항목 {n}개",
+                "foot/items": "항목 {n}개",
+                "foot/loading": "불러오는 중...",
+                "help/about": "Drive 정보",
+                "help/shortcuts": "키보드 단축키",
+                "host/back": "Drive로 돌아가기",
+                "host/close": "이 앱 닫기",
+                "host/newTab": "새 브라우저 탭에서 열기",
+                "host/openApps": "앱 {n}개 실행 중",
+                "host/return": "열린 앱으로 돌아가기",
+                "host/untitled": "앱",
+                "level/edit": "편집 가능",
+                "level/editFolder": "편집 가능 (파일 업로드 및 변경)",
+                "level/view": "보기 가능",
+                "menu/actions": "작업",
+                "menu/compress": "ZIP으로 압축",
+                "menu/copy": "복사",
+                "menu/copyLink": "링크 복사",
+                "menu/copyPath": "경로 복사",
+                "menu/copyTo": "복사 위치...",
+                "menu/cut": "잘라내기",
+                "menu/delete": "삭제",
+                "menu/details": "세부정보 보기",
+                "menu/downloadFolder": "ZIP으로 다운로드",
+                "menu/duplicate": "사본 만들기",
+                "menu/extract": "여기에 압축 풀기",
+                "menu/folderDetails": "폴더 세부정보",
+                "menu/moveTo": "이동 위치...",
+                "menu/nothing": "항목 없음",
+                "menu/open": "열기",
+                "menu/openNewTab": "새 탭에서 열기",
+                "menu/openWith": "연결 프로그램",
+                "menu/paste": "붙여넣기",
+                "menu/pasteInto": "폴더에 붙여넣기",
+                "menu/preview": "미리보기",
+                "menu/refresh": "새로고침",
+                "menu/rename": "이름 바꾸기",
+                "menu/selectAll": "모두 선택",
+                "menu/showHidden": "숨김 파일 표시",
+                "menu/showLocation": "파일 위치 보기",
+                "menu/star": "별표 추가",
+                "menu/trash": "휴지통으로 이동",
+                "menu/unstar": "별표 해제",
+                "menu/versions": "버전 기록",
+                "misc/pathCopied": "경로를 복사했습니다",
+                "move/here": "여기로 이동",
+                "move/title": "항목 {n}개 이동",
+                "move/titleOne": "\"{name}\" 이동",
+                "nav/categories": "카테고리",
+                "nav/drives": "저장소",
+                "nav/myDrive": "내 드라이브",
+                "nav/readonly": "읽기 전용",
+                "nav/recent": "최근 문서함",
+                "nav/shared": "공유 문서함",
+                "nav/starred": "별표 문서함",
+                "nav/tmp": "임시 공간",
+                "nav/trash": "휴지통",
+                "new/fileTitle": "새 {type}",
+                "new/untitled": "제목 없음",
+                "notif/clear": "모두 지우기",
+                "notif/markRead": "모두 읽음으로 표시",
+                "notif/none": "새 알림이 없습니다",
+                "notif/open": "열기",
+                "notif/title": "알림",
+                "open/always": ".{ext} 파일은 항상 선택한 앱으로 열기",
+                "open/choose": "다른 앱 선택...",
+                "open/noApp": ".{ext} 파일을 여는 앱이 설치되어 있지 않습니다. 미리보기, 다운로드 또는 다른 앱을 사용해 보세요.",
+                "open/otherApps": "기타 앱",
+                "open/preview": "Drive 미리보기",
+                "open/suggested": "추천 앱",
+                "open/withTitle": "\"{name}\" 열기 방법",
+                "owner/me": "나",
+                "pager/next": "다음 페이지",
+                "pager/prev": "이전 페이지",
+                "perm/access": "접근 권한",
+                "perm/links": "공유 링크",
+                "perm/mode": "저장소 권한 비트",
+                "perm/noLinks": "이 항목에는 공유 링크가 없습니다.",
+                "perm/owner": "소유자",
+                "perm/readOnly": "읽기 전용",
+                "perm/readWrite": "읽기 및 쓰기",
+                "perm/yourAccess": "{root}을(를) 통한 내 접근 권한",
+                "picker/locations": "위치",
+                "picker/noFolders": "여기에는 폴더가 없습니다. 이 폴더를 선택할 수도 있습니다.",
+                "picker/up": "상위 폴더로",
+                "prompt/fileName": "파일 이름",
+                "prompt/folderName": "폴더 이름",
+                "prompt/newName": "새 이름",
+                "prompt/untitledFolder": "제목 없는 폴더",
+                "pv/cannotPlay": "브라우저에서 이 동영상 형식을 재생할 수 없습니다.",
+                "pv/cannotShow": "브라우저에서 이 이미지를 표시할 수 없습니다.",
+                "pv/next": "다음",
+                "pv/none": "이 파일 형식은 미리 볼 수 없습니다.",
+                "pv/prev": "이전",
+                "pv/rotate": "회전",
+                "pv/zoomIn": "확대",
+                "pv/zoomOut": "축소",
+                "recent/clear": "열기 기록 지우기",
+                "recent/modified": "수정",
+                "recent/opened": "열람",
+                "search/in": "{where}에서 검색",
+                "search/sub": "{where}에서 \"{q}\" 검색",
+                "search/thisFolder": "이 폴더",
+                "search/title": "검색 결과",
+                "search/where": "검색 위치",
+                "sel/all": "모두 선택",
+                "sel/clear": "선택 해제",
+                "sel/count": "{n}개 선택됨",
+                "sel/select": "선택",
+                "set/account": "내 계정",
+                "set/accountGroup": "계정",
+                "set/all": "전체",
+                "set/appearance": "모양",
+                "set/behaviour": "열기 및 삭제",
+                "set/comfortable": "보통",
+                "set/compact": "좁게",
+                "set/confirmTrash": "휴지통으로 이동하기 전에 확인",
+                "set/confirmTrashDesc": "휴지통의 항목은 언제든지 복원할 수 있습니다",
+                "set/density": "표시 밀도",
+                "set/densityDesc": "각 행이 차지하는 공간",
+                "set/details": "세부정보",
+                "set/fileAccess": "파일 접근",
+                "set/files": "파일 및 폴더",
+                "set/foldersFirstDesc": "파일보다 폴더를 먼저 표시",
+                "set/hiddenDesc": "이름이 점으로 시작하는 파일과 폴더",
+                "set/inDrive": "Drive 안에서",
+                "set/inTab": "새 브라우저 탭에서",
+                "set/langAuto": "브라우저 설정 따르기",
+                "set/language": "언어",
+                "set/languageDesc": "이 브라우저의 다른 ArozOS 앱에도 적용됩니다",
+                "set/open": "열기",
+                "set/openIn": "파일 열기",
+                "set/openInDesc": "파일을 열 때 앱이 표시되는 위치",
+                "set/order": "순서",
+                "set/pageSize": "페이지당 항목 수",
+                "set/pageSizeDesc": "페이지를 나누면 큰 폴더도 빠르게 표시됩니다",
+                "set/reducedDesc": "제한됨: 휴지통, 버전 기록, 대용량 업로드를 사용하려면 관리자에게 \"파일 관리자\" 권한을 요청하세요",
+                "set/saved": "설정을 저장했습니다",
+                "set/sort": "정렬 기준",
+                "set/switch": "전환",
+                "set/switchDesc": "이 계정은 데스크톱 같은 다른 인터페이스도 사용할 수 있습니다",
+                "set/systemDesc": "비밀번호, 네트워크, 사용자 등 시스템 전체 설정",
+                "set/theme": "테마",
+                "set/themeAuto": "기기 설정 따르기",
+                "set/themeDark": "어둡게",
+                "set/themeDesc": "Drive에서 연 앱도 지원하는 경우 이 테마를 따릅니다",
+                "set/themeLight": "밝게",
+                "set/thumbs": "미리보기 이미지",
+                "set/thumbsDesc": "목록에 사진과 동영상 미리보기 표시",
+                "set/used": "사용한 공간",
+                "set/view": "기본 레이아웃",
+                "share/allowDownload": "다운로드 허용",
+                "share/badDate": "미래의 날짜를 선택하세요",
+                "share/copied": "링크를 복사했습니다",
+                "share/create": "링크 만들기",
+                "share/created": "링크를 만들었습니다",
+                "share/createdCopied": "링크를 만들고 복사했습니다",
+                "share/date": "만료일",
+                "share/edit": "링크 편집",
+                "share/editTitle": "링크 편집",
+                "share/expired": "만료됨",
+                "share/expires": "{date} 만료",
+                "share/expiry": "링크 만료",
+                "share/hasPassword": "비밀번호",
+                "share/in1": "1일 후",
+                "share/in30": "30일 후",
+                "share/in7": "7일 후",
+                "share/keepPassword": "비워 두면 현재 비밀번호가 유지됩니다",
+                "share/level": "접근 권한",
+                "share/linkCopied": "링크를 복사했습니다. 링크가 있는 모든 사람이 볼 수 있습니다.",
+                "share/manage": "관리",
+                "share/manageSharing": "공유 관리",
+                "share/needGroups": "그룹을 하나 이상 선택하세요",
+                "share/needPassword": "비밀번호를 입력하거나 이 옵션을 끄세요",
+                "share/needUsers": "사용자를 한 명 이상 선택하세요",
+                "share/never": "없음",
+                "share/newLink": "링크 만들기",
+                "share/newPassword": "비밀번호",
+                "share/noDownload": "다운로드 불가",
+                "share/noList": "파일 목록 숨김",
+                "share/nobody": "선택할 대상이 없습니다",
+                "share/notOwner": "이 항목은 다른 사용자가 공유했습니다. 링크는 해당 사용자만 변경할 수 있습니다.",
+                "share/notOwnerShort": "이 항목은 다른 사용자가 공유했습니다",
+                "share/onDate": "날짜 지정...",
+                "share/password": "비밀번호 필요",
+                "share/passwordHint": "비밀번호를 아는 사람만 링크를 열 수 있습니다",
+                "share/passwordSet": "비밀번호가 설정되어 있습니다. 새 비밀번호를 입력하면 변경됩니다.",
+                "share/pickGroups": "그룹 선택",
+                "share/pickUsers": "사용자 선택",
+                "share/private": "공유되지 않음",
+                "share/privateText": "나와 이 위치에 접근할 수 있는 사용자만 열 수 있습니다.",
+                "share/qr": "QR 코드",
+                "share/reducedText": "제한 모드에서는 항목마다 공개 링크를 하나만 만들 수 있습니다.",
+                "share/remove": "링크 삭제",
+                "share/removed": "링크를 삭제했습니다",
+                "share/saved": "링크를 업데이트했습니다",
+                "share/showList": "파일 목록 표시",
+                "share/stop": "공유 중지",
+                "share/stopped": "공유를 중지했습니다",
+                "share/title": "\"{name}\" 공유",
+                "share/who": "열 수 있는 사용자",
+                "shared/by": "{name}님이 공유함",
+                "shared/byMe": "내가 공유함",
+                "shared/withMe": "나와 공유됨",
+                "shortcut/copy": "복사 / 잘라내기 / 붙여넣기",
+                "shortcut/delete": "영구 삭제",
+                "shortcut/details": "세부정보 표시 / 숨기기",
+                "shortcut/download": "다운로드",
+                "shortcut/move": "포커스 이동",
+                "shortcut/newFolder": "새 폴더",
+                "shortcut/open": "열기",
+                "shortcut/preview": "미리보기",
+                "shortcut/rename": "이름 바꾸기",
+                "shortcut/search": "검색",
+                "shortcut/select": "모두 선택",
+                "shortcut/trash": "휴지통으로 이동",
+                "shortcut/up": "상위 폴더",
+                "shortcut/upload": "파일 업로드",
+                "sort/asc": "오름차순",
+                "sort/by": "정렬 기준",
+                "sort/desc": "내림차순",
+                "sort/foldersFirst": "폴더를 위에 표시",
+                "sort/order": "순서",
+                "srv/Access Denied": "접근이 거부되었습니다",
+                "srv/File already exists": "파일이 이미 있습니다",
+                "srv/File not exists": "파일이 없습니다",
+                "srv/Filename contains illegal characters": "파일 이름에 사용할 수 없는 문자가 있습니다",
+                "srv/Folder not exists": "폴더가 없습니다",
+                "srv/Given filename already exists": "같은 이름의 파일이 이미 있습니다",
+                "srv/Given folder already exists": "폴더가 이미 있습니다",
+                "srv/Permission Denied": "권한이 없습니다",
+                "srv/Storage Quota Full": "저장공간 할당량이 가득 찼습니다",
+                "srv/TRASH_QUOTA_EXCEEDED": "휴지통이 가득 찼습니다",
+                "srv/This directory is Read Only": "이 디렉터리는 읽기 전용입니다",
+                "srv/User Storage Quota Exceeded": "사용자 저장공간 할당량을 초과했습니다",
+                "storage/title": "저장공간",
+                "storage/usedOf": "{total} 중 {used} 사용",
+                "storage/usedUnlimited": "{used} 사용 · 할당량 없음",
+                "sub/category": "내 드라이브의 최신 {what}",
+                "sub/myDrive": "개인 저장공간",
+                "sub/readonlyRoot": "{name} · 읽기 전용",
+                "sub/recent": "최근 30일 동안 열거나 변경한 파일",
+                "sub/settings": "이 설정은 Drive에만 적용됩니다. 시스템 전체 옵션은 시스템 설정에 있습니다.",
+                "sub/sharedByMe": "내가 만든 공유 링크",
+                "sub/sharedWithMe": "다른 사용자가 나와 공유한 파일",
+                "sub/starred": "별표한 항목에 빠르게 접근",
+                "sub/trash": "휴지통의 항목은 보관 기간이 지나면 영구적으로 삭제됩니다",
+                "time/days": "{n}일 전",
+                "time/hours": "{n}시간 전",
+                "time/inDays": "{n}일 후",
+                "time/inHours": "{n}시간 후",
+                "time/inMinutes": "{n}분 후",
+                "time/justNow": "방금",
+                "time/minutes": "{n}분 전",
+                "toast/clip": "항목 {n}개를 클립보드에 복사했습니다",
+                "toast/clip1": "항목 1개를 클립보드에 복사했습니다",
+                "toast/copied": "항목 {n}개를 {name}에 복사했습니다",
+                "toast/copied1": "항목 1개를 {name}에 복사했습니다",
+                "toast/created": "{name}을(를) 만들었습니다",
+                "toast/cut": "항목 {n}개를 잘라냈습니다. 다른 폴더에 붙여넣으세요.",
+                "toast/cut1": "항목 1개를 잘라냈습니다. 다른 폴더에 붙여넣으세요.",
+                "toast/deleted": "항목 {n}개를 삭제했습니다",
+                "toast/deleted1": "항목 1개를 삭제했습니다",
+                "toast/duplicated": "사본 {n}개를 만들었습니다",
+                "toast/duplicated1": "사본을 만들었습니다",
+                "toast/moved": "항목 {n}개를 {name}(으)로 이동했습니다",
+                "toast/moved1": "항목 1개를 {name}(으)로 이동했습니다",
+                "toast/open": "열기",
+                "toast/renamed": "이름을 {name}(으)로 변경했습니다",
+                "toast/restored": "복원했습니다",
+                "toast/restoredN": "항목 {n}개를 복원했습니다",
+                "toast/restoredN1": "항목 1개를 복원했습니다",
+                "toast/show": "보기",
+                "toast/starred": "항목 {n}개에 별표를 추가했습니다",
+                "toast/starred1": "별표를 추가했습니다",
+                "toast/trashEmptied": "휴지통을 비웠습니다",
+                "toast/trashed": "항목 {n}개를 휴지통으로 이동했습니다",
+                "toast/trashed1": "항목 1개를 휴지통으로 이동했습니다",
+                "toast/undo": "실행 취소",
+                "toast/unstarred": "항목 {n}개의 별표를 해제했습니다",
+                "toast/unstarred1": "별표를 해제했습니다",
+                "toolbar/download": "다운로드",
+                "toolbar/more": "더보기",
+                "toolbar/move": "이동",
+                "toolbar/new": "새로 만들기",
+                "toolbar/newFile": "새 파일",
+                "toolbar/newFolder": "새 폴더",
+                "toolbar/share": "공유",
+                "toolbar/upload": "업로드",
+                "toolbar/uploadFiles": "파일 업로드",
+                "toolbar/uploadFolder": "폴더 업로드",
+                "top/help": "도움말",
+                "top/menu": "메뉴",
+                "top/notifications": "알림",
+                "top/search": "파일, 폴더 등 검색...",
+                "top/searchBtn": "검색",
+                "trash/confirmText": "항목 {n}개가 휴지통으로 이동됩니다.",
+                "trash/confirmText1": "\"{name}\"이(가) 휴지통으로 이동됩니다.",
+                "trash/confirmTitle": "휴지통으로 이동할까요?",
+                "trash/deleteForever": "영구 삭제",
+                "trash/empty": "휴지통 비우기",
+                "trash/emptyText": "휴지통의 모든 항목이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
+                "trash/emptyTitle": "휴지통을 비울까요?",
+                "trash/fullText": "휴지통이 가득 찼습니다. 대신 이 항목들을 영구적으로 삭제할까요?",
+                "trash/fullTitle": "휴지통으로 이동할 수 없습니다",
+                "trash/move": "휴지통으로 이동",
+                "trash/restore": "복원",
+                "trash/unsupported": "이 저장소에는 휴지통이 없습니다. 대신 이 항목들을 영구적으로 삭제할까요?",
+                "type/archive": "압축 파일",
+                "type/audio": "오디오",
+                "type/code": "코드",
+                "type/data": "데이터베이스",
+                "type/design": "디자인",
+                "type/disk": "디스크 이미지",
+                "type/doc": "문서",
+                "type/ebook": "전자책",
+                "type/exe": "애플리케이션",
+                "type/extFile": "{ext} 파일",
+                "type/file": "파일",
+                "type/folder": "폴더",
+                "type/font": "글꼴",
+                "type/image": "이미지",
+                "type/model": "3D 모델",
+                "type/pdf": "PDF",
+                "type/sheet": "스프레드시트",
+                "type/shortcut": "바로 가기",
+                "type/slide": "프레젠테이션",
+                "type/text": "텍스트",
+                "type/video": "동영상",
+                "unit/bytes": "바이트",
+                "user/darkMode": "어두운 테마로 전환",
+                "user/driveSettings": "Drive 설정",
+                "user/lightMode": "밝은 테마로 전환",
+                "user/logout": "로그아웃",
+                "user/logoutConfirm": "이 브라우저에서 ArozOS에서 로그아웃할까요?",
+                "user/switchInterface": "작업 공간 전환",
+                "user/systemSettings": "시스템 설정",
+                "ver/current": "현재 버전",
+                "ver/delete": "이 버전 삭제",
+                "ver/deleteAll": "모든 버전 삭제",
+                "ver/deleteAllText": "이 파일의 모든 이전 버전이 삭제됩니다.",
+                "ver/none": "이전 버전이 없습니다. 파일을 덮어쓰거나 여기에서 저장하면 버전이 남습니다.",
+                "ver/restore": "이 버전 복원",
+                "ver/restoreOk": "복원",
+                "ver/restoreText": "현재 내용이 이 버전으로 바뀝니다.",
+                "ver/restored": "버전을 복원했습니다",
+                "ver/save": "지금 버전 저장",
+                "ver/saved": "버전을 저장했습니다",
+                "view/details": "세부정보 보기",
+                "view/grid": "격자 보기",
+                "view/list": "목록 보기",
+                "xfer/cancelAll": "전송 취소",
+                "xfer/cancelAllText": "전송 {n}개가 아직 진행 중입니다. 취소할까요?",
+                "xfer/cancelAllTitle": "전송을 취소할까요?",
+                "xfer/cancelled": "취소됨",
+                "xfer/collapse": "접기",
+                "xfer/connectionLost": "연결이 끊어졌습니다",
+                "xfer/copiedTo": "{name}에 복사함",
+                "xfer/copyMade": "사본을 만들었습니다",
+                "xfer/copying": "{name} 복사 중",
+                "xfer/done": "완료",
+                "xfer/doneN": "전송 {n}개 완료",
+                "xfer/downloadAgain": "다시 다운로드",
+                "xfer/expand": "펼치기",
+                "xfer/extracted": "압축을 풀었습니다",
+                "xfer/failed": "실패",
+                "xfer/failedN": "항목 {n}개 실패",
+                "xfer/minutesLeft": "{n}분 남음",
+                "xfer/movedTo": "{name}(으)로 이동함",
+                "xfer/moving": "{name} 이동 중",
+                "xfer/preparing": "{name} 준비 중",
+                "xfer/processing": "마무리하는 중",
+                "xfer/queued": "대기 중",
+                "xfer/secondsLeft": "{n}초 남음",
+                "xfer/showFolder": "폴더에서 보기",
+                "xfer/tooBigReduced": "파일 {n}개가 24MB를 넘습니다. 업로드하려면 \"파일 관리자\" 권한이 필요합니다",
+                "xfer/unzipping": "{name} 압축 푸는 중",
+                "xfer/uploadFailed": "업로드 실패 (파일이 너무 크거나 디스크가 가득 찼을 수 있습니다)",
+                "xfer/uploadingN": "항목 {n}개 업로드 중",
+                "xfer/working": "처리 중",
+                "xfer/workingN": "전송 {n}개 진행 중",
+                "xfer/zipped": "압축 파일을 만들었습니다",
+                "xfer/zipping": "{name} 압축 중"
+            }
+        }
+    }
+}