Preparation for https://github.com/webrecorder/archiveweb.page/issues/198 / https://github.com/webrecorder/archiveweb.page/pull/199. ## Changes - Organizes published files more cleanly, using `dist/types` folder as the target for type declarations and sourcemaps - Configures type field in `package.json` - Updates ESLint & its config - Sets up type-informed linting rules mirroring browsertrix - Adds a bunch of other useful eslint and eslint-typescript rules, and applies auto-fixes to the codebase - Cleans up a bunch of types throughout the codebase, particularly parts of the code used by ArchiveWebPage - Changes `ItemIndex#_deleting` from `private` to `protected` so that a part of AWP that extends it can do so properly - Makes a bunch of previously-implied [non-null assertions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#non-null-assertion-operator-postfix-) explicit - Updates `URLResource` and `ItemType` types (and renames `Item` → `ItemType` to disambiguate from the `Item` class) - Removes `node-sass` and replaces it with `sass-embedded` for much quicker installs and compilation
372 lines
9.6 KiB
TypeScript
372 lines
9.6 KiB
TypeScript
import { LitElement, html, css, type PropertyValues } from "lit";
|
|
import { property, state } from "lit/decorators.js";
|
|
import { wrapCss, apiPrefix } from "./misc";
|
|
|
|
import fasArrowUp from "@fortawesome/fontawesome-free/svgs/solid/angle-double-up.svg";
|
|
import fasArrowDown from "@fortawesome/fontawesome-free/svgs/solid/angle-double-down.svg";
|
|
|
|
import fasSearch from "@fortawesome/fontawesome-free/svgs/solid/search.svg";
|
|
|
|
import type { ItemType } from "./types";
|
|
|
|
import "./item-info";
|
|
|
|
// ===========================================================================
|
|
class ItemIndex extends LitElement {
|
|
@property({ type: Array })
|
|
items: ItemType[] = [];
|
|
|
|
@property({ type: String })
|
|
query = "";
|
|
|
|
@property({ type: Array })
|
|
filteredItems: ItemType[] = [];
|
|
|
|
@property({ type: Array })
|
|
sortedItems: ItemType[] = [];
|
|
|
|
@property({ type: Boolean })
|
|
hideHeader = false;
|
|
|
|
@property({ type: String })
|
|
dateName = "Date Loaded";
|
|
|
|
@property({ type: String })
|
|
headerName = "Loaded Archives";
|
|
|
|
@state()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO fixme
|
|
protected _deleting: any = {};
|
|
|
|
private get typeFilter() {
|
|
return "";
|
|
}
|
|
private get indexParams() {
|
|
return "";
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.hideHeader = localStorage.getItem("index:hideHeader") === "1";
|
|
}
|
|
|
|
get sortKeys() {
|
|
return [
|
|
{ key: "title", name: "Title" },
|
|
|
|
{ key: "sourceUrl", name: "Source" },
|
|
|
|
{ key: "ctime", name: this.dateName },
|
|
|
|
{ key: "size", name: "Total Size" },
|
|
];
|
|
}
|
|
|
|
firstUpdated() {
|
|
this.loadItems();
|
|
}
|
|
|
|
updated(changedProperties: PropertyValues<this>) {
|
|
if (changedProperties.has("hideHeader")) {
|
|
localStorage.setItem("index:hideHeader", this.hideHeader ? "1" : "0");
|
|
}
|
|
if (changedProperties.has("items") || changedProperties.has("query")) {
|
|
this.filter();
|
|
}
|
|
}
|
|
|
|
filter() {
|
|
if (!this.query) {
|
|
this.filteredItems = this.items;
|
|
return;
|
|
}
|
|
|
|
this.filteredItems = [];
|
|
|
|
for (const item of this.items) {
|
|
if (
|
|
item.sourceUrl.indexOf(this.query) >= 0 ||
|
|
item.filename.indexOf(this.query) >= 0 ||
|
|
Boolean(item.loadUrl && item.loadUrl.indexOf(this.query) >= 0) ||
|
|
(item.title && item.title.indexOf(this.query) >= 0)
|
|
) {
|
|
this.filteredItems.push(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
async loadItems() {
|
|
const resp = await fetch(`${apiPrefix}/coll-index?${this.indexParams}`);
|
|
try {
|
|
if (resp.status !== 200) {
|
|
throw new Error("Invalid API Response, Retry");
|
|
}
|
|
const json = await resp.json();
|
|
this.items = json.colls.map((item: ItemType) => {
|
|
item.title = item.title ?? item.filename;
|
|
return item;
|
|
});
|
|
|
|
this._deleting = {};
|
|
this.sortedItems = [];
|
|
} catch (e) {
|
|
// likely no sw registered yet, or waiting for new sw to register, retry again
|
|
setTimeout(() => this.loadItems(), 500);
|
|
}
|
|
}
|
|
|
|
async onDeleteItem(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!this.sortedItems) {
|
|
return;
|
|
}
|
|
|
|
const index = Number(event.currentTarget.getAttribute("data-coll-index"));
|
|
|
|
const item = this.sortedItems[index];
|
|
|
|
if (!item || this._deleting[item.sourceUrl]) {
|
|
return;
|
|
}
|
|
|
|
this._deleting[item.sourceUrl] = true;
|
|
this.requestUpdate();
|
|
|
|
const resp = await fetch(`${apiPrefix}/c/${item.id}`, { method: "DELETE" });
|
|
if (resp.status === 200) {
|
|
const json = await resp.json();
|
|
this.items = json.colls;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static get styles() {
|
|
return wrapCss(ItemIndex.compStyles);
|
|
}
|
|
|
|
static get compStyles() {
|
|
return css`
|
|
:host {
|
|
overflow-y: auto;
|
|
min-width: 0;
|
|
}
|
|
.size {
|
|
margin-right: 20px;
|
|
}
|
|
.extra-padding {
|
|
padding: 2em;
|
|
}
|
|
.no-top-padding {
|
|
padding-top: 1em;
|
|
}
|
|
.panel-heading {
|
|
font-size: 0.85rem;
|
|
}
|
|
.is-loading {
|
|
line-height: 1.5em;
|
|
height: 1.5em;
|
|
border: 0px;
|
|
background-color: transparent !important;
|
|
width: auto;
|
|
}
|
|
div.panel.is-light {
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
fa-icon {
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.panel-color {
|
|
background-color: rgb(210, 249, 214);
|
|
}
|
|
|
|
.copy {
|
|
color: black;
|
|
margin: 0px;
|
|
margin: 0;
|
|
line-height: 0.4em;
|
|
padding: 6px;
|
|
border-radius: 10px;
|
|
position: absolute;
|
|
}
|
|
.copy:active {
|
|
background-color: lightgray;
|
|
}
|
|
.sort-header {
|
|
padding: 0.3rem 0.3rem 0.3rem 0;
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-flow: row wrap;
|
|
}
|
|
.sort-header .control {
|
|
flex: auto;
|
|
|
|
padding-left: 0.3rem;
|
|
width: initial;
|
|
}
|
|
wr-sorter {
|
|
padding: 0.3rem;
|
|
}
|
|
a.button.is-small.collapse {
|
|
border-radius: 6px;
|
|
}
|
|
.icon.is-left {
|
|
margin-left: 0.5rem;
|
|
}
|
|
.coll-block {
|
|
position: relative;
|
|
}
|
|
.delete-button {
|
|
width: 32px;
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
}
|
|
#sort-select::after {
|
|
display: none;
|
|
}
|
|
header {
|
|
transform: translate(0px, 0px);
|
|
transition: all 0.5s ease 0s;
|
|
visibility: visible;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
header.closed {
|
|
transform: translate(0, -100%);
|
|
transition: all 0.5s ease 0s;
|
|
visibility: visible;
|
|
height: 269px;
|
|
margin-top: -269px;
|
|
}
|
|
`;
|
|
}
|
|
|
|
renderHeader() {
|
|
return html`<h2 class="panel-heading panel-color">
|
|
<span>${this.headerName}</span>
|
|
</h2>`;
|
|
}
|
|
|
|
renderSearchHeader() {
|
|
return "";
|
|
}
|
|
|
|
render() {
|
|
const hasHeader = this.childElementCount > 0;
|
|
|
|
return html`
|
|
<header class="${this.hideHeader ? "closed" : ""}">
|
|
<slot name="header"></slot>
|
|
</header>
|
|
<section class="section no-top-padding">
|
|
<div class="sort-header is-small">
|
|
${hasHeader
|
|
? html`
|
|
<button @click=${() =>
|
|
(this.hideHeader =
|
|
!this.hideHeader)} class="collapse button is-small">
|
|
<span class="icon"><fa-icon .svg=${
|
|
this.hideHeader ? fasArrowDown : fasArrowUp
|
|
}></span>
|
|
<span>${
|
|
this.hideHeader ? "Show " : "Hide"
|
|
} <span class="is-sr-only">Header</span></span>
|
|
</button>`
|
|
: ""}
|
|
</div>
|
|
<div class="panel">
|
|
${this.renderHeader()}
|
|
${this.items.length
|
|
? html`
|
|
<div class="panel-block sort-header is-small">
|
|
${this.renderSearchHeader()}
|
|
<div class="control has-icons-left has-addons">
|
|
<input
|
|
type="text"
|
|
class="input is-small"
|
|
@input="${(e: Event) =>
|
|
(this.query = (
|
|
e.currentTarget as HTMLInputElement
|
|
).value)}"
|
|
.value="${this.query}"
|
|
placeholder="Search by Archive Title or Source"
|
|
/>
|
|
<span class="icon is-left is-small">
|
|
<fa-icon .svg="${fasSearch}"></fa-icon>
|
|
</span>
|
|
</div>
|
|
<wr-sorter
|
|
id="index"
|
|
sortKey="ctime"
|
|
?sortDesc="${true}"
|
|
.sortKeys="${this.sortKeys}"
|
|
.data="${this.filteredItems}"
|
|
@sort-changed="${(
|
|
e: CustomEvent<{
|
|
sortKey: string | null;
|
|
sortDesc: boolean | null;
|
|
sortedData: ItemType[];
|
|
}>,
|
|
) => (this.sortedItems = e.detail.sortedData)}"
|
|
>
|
|
</wr-sorter>
|
|
</div>
|
|
|
|
<div class="coll-list">
|
|
${this.sortedItems?.map(
|
|
(item, i) => html`
|
|
<div class="coll-block panel-block">
|
|
${this.renderItemInfo(item)}
|
|
${!this._deleting[item.sourceUrl]
|
|
? html`
|
|
<button
|
|
class="delete delete-button"
|
|
aria-label="Unload Item"
|
|
title="Unload Item"
|
|
data-coll-index="${i}"
|
|
@click="${this.onDeleteItem}"
|
|
></button>
|
|
`
|
|
: html` <span
|
|
class="button delete-button is-loading is-static"
|
|
>
|
|
Deleting
|
|
</span>`}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: html`
|
|
<div class="panel-block extra-padding">
|
|
${this.sortedItems === null
|
|
? html`<i>Loading Archives...</i>`
|
|
: this.renderEmpty()}
|
|
</div>
|
|
`}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
renderItemInfo(item: ItemType) {
|
|
return html`<wr-item-info .item=${item}></wr-item-info>`;
|
|
}
|
|
|
|
renderEmpty() {
|
|
return html`<i
|
|
>No Archives so far! Archives loaded in the section above will appear
|
|
here.</i
|
|
>`;
|
|
}
|
|
}
|
|
|
|
customElements.define("wr-item-index", ItemIndex);
|
|
|
|
export { ItemIndex };
|