From 1ce254200732d852e979f2472e15f6755d3a90a1 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 26 Apr 2020 11:25:47 -0700 Subject: [PATCH] new frontend first pass! with index, collection page, loading screen, gdrive support loaders, api split progress update on load --- src/frontend.js | 894 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 src/frontend.js diff --git a/src/frontend.js b/src/frontend.js new file mode 100644 index 0000000..4c747ca --- /dev/null +++ b/src/frontend.js @@ -0,0 +1,894 @@ +import { LitElement, html, css, customElement } from 'lit-element'; +import {unsafeHTML} from 'lit-html/directives/unsafe-html.js'; +import { styleMap } from 'lit-html/directives/style-map'; + +import { initSW, digestMessage, getTS } from './pageutils'; + +import marked from 'marked'; + +import '../scss/main.scss'; + +import 'fa-icons'; + + +// =========================================================================== +class AppMain extends LitElement +{ + + constructor() { + super(); + this.coll = ""; + this.collInfo = {}; + this.loadInfo = {}; + this.updateRoute(); + } + + + static get properties() { + return { + pageState: { type: String }, + pageParams: { type: Object }, + coll: { type: String }, + collInfo: { type: Object } + } + } + + async updateRoute() { + this.pageParams = new URLSearchParams(window.location.search); + const source = this.pageParams.get("source"); + if (source) { + const {url, coll} = await this.sourceToId(source); + + this.coll = coll; + + const resp = await fetch(`/wabac/api/${coll}`); + if (resp.status != 200) { + this.sourceUrl = source; + this.pageState = "load"; + } else { + const json = await resp.json(); + this.collInfo = json; + this.pageState = "coll"; + } + return; + } + + // Google Drive + let state = this.pageParams.get("state"); + if (state) { + try { + state = JSON.parse(state); + if ((state.ids instanceof Array) && state.userId && state.action === "open") { + //this.pageState = "gdriveLoad"; + //this.loadInfo = state; + this.pageParams.set("source", "googledrive://" + state.ids[0]); + this.pageParams.delete("state"); + window.location.search = this.pageParams.toString(); + //window.history.pushState({}, document.title, ) + return; + } + } catch (e) { + console.log(e); + } + } + + + this.pageState = "index"; + } + + async sourceToId(url) { + try { + url = new URL(url, window.location.origin).href; + } catch(e) {} + + const digest = await digestMessage(url, 'SHA-256'); + const coll = "id-" + digest.slice(0, 12); + return {url, coll}; + } + + onCollLoaded(event) { + this.updateRoute(); + } + + render() { + return html` + +
+
+

Webrecorder Viewer

+
+
+ ${this.renderContent()} + `; + } + + renderContent() { + switch (this.pageState) { + case "load": + return html``; + + case "coll": + return html``; + + case "index": + return html``; + + default: + return html``; + } + } + + //createRenderRoot() { return this; } +} + +// =========================================================================== +class WrLoader extends LitElement +{ + constructor() { + super(); + this.progress = 0; + this.total = 0; + this.percent = 0; + this.coll = ""; + this.state = "waiting"; + + this.worker = new Worker("dbworker.js"); + } + + static get properties() { + return { + sourceUrl: { type: String }, + state: { type: String }, + progress: { type: Number }, + percent: { type: Number }, + total: { type: Number }, + status: { type: String }, + coll: { type: String }, + } + } + + firstUpdated(props) { + this.initMessages(); + this.doLoad(); + } + + initMessages() { + this.worker.addEventListener("message", (event) => { + switch (event.data.msg_type) { + case "collProgress": + if (event.data.name === this.coll) { + this.progress = event.data.progress; + this.total = event.data.total; + this.percent = Math.round(this.progress / this.total * 100); + } + break; + + case "collAdded": + if (event.data.name === this.coll) { + if (!this.total) { + this.total = 100; + } + this.progress = this.total; + this.percent = 100; + this.dispatchEvent(new CustomEvent("coll-loaded", {detail: event.data})); + } + break; + } + }); + } + + async doLoad() { + const sourceUrl = this.sourceUrl; + let source = null; + + if (sourceUrl.startsWith("googledrive://")) { + this.state = "googledrive"; + this.fileId = sourceUrl.slice("googledrive://".length); + source = await this.googledriveInit(); + } else { + source = {sourceUrl}; + } + + this.state = "started"; + + const msg = {"msg_type": "addColl", "name": this.coll, skipExisting: true, file: source}; + + this.worker.postMessage(msg); + } + + googledriveInit() { + this._gdWait = new Promise((resolve) => this._gdResolve = resolve); + return this._gdWait; + } + + async onLoadReady(event) { + console.log(event); + if (this._gdResolve) { + //const digest = await digestMessage(url, 'SHA-256'); + //this.coll = "id-" + digest.slice(0, 12); + + this._gdResolve(event.detail); + } + } + + updated(changedProperties) { + if (changedProperties.get("sourceUrl")) { + this.doLoad(); + } + } + + render() { + return html` + +
+
+

Loading ${this.sourceUrl}...

+
+
+
+ ${this.renderContent()} +
+
+
+ `; + } + + renderContent() { + switch (this.state) { + case "googledrive": + return html`` + + case "started": + return html` + `; + + case "waiting": + default: + return html``; + } + } + + //createRenderRoot() { return this; } +} + +// =========================================================================== +class WrIndex extends LitElement +{ + constructor() { + super(); + this.colls = []; + } + + static get properties() { + return { + colls: { type: Array }, + } + } + + static get styles() { + return css` + fa-icon { + cursor: pointer; + display: inline-flex; + } + + fa-icon.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; } + } + + @-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); } } + + @keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); } } + `; + } + + firstUpdated() { + this.loadColls(); + } + + async loadColls() { + const resp = await fetch("/wabac/api/index"); + const json = await resp.json(); + this.colls = json.colls; + } + + async deleteColl(event) { + event.preventDefault(); + + const index = Number(event.target.getAttribute("data-coll-index")); + const coll = this.colls[index]; + if (!coll || coll.deleting) { + return; + } + this.colls[index].deleting = true; + + //event.target.setAttribute("class", "fas fa-cog fa-spin"); + //event.target.iClass = "fas fa-cog fa-spin"; + //event.target.firstUpdated(); + //event.target.requestUpdate(); + + const resp = await fetch(`/wabac/api/${coll.id}`, {method: 'DELETE'}); + if (resp.status === 200) { + const json = await resp.json(); + this.colls = json.colls; + } + return false; + } + + render() { + return html` + +
+ +
+ `; + } + + //createRenderRoot() { return this; } +} + +// =========================================================================== +class WrColl extends LitElement +{ + constructor() { + super(); + this.coll = ""; + this.offset = 0; + this.currList = 0; + this.bookmarks = {}; + this.collInfo = {lists: []}; + this.currList = null; + this.lastST = 0; + + this.hasCurated = false; + this.currTab = ""; + + this.clickTime = 0; + } + + static get properties() { + return { + hasCurated: { type: Boolean }, + collInfo: { type: Object }, + coll: { type: String }, + bookmarks: { type: Object }, + currList: { type: Number }, + currTab: { type: String } + } + } + + async doLoadBookmarks() { + this.hasCurated = (this.collInfo.lists && this.collInfo.lists.length); + this.bookmarks = {}; + + if (!this.hasCurated) { + return; + } + + const resp = await fetch(`/wabac/api/${this.coll}/bookmarks?offset=${this.offset}`); + const json = await resp.json(); + + this.total = json.total; + + for (const bookmark of json.bookmarks) { + if (!this.bookmarks[bookmark.list]) { + this.bookmarks[bookmark.list] = []; + } + this.bookmarks[bookmark.list].push(bookmark); + } + } + + firstUpdated() { + window.addEventListener("hashchange", () => this.onHashChange()); + + if (!this.collInfo.title) { + this.collInfo.title = "Archive from " + this.collInfo.displayName; + } + + this.doLoadBookmarks().then(() => this.onHashChange()); + } + + onHashChange() { + const hash = window.location.hash; + if (!hash) { + this.currTab = this.hasCurated ? "curated" : "resources"; + return; + } + + if (hash === "#resources") { + this.currTab = "resources"; + } else if (hash === "#curated") { + this.currTab = "curated"; + } + + if (hash.startsWith("#list-")) { + const id = hash.slice(6); + this.currTab = "curated"; + this.scrollToList(id); + } + } + + updated(changedProperties) { + if (changedProperties.get("lists")) { + this.doLoadBookmarks(); + } + } + + onTabClick(event) { + event.preventDefault(); + const hash = event.target.getAttribute("href"); + //this.currTab = hash.slice(1); + window.location.hash = hash; + return false; + } + + render() { + return html` + + + `; + } + + renderContent() { + return html` + ${this.collInfo.lists.map((list, i) => html` +
+
+

${list.title}

+
+
${list.desc}
+
    + ${this.bookmarks[list.id] ? this.bookmarks[list.id].map((b) => html` +
  1. + `) : html``} +
+
+
+ `)} + `; + } + + onClickScroll(event) { + this.scrollToList(event.target.getAttribute("data-list")); + } + + scrollToList(id) { + this.currList = Number(id); + const opts = {behavior: "smooth", block: "start", inline: "nearest"}; + this.clickTime = new Date().getTime(); + this.renderRoot.getElementById("list-" + this.currList).scrollIntoView(opts); + } + + onScroll(event) { + const scrollable = event.target;//this.renderRoot.querySelector("#curated"); + const curr = this.renderRoot.getElementById("list-" + this.currList); + + if (!curr) { + return; + } + + let next = curr; + const target = scrollable.offsetTop; + const currST = scrollable.scrollTop; + + if (currST > this.lastST) { + while (next.nextElementSibling && next.nextElementSibling.getBoundingClientRect().top < target) { + next = next.nextElementSibling; + } + } else { + while (next.previousElementSibling && next.previousElementSibling.getBoundingClientRect().bottom >= target) { + next = next.previousElementSibling; + } + } + this.lastST = currST; + if (next && next != curr) { + if (next.id.startsWith("list-")) { + this.currList = Number(next.id.slice(5)); + } + } + + if ((new Date().getTime() - this.clickTime) < 1000) { + return; + } + + const sel = this.renderRoot.querySelector(`a[data-list="${this.currList}"]`); + if (sel) { + const opts = {behavior: "smooth", block: "nearest", inline: "nearest"}; + sel.scrollIntoView(opts); + } + } + + bookmarkUrl(b) { + return `/wabac/${this.coll}/${getTS(b.date)}/${b.url}`; + } + + //createRenderRoot() { return this; } +} + +// =========================================================================== +class WrResources extends LitElement +{ + constructor() { + super(); + this.currMime = "text/html,text/xhtml"; + this.results = []; + this.filteredResults = []; + this.tryMore = false; + this.loading = false; + + this.urlSearch = ""; + this.urlSearchType = "exact"; + + this.filters = [ + {name: "HTML", filter: "text/html,text/xhtml"}, + {name: "Images", filter: "image/"}, + {name: "Audio/Video", filter: "audio/,video/"}, + {name: "PDF", filter: "application/pdf"}, + {name: "All", filter: ""} + ] + } + + static get properties() { + return { + coll: { type: String }, + currMime: { type: String }, + //filterText: { type: String }, + filteredResults: { type: Array }, + //loading: { type: Boolean } + } + } + + static get styles() { + return css` + + table { + table-layout: fixed; + word-wrap: break-word; + text-overflow: ellipsis; + } + tbody > tr { + display: table; + width: 100%; + } + .col-url { + width: 66%; + max-width: 66%; + min-width: 66%; + word-break: break-word; + } + .col-ts { + width: 16%; + max-width: 16%; + word-break: break-word; + } + .loading-cog { + width: 100vw; + display: flex; + } + `; + } + + firstUpdated() { + this.doLoadResources(); + } + + async doLoadResources() { + const count = 100; + const url = (this.urlSearchType !== "contains" ? this.urlSearch : ""); + const prefix = url && this.urlSearchType === "prefix" ? 1 : 0; + + const params = new URLSearchParams({ + mime: this.currMime, + url, + prefix, + count + }).toString(); + let resp = await fetch(`/wabac/api/${this.coll}/urls?${params}`); + resp = await resp.json(); + this.results = resp.urls; + this.tryMore = (resp.urls.length === count); + this.filter(); + } + + async doLoadMore() { + const count = 100; + + if (!this.tryMore || !this.results.length) { + return; + } + if (this.loading) { + return; + } + this.loading = true; + + const url = (this.urlSearchType !== "contains" ? this.urlSearch : ""); + const prefix = url && this.urlSearchType === "prefix" ? 1 : 0; + + const last = this.results[this.results.length - 1]; + const params = new URLSearchParams({ + mime: this.currMime, + url, + prefix, + fromMime: last.mime, + fromUrl: last.url, + fromTs: new Date(last.date).getTime(), + count + }).toString(); + + let resp = await fetch(`/wabac/api/${this.coll}/urls?${params}`); + resp = await resp.json(); + this.results = this.results.concat(resp.urls); + console.log(resp.urls.length); + this.tryMore = (resp.urls.length === count); + this.filter(); + this.loading = false; + } + + onChangeSearch(event) { + this.currMime = event.target.value; + this.doLoadResources(); + } + + onChangeUrlSearch(event) { + this.urlSearch = event.target.value; + this.doLoadResources(); + } + + onClickUrlType(event) { + this.urlSearchType = event.target.value; + this.doLoadResources(); + } + + filter() { + const filteredResults = []; + const filterText = (this.urlSearchType === "contains" ? this.urlSearch : ""); + for (const result of this.results) { + if (!filterText || result.url.indexOf(filterText) >= 0) { + filteredResults.push(result); + } + } + this.filteredResults = filteredResults; + } + + onScroll(event) { + //console.log("pos", event.target.firstElementChild.getBoundingClientRect().bottom); + const element = event.target; + const diff = (element.scrollHeight - element.scrollTop) - element.clientHeight; + if (this.tryMore && diff < 40) { + this.doLoadMore(); + } + } + + render() { + return html` + +
+
+
+ +
+ + + +
+
+

Showing ${this.filteredResults.length} Results

+
+
+ Resource Type:   + ${this.filters.map((filter) => html` + + `)} +
+
+
+ + + + + + + + + + + ${this.filteredResults.length ? + this.filteredResults.map((result) => html` + + + + + + + `) : html`
No Results Found.
`} + +
URLTimestampMimeStatus
${result.url}${new Date(result.date).toLocaleString()}${result.mime}${result.status}
+
+ `; + } +} + +// =========================================================================== +class WrGdrive extends LitElement +{ + constructor() { + super(); + this.manual = false; + this.fileId = ""; + } + + static get properties() { + return { + manual: { type: Boolean }, + fileId: { type: String } + } + } + + onLoad() { + this.gauth('none', (response) => { + if (response.error === "immediate_failed") { + this.manual = true; + } else { + this.authed(response); + } + }); + } + + onClickAuth() { + this.gauth(undefined, (response) => { + if (!response.error) { + this.authed(response); + } + }); + } + + async authed(response) { + const fileId = this.fileId; + const metadataUrl = `https://www.googleapis.com/drive/v3/files/${fileId}`; + const authToken = response.access_token; + const headers = {"Authorization": `Bearer ${authToken}`}; + + const resp = await fetch(metadataUrl + "?fields=name", {headers}); + const metadata = await resp.json(); + + const sourceId = `googledrive://${fileId}`; + const name = metadata.name; + const displayName = `Google Drive file: ` + metadata.name; + + const sourceUrl = metadataUrl + "?alt=media"; + + this.dispatchEvent(new CustomEvent("load-ready", {detail: {name, displayName, sourceId, sourceUrl, headers}})); + } + + render() { + return html` + ${this.script()} + ${!this.manual ? html` +

Authorizing Google Drive...

+ ` : html` + + `}`; + } + + script() { + const script = document.createElement('script'); + script.onload = (() => this.onLoad()); + script.src = 'https://apis.google.com/js/platform.js'; + return script; + } + + gauth(prompt, callback) { + gapi.load('auth2', () => { + gapi.auth2.authorize({ + client_id: "758792702348-kokjbpv1leqid7ac7tjq7pbp37cg05kq.apps.googleusercontent.com", + scope: "https://www.googleapis.com/auth/drive.file", + prompt + }, callback); + }); + } +} + + +// =========================================================================== +// =========================================================================== + +async function main() { + const swPromise = initSW("swonly.js?replayPrefix=wabac&stats=true"); + await swPromise; + customElements.define("app-main", AppMain); + customElements.define("wr-index", WrIndex); + customElements.define("wr-loader", WrLoader); + customElements.define("wr-coll", WrColl); + customElements.define("wr-coll-resources", WrResources); + customElements.define("wr-gdrive", WrGdrive); +} + +main();