Преглед изворни кода

Infinite loading and original file download

isundil пре 1 година
родитељ
комит
869312733e

+ 10 - 2
model/mediaService.js

@@ -1,10 +1,13 @@
 
+const path = require('path');
+
 function Media()
 {
 }
 
 function MediaStruct(i) {
     this.path = i.path;
+    this.fileName = path.parse(i.path).name;
     this.md5sum = i.md5sum;
     this.date = i.date;
     this.meta = {};
@@ -42,7 +45,7 @@ module.exports.fetchOne = async function(app, md5sum, accessList) {
         left join mediaMeta on mediaMeta.md5sum=mediaFile.md5sum
         left join mediaTag on mediaTag.md5sum=mediaFile.md5sum
         where mediaFile.md5sum=?`, md5sum)) || []).reduce(reduceReqToMediaStruct, {})[md5sum] || null;
-    return result.HaveAccess(accessList) ? result : null;
+    return result?.HaveAccess(accessList) ? result : null;
 }
 
 module.exports.fetchMedias = async function(app, startTs, count) {
@@ -76,6 +79,11 @@ module.exports.fetchMediasWithAccess = async function(app, startTs, count, acces
         if (tmp.length)
             result = result.concat(tmp);
     }
-    return result;
+    return result.slice(0, count);
 };
 
+module.exports.getMediaRange = async function(app) {
+    let result = await app.databaseHelper.runSql("select min(date) as _min, max(date) as _max from mediaFile");
+    return [ result?.[0]?._min || 0, result?.[0]?._max || 0 ];
+}
+

+ 40 - 4
router/api.js

@@ -1,5 +1,7 @@
 
+const mime = require("mime-types");
 const fs = require('fs');
+const Path = require('path');
 const Security = require('../src/security.js');
 const MediaService = require('../model/mediaService.js');
 
@@ -23,20 +25,42 @@ module.exports = { register: app => {
     });
     app.router.get("/api/media/list", async (req, res) => {
         app.routerUtils.onApiRequest(req, res);
+        let first = undefined,
+            last = undefined;
+        if (req.body?.chronology !== undefined) {
+            let range = await MediaService.getMediaRange(app);
+            first = range[0];
+            last = range[1];
+        }
+        let fromDate = parseInt(req.body?.from);
+        let count = parseInt(req.body?.count);
         app.routerUtils.jsonResponse(res, {
-            data: await MediaService.fetchMediasWithAccess(app, 0, 50, req.accessList)
+            data: await MediaService.fetchMediasWithAccess(
+                app,
+                isNaN(fromDate) ? 0 : fromDate,
+                isNaN(count) ? 25 : Math.min(75, count),
+                req.accessList),
+            first: first,
+            last: last
         });
     });
-    app.router.get("/api/media/thumbnail/:md5sum.png", async (req, res) => {
+    app.router.get("/api/media/:md5sum", async (req, res) => {
+        app.routerUtils.onApiRequest(req, res);
+        let data = await MediaService.fetchOne(app, req.params.md5sum, req.accessList);
+        if (!data)
+            return app.routerUtils.onPageNotFound(res);
+        app.routerUtils.jsonResponse(res, data);
+    });
+    app.router.get("/api/media/thumbnail/:md5sum.jpg", async (req, res) => {
         app.routerUtils.onApiRequest(req, res);
         let data = await MediaService.fetchOne(app, req.params.md5sum, req.accessList);
         if (!data)
             return app.routerUtils.onPageNotFound(res);
         try {
-            let thumbnail = await (await app.libraryManager.findMedia(data.path))?.createThumbnail(req.body?.w || 0, req.body?.h || 0);
+            let thumbnail = await (await app.libraryManager.findMedia(data.path))?.createThumbnail(req.body?.w || 0, req.body?.h || 0, req.body?.q || 6);
             if (!thumbnail)
                 return app.routerUtils.onPageNotFound(res);
-            res.setHeader("Content-Type", "image/png");
+            res.setHeader("Content-Type", "image/jpeg");
             res.setHeader("Content-Length", fs.statSync(thumbnail.name)?.size || undefined);
             res.setHeader("Cache-Control", "private, max-age=2630000"); // 1 month cache
             let rd = fs.createReadStream(thumbnail.name);
@@ -48,5 +72,17 @@ module.exports = { register: app => {
             app.routerUtils.onPageNotFound(res);
         }
     });
+    app.router.get("/api/media/original/:md5sum", async (req, res) => {
+        app.routerUtils.onApiRequest(req, res);
+        let data = await MediaService.fetchOne(app, req.params.md5sum, req.accessList);
+        if (!data)
+            return app.routerUtils.onPageNotFound(res);
+        const fileName = Path.basename(data.path);
+        res.setHeader("Content-Type", mime.lookup(data.path));
+        res.setHeader("Content-Length", fs.statSync(data.path)?.size || undefined);
+        res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
+        res.setHeader("Cache-Control", "private, max-age=2630000"); // 1 month cache
+        fs.createReadStream(data.path).pipe(res);
+    });
 }};
 

+ 2 - 2
src/fileTypeManager.js

@@ -4,10 +4,10 @@ const parsers = [
     require ('./filetype/meta.js')
 ];
 
-module.exports.createThumbnail = async function(fileObj, w, h) {
+module.exports.createThumbnail = async function(fileObj, w, h, quality) {
     for (let i of parsers)
     {
-        let result = await i.createThumbnail(fileObj, w, h);
+        let result = await i.createThumbnail(fileObj, w, h, quality);
         if (result)
             return result;
     }

+ 11 - 19
src/filetype/imagemagick.js

@@ -117,31 +117,23 @@ module.exports.parse = async (fileObj) => {
     return result;
 }
 
-module.exports.createThumbnail = (fileObj, width, height) => {
+module.exports.createThumbnail = (fileObj, width, height, quality) => {
     return new Promise((ok, ko) => {
         if (!fileObj?.meta?.width || !fileObj?.meta?.height)
             return null;
         if (!width && !height)
             width = height = 420;
-        let ratio = Math.min((width / fileObj.meta.width) || 1, (height / fileObj.meta.height) || 1);
+        let ratio = Math.min((width / fileObj.meta.width) || 1, (height / fileObj.meta.height) || 1, 1);
         const output = tmp.fileSync({ discardDescriptor: true });
-        if (ratio >= 1) {
-            im.convert([
-                fileObj.path,
-                `PNG:${output.name}`
-            ], err => {
-                if (err)
-                    throw err;
-                ok(output);
-            });
-            return;
-        }
-        im.resize({
-            srcPath: fileObj.path,
-            dstPath: `PNG:${output.name}`,
-            height: fileObj.meta.height * ratio,
-            width: fileObj.meta.width * ratio
-        }, err => {
+        im.convert([
+            fileObj.path,
+            '-strip',
+            '-interlace', 'Plane',
+            '-resize', (Math.floor(fileObj.meta.width * ratio)),
+            '-quality', '' + (Math.floor(Math.max(0, Math.min(100, (quality *10))))) + '%',
+            '-sampling-factor', '4:2:0',
+            `JPG:${output.name}`,
+        ], err => {
             if (err)
                 throw err;
             ok(output);

+ 1 - 1
src/filetype/meta.js

@@ -7,4 +7,4 @@ module.exports.parse = async (fileObj) => {
     };
 }
 
-module.exports.createThumbnail = async (fileObj, w, h) => { return null; };
+module.exports.createThumbnail = async (fileObj, w, h, quality) => { return null; };

+ 2 - 2
src/library.js

@@ -45,8 +45,8 @@ File.prototype.saveDb = async function(db, libraryHash) {
     await db.insertMultipleSameTable(metaEntities);
 }
 
-File.prototype.createThumbnail = async function(w, h) {
-    return await FileTypeManager.createThumbnail(this, w, h);
+File.prototype.createThumbnail = async function(w, h, quality) {
+    return await FileTypeManager.createThumbnail(this, w, h, quality);
 }
 
 async function enrichAll(lib) {

+ 73 - 2
static/public/css/style.css

@@ -1,13 +1,47 @@
+#pch-navbar .dropdown-menu[data-bs-popper] {
+    left: initial;
+    right: 0;
+}
+
+#pch-page {
+    margin-top: 6rem;
+    padding: 1em 3em;
+}
+
+#pch-page > .spinner {
+    text-align: center;
+}
+
+#pch-page > .spinner.hidden {
+    display: none;
+}
+
 #pch-mediaList {
     list-style: none;
-    margin: 0;
+    margin: 0 auto 0 auto;
     padding: 0;
+    text-align: center;
 }
 
 #pch-mediaList > .pch-image {
-    display: flex;
+    display: inline-flex;
     height: 450px;
+    min-width: 450px;
     justify-content: center;
+    padding: 1em;
+}
+
+#pch-mediaList > .pch-image img {
+    transition: scale 300ms;
+}
+
+#pch-mediaList > .pch-image:hover img {
+    scale: 98%;
+    transition: scale 700ms;
+}
+
+#pch-mediaList > h3, #pch-mediaList > h4 {
+    text-align: left;
 }
 
 .pch-image {
@@ -31,3 +65,40 @@
     visibility: hidden;
 }
 
+#pch-fullPageMedia.hidden {
+    display: none;
+}
+
+#pch-fullPageMedia {
+    display: flex;
+    position: fixed;
+    left: 0;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    padding: 1.5em;
+    overflow: hidden;
+}
+
+#pch-fullPageMedia > div {
+    margin: 0;
+    width: 100%;
+    max-width: initial;
+    overflow: hidden;
+}
+
+#pch-fullPageMedia .modal-content {
+    height: 100%;
+}
+
+#pch-fullPageMedia .modal-body {
+    overflow-x: hidden;
+    overflow-y: auto;
+    max-width: initial;
+}
+
+#pch-fullPageDetail {
+    overflow: hidden;
+    word-break: break-word;
+}
+

+ 29 - 0
static/public/js/chronology.js

@@ -0,0 +1,29 @@
+
+$(() => {
+    var _MIN = _MAX = null;
+    window.chronology = {};
+
+    function onChronologyUpdated() {
+        console.log("Chronology update with ", _MIN, _MAX);
+    }
+
+    window.chronology.rebuildRange = function(_minTs, _maxTs) {
+        let updated = false;
+
+        if (!_MIN || _MIN.getTime() !== _minTs) {
+            _MIN = new Date(_minTs);
+            updated = true;
+        }
+        if (!_MAX || _MAX.getTime() !== _maxTs) {
+            _MAX = new Date(_maxTs);
+            updated = true;
+        }
+        if (updated)
+            onChronologyUpdated();
+    }
+
+    window.chronology.isInitialized = function() {
+        return _MIN && _MAX;
+    }
+
+});

+ 48 - 13
static/public/js/common.js

@@ -1,23 +1,58 @@
 
-function RebuildAccess() {
-    LoadingTasks.push(() => {
-        $.get("/api/access/list", data => {
-            console.log("Access list => ", data);
+$(() => {
+    function RebuildAccess() {
+        LoadingTasks.push(() => {
+            $.get("/api/access/list", data => {
+                console.log("Access list => ", data);
+            });
         });
-    });
-}
+    }
 
-function ReadMediaList() {
-    LoadingTasks.push(() => {
-        $.get("/api/media/list", data => {
-            MediaStorage.Instance.pushAll(data.data.map(i => new Media(i)));
-            $.get("/api/media/thumbnail/" + MediaStorage.Instance.newest.md5sum +".png");
+    let loadingMediaList = false;
+    function ReadMediaList() {
+        if (loadingMediaList)
+            return;
+        LoadingTasks.push(() => {
+            return new Promise(ok => {
+                let chronology = window.chronology.isInitialized() ? "" : "&chronology"
+                let oldest = (MediaStorage.Instance.oldest?.date?.getTime() || 0);
+                let oldestArg = oldest ? `&from=${oldest}` : "";
+                $.get(`/api/media/list?count=25${chronology}${oldestArg}`, data => {
+                    MediaStorage.Instance.pushAll(data.data.map(i => new Media(i)));
+                    if (data.first || data.last)
+                        window.chronology.rebuildRange(data.first, data.last);
+                    loadingMediaList = false;
+                    if ((data.data?.length || 0) < 25) {
+                        loadingMediaList = true;
+                        document.getElementById("pch-infiniteScrollLoading").classList.add("hidden");
+                    }
+                    ok();
+                });
+            });
         });
+    }
+
+    function loadHash(md5sum) {
+        if (md5sum && md5sum.length)
+            LoadingTasks.push(async () => displayMediaFullPage(await MediaStorage.Instance.getMedia(md5sum)));
+        else
+            document.getElementById("pch-fullPageMedia").classList.add("hidden");
+    }
+
+    window.addEventListener("hashchange", x => {
+        x.preventDefault();
+        loadHash(document.location?.hash?.substr(1))
+        return true;
+    });
+
+    window.addEventListener("scroll", evt => {
+        if (window.scrollY+window.innerHeight >= document.body.clientHeight) {
+            ReadMediaList();
+        }
     });
-}
 
-$(() => {
     RebuildAccess();
+    loadHash(document.location?.hash?.substr(1));
     ReadMediaList();
 });
 

+ 50 - 5
static/public/js/medias.js

@@ -4,11 +4,38 @@ class Media {
         this.date = new Date(data.date);
         this.md5sum = data.md5sum;
         this.path = data.path;
+        this.fileName = data.fileName;
         this.meta = data.meta || {};
+        this.meta.height = this.meta.height ? parseInt(this.meta.height) : undefined;
+        this.meta.width = this.meta.width ? parseInt(this.meta.width) : undefined;
         this.tags = data.tags || [];
-        this.thumbnail = `/api/media/thumbnail/${data.md5sum}.png`;
+        this.thumbnail = `/api/media/thumbnail/${data.md5sum}.jpg`;
+        this.original = `/api/media/original/${data.md5sum}`;
         this.ui = null;
     }
+
+    resize(maxWidth, maxHeight) {
+        let ratio = Math.min(1, Math.max(
+            maxWidth / (this.meta?.width || maxWidth),
+            maxHeight / (this.meta?.height || maxHeight)));
+        return {
+            width: Math.floor(this.meta.width *ratio),
+            height: Math.floor(this.meta.height *ratio),
+        };
+    }
+}
+
+function tryLoadMedia(md5sum) {
+    return new Promise((ok, ko) => {
+        $.get("/api/media/" +md5sum, data => {
+            let item = new Media(data);
+            MediaStorage.Instance.pushAll([item], true);
+            ok(item);
+        }).fail(err => {
+            console.error("Trying to get media with md5sum " +md5sum +" failed:", err.responseText);
+            ok(null);
+        });
+    });
 }
 
 class MediaStorage extends EventTarget
@@ -20,14 +47,32 @@ class MediaStorage extends EventTarget
         this.newest = null;
     }
 
-    pushAll(arr) {
+    pushAll(arr, partialLoad) {
+        let result = [];
+        let reorder = false;
         for (let i of arr) {
+            if (this.medias.find(x => x.md5sum === i.md5sum))
+                continue;
+            if (this.medias.length && this.medias[this.medias.length -1].date.getTime() < i.date.getTime())
+                reorder = true;
             this.medias.push(i);
-            this.oldest = !this.oldest || this.oldest.date.getTime() < i.date.getTime() ? i : this.oldest;
-            this.newest = !this.newest || this.newest.date.getTime() > i.date.getTime() ? i : this.newest;
+            if (partialLoad !== true) {
+                this.oldest = !this.oldest || this.oldest.date.getTime() > i.date.getTime() ? i : this.oldest;
+                this.newest = !this.newest || this.newest.date.getTime() < i.date.getTime() ? i : this.newest;
+            }
+            result.push(i);
         }
-        for (let i of arr)
+        for (let i of result)
             this.dispatchEvent(new CustomEvent("newMedia", { detail: i }));
+        if (reorder)
+            console.log("Warning: unordered databank ! Need to reorder"); // FIXME
+    }
+
+    async getMedia(md5sum) {
+        let media = this.medias.find(x => x.md5sum === md5sum);
+        if (media)
+            return media;
+        return await tryLoadMedia(md5sum);
     }
 }
 

+ 127 - 0
static/public/js/uiMedia.js

@@ -0,0 +1,127 @@
+
+$(() => {
+    function buildThumbnail(mediaItem) {
+        if (mediaItem.ui)
+            return mediaItem.ui;
+        let root = document.createDocumentFragment();
+        let img = document.createElement("img");
+        let container = document.createElement("li");
+        let loadingImg = document.createElement("div");
+        container.classList.add("pch-image");
+        container.classList.add("loading");
+        loadingImg.classList.add("spinner");
+        loadingImg.innerHTML = "<span class='spinner-grow'></span>";
+        container.dataset.md5sum = mediaItem.md5sum;
+        img.loading = "lazy";
+        let requestSize = mediaItem.resize(450, 450);
+        img.src = `${mediaItem.thumbnail}?w=${requestSize.width}&h=${requestSize.height}&q=4`;
+        img.classList.add("img-fluid");
+        img.classList.add("img-thumbnail");
+        img.addEventListener("load", () => {
+            container.classList.remove("loading");
+            container.classList.remove("spinner-grow");
+        });
+        container.style.width = `${requestSize.width}px`;
+        container.appendChild(loadingImg);
+        container.appendChild(img);
+        container.addEventListener("click", () => {
+            document.location.hash = mediaItem.md5sum;
+        });
+        root.appendChild(container);
+        return mediaItem.ui = {
+            root: root,
+            img: img
+        };
+    }
+
+    function buildYear(date) {
+        let result = document.createElement('h3');
+        result.textContent = date.getUTCFullYear();
+        return result;
+    }
+
+    function buildMonth(date) {
+        let result = document.createElement('h4');
+        result.textContent = date.toLocaleString('default', { month: 'long' });
+        return result;
+    }
+
+    MediaStorage.Instance.addEventListener("newMedia", (evt) => {
+        buildThumbnail(evt.detail);
+        if (!evt.detail.ui)
+            return;
+        let container = document.getElementById('pch-mediaList');
+
+        let yearUpdated = !container.dataset.lastItemYear || container.dataset.lastItemYear != evt.detail.date.getUTCFullYear();
+        if (yearUpdated) {
+            container.appendChild(buildYear(evt.detail.date));
+            container.dataset.lastItemYear = evt.detail.date.getUTCFullYear();
+        }
+        if (yearUpdated || container.dataset.lastItemMonth === undefined || container.dataset.lastItemMonth != evt.detail.date.getUTCMonth()) {
+            container.appendChild(buildMonth(evt.detail.date));
+            container.dataset.lastItemMonth = evt.detail.date.getUTCMonth();
+        }
+        container.appendChild(evt.detail.ui.root);
+    });
+
+    function _displayMediaFullPage(fileName, imgUrl, metaData, downloadLink) {
+        return new Promise(ok => {
+            document.getElementById("pch-fullPagePreviewContainer").classList.add("loading");
+            document.getElementById("pch-fullPageMedia-title").innerText = fileName;
+            document.getElementById("pch-fullPagePreview").onceLoaded = ok;
+            document.getElementById("pch-fullPagePreview").src = imgUrl;
+            document.getElementById("pch-fullPageDetail").innerText = "";
+            let metaList = document.createElement("ul");
+            for (let i in metaData || {}) {
+                let li = document.createElement("li");
+                li.innerText = `${i}: ${metaData[i]}`;
+                metaList.appendChild(li);
+            }
+            document.getElementById("pch-fullPageDetail").appendChild(metaList);
+            if (downloadLink) {
+                document.getElementById("pch-fullPageDetail-dlButton").classList.remove("hidden");
+                document.getElementById("pch-fullPageDetail-dlButton").dataset["link"] = downloadLink;
+            } else {
+                document.getElementById("pch-fullPageDetail-dlButton").classList.add("hidden");
+            }
+        });
+    }
+
+    window.displayMediaFullPage = function(mediaItem) {
+        document.getElementById("pch-fullPageMedia").classList.remove("hidden");
+        if (!mediaItem)
+            return _displayMediaFullPage("Error", null, {}, null);
+        let containerSize = document.getElementById("pch-fullPageMedia").getBoundingClientRect();
+        let requestSize = mediaItem.resize(containerSize.width, containerSize.height);
+        document.getElementById("pch-fullPagePreview").parentNode.style.maxWidth = "100%";
+        document.getElementById("pch-fullPagePreview").parentNode.style.maxHeight = "100%";
+        let meta = {
+            ...mediaItem.meta,
+            date: mediaItem.date || undefined,
+            filename: mediaItem.fileName || undefined
+        };
+        return _displayMediaFullPage(mediaItem.fileName, `${mediaItem.thumbnail}?w=${requestSize.width}&h=${requestSize.height}&q=6`, meta, mediaItem.original);
+    }
+
+    document.getElementById("pch-fullPageMedia-closeBt")
+        .addEventListener("click", () => {
+            document.getElementById("pch-fullPageMedia").classList.add("hidden");
+            history.pushState({}, '', '#');
+        });
+    document.getElementById("pch-fullPagePreview").addEventListener("load", () => {
+        document.getElementById("pch-fullPagePreviewContainer").classList.remove("loading");
+        let domItem = document.getElementById("pch-fullPagePreview");
+        domItem.onceLoaded && domItem.onceLoaded();
+        domItem.onceLoaded = null;
+    });
+    document.getElementById('pch-fullPageDetail-dlButton').addEventListener("click", (evt) => {
+        if (!evt.target?.dataset?.link)
+            return;
+        let link = document.createElement('a');
+        link.target='_blank';
+        link.setAttribute("download", "");
+        link.href = evt.target.dataset.link;
+        link.click();
+    });
+});
+

+ 1 - 0
templates/footer.js

@@ -5,6 +5,7 @@ module.exports = `
 <script src="/public/js/taskQueue.js"></script>
 <script src="/public/js/medias.js"></script>
 <script src="/public/js/uiMedia.js"></script>
+<script src="/public/js/chronology.js"></script>
 <script src="/public/js/common.js"></script>
 </body></html>`;
 

+ 1 - 1
templates/header.js

@@ -1,7 +1,7 @@
 
 module.exports = `
 <!DOCTYPE html>
-<html>
+<html data-bs-theme="dark">
 <head>
 <link type="text/css" rel="stylesheet" href="/public/mdi/materialdesignicons.min.css"/>
 <link type="text/css" rel="stylesheet" href="/public/bootstrap/bootstrap.min.css"/>

+ 30 - 1
templates/index.js

@@ -1,5 +1,34 @@
 
 module.exports = require('./header.js') +require('./menu.js')
-    +`<ul id="pch-mediaList"></ul>`
+    +`
+<div id="pch-page">
+    <ul id="pch-mediaList"></ul>
+    <div class="spinner" id="pch-infiniteScrollLoading">
+        <div class="spinner-grow"></div>
+    </div>
+    <div id="pch-fullPageMedia" class="modal hidden" role="dialog"><div class="modal-dialog"><div class="modal-content">
+        <div class="modal-header">
+            <h5 id="pch-fullPageMedia-title" class="modal-title"></h5>
+            <button id="pch-fullPageMedia-closeBt" type="button" class="close" data-dismiss="modal" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+            </button>
+        </div>
+        <div class="container modal-body">
+            <div class="row">
+                <div class="col col-12 col-xl-9">
+                    <div id="pch-fullPagePreviewContainer" class="pch-image loading">
+                        <div id="pch-fullPagePreviewSpinner" class="spinner"><span class="spinner-grow"></span></div>
+                        <img id="pch-fullPagePreview" src="" class="img-fluid"/>
+                    </div>
+                </div>
+                <div class="col col-12 col-xl-3">
+                    <div>
+                        <button type="button" class="btn btn-primary" id="pch-fullPageDetail-dlButton">Download</button>
+                    </div>
+                    <div id="pch-fullPageDetail"></div>
+                </div>
+            </div>
+        </div>
+</div></div></div></div>`
     +require('./footer.js');
 

+ 1 - 1
templates/menu.js

@@ -1,7 +1,7 @@
 
 module.exports = `
 <body>
-    <nav class="navbar navbar-expand-lg bg-body-tertiary">
+    <nav id="pch-navbar" class="navbar navbar-expand-lg bg-body-tertiary fixed-top">
         <div class="container-fluid" id="navbarSupportedContent">
             <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                 <li class="nav-item">