From af820f08cef9bd072d925430697de012617ddb67 Mon Sep 17 00:00:00 2001 From: Sebastien DUMETZ Date: Fri, 29 Nov 2024 10:41:32 +0100 Subject: [PATCH] [WIP] stage history card --- source/server/routes/history/get.test.ts | 24 +++- source/server/routes/history/get.ts | 12 +- source/server/routes/history/post.ts | 4 +- source/server/vfs/Scenes.ts | 66 ++++++--- source/server/vfs/types.ts | 6 + source/server/vfs/vfs.test.ts | 117 ++++++++++++---- source/ui/composants/HistoryAggregation.ts | 156 +++++++++++++++++++++ source/ui/screens/SceneHistory.ts | 69 ++------- source/ui/styles/buttons.scss | 22 +++ source/voyager | 2 +- 10 files changed, 366 insertions(+), 112 deletions(-) create mode 100644 source/ui/composants/HistoryAggregation.ts diff --git a/source/server/routes/history/get.test.ts b/source/server/routes/history/get.test.ts index 752de39a..75024d88 100644 --- a/source/server/routes/history/get.test.ts +++ b/source/server/routes/history/get.test.ts @@ -73,7 +73,29 @@ describe("GET /history/:scene", function(){ await vfs.createScene("empty", user.uid); let res = await request(this.server).get("/history/empty") .expect(200); - }) + }); + + it("can ?limit results", async function(){ + let res = await request(this.server).get("/history/foo?limit=1") + .set("Accept", "application/json") + .expect(200) + .expect("Content-Type", "application/json; charset=utf-8"); + + expect(res.body.map((i:any)=>([i.name, i.generation]))).to.deep.equal([ + ["scene.svx.json", 1], + ]); + }); + + it("can ?offset results", async function(){ + let res = await request(this.server).get("/history/foo?limit=1&offset=1") + .set("Accept", "application/json") + .expect(200) + .expect("Content-Type", "application/json; charset=utf-8"); + + expect(res.body.map((i:any)=>([i.name, i.generation]))).to.deep.equal([ + ["models", 1], + ]); + }); describe("requires read access", function(){ this.beforeAll(async function(){ diff --git a/source/server/routes/history/get.ts b/source/server/routes/history/get.ts index 72e2174c..6a85bf84 100644 --- a/source/server/routes/history/get.ts +++ b/source/server/routes/history/get.ts @@ -6,8 +6,18 @@ import { getVfs } from "../../utils/locals.js"; export default async function getSceneHistory(req :Request, res :Response){ let vfs = getVfs(req); let {scene:sceneName} = req.params; + let { + limit, + offset, + orderDirection, + } = req.query; + let scene = await vfs.getScene(sceneName); - let documents = await vfs.getSceneHistory(scene.id); + let documents = await vfs.getSceneHistory(scene.id, { + limit: limit? parseInt(limit as string): undefined, + offset: offset? parseInt(offset as string): undefined, + orderDirection: orderDirection as any, + }); res.format({ "application/json":()=>res.status(200).send(documents), "text": ()=> res.status(200).send(documents.map(doc =>`${doc.name}#${doc.generation}`).join("\n")+"\n"), diff --git a/source/server/routes/history/post.ts b/source/server/routes/history/post.ts index e1fc84d9..5875b4b7 100644 --- a/source/server/routes/history/post.ts +++ b/source/server/routes/history/post.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { BadRequestError } from "../../utils/errors.js"; import { getUser, getVfs } from "../../utils/locals.js"; -import { ItemEntry } from "../../vfs/index.js"; +import { HistoryEntry, ItemEntry } from "../../vfs/index.js"; /** @@ -18,7 +18,7 @@ export async function postSceneHistory(req :Request, res :Response){ let requester = getUser(req); let {scene:sceneName} = req.params; let {name, generation } = req.body; - let files :Map = new Map(); + let files :Map = new Map(); if(!(typeof name === "string" && typeof generation === "number")){ throw new BadRequestError(`History restoration requires either of "name" and "generation" or "id" and "type" or "name" to be set`); } diff --git a/source/server/vfs/Scenes.ts b/source/server/vfs/Scenes.ts index 4dca3ca5..b2b36fe5 100644 --- a/source/server/vfs/Scenes.ts +++ b/source/server/vfs/Scenes.ts @@ -3,7 +3,7 @@ import config from "../utils/config.js"; import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js"; import { Uid } from "../utils/uid.js"; import BaseVfs from "./Base.js"; -import { ItemEntry, Scene, SceneQuery } from "./types.js"; +import { HistoryEntry, ItemEntry, ItemProps, Scene, SceneQuery, Stored } from "./types.js"; export default abstract class ScenesVfs extends BaseVfs{ @@ -104,6 +104,34 @@ export default abstract class ScenesVfs extends BaseVfs{ ) IN (${ AccessTypes.slice(AccessTypes.indexOf(accessMin)).map(s=>`'${s}'`).join(", ") }) `; } + + /** + * Performs a type and limit check on a SceneQuery object and throws if anything is unacceptable + * @param q + */ + static _parseSceneQuery(q :SceneQuery):SceneQuery{ + //Check various parameters compliance + if(Array.isArray(q.access) && q.access.find(a=>AccessTypes.indexOf(a) === -1)){ + throw new BadRequestError(`Bad access type requested : ${q.access.join(", ")}`); + } + if(typeof q.limit !== "undefined"){ + if(typeof q.limit !="number" || Number.isNaN(q.limit) || !Number.isInteger(q.limit)) throw new BadRequestError(`When provided, limit must be an integer`); + if(q.limit <= 0) throw new BadRequestError(`When provided, limit must be >0`); + if(100 < q.limit) throw new BadRequestError(`When provided, limit must be <= 100`); + } + if(typeof q.offset !== "undefined"){ + if(typeof q.offset !="number" || Number.isNaN(q.offset) || !Number.isInteger(q.offset)) throw new BadRequestError(`When provided, offset must be an integer`); + if(q.offset < 0) throw new BadRequestError(`When provided, limit must be >= 0`); + } + + if(typeof q.orderDirection !== "undefined" && (typeof q.orderDirection !== "string" || ["asc", "desc"].indexOf(q.orderDirection.toLowerCase()) === -1)){ + throw new BadRequestError(`Invalid orderDirection: ${q.orderDirection}`); + } + if(typeof q.orderBy !== "undefined" && (typeof q.orderBy !== "string" || ["ctime", "mtime", "name"].indexOf(q.orderBy.toLowerCase()) === -1)){ + throw new BadRequestError(`Invalid orderBy: ${q.orderBy}`); + } + return q; + } /** * get all scenes, including archvied scenes Generally not used outside of tests and internal routines @@ -117,19 +145,8 @@ export default abstract class ScenesVfs extends BaseVfs{ * Get only archived scenes. */ async getScenes(user_id:null, q :{access:["none"]}) :Promise; - async getScenes(user_id ?:number|null, {access, match, limit =10, offset = 0, orderBy="name", orderDirection="asc"} :SceneQuery = {}) :Promise{ - - //Check various parameters compliance - if(Array.isArray(access) && access.find(a=>AccessTypes.indexOf(a) === -1)){ - throw new BadRequestError(`Bad access type requested : ${access.join(", ")}`); - } - - if(typeof limit !="number" || Number.isNaN(limit) || limit < 0) throw new BadRequestError(`When provided, limit must be a number`); - if(typeof offset != "number" || Number.isNaN(offset) || offset < 0) throw new BadRequestError(`When provided, offset must be a number`); - - if(["asc", "desc"].indexOf(orderDirection.toLowerCase()) === -1) throw new BadRequestError(`Invalid orderDirection: ${orderDirection}`); - if(["ctime", "mtime", "name"].indexOf(orderBy.toLowerCase()) === -1) throw new BadRequestError(`Invalid orderBy: ${orderBy}`); - + async getScenes(user_id ?:number|null, q:SceneQuery = {}) :Promise{ + const {access, match, limit =10, offset = 0, orderBy="name", orderDirection="asc"} = ScenesVfs._parseSceneQuery(q); let with_filter = typeof user_id === "number" || match || access?.length; const sortString = (orderBy == "name")? "LOWER(scene_name)": orderBy; @@ -235,7 +252,7 @@ export default abstract class ScenesVfs extends BaseVfs{ `, { ...mParams, $user_id: (user_id? user_id.toString(10) : (access?.length? "0": undefined)), - $limit: Math.min(limit, 100), + $limit: limit, $offset: offset, })).map(({ctime, mtime, id, access, ...m})=>({ ...m, @@ -299,13 +316,15 @@ export default abstract class ScenesVfs extends BaseVfs{ * This could get quite large... * * Return order is **DESCENDING** over ctime, name, generation (so, new files first). + * Result is **NOT** access-dependant so it should only be returned for someone that has the required access level * - * @warning It doesn't have any of the filters `listFiles` has. - * @todo handle size limit and pagination * @see listFiles for a list of current files. */ - async getSceneHistory(id :number) :Promise>{ - let entries = await this.db.all(` + async getSceneHistory(id :number, query:Pick ={}) :Promise>{ + const {limit = 10, offset = 0, orderDirection = "desc"} = ScenesVfs._parseSceneQuery(query); + + const dir = orderDirection.toUpperCase() as Uppercase; + let entries = await this.db.all,"mtime">[]>(` SELECT name, mime, id, generation, ctime, username AS author, author_id, size FROM( SELECT @@ -331,8 +350,13 @@ export default abstract class ScenesVfs extends BaseVfs{ WHERE fk_scene_id = $scene ) INNER JOIN users ON author_id = user_id - ORDER BY ctime DESC, name DESC, generation DESC - `, {$scene: id}); + ORDER BY ctime ${dir}, name ${dir}, generation ${dir} + LIMIT $offset, $limit + `, { + $scene: id, + $offset: offset, + $limit: limit, + }); return entries.map(m=>({ ...m, diff --git a/source/server/vfs/types.ts b/source/server/vfs/types.ts index e292d592..8cb5cdff 100644 --- a/source/server/vfs/types.ts +++ b/source/server/vfs/types.ts @@ -36,6 +36,7 @@ export interface ItemProps{ id :number; name :string; } + export type Stored = Omit & {mtime:string, ctime: string}; /** any item stored in a scene, with a name that identifies it */ @@ -45,6 +46,11 @@ export interface ItemEntry extends ItemProps{ mime :string; } +/** + * Like `ItemEntry` but `mtime` is omitted because it doesn't make any sense in history context + */ +export type HistoryEntry = Pick; + export interface FileProps extends ItemEntry{ /**sha254 base64 encoded string or null for deleted files */ hash :string|null; diff --git a/source/server/vfs/vfs.test.ts b/source/server/vfs/vfs.test.ts index c1544f0c..5545d09a 100644 --- a/source/server/vfs/vfs.test.ts +++ b/source/server/vfs/vfs.test.ts @@ -7,6 +7,7 @@ import { Uid } from "../utils/uid.js"; import UserManager from "../auth/UserManager.js"; import User from "../auth/User.js"; import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js"; +import ScenesVfs from "./Scenes.js"; async function *dataStream(src :Array =["foo", "\n"]){ for(let d of src){ @@ -45,6 +46,7 @@ describe("Vfs", function(){ await Vfs.Open(this.dir); await expect(fs.access(path.join(this.dir, "uploads"))).to.be.fulfilled; }); + describe("isolate", function(){ it("can rollback on error", async function(){ let vfs = await Vfs.Open(this.dir); @@ -72,6 +74,50 @@ describe("Vfs", function(){ }); }); + describe("validate search params", function(){ + it("accepts no parameters", function(){ + expect(()=>ScenesVfs._parseSceneQuery({})).not.to.throw(); + }); + it("requires limit to be a positive integer", function(){ + [null, "foo", 0.5, "0", 0, -1, 101].forEach((limit)=>{ + expect(()=>ScenesVfs._parseSceneQuery({limit} as any), `{limit: ${limit}}`).to.throw(); + }); + + [1, 10, 100].forEach((limit)=>{ + expect(()=>ScenesVfs._parseSceneQuery({limit} as any)).not.to.throw(); + }); + }); + + it("requires offset to be a positive integer", function(){ + [null, "foo", 0.5, "0", -1].forEach((offset)=>{ + expect(()=>ScenesVfs._parseSceneQuery({offset} as any), `{offset: ${offset}}`).to.throw(); + }); + + [0, 1, 10, 100, 1000].forEach((offset)=>{ + expect(()=>ScenesVfs._parseSceneQuery({offset} as any)).not.to.throw(); + }); + }); + + it("requires orderDirection to match", function(){ + ["AS", "DE", null, 0, -1, 1, "1"].forEach((orderDirection)=>{ + expect(()=>ScenesVfs._parseSceneQuery({orderDirection} as any), `{orderDirection: ${orderDirection}}`).to.throw("Invalid orderDirection"); + }); + ["ASC", "DESC", "asc", "desc"].forEach((orderDirection)=>{ + expect(()=>ScenesVfs._parseSceneQuery({orderDirection} as any)).not.to.throw(); + }) + }); + + it("requires orderBy to match", function(){ + ["foo", 1, -1, null].forEach((orderBy)=>{ + expect(()=>ScenesVfs._parseSceneQuery({orderBy} as any), `{orderBy: ${orderBy}}`).to.throw(`Invalid orderBy`); + }); + + ["ctime", "mtime", "name"].forEach((orderBy)=>{ + expect(()=>ScenesVfs._parseSceneQuery({orderBy} as any), `{orderBy: "${orderBy}"}`).not.to.throw(); + }); + }); + }); + describe("", function(){ let vfs :Vfs; //@ts-ignore @@ -430,13 +476,8 @@ describe("Vfs", function(){ }); it("limits LIMIT to 100", async function(){ - for(let i = 0; i < 110; i++){ - await vfs.createScene(`scene_${i}`); - } - let res = await vfs.getScenes(0, {limit: 110, offset: 0}) - expect(res).to.have.property("length", 100); - expect(res[0]).to.have.property("name", "scene_0"); - }) + await expect(vfs.getScenes(0, {limit: 110, offset: 0})).to.be.rejectedWith("[400]"); + }); }); }); @@ -1020,25 +1061,51 @@ describe("Vfs", function(){ describe("getSceneHistory()", function(){ let default_folders = 2 - it("get an ordered history of all writes to a scene", async function(){ - let fileProps :WriteFileParams = {user_id: 0, scene:scene_id, mime: "model/gltf-binary", name:"models/foo.glb"} - await vfs.writeFile(dataStream(), fileProps); - await vfs.writeDoc("{}", scene_id, 0); - await vfs.writeFile(dataStream(), fileProps); - await vfs.writeDoc("{}", scene_id, 0); - let history = await vfs.getSceneHistory(scene_id); - expect(history).to.have.property("length", 4 + default_folders); - //Couln't easily test ctime sort - expect(history.map(e=>e.name)).to.deep.equal([ - "scene.svx.json", - "scene.svx.json", - "models/foo.glb", - "models/foo.glb", - "models", - "articles", - ]); - expect(history.map(e=>e.generation)).to.deep.equal([2,1,2,1,1,1]); + describe("get an ordered history", function(){ + this.beforeEach(async function(){ + let fileProps :WriteFileParams = {user_id: 0, scene:scene_id, mime: "model/gltf-binary", name:"models/foo.glb"} + await vfs.writeFile(dataStream(), fileProps); + await vfs.writeDoc("{}", scene_id, 0); + await vfs.writeFile(dataStream(), fileProps); + await vfs.writeDoc("{}", scene_id, 0); + }); + + it("all events", async function(){ + let history = await vfs.getSceneHistory(scene_id); + expect(history).to.have.property("length", 4 + default_folders); + //Couln't easily test ctime sort + expect(history.map(e=>e.name)).to.deep.equal([ + "scene.svx.json", + "scene.svx.json", + "models/foo.glb", + "models/foo.glb", + "models", + "articles", + ]); + expect(history.map(e=>e.generation)).to.deep.equal([2,1,2,1,1,1]); + }); + + it("with limit", async function(){ + let history = await vfs.getSceneHistory(scene_id, {limit: 1}); + expect(history).to.have.property("length", 1); + //Couln't easily test ctime sort + expect(history.map(e=>e.name)).to.deep.equal([ + "scene.svx.json", + ]); + expect(history.map(e=>e.generation)).to.deep.equal([2]); + }); + it("with offset", async function(){ + let history = await vfs.getSceneHistory(scene_id, {limit: 2, offset: 1}); + expect(history).to.have.property("length", 2); + //Couln't easily test ctime sort + expect(history.map(e=>e.name)).to.deep.equal([ + "scene.svx.json", + "models/foo.glb", + ]); + expect(history.map(e=>e.generation)).to.deep.equal([1,2]); + }); }); + it("reports proper size for data strings", async function(){ //By default sqlite counts string length as char length and not byte length let str = `{"id":"你好"}`; diff --git a/source/ui/composants/HistoryAggregation.ts b/source/ui/composants/HistoryAggregation.ts new file mode 100644 index 00000000..b6128be9 --- /dev/null +++ b/source/ui/composants/HistoryAggregation.ts @@ -0,0 +1,156 @@ +import { css, customElement, html, LitElement, property, PropertyValues, state } from "lit-element"; + +import Notification from "../composants/Notification"; +import i18n from "../state/translate"; + +import styles from '!lit-css-loader?{"specifier":"lit-element"}!sass-loader!../styles/common.scss'; + +export interface HistoryEntryJSON{ + name :string; + id :number; + generation :number; + ctime :string; + author_id :number; + author :string; + size :number; + mime :string; +} + +export type HistoryEntry = Omit & {ctime: Date}; + + +type AggregatedEntry = [HistoryEntry, ...HistoryEntry[]]; + + + +function acceptsEntry(aggregate:AggregatedEntry, entry: HistoryEntry):boolean{ + if( !aggregate?.length) return false; + const previousName = aggregate.find(a => a.name === entry.name); + const dtime = aggregate[0].ctime.valueOf() - entry.ctime.valueOf(); + console.log("dtime : ", dtime, 1000*60*60*24 ) + if(!previousName && dtime < 1000*60*60*24) return true; //Unique names within 24h are auto-grouped + else if(dtime < 1000*60*60 && previousName.author == entry.author) return true; //Changes within one hour by the same author are grouped + return false; +} + +function aggregate(aggregate: AggregatedEntry[], entry: HistoryEntry, entryIndex: number, array: HistoryEntry[]): AggregatedEntry[]{ + let currentPtr = aggregate.slice(-1)[0]; + if( acceptsEntry(currentPtr, entry) ) { + currentPtr.push(entry); + }else{ + currentPtr = [entry]; + aggregate.push(currentPtr); + } + + return aggregate; +} + + + + +@customElement("history-aggregation") +export default class HistoryAggregation extends i18n(LitElement){ + + @property({attribute: false, type: Array}) + entries :HistoryEntry[]; + + @state() + selected?:number; + + + private renderExtendedHistoryEntry = (n:HistoryEntry)=>{ + let diff = {color:"warning", char: "~"}; + if(n.generation == 1){ + diff = {color:"success", char: "+"}; + }else if(n.size === 0){ + diff = {color:"error", char: "-"}; + } + return html`
+ ${n.ctime.toLocaleString(this.language)} + ${diff.char} + ${n.name} + ${n.size? html`` : n.mime==="text/directory"?"/":null} + + this.onRestore(n)} text="restore" icon="restore"> + +
` + } + + + private renderAggregation = (v : AggregatedEntry, index :number)=>{ + let mainFile = v.slice(-1)[0]; //File we would restore to by default + let name = (3 < v.length)? + html`${mainFile.name} ${this.t("info.etAl", {count:v.length-1})}` + : v.map(e=>e.name).join(", "); + + let authors = Array.from(new Set(v.map(e=>e.author))) + let authored = (3 < authors.length )? html`${authors.slice(-2).join(", ")} ${this.t("info.etAl", {count:v.length-2})}`: authors.join(", "); + + const selected = this.selected === index; + return html` +
{this.selected = index}} > + ${selected?html` +
+ ${v.map(this.renderExtendedHistoryEntry)} +
+ `: html` +
+
${name}
+
${authored} ${v[0].ctime.toLocaleString(this.language)}
+
+ ${index==0?html`active`:html`this.onRestore(mainFile)} text="restore" icon="restore">`} + `} +
+ ` + } + + protected render(){ + //versions could easily be memcached if it becomes a bottleneck to compute on large histories. It is currently not though. + let versions = this.entries.reduce(aggregate, []); + return html` +
+ ${versions.map(this.renderAggregation)} +
+ ` + } + + + onRestore(entry :HistoryEntry){ + this.dispatchEvent(new CustomEvent("restore", {detail: entry})); + } + + static styles = [ + styles, + css` + .list-items .list-item.selected{ + background: var(--color-highlight); + } + + .history-detail-grid{ + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + } + + .history-detail-entry{ + display: flex; + flex-direction: row; + justify-content: stretch; + gap: 4px; + border-bottom: 1px solid transparent; + } + .history-detail-entry:not(:last-child){ + border-bottom-color: var(--color-highlight2); + } + + .history-detail-entry:hover{ + border-bottom-color: var(--color-element); + } + .history-detail-entry> *{ + display block; + flex-grow: 0; + } + ` + ] +} \ No newline at end of file diff --git a/source/ui/screens/SceneHistory.ts b/source/ui/screens/SceneHistory.ts index a9585d8f..4462bba4 100644 --- a/source/ui/screens/SceneHistory.ts +++ b/source/ui/screens/SceneHistory.ts @@ -6,6 +6,7 @@ import "../composants/Button"; import "../composants/Spinner"; import "../composants/Size"; import "../composants/TagList"; +import "../composants/HistoryAggregation"; import { nothing } from "lit-html"; import i18n from "../state/translate"; @@ -14,6 +15,7 @@ import { navigate } from "../state/router"; import Modal from "../composants/Modal"; import { AccessType, Scene } from "state/withScenes"; import HttpError from "../state/HttpError"; +import { HistoryEntry, HistoryEntryJSON } from "../composants/HistoryAggregation"; const AccessTypes = [ @@ -92,7 +94,7 @@ class SceneVersion{ @property({attribute: false, type: Object}) scene: Scene = null; @property({attribute: false, type:Array}) - versions : SceneVersion[]; + versions : HistoryEntry[]; @property({attribute: false, type:Array}) permissions :AccessRights[] =[]; @@ -149,11 +151,11 @@ class SceneVersion{ async fetchHistory(){ const signal = this.#c.signal; - await fetch(`/history/${encodeURIComponent(this.name)}`, {signal}).then(async (r)=>{ + await fetch(`/history/${encodeURIComponent(this.name)}?limit=100`, {signal}).then(async (r)=>{ if(!r.ok) throw new Error(`[${r.status}]: ${r.statusText}`); let body = await r.json(); if(signal.aborted) return; - this.versions = this.aggregate(body as ItemEntry[]); + this.versions = (body as HistoryEntryJSON[]).map(e=>({...e, ctime:new Date(e.ctime)})); }).catch((e)=> { if(e.name == "AbortError") return; console.error(e); @@ -165,21 +167,6 @@ class SceneVersion{ return AccessTypes.indexOf(a ) <= AccessTypes.indexOf(this.scene.access.user) || this.user?.isAdministrator; } - aggregate(entries :ItemEntry[]) :SceneVersion[]{ - if(!entries || entries.length == 0) return []; - let versions = [new SceneVersion(entries.pop())]; - let last_ref = versions[0]; - while(entries.length){ - let entry = entries.pop(); - if(!last_ref.accepts(entry) ){ - last_ref = new SceneVersion(entry); - versions.push(last_ref); - }else{ - last_ref.add(entry); - } - } - return versions.reverse(); - } protected render() :TemplateResult { if(!this.versions || !this.scene){ @@ -189,13 +176,7 @@ class SceneVersion{

`; } - let articles = new Set(); - for(let version of this.versions){ - for(let name of version.names){ - if(name.startsWith("articles/")) articles.add(name); - } - } - let size = this.versions.reduce((s, v)=>s+v.size, 0); + let scene = encodeURIComponent(this.name); return html`

${this.name}

@@ -207,11 +188,6 @@ class SceneVersion{ ${this.renderTags()}
-
-

Total size:

-

${articles.size} article${(1 < articles.size?"s":"")}

-
-
- ${this.renderHistory()} +

${this.t("ui.history")}

+
${this.can("admin")? html`
@@ -324,36 +301,6 @@ class SceneVersion{ `; } - renderHistory(){ - return html` -

Historique

-
- ${this.versions.map((v, index)=>{ - - let name = (3 < v.names.size)? html`${v.names.values().next().value} e.target.parentNode.classList.toggle("visible")}>${this.t("info.etAl", {count:v.names.size})}` - : [...v.names.values()].join(", "); - - let authors = [...v.authors.values()].join(", ") - - return html` -
-
-
${name} -
    ${[...v.entries].map((n, index)=>{ - return html`
  • ${n.name} ${n.mime != "text/directory"? html`(${n.size?html``:"DELETED"})`:null}
  • ` - })}
-
- -
${authors} ${new Date(v.start).toLocaleString(this.language)}
-
- - ${index==0?html`active`:html`this.onRestore(v.entries.slice(-1)[0])} text="restore" icon="restore">`} -
- ` - })} -
- ` - } renderPermissionSelection(username:string, selected :AccessRights["access"], disabled :boolean = false){ const onSelectPermission = (e:Event)=>{ diff --git a/source/ui/styles/buttons.scss b/source/ui/styles/buttons.scss index f5f30cf8..77016158 100644 --- a/source/ui/styles/buttons.scss +++ b/source/ui/styles/buttons.scss @@ -55,6 +55,11 @@ a{ box-shadow: 2px 2px rgba(20, 20, 20, 0.3); } } + + &.btn-small{ + padding: 0 4px; + min-width: auto; + } &.btn-outline{ background: transparent; @@ -119,3 +124,20 @@ a{ } } } + +.caret{ + &::before{ + content: "⌄"; + display: inline; + line-height: 75%; + vertical-align: top; + } + + .visible &::before, + .active &::before, + &.active::before + { + content: "⌃"; + vertical-align: bottom; + } +} \ No newline at end of file diff --git a/source/voyager b/source/voyager index 89ed238f..ab0264aa 160000 --- a/source/voyager +++ b/source/voyager @@ -1 +1 @@ -Subproject commit 89ed238f0e1f4f67be54e030377d6b2b29214bf2 +Subproject commit ab0264aa6de46ac36b83af0907e76fba2e06fbb1