import { ApiFolderContentResponse, ApiInfoResponse, ApiTimeGrouping, ApiTimeGroupResponse, FolderInfoBase, QueryBuilder } from "@labir/api"; import { AvailableThermalPalettes } from "@labir/core"; import { provide } from "@lit/context"; import { format } from "date-fns"; import { cs, cy, de, enGB, fr } from "date-fns/locale"; import { t } from "i18next"; import { css, CSSResultGroup, html, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { createRef, ref, Ref } from "lit/directives/ref.js"; import { RegistryProviderElement } from "../../hierarchy/providers/RegistryProvider"; import { T } from "../../translations/Languages"; import { initLocalesInTopLevelElement, IWithlocale, localeContext, localeConverter, Locales } from "../../translations/localeContext"; import { interactiveAnalysisContext } from "../../utils/context"; import { booleanConverter } from "../../utils/converters/booleanConverter"; import { BaseAppWithPngExportContext } from "../../utils/converters/pngExportContext"; enum STATE { MAIN, ONE, MULTIPLE, DETAIL } const loc = { en: enGB, fr, de, cy, cs } @customElement("remote-browser-app") export class RemoteBrowser extends BaseAppWithPngExportContext implements IWithlocale { @property({ type: String, reflect: true }) label?: string; @property({ type: String, reflect: true }) license?: string; @property({ type: String, reflect: true }) author?: string; @property({ type: String, reflect: true, attribute: true }) palette: AvailableThermalPalettes = "jet"; @property({ type: Boolean, reflect: true, converter: booleanConverter(true) }) enablegrouping: boolean = false; /** The API endpoint main URL address */ @property({ type: String, reflect: true }) public url!: string; /** An optional subfolder relative to the API endpoint in `this.url` */ @property({ type: String, reflect: true }) public subfolder?: string; @state() protected info?: ApiInfoResponse; @state() protected error?: string; @state() protected loadingInfo: boolean = false; @state() protected loadingData: boolean = false; @state() protected only: string[] = []; @state() protected state: STATE = STATE.MAIN; @state() protected by: ApiTimeGrouping = ApiTimeGrouping.HOURS; @state() protected dataOnly?: ApiFolderContentResponse; @state() protected dataMultiple?: ApiTimeGroupResponse; @state() protected folders: { [index: string]: FolderInfoBase } = {}; protected registryRef: Ref = createRef(); @provide({ context: interactiveAnalysisContext }) protected interactiveAnalysis: boolean = true; @state() protected detail?: { folder: string, lrc: string, png?: string } = undefined; @provide({ context: localeContext }) @property({ reflect: true, converter: localeConverter }) public locale!: Locales; connectedCallback(): void { super.connectedCallback(); const updatePosition = () => { const rect = this.getBoundingClientRect(); if (rect.top < -50) { this.classList.add("is-pinned"); } else { this.classList.remove("is-pinned"); } }; window.addEventListener("scroll", updatePosition); window.addEventListener("resize", updatePosition); } protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); initLocalesInTopLevelElement(this); } protected updated(_changedProperties: PropertyValues): void { super.updated(_changedProperties); if (_changedProperties.has("url") || _changedProperties.has("subfolder")) { this.loadInfo(this.url, this.subfolder); } if (_changedProperties.has("only") || _changedProperties.has("by")) { if (this.url !== undefined) { this.loadData( this.only, this.by, this.url, this.subfolder ); } } if (_changedProperties.has("folders")) { const keys = Object.keys(this.folders); if (keys.length === 1) { this.actionOpenOneFolder(keys[0]); } } if (this.registryRef.value) { this.registryRef.value.registry.batch.onBatchComplete.set(this.UUID, () => { this.registryRef.value?.registry.range.applyMinmax(); }); } } protected async showDetail( folder: string, lrc: string, png?: string ) { this.detail = { folder, lrc, png }; this.state = STATE.DETAIL; this.resetRegistry(); this.scrollToComponent(); } protected async closeDetail() { delete this.detail; this.detail = undefined; const data = this.dataMultiple ?? this.dataOnly; switch (typeof data) { case "undefined": this.state = STATE.MAIN; break; case typeof this.dataOnly: this.state = STATE.ONE; break; case typeof this.dataMultiple: this.state = STATE.MULTIPLE; break; } this.scrollToComponent(); } protected scrollToComponent() { this.scrollIntoView({ behavior: "smooth", block: "start" }); } protected getApiUrl( url: string, subfolder?: string ) { return subfolder !== undefined ? `${url}?subfolder="${subfolder}"` : url; } protected async loadInfo( url: string, subfolder?: string ) { this.loadingInfo = true; // this.scrollToComponent(); try { const query = new QueryBuilder(url, subfolder); const json = await query.info(); this.info = json; this.folders = json.folders; this.loadingInfo = false; } catch (err) { this.error = "There was an error loading info" } } protected async loadDataOne( folder: string, url: string, subfolder?: string ) { this.loadingData = false; this.dataOnly = undefined; this.dataMultiple = undefined; this.scrollToComponent(); const query = new QueryBuilder(url, subfolder); const data = await query.folder(folder); this.log("folder", folder, data); this.scrollToComponent(); this.dataOnly = data; this.loadingData = false; // Turn on the synchronisation of analyses if (this.registryRef.value) { this.registryRef.value.registry.groups.addListener( this.UUID, groups => { groups.forEach(group => group.files.addListener( this.UUID, files => { if (files[0]) { group.analysisSync.turnOn(files[0]); } } )); } ); } } protected async loadDataMultiple( only: string[], grouping: ApiTimeGrouping, url: string, subfolder?: string ) { this.loadingData = true; this.dataOnly = undefined; this.dataMultiple = undefined; this.scrollToComponent(); const query = new QueryBuilder(url, subfolder); query.setOnly(only.join(",")); query.setGrid(true); const data = await query.grid(grouping); this.scrollToComponent(); this.dataMultiple = data; this.loadingData = false; // Turn off synchronisation of analyses this.registryRef.value?.registry.groups.removeListener(this.UUID); } protected async loadData( only: string[], grouping: ApiTimeGrouping, url: string, subfolder?: string ) { if (only.length > 1) { this.loadDataMultiple(only, grouping, url, subfolder); } else if (only.length === 1) { this.loadDataOne(only[0], url, subfolder); } } protected renderMainScreen() { return html`
`; } protected resetRegistry() { if (this.registryRef.value) { this.registryRef.value.registry.forEveryInstance(instance => instance.unmountFromDom()); this.registryRef.value.registry.reset(); this.registryRef.value.registry.minmax.reset(); this.registryRef.value.registry.range.reset(); this.registryRef.value.registry.opacity.imposeOpacity(1); } } protected actionCloseToHomepage() { this.state = STATE.MAIN; this.only = []; delete this.dataOnly; delete this.dataMultiple; this.resetRegistry(); } protected actionOpenOneFolder( folderKey: string ) { if ( !this.only.includes(folderKey) && Object.keys(this.folders).includes(folderKey) ) { this.state = STATE.ONE; this.only = [folderKey]; this.resetRegistry(); } } protected actionToggleFolder( folderKey: string ) { if (this.only.includes(folderKey)) { this.only = this.only.filter(f => f !== folderKey); this.resetRegistry(); if (this.only.length === 0) { this.actionCloseToHomepage(); } else if (this.only.length === 1) { this.state = STATE.ONE; } else if (this.only.length > 1) { this.state = STATE.MULTIPLE; } } else if (Object.keys(this.folders).includes(folderKey)) { this.only = [...this.only, folderKey]; this.resetRegistry(); if (this.only.length > 0) { this.state = STATE.MULTIPLE; } } } protected actionShowEverything() { this.only = Object.keys(this.folders); this.resetRegistry(); this.state = STATE.MULTIPLE; } protected renderLoading(what: string) { return html`
${what}
`; } protected renderOne() { if (this.loadingData || this.dataOnly === undefined) { return this.renderLoading("Loading folder data"); } return html` ${Object.values(this.dataOnly.files).map(file => { return html`
{ this.showDetail(this.dataOnly!.info.name, file.lrc, file.png) }} >
`; })}
`; } protected renderMultiple() { if (this.loadingData || this.dataMultiple === undefined || this.dataMultiple.data === undefined) { return this.renderLoading("Loading selected folders' data"); } const groups = this.dataMultiple.data; const header = Object.entries( Object.values( Object.values(this.dataMultiple.data) )[0] ).map(([f, v]) => ({ name: v.name, key: f })); const columns = header.length; const sortedKeys = Object.keys(Object.values(groups)[0]).sort((a, b) => a < b ? -1 : 1); return html` ${Object.entries(groups).map(([timestamp, folders]) => { let title: string | undefined = undefined; const groupTimestamp = parseInt(timestamp); if (this.by === ApiTimeGrouping.HOURS) { title = format(groupTimestamp * 1000, "d. M. yyyy - HH") + ":00"; } else if (this.by === ApiTimeGrouping.DAYS) { title = format(groupTimestamp * 1000, "d. M. yyyy"); } else if (this.by === ApiTimeGrouping.WEEKS) { title = format(groupTimestamp * 1000, "wo"); } else if (this.by === ApiTimeGrouping.MONTHS) { title = format(groupTimestamp * 1000, "LLLL yyyy", { locale: loc[this._locale! as keyof typeof loc] }) } else if (this.by === ApiTimeGrouping.YEARS) { title = format(groupTimestamp * 1000, "yyyy"); } return html` ${sortedKeys.map((key) => { const info = folders[key]; return html``; })} `; } )}

${title}

${Object.values(info.files).map(file => html`
{ this.showDetail(info.name, file.lrc, file.png) }} >
` )}
`; } protected renderTimeToggle() { const bys = ["hours", "days", "weeks", "months", "years"]; return html` ${t(T[`by${this.by}`])} ${bys.map(by => html`
this.by = by as ApiTimeGrouping}> ${t(T[`by${by}` as T])}
` )}
`; } protected renderInfo() { if (this.state === STATE.MAIN) { return nothing; } let content: TemplateResult<1> | undefined = undefined; if (this.state === STATE.ONE) { const theOne = this.folders[this.only[0]]; const theOthers = Object.values(this.folders).filter(f => f.folder !== theOne.folder); const dropdown = theOthers.length > 0 ? html` ${theOne.name} ${theOthers.map(f => html`
this.actionOpenOneFolder(f.folder)}> ${f.name}
`)}
` : html`${theOne.name}`; const add = theOthers.length > 0 ? theOthers.map((f, i) => html` this.actionToggleFolder(f.folder)}> + ${f.name} ${i !== theOthers.length - 1 ? ` ${t(T.or)} ` : nothing}`) : html`${t(T.remotefoldersbrowseraddfolderhint)}`; content = html`${t(T.showingfolder)} ${dropdown}. ${theOthers.length > 0 ? html` ${t(T.doyouwanttoadd)} ${add}?` : add} `; } else if (this.state === STATE.MULTIPLE) { const displayed: FolderInfoBase[] = []; const available: FolderInfoBase[] = []; Object.values(this.folders).forEach(folder => { if (this.only.includes(folder.folder)) { displayed.push(folder); } else { available.push(folder); } }); content = html` ${t(T.showingfolders)} ${displayed.map((f, i) => html` this.actionToggleFolder(f.folder)} > ${f.name} ✕ ${i !== displayed.length - 1 ? ` ${t(T.and)} ` : nothing}`)} ${t(T.groupped)} ${this.renderTimeToggle()}. ${available.length > 0 ? html`${t(T.youmayalsoadd)} ${available.map((f, i) => html` this.actionToggleFolder(f.folder)} > + ${f.name} ${i !== available.length - 1 ? ` ${t(T.or)} ` : nothing} `)}.` : nothing } `; } if (content === undefined) { return nothing; } return html`
${content}
`; } protected renderBrowser() { return html`
${this.state === STATE.ONE ? this.renderOne() : nothing} ${this.state === STATE.MULTIPLE ? this.renderMultiple() : nothing} ${this.state === STATE.DETAIL ? this.renderDetail() : nothing}
`; } protected renderDetail() { if (this.detail === undefined) { return this.renderLoading("Loading the IR image"); } return html`
this.closeDetail()} variant="foreground">${t(T.close)} ${this.detail.folder}
`; } protected renderApp() { if (this.info === undefined) { return this.renderLoading(t(T.loading)); } if (this.state === STATE.MAIN) { return this.renderMainScreen(); } else { return this.renderBrowser(); } } static styles?: CSSResultGroup | undefined = css` :host { font-size: var(--thermal-fs); line-height: 1em; --table-gap: calc( var( --thermal-gap ) * .8 ); --table-gap-sm: calc( var( --thermal-gap ) * .4 ); --thermal-browser-width: 150px; @media(min-width: 400px) { --thermal-browser-width: 300px; } } .reset-text, h1, h2, h3, h4, h5 { margin: 0; padding: 0; font-size: var(--thermal-fs); line-height: 1em; } .screen { } .screen-main { } .screen-main-folders { display: grid; width: 100%; grid-template-columns: repeat( auto-fill, minmax(var(--thermal-browser-width), 1fr) ); gap: var(--thermal-gap); .folder { padding: 0; overflow: hidden; border-radius: var(--thermal-radius); border: 1px solid var( --thermal-slate ); background: var(--thermal-background); color: var(--thermal-foreground); cursor: pointer; flex-grow: 1; transition: all .2s ease-in-out; file-canvas, file-provider { displaY: block; } file-provider { overflow: hidden; position: relative; } file-canvas { transition: all .4s ease-in-out; } .open-button { position: absolute; bottom: 0; left: 0; right: 0; top: 0; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all .4s ease-in-out; opacity: 0; pointer-events: none; transform: translateY(20px); } .folder-header { padding: var(--thermal-gap); display: grid; gap: var(--thermal-gap); grid-template-columns: auto 2rem; text-align: left; } .folder-header-icon { color: var(--thermal-slate-light); transition: all .4s ease-in-out; } &:hover, &:focus { background: var(--thermal-background); box-shadow: var(--thermal-shadow); file-canvas { transform: scale(1.2); } .open-button { opacity: 1; transform: translateY(0px); } .folder-header-icon { color: var(--thermal-primary); } } } } .screen-browser { } .screen-browser-header { flex-grow: 1; } .screen-browser-header-buttons { > * { display: inline-block; } } .screen-browser__one { group-provider { display: grid; grid-template-columns: repeat( auto-fill, minmax(var(--thermal-browser-width), 1fr) ); gap: var(--thermal-gap); } } table.affected { width: 100%; border-collapse: collapse; table-layout: fixed; color: var(--thermal-foreground); th, td { padding: var(--table-gap-sm); } .cell-header { background: var(--thermal-background); color: var(--thermal-foreground); text-align: left; padding: var(--table-gap); border-radius: var(--thermal-radius); } .cell-label { border-left: 1px solid var(--thermal-slate); border-right: 1px solid var(--thermal-slate); border-top: 1px solid var(--thermal-slate); padding: var(--table-gap); font-weight: bold; > div { display: flex; gap: var(--thermal-gap); h2 { // flex-grow: 1; } } } .cell-content { border-bottom: 1px solid var(--thermal-slate); &:first-child { border-left: 1px solid var(--thermal-slate); } &:last-child { border-right: 1px solid var(--thermal-slate); } .file { background: var(--thermal-background); padding: var(--table-gap); border-radius: var(--thermal-radius); } .file:not(:last-child) { margin-bottom: var( --table-gap-sm ); } } .cell-separator { height: 1rem; } } .screen-browser__multiple { group-provider.row { display: table-row; } group-provider.buttons { display: flex; gap: 5px; align-items: center; } } .info { padding: var(--thermal-gap); border: 1px solid var(--thermal-slate); border-radius: var(--thermal-radius); background-color: var(--thermal-slate-light); margin-bottom: 1em; thermal-button, thermal-dropdown { display: inline-block; } .button-inline-icon { opacity: .5; } } thermal-dropdown.selector::part(invoker) { --thermal-slate-light: var( --thermal-background ); } .detail { padding: var(--thermal-gap); border-radius: var(--thermal-radius); border: 1px solid var( --thermal-slate ); background-color: var(--thermal-background); box-sizing: border-box; width: 100%; main { display: grid; gap: var(--thermal-gap); grid-template-columns: 1fr; @media ( min-width: 900px ) { grid-template-columns: 2fr 1fr; } @media ( min-width: 1300px ) { grid-template-columns: 1fr 1fr; } } header { width: 100%; display: flex; gap: 5px; margin-bottom: var(--thermal-gap); align-items: center; } } .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; border: 1px solid var(--thermal-slate); border-radius: var(--thermal-radius); box-sizing: border-box; padding: var(--thermal-gap); min-height: 30vh; color: var(--thermal-slate-dark); background: var(--thermal-slate-light); } .lds-facebook { /* change color here */ color: var(--thermal-slate-dark); } .lds-facebook, .lds-facebook div { box-sizing: border-box; } .lds-facebook { display: inline-block; position: relative; width: 80px; height: 80px; } .lds-facebook div { display: inline-block; position: absolute; left: 8px; width: 16px; background: currentColor; animation: lds-facebook 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; } .lds-facebook div:nth-child(1) { left: 8px; animation-delay: -0.24s; } .lds-facebook div:nth-child(2) { left: 32px; animation-delay: -0.12s; } .lds-facebook div:nth-child(3) { left: 56px; animation-delay: 0s; } @keyframes lds-facebook { 0% { top: 8px; height: 64px; } 50%, 100% { top: 24px; height: 32px; } } `; protected renderHeader() { if (this.state === STATE.MAIN) { return nothing; } return html` ${this.state === STATE.ONE && this.dataOnly !== undefined ? html` ` : nothing } `; } protected renderHistogram() { if (this.state === STATE.MAIN) return nothing; return html` `; } protected renderTableHeader() { if (this.state !== STATE.MULTIPLE) return nothing; const header = Object.values(this.folders).filter(f => this.only.includes(f.folder)); return html` ${header.map(folder => html`` )}
${folder.name}
`; } protected render(): unknown { let title = t(T.remotefoldersbrowser); let onlabel: undefined | (() => void) = undefined; if (this.info === undefined) { title = t(T.loading) + "..."; } else { // In case there is only one group in the entire app, show a special title if (Object.keys(this.folders).length === 1 && this.label) { title = this.label; } // If looking on the main page, show the label else if (this.state === STATE.MAIN && this.label) { title = this.label; } // In all other cases, show the close button else if (this.state !== STATE.MAIN) { title = t(T.close); onlabel = () => this.actionCloseToHomepage(); } } return html` ${this.state !== STATE.MAIN ? html`` : nothing } ${this.state === STATE.MAIN && Object.keys(this.folders).length > 1 ? html` { this.actionShowEverything(); }}>${t(T.showeverything)}` : nothing } ${this.renderHeader()}
${this.renderInfo()} ${this.renderHistogram()} ${this.renderTableHeader()}
${this.renderApp()}
`; } }