<file_sep>export interface DbDoc {
  _id?: string;
  timestamp?: Date;
}

export interface Serie extends DbDoc {
  name: string;
  nameAlt: string;
  image: string;
  url: string;
  chapter: number;
}

export interface Category extends DbDoc {
  name: string;
}
<file_sep>export const CustomElement = (tag: string) => (cls: any) => {
  window.customElements.define(tag, cls)
}
<file_sep>import AuthStatus from '../../interfaces/AuthStatus'
import { IAuthController } from '../../interfaces/IAuthController'

enum Permission {
  R = 'read',
  RW = 'read-write',
}

export default class WSAuthController implements IAuthController {
  private permission: Permission = Permission.R
  static readonly AUTH_ITEM: string = 'auth'

  onAuthChange?(x: { newAuth: Permission }): void;

  constructor () {
    const auth = window.localStorage.getItem(WSAuthController.AUTH_ITEM)
    if (auth != null) {
      this.permission = Permission.RW
    }
  }

  getStatus (): AuthStatus {
    throw new Error('Method not implemented.')
  }

  isSudo (): boolean {
    return this.permission === Permission.RW
  }

  login (x: { pass: string; url: string }): void {
    const ws = new WebSocket(x.url)
    ws.onopen = () => {
      ws.send(JSON.stringify({ pass: x.pass }))
    }
    ws.onmessage = (ev) => {
      if (ev.data === 'OK') {
        this.permission = Permission.RW
      } else {
        this.permission = Permission.R
      }
      ws.close(1000)
      if (this.onAuthChange !== undefined) {
        this.onAuthChange({ newAuth: this.permission })
      }
    }
  }

  logout (): void {
    this.permission = Permission.R
  }

  remember (): void {
    throw new Error('Method not implemented.')
  }
}
<file_sep>import { IAuthController } from '../../interfaces/IAuthController'
import { getAuth, onAuthStateChanged } from 'firebase/auth'
import { FirebaseApp } from 'firebase/app'
import AuthStatus from '../../interfaces/AuthStatus'
import { isEmailSudo } from './AuthUtil'
import { isDebug } from '../../../app.config'

export type AuthChangeEvent = {
  status: AuthStatus;
};

export default class FirebaseAuthController implements IAuthController {
  private readonly auth
  private status = AuthStatus.ANONYMOUS

  onAuthChange?(x: AuthChangeEvent): void;

  constructor (private readonly app: FirebaseApp) {
    this.auth = getAuth(this.app)
    if (isDebug()) {
      import('firebase/auth').then(({ connectAuthEmulator }) => {
        connectAuthEmulator(this.auth, 'http://localhost:9099', {
          disableWarnings: true
        })
      })
    }
    onAuthStateChanged(this.auth, async (user) => {
      if (user === null) {
        this.status = AuthStatus.ANONYMOUS
      } else {
        const isSudo = user.email ? await isEmailSudo(user.email) : false
        this.status = isSudo ? AuthStatus.SUDO : AuthStatus.SIGNED
      }
      if (this.onAuthChange) this.onAuthChange({ status: this.status })
      console.log(
        'Firebase logged:',
        user,
        'with status',
        AuthStatus[this.status].toString()
      )
    })
  }

  getStatus (): AuthStatus {
    return this.status
  }

  async login (): Promise<void> {
    const { GoogleAuthProvider, signInWithRedirect } = await import(
      'firebase/auth'
    )
    const provider = new GoogleAuthProvider()
    await signInWithRedirect(this.auth, provider)
  }

  async logout (): Promise<void> {
    await this.auth.signOut()
  }

  remember (): void {
    throw new Error('Method not implemented.')
  }
}
<file_sep>import ModalView from '../interfaces/ModalView'
import { Serie } from '../interfaces/Models'
import { isDebug } from '../../app.config'
import { CustomElement } from '../interfaces/CustomElement'
import { SuggestionsList } from './SuggestionsList'

@CustomElement('sl-add-serie-modal')
export class AddSerieModal extends ModalView {
  onSubmit?(serie: Serie): Promise<void>;

  private nameInput = document.createElement('input')
  private altNameInput = document.createElement('input')
  private imgInput = document.createElement('input')
  private imgPrev = document.createElement('img')
  private urlInput = document.createElement('input')
  private chapterInput = document.createElement('input')

  private titleDiv = document.createElement('div')
  private titleSpan = document.createElement('span')
  private modalClose = document.createElement('button')

  private submitBtn = document.createElement('button')
  private suggestionsWorker?: Worker

  async connectedCallback () {
    const separator = document.createElement('div')
    separator.className = 'separator'
    const separator2 = document.createElement('div')
    separator2.className = 'separator'
    const inputsContainer = document.createElement('div')
    const allContainer = document.createElement('div')
    inputsContainer.className = 'inputs-container'
    allContainer.className = 'all-container'
    this.imgPrev.className = 'img-prev'
    const suggestionList = new SuggestionsList()
    const suggestionListAlt = new SuggestionsList()

    inputsContainer.append(
      this.nameInput,
      suggestionList,
      this.altNameInput,
      suggestionListAlt,
      this.imgInput,
      this.urlInput,
      this.chapterInput
    )

    allContainer.append(inputsContainer, this.imgPrev)

    this.window.append(
      this.titleDiv,
      separator,
      allContainer,
      separator2,
      this.submitBtn
    )
    this.titleDiv.append(this.titleSpan, this.modalClose)

    this.titleDiv.className = 'title'

    this.nameInput.type = 'text'
    this.altNameInput.type = 'text'
    this.imgInput.type = 'text'
    this.urlInput.type = 'text'
    this.chapterInput.type = 'number'

    this.nameInput.placeholder = 'Nombre'
    this.altNameInput.placeholder = 'Nombre alternativo'
    this.imgInput.placeholder = 'Link imagen'
    this.urlInput.placeholder = 'Url'
    this.chapterInput.value = '0'

    this.titleSpan.innerText = 'Añadir serie'
    this.modalClose.className = 'title-btn'
    this.modalClose.innerText = 'X'
    this.modalClose.onclick = () => {
      if (this.suggestionsWorker !== undefined) {
        this.suggestionsWorker.terminate()
      }
      this.remove()
    }
    this.submitBtn.innerText = 'Añadir'
    this.submitBtn.onclick = async () => await this.submit()
    this.onkeydown = async (e) => {
      if (e.key === 'Enter') {
        await this.submit()
      } else if (e.key === 'Escape') {
        this.remove()
      }
    }
    if (isDebug()) {
      this.submitBtn.oncontextmenu = async (e) => {
        e.preventDefault()
        const { default: Placeholders } = await import(
          '../../test/Placeholders'
        )
        const { runLoading } = await import('./RunLoading')
        await runLoading(async () => {
          for (let i = 0; i < 14; i++) {
            const s = await Placeholders.getRandomSerie()
            delete s._id
            delete s.timestamp
            await this.onSubmit!(s)
          }
        }, this.submitBtn)
      }
    }
    this.imgInput.oninput = () => {
      this.imgPrev.src = this.imgInput.value
    }

    this.nameInput.oninput = (mEv) => {
      if (this.nameInput.value.length < 3) return
      if (this.suggestionsWorker === undefined) {
        this.suggestionsWorker = new Worker(new URL('../../public/workers/autocomplete.js', import.meta.url), { type: 'classic' })
      }
      suggestionList.generateList!({ input: this.nameInput, worker: this.suggestionsWorker })
    }

    this.altNameInput.oninput = () => {
      if (this.altNameInput.value.length < 3) return
      if (this.suggestionsWorker === undefined) {
        this.suggestionsWorker = new Worker(new URL('../../public/workers/autocomplete.js', import.meta.url), { type: 'classic' })
      }
      suggestionListAlt.generateList!({ input: this.altNameInput, worker: this.suggestionsWorker })
    }

    this.generateData()
  }

  private async submit () {
    const { runLoading } = await import('./RunLoading')
    await runLoading(async () => {
      await this.onSubmit!({
        name: this.nameInput.value,
        nameAlt: this.altNameInput.value,
        chapter: this.chapterInput.valueAsNumber,
        image: this.imgInput.value,
        url: this.urlInput.value
      })
    }, this.submitBtn)
    await this.clearInputs()
    await this.generateData()
  }

  private async clearInputs () {
    this.nameInput.value = ''
    this.altNameInput.value = ''
    this.imgInput.value = ''
    this.urlInput.value = ''
    this.chapterInput.value = '0'
  }

  private async generateData () {
    if (isDebug()) {
      const { default: Placeholders } = await import('../../test/Placeholders')
      const serie = await Placeholders.getRandomSerie()
      this.nameInput.value = serie.name
      this.altNameInput.value = serie.nameAlt
      this.imgInput.value = serie.image
      this.urlInput.value = serie.url
      this.imgPrev.src = serie.image
    }
  }
}
<file_sep>export async function runLoading (func: () => Promise<void>, el: HTMLElement) {
  el.setAttribute('disabled', '')
  const prevHTML = el.innerHTML
  const prog = document.createElement('progress')
  el.textContent = ''
  el.append(prog)
  await func()
  prog.remove()
  el.innerHTML = prevHTML
  el.removeAttribute('disabled')
}
<file_sep>import { APP_NAME } from '../../app.config'
import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'
import { Category, Serie } from '../interfaces/Models'
import { ITab, Tab } from './Tab'

@CustomElement('sl-tabs-menu')
export class TabsMenu extends IComponent {
  private tabs: Tab[]
  private addCategTab: Tab

  onTabsClick?(tab: ITab): void;
  onSerieDrop?(x: { serie: Serie; categoryId: string }): void;
  onRequestDelete?(categId: string): Promise<void>;
  onRequestNewCateg?(): Promise<void>;
  onRequestEditCateg?(categ: Category): Promise<void>;

  static addTabId: string = 'addtab'

  constructor () {
    super()
    this.tabs = []
    this.addCategTab = new Tab({
      name: 'Añadir',
      url: ''
    })
    this.addCategTab.onDropSerie = () => {
      alert('Eu non fago iso')
    }
    this.addCategTab.style.display = 'none'
    this.addCategTab.id = 'addtab'
    this.addCategTab.onclick = async () => await this.onRequestNewCateg!()
    this.addCategTab.oncontextmenu = () => {}
  }

  connectedCallback () {
    this.showAddCategTab(false)
    this.append(this.addCategTab)
  }

  showAddCategTab (visible: boolean = true) {
    this.addCategTab.style.display = visible ? 'block' : 'none'
    console.log('showAddCategTab:', this.addCategTab.style.display)
  }

  async addTab (tab: ITab): Promise<void> {
    const newTab = new Tab(tab)
    newTab.onActive = async (_tab: ITab = tab) => {
      await this.setActiveTab(newTab)
      this.onTabsClick!(tab)
    }
    newTab.onDropSerie = (serie) => {
      this.onSerieDrop!({ serie, categoryId: tab._id! })
    }
    newTab.onEditCateg = (categ) => this.onRequestEditCateg!(categ)
    this.tabs.unshift(newTab)
    this.insertBefore(this.tabs[0], this.addCategTab)
  }

  async addAllTab (tabs: ITab[]): Promise<void> {
    const n = this.tabs.length
    this.tabs = this.tabs.concat(
      tabs.map((t) => {
        const elT = new Tab(t)
        elT.onDropSerie = (serie) => {
          this.onSerieDrop!({ serie, categoryId: t._id! })
        }
        elT.onActive = async (tab: ITab = t) => {
          await this.setActiveTab(elT)
          this.onTabsClick!(tab)
        }
        elT.onEditCateg = (categ) => this.onRequestEditCateg!(categ)
        elT.onDelete = this.onRequestDelete
        return elT
      })
    )
    for (let i = n; i < this.tabs.length; i++) {
      this.insertBefore(this.tabs[i], this.addCategTab)
    }
  }

  async setActiveTab (newTab: Tab) {
    this.tabs.forEach((i) => {
      i.setAttribute(Tab.observedAttributes[0], 'false')
    })
    newTab.setAttribute(Tab.observedAttributes[0], 'true') // Attribute 'active'
    document.title = APP_NAME + ' - ' + newTab.tab.name
  }

  async setActiveTabByPathname () {
    let i = 0
    while (
      i < this.tabs.length &&
      this.tabs[i].tab._id !== window.location.pathname.replace('/', '')
    ) {
      i++
    }
    await this.setActiveTab(this.tabs[i])
  }

  async updateTab (data: Category) {
    this.tabs.forEach((i) => {
      if (i.tab._id === data._id!) {
        i.innerText = data.name
        document.title = `${APP_NAME} - ${data.name}`
      }
    })
  }

  async deleteTab (tab: ITab): Promise<void> {
    let i = 0
    while (i < this.tabs.length || this.tabs[i].getData() !== tab) {
      i++
    }
    if (this.tabs[i].getData() !== tab) {
      throw new Error('Tab is not in the tabs menu')
    }
    this.removeChild(this.tabs[i])
    this.tabs.splice(i, 1)
  }

  async clearTabs (): Promise<void> {
    // this.textContent = "";
    this.tabs.forEach((i) => i.remove())
    this.tabs = []
  }
}
<file_sep>declare function searchStart(term: string, input: string[]): Promise<number>;
declare function searchEnd(term: string, input: string[]): Promise<number>;
declare function searchStartWith(term: string, input: string[]): Promise<string[]>;
<file_sep>export enum Route {
  SERIES = '/',
  MANAGE = '/manage',
}
<file_sep>import ModalView from '../interfaces/ModalView'
import { Category } from '../interfaces/Models'
import { isDebug } from '../../app.config'
import { CustomElement } from '../interfaces/CustomElement'

@CustomElement('sl-add-category-modal')
export class AddCategoryModal extends ModalView {
  onSubmit?(categ: Category): Promise<void>;

  private titleDiv: HTMLDivElement = document.createElement('div')
  private titleSpan: HTMLSpanElement = document.createElement('span')
  private closeBtn: HTMLButtonElement = document.createElement('button')
  private nameInput: HTMLInputElement = document.createElement('input')
  private submitBtn: HTMLButtonElement = document.createElement('button')
  private name: string = ''

  async connectedCallback () {
    const separator = document.createElement('div')
    separator.className = 'separator'
    const separator2 = document.createElement('div')
    separator2.className = 'separator'
    this.window.append(
      this.titleDiv,
      separator,
      this.nameInput,
      separator2,
      this.submitBtn
    )
    this.titleDiv.append(this.titleSpan, this.closeBtn)
    this.titleDiv.className = 'title'

    this.nameInput.type = 'text'

    this.titleSpan.innerText = 'Añadir categoría'
    this.closeBtn.className = 'title-btn'
    this.closeBtn.innerText = 'X'
    this.closeBtn.onclick = () => {
      this.remove()
    }

    this.submitBtn.innerText = 'Añadir'
    this.onkeydown = async (ev) => {
      if (ev.key === 'Enter') {
        await this.submit()
      } else if (ev.key === 'Escape') {
        this.remove()
      }
    }
    this.submitBtn.onclick = async () => {
      await this.submit()
    }
    await this.setRandomName()
  }

  private async submit () {
    console.log(this.nameInput)
    console.log('Create category with name', this.nameInput.value)
    await this.onSubmit!({
      name: this.nameInput.value,
      timestamp: new Date()
    })
    this.remove()
  }

  private async setRandomName () {
    if (isDebug()) {
      const { default: Placeholders } = await import('../../test/Placeholders')
      const categ = await Placeholders.getRandomCategory()
      this.nameInput.value = categ.name
    }
  }
}

// window.customElements.define('sl-add-category-modal', AddCategoryModal)
<file_sep>import * as fs from 'fs/promises'
import path from 'path'
import { brotliCompress, gzip } from 'zlib'
import { argv } from 'process'
import { parseArgs } from './argsParser.js'

const args = await parseArgs(argv.slice(2))

/**  @type {BuildOptions} */
const options = {
  compression: args.compression | null
}

if (options.compression === 'brotli') {
  for await (const c of await fs.readdir('./dist', { encoding: 'utf-8' })) {
    brotliCompress(
      await fs.readFile(path.join('./dist', c)),
      async (err, res) => {
        if (err) console.log(err)
        else {
          await fs.rm(path.join('./dist', c))
          await fs.writeFile(path.join('./dist', c), new Uint8Array(res), {
            encoding: 'utf-8'
          })
          console.log(`${c} Brotli zipped`)
        }
      }
    )
  }
} else if (options.compression === 'gzip') {
  for await (const c of await fs.readdir('./dist', { encoding: 'utf-8' })) {
    gzip(await fs.readFile(path.join('./dist', c)), async (err, res) => {
      if (err) console.log(err)
      else {
        await fs.writeFile(path.join('./dist', c), new Uint8Array(res), {
          encoding: 'utf-8'
        })
        console.log(`${c} gzipped`)
      }
    })
  }
}
<file_sep>import IComponent from '../../interfaces/Component'

export default class MigrationsView extends IComponent {
  constructor (private x?: { fromOldMigration?(): void }) {
    super()
  }

  connectedCallback () {
    const list = document.createElement('ul')
    const el = document.createElement('span')
    el.innerText = 'xd, mejor no tocar nada'
    this.append(list)
    list.append(el)
  }
}

window.customElements.define('sl-migrations-view', MigrationsView)
<file_sep>import { IDbClient } from '../src/interfaces/DbClient'
import { Serie, Category } from '../src/interfaces/Models'
import Placeholders from './Placeholders'

export class FakeClient implements IDbClient {
  private categs: Category[] = []
  private data: Map<string, Serie[]> = new Map()

  async getSeriesLimitFirst (x: { categId: string }): Promise<Serie[]> {
    const result = []
    const q = this.data.get(x.categId)
    if (q === undefined) return []
    for (let i = 0; i < 14; i++) {
      result.push(q[i])
    }
    return result
  }

  getSeriesLimitAfter (x: { categId: string; start: Date }): Promise<Serie[]> {
    throw new Error('Method not implemented.')
  }

  async setup (): Promise<void> {
    for (let i = 0; i < Math.floor(Math.random() * 20); i++) {
      const series = []
      for (let j = 0; j < Math.floor(Math.random() * 100); i++) {
        series.push(await Placeholders.getRandomSerie())
      }
      const categ = await Placeholders.getRandomCategory()
      this.categs.push(categ)
      this.data.set(categ._id!!, series)
    }
  }

  moveSerie (
    oldCategId: string,
    newCategId: string,
    serie: Serie
  ): Promise<string> {
    throw new Error('Method not implemented.')
  }

  updateSerieInfo (categId: string, serie: Serie): Promise<void> {
    throw new Error('Method not implemented.')
  }

  updateSerieChapter (
    serieId: string,
    categId: string,
    chapter: number
  ): Promise<void> {
    throw new Error('Method not implemented.')
  }

  updateCategory (oldCategId: string, newCateg: Category): Promise<void> {
    throw new Error('Method not implemented.')
  }

  async getAllSeries (): Promise<Serie[]> {
    let total: Serie[] = []
    this.data.forEach((s) => {
      total = total.concat(s)
    })
    return total
  }

  async getAllCategories (): Promise<Category[]> {
    return this.categs
  }

  async getSeriesByCategoryId (categ: string): Promise<Serie[]> {
    return this.data.get(categ) || []
  }

  async addSerie (serie: Serie, categ: string): Promise<string> {
    serie._id = await Placeholders.getRandomText()
    this.data.set(categ, this.data.get(categ)?.concat(serie)!!)
    return serie._id
  }

  async sumChapter (serie: Serie, categ: Category, num: number): Promise<void> {}
  async addCategory (categ: Category): Promise<string> {
    this.categs.push(categ)
    return await Placeholders.getRandomText()
  }

  async deleteCategoryById (categ: string): Promise<void> {}
  async deleteSerieById (serie: string, categ: string): Promise<void> {}
}
<file_sep>import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'

@CustomElement('sl-float-bottom-menu')
export class FloatBottomMenu extends IComponent {
  onNewSerie?(): void;
  onAltSwitch?(x: { alt: boolean }): void;

  private addSerieBtn: HTMLButtonElement = document.createElement('button')
  private switchAltBtn: HTMLButtonElement = document.createElement('button')
  private alt: boolean = false

  connectedCallback (): void {
    this.append(this.switchAltBtn, this.addSerieBtn)
    this.switchAltBtn.innerText = '🇯🇵'
    this.switchAltBtn.onclick = () => {
      this.alt = !this.alt
      this.switchAltBtn.innerText = this.alt ? '🇬🇧' : '🇯🇵'
      this.onAltSwitch!({ alt: this.alt })
    }
    this.addSerieBtn.innerText = '+'
    this.addSerieBtn.onclick = () => {
      this.onNewSerie!()
    }
  }
}
<file_sep>import { initializeApp } from "firebase/app";
import { APP_NAME, FIREBASE_KEYS } from "../app.config";
import FirebaseAuth from "./components/auth/FirebaseAuth";
import TopBar from "./components/TopBar";
import FirebaseAuthController, {
  AuthChangeEvent,
} from "./controllers/auth/FirebaseAuthController";
import FirebaseClient from "./controllers/db/FirebaseClient";
import AuthStatus from "./interfaces/AuthStatus";
import { IDbClient } from "./interfaces/DbClient";
import { IAuthController } from "./interfaces/IAuthController";
import { Route } from "./routes";
import View from "./interfaces/View";
import "./styles/main.scss";

window.onload = async () => {
  const main = document.querySelector("main");
  if (main == null) throw new Error("Main not found");
  const appDiv = document.createElement("div");
  appDiv.id = "app";
  let app: View | null;
  const firebaseApp = initializeApp(FIREBASE_KEYS);
  const dbClient: IDbClient = new FirebaseClient(firebaseApp);
  const authController: IAuthController = new FirebaseAuthController(
    firebaseApp
  );
  const authModule: FirebaseAuth = new FirebaseAuth(
    authController as FirebaseAuthController
  );

  async function changeView(path: Route) {
    if (app != null) app.remove();
    if (main == null) throw new Error("Page couldn't load");
    switch (path) {
      case Route.SERIES: {
        console.time("Loading SeriesView");
        const { default: SeriesView } = await import("./pages/SeriesView");
        const newSeriesView = new SeriesView({
          dbClient,
          changeView,
        });
        appDiv.append(newSeriesView);
        app = newSeriesView;
        console.timeEnd("Loading SeriesView");
        break;
      }
      case Route.MANAGE: {
        console.time("Loading ManageView");
        const { default: ManageView } = await import("./pages/ManageView");
        const newManageView = new ManageView({
          dbClient,
          changeView,
        });
        appDiv.append(newManageView);
        app = newManageView;
        console.timeEnd("Loading ManageView");
        break;
      }
    }

    app.authChangeEvent({ status: authController.getStatus() });
  }

  const topBar = new TopBar({
    authModule,
    changeView,
  });

  authController.onAuthChange = async (x: AuthChangeEvent) => {
    authModule.setState(x.status);
    console.log("Auth changed!! =>", AuthStatus[x.status].toString());
    app?.authChangeEvent(x);
    topBar.authChangeEvent!(x);
  };

  console.log("Initial pathname:", window.location.pathname);
  if (window.location.pathname === "") {
    console.log("Initial view: Route.SERIES");
    await changeView(Route.SERIES);
  } else if (window.location.pathname.startsWith("/manage")) {
    console.log("Initial view: Route.MANAGE");
    await changeView(Route.MANAGE);
  } else {
    console.log("Initial view: Route.SERIES");
    await changeView(Route.SERIES);
  }

  topBar.setAttribute("title", APP_NAME);

  main.append(topBar, appDiv);
};
<file_sep>import { Category, Serie } from './Models'

export interface IDbClient {
  onInitialize?(): void;

  moveSerie(
    oldCategId: string,
    newCategId: string,
    serie: Serie
  ): Promise<string>;

  updateSerieInfo(categId: string, serie: Serie): Promise<void>;
  updateSerieChapter(
    serieId: string,
    categId: string,
    chapter: number
  ): Promise<void>;
  updateCategory(newCateg: Category): Promise<void>;
  getAllSeries(): Promise<Serie[]>;
  getSeriesLimitFirst(x: { categId: string }): Promise<Serie[]>;
  getSeriesLimitAfter(x: { categId: string; start: Date }): Promise<Serie[]>;
  getAllCategories(): Promise<Category[]>;
  getSeriesByCategoryId(categId: string): Promise<Serie[]>;
  addSerie(serie: Serie, categId: string): Promise<string>;
  addCategory(categ: Category): Promise<string>;
  deleteCategoryById(categId: string): Promise<void>;
  deleteSerieById(serieId: string, categId: string): Promise<void>;
}
<file_sep>import { CustomElement } from '../interfaces/CustomElement'
import ModalView from '../interfaces/ModalView'
import { Category } from '../interfaces/Models'

@CustomElement('sl-edit-category-modal')
export class EditCategoryModal extends ModalView {
  onSubmit?(categ: Category): Promise<void>;

  private titleDiv: HTMLDivElement = document.createElement('div')
  private titleSpan: HTMLSpanElement = document.createElement('span')
  private closeBtn: HTMLButtonElement = document.createElement('button')
  private nameInput: HTMLInputElement = document.createElement('input')
  private submitBtn: HTMLButtonElement = document.createElement('button')

  constructor (private categ: Category) {
    super()
  }

  async connectedCallback () {
    const separator = document.createElement('div')
    separator.className = 'separator'
    const separator2 = document.createElement('div')
    separator2.className = 'separator'
    this.window.append(
      this.titleDiv,
      separator,
      this.nameInput,
      separator2,
      this.submitBtn
    )
    this.titleDiv.append(this.titleSpan, this.closeBtn)
    this.titleDiv.className = 'title'

    this.nameInput.type = 'text'
    this.nameInput.value = this.categ.name

    this.titleSpan.innerText = 'Renombrar categoría'
    this.closeBtn.className = 'title-btn'
    this.closeBtn.innerText = 'X'
    this.closeBtn.onclick = () => {
      this.remove()
    }

    this.submitBtn.innerText = 'Renombrar'
    this.submitBtn.onclick = this.submit
    this.onkeydown = async (ev) => {
      if (ev.key === 'Enter') {
        await this.submit()
      } else if (ev.key === 'Escape') {
        this.remove()
      }
    }
  }

  private submit = async () => {
    console.log('Edit category with name', this.nameInput.value)
    await this.onSubmit!({
      _id: this.categ._id!,
      name: this.nameInput.value,
      timestamp: new Date()
    })
    this.remove()
  }
}
<file_sep>export class ContextMenuBuilder {
  private container: HTMLDivElement = document.createElement('div')
  private list: HTMLUListElement = document.createElement('ul')
  private buttons: { text: string; action: () => Promise<void> }[] = []
  private shadowDom: ShadowRoot = this.container.attachShadow({ mode: 'open' })

  constructor () {
    this.shadowDom.innerHTML = `
    <style>
      ul {
        position: absolute;
        display: block;
        padding: 0;
        background: aqua;
        list-style-type:none;
        border-radius: 10px;
        border: solid 3px white;
        overflow: hidden;
      }
      button {
        background-color: #2e3345;
        font-family: Arial;
        color: white;
        width: 100%;
        padding: 6px;
        font-weight: bold;
        border:none;
      }
      button:hover {
        background-color: #3b3f49;
      }
      button:active {
        background-color: #353a4b;
      }
    </style>
    `
  }

  button (text: string, action: () => Promise<void>) {
    this.buttons.push({ text, action })
    return this
  }

  async build (
    x: { mouseY: number; mouseX: number } = { mouseX: 0, mouseY: 0 }
  ) {
    this.container.className = 'context-menu'
    for await (const i of this.buttons) {
      const li = document.createElement('li')
      const el = document.createElement('button')
      el.innerText = i.text
      el.onclick = async (ev) => {
        await i.action()
        this.container.remove()
        document.body.onclick = document.body.onclick = null
      }
      this.list.append(li)
      li.append(el)
    }
    this.shadowDom.append(this.list)
    this.list.style.top = `${x.mouseY}px`
    this.list.style.left = `${x.mouseX}px`
    document.body.onclick = document.body.onauxclick = (ev) => {
      console.log(ev.target, this.container.parentElement)
      if (
        ev.target !== this.container &&
        ev.target !== this.container.parentElement
      ) {
        this.container.remove()
        document.body.onclick = document.body.onclick = null
      }
    }
    return this.container
  }
}
<file_sep>import { FirebaseApp } from 'firebase/app'
import {
  getFirestore,
  Firestore,
  DocumentData,
  QuerySnapshot
} from 'firebase/firestore/lite'
import { IDbClient } from '../../interfaces/DbClient'
import { Category, Serie } from '../../interfaces/Models'
import { isDebug, SERIES_LIMIT } from '../../../app.config'

export default class FirebaseClient implements IDbClient {
  readonly db: Firestore
  onInitialize?(): void;
  constructor (private readonly app: FirebaseApp) {
    this.db = getFirestore(this.app)
    if (isDebug()) {
      import('firebase/firestore/lite').then(({ connectFirestoreEmulator }) => {
        connectFirestoreEmulator(this.db, 'localhost', 8080)
      })
    }
  }

  async getSeriesLimitFirst (x: { categId: string }): Promise<Serie[]> {
    const { query, collection, orderBy, limit, getDocs } = await import(
      'firebase/firestore/lite'
    )
    const q = query(
      collection(this.db, x.categId),
      orderBy('timestamp', 'desc'),
      limit(SERIES_LIMIT)
    )
    const docs = await getDocs(q)
    return await FirebaseClient.formatDocsToSeries(docs)
  }

  async getSeriesLimitAfter (x: {
    categId: string;
    start: Date;
  }): Promise<Serie[]> {
    const { query, collection, orderBy, limit, getDocs, startAfter } =
      await import('firebase/firestore/lite')
    const q = query(
      collection(this.db, x.categId),
      orderBy('timestamp', 'desc'),
      limit(SERIES_LIMIT),
      startAfter(x.start)
    )
    const docs = await getDocs(q)
    return await FirebaseClient.formatDocsToSeries(docs)
  }

  private static async formatDocsToSeries (
    docs: QuerySnapshot<DocumentData>
  ): Promise<Serie[]> {
    const series: Serie[] = []
    docs.forEach((d) => {
      series.push(Object.assign({ _id: d.id }, d.data()) as Serie)
    })
    return series
  }

  async moveSerie (
    oldCategId: string,
    newCategId: string,
    serie: Serie
  ): Promise<string> {
    if (serie._id === undefined) throw new Error('Serie id must be defined')
    const { addDoc, deleteDoc, doc, collection } = await import(
      'firebase/firestore/lite'
    )
    const oldSerieId = serie._id
    delete serie._id
    serie.timestamp = new Date()
    const newSerie = await addDoc(collection(this.db, newCategId), serie)
    await deleteDoc(doc(this.db, oldCategId, oldSerieId))
    serie._id = newSerie.id
    return newSerie.id
  }

  async updateSerieInfo (categId: string, serie: Serie): Promise<void> {
    if (serie._id === undefined) throw new Error('Serie id must be defined')
    const { updateDoc, doc } = await import('firebase/firestore/lite')
    const serieId = serie._id
    delete serie._id
    serie.timestamp = new Date()
    await updateDoc(doc(this.db, categId, serieId), serie as any)
    serie._id = serieId
  }

  async updateSerieChapter (serieId: string, categId: string, chapter: number) {
    console.log('Requesting chapter update for serie:', serieId)
    const { updateDoc, doc } = await import('firebase/firestore/lite')
    const ref = doc(this.db, categId, serieId)
    await updateDoc(ref, {
      chapter
    })
  }

  async updateCategory (newData: Category): Promise<void> {
    const { updateDoc, doc } = await import('firebase/firestore/lite')
    await updateDoc(doc(this.db, 'categories', newData._id!), {
      name: newData.name,
      timestamp: newData.timestamp
    })
  }

  async getAllSeries (): Promise<Serie[]> {
    const categories = await this.getAllCategories()
    const toret: Serie[] = []
    categories.forEach(async (categ) => {
      if (categ._id === undefined) {
        throw new Error('Category is undefined')
      }
      (await this.getSeriesByCategoryId(categ._id)).forEach((ser) => {
        toret.push(ser)
      })
    })
    return toret
  }

  async getAllCategories (): Promise<Category[]> {
    const toret: Category[] = []

    console.log('Fetching all categories...')
    console.time('Fetching categories')
    const { query, collection, getDocs, orderBy } = await import(
      'firebase/firestore/lite'
    )

    const q = query(
      collection(this.db, 'categories'),
      orderBy('timestamp', 'desc')
    )
    const snapshot = await getDocs(q)
    snapshot.forEach((doc) => {
      toret.push(Object.assign({ _id: doc.id }, doc.data()) as Category)
    })

    console.timeEnd('Fetching categories')
    console.log('Categories fetched:', toret)

    return toret
  }

  async getSeriesByCategoryId (categId: string): Promise<Serie[]> {
    const toret: Serie[] = []

    if (categId === undefined) {
      throw new Error('Category id is undefined')
    }

    console.time('Fetching series')
    const { query, collection, getDocs } = await import(
      'firebase/firestore/lite'
    )
    const q = query(collection(this.db, categId))
    const snapshot = await getDocs(q)
    snapshot.forEach((doc) => {
      toret.push(Object.assign({ _id: doc.id }, doc.data()) as Serie)
    })

    console.timeEnd('Fetching series')
    console.log('Series:', toret)

    return toret
  }

  async addSerie (serie: Serie, categId: string): Promise<string> {
    console.time('Adding serie')
    const { addDoc, Timestamp, collection } = await import(
      'firebase/firestore/lite'
    )
    const res = await addDoc(
      collection(this.db, categId),
      Object.assign(serie, { timestamp: Timestamp.fromDate(new Date()) })
    )
    console.timeEnd('Adding serie')
    return res.id
  }

  async addCategory (categ: Category): Promise<string> {
    console.time('Creating category:')
    const { addDoc, collection } = await import('firebase/firestore/lite')
    const doc = await addDoc(
      collection(this.db, 'categories'),
      Object.assign(categ, { timestamp: new Date() })
    )
    console.timeEnd('Creating category:')
    return doc.id
  }

  async deleteCategoryById (categId: string): Promise<void> {
    console.time(`Deleting category with id ${categId}`)
    const { deleteDoc, doc } = await import('firebase/firestore/lite')
    await deleteDoc(doc(this.db, 'categories', categId))

    const { collection, getDocs, query } = await import(
      'firebase/firestore/lite'
    )
    const docs = await getDocs(query(collection(this.db, categId)))
    docs.forEach(async (d) => {
      await deleteDoc(d.ref)
    })
    console.timeEnd(`Deleting category with id ${categId}`)
  }

  async deleteSerieById (serieId: string, categId: string): Promise<void> {
    const { deleteDoc, doc } = await import('firebase/firestore/lite')
    await deleteDoc(doc(this.db, categId, serieId))
  }
}
<file_sep>import FirebaseClient from './FirebaseClient'

export async function migrateOld (x: { client: FirebaseClient }): Promise<void> {
  console.time('Migrating from old')
  const categs = ['viendo', 'vistos', 'favoritos', 'abandonados', 'pendientes']
  const { getDocs, collection } = await import('firebase/firestore/lite')
  for await (const c of categs) {
    const categId = await x.client.addCategory({ name: c })
    const docs = await getDocs(collection(x.client.db, c))
    docs.forEach(async (d) => {
      const data = d.data()
      await x.client.addSerie(
        {
          name: data.nombre_jp,
          nameAlt: data.nombre_en,
          image: data.imagen,
          chapter: data.capitulo,
          timestamp: data.actualizado_en,
          url: ''
        },
        categId
      )
    })
  }
  console.timeEnd('Migrating from old')
}
<file_sep>import { ContextMenuBuilder } from '../builders/ContextMenu'
import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'
import AuthStatus from '../interfaces/AuthStatus'
import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'
import { IDbClient } from '../interfaces/DbClient'
import { Serie } from '../interfaces/Models'
import '../styles/SerieCard.scss'

@CustomElement('sl-serie-card')
export class SerieCard extends IComponent {
  private initialChapter: number
  private readonly titleSpan: HTMLSpanElement = document.createElement('span')
  private readonly img: HTMLImageElement = document.createElement('img')
  private readonly chapter: HTMLElement = document.createElement('i')
  private readonly actions = document.createElement('div')
  private readonly addChapterBtn = document.createElement('button')
  private readonly lessChapterBtn = document.createElement('button')
  private readonly editBtn = document.createElement('button')
  private readonly deleteBtn = document.createElement('button')
  private readonly viewBtn = document.createElement('button')

  static get observedAttributes (): string[] {
    return ['alt']
  }

  constructor (
    private serie: Serie,
    private categId: string,
    private client: IDbClient
  ) {
    super()
    this.initialChapter = this.serie.chapter
    this.append(this.img, this.titleSpan, this.chapter)
  }

  connectedCallback (): void {
    this.id = this.serie._id!

    this.img.draggable = false
    this.img.loading = 'lazy'

    this.img.src = this.serie.image
    this.img.alt = `Cover art of ${this.serie.name} (${this.serie.nameAlt})`

    this.titleSpan.innerText = this.serie.name
    this.chapter.innerText = 'Capítulo: '.concat(this.serie.chapter.toString())

    this.actions.classList.add('card', 'actions')
    this.addChapterBtn.innerText = '▶'
    this.lessChapterBtn.innerText = '◀'
    this.editBtn.innerText = '✏'
    this.deleteBtn.innerText = '🗑'
    this.viewBtn.innerText = '↗'

    this.actions.append(
      this.lessChapterBtn,
      this.addChapterBtn,
      this.viewBtn,
      this.editBtn,
      this.deleteBtn
    )

    this.addChapterBtn.onclick = async () => {
      this.serie.chapter++
      this.chapter.innerText = 'Capítulo: '.concat(
        this.serie.chapter.toString()
      )
    }
    this.lessChapterBtn.onclick = () => {
      this.serie.chapter--
      this.chapter.innerText = 'Capítulo: '.concat(
        this.serie.chapter.toString()
      )
    }
    this.editBtn.onclick = async () => {
      const { default: EditSerieModal } = await import('./EditSerieModal')
      const editModal = new EditSerieModal(this.serie)
      this.draggable = false
      editModal.onSubmit = async (serie) => {
        console.log('Serie edited:', serie)
        this.serie = serie
        await this.client.updateSerieInfo(this.categId, serie)
        this.img.style.backgroundImage = `url(${this.serie.image})`
        this.titleSpan.innerText = this.serie.name
        this.chapter.innerText = 'Capítulo: '.concat(
          this.serie.chapter.toString()
        )
      }
      editModal.disconnectedCallback = () => (this.draggable = true)
      this.append(editModal)
    }
    this.actions.onmouseleave = this.saveChapter
    this.deleteBtn.onclick = this.deleteSerie
    this.viewBtn.onclick = () => {
      window.open(this.serie.url, '_blank')
    }
    this.oncontextmenu = async (ev: MouseEvent) => {
      ev.preventDefault()
      console.log('context menu for seriecard')
      const menu = await new ContextMenuBuilder()
        .button('Copiar título', async () => {
          navigator.clipboard.writeText(this.serie.name)
        })
        .button('Copiar título alternativo', async () => {
          navigator.clipboard.writeText(this.serie.nameAlt)
        })
        .build({ mouseX: ev.pageX, mouseY: ev.pageY })
      this.append(menu)
    }
    this.ondragstart = (ev: DragEvent) => {
      ev.dataTransfer?.setData('serie', JSON.stringify(this.serie))
    }
  }

  saveChapter = async () => {
    if (this.initialChapter !== this.serie.chapter) {
      console.log('Saving chapter for serie:', this.serie)
      await this.client.updateSerieChapter(
        this.serie._id!!,
        this.categId,
        this.serie.chapter
      )
      this.initialChapter = this.serie.chapter
    }
  }

  deleteSerie = async () => {
    this.style.transition = 'visibility 0.3s linear,opacity 0.3s linear'
    this.style.opacity = '0'
    this.style.visibility = 'hidden'
    await this.client.deleteSerieById(this.serie._id!!, this.categId)
    setTimeout(() => {
      this.remove()
    }, 1000)
  }

  attributeChangedCallback (name: string, lastValue: any, newValue: any): void {
    if (name === SerieCard.observedAttributes[0]) {
      if (newValue === 'true') {
        this.titleSpan.innerText = this.serie.nameAlt
      } else {
        this.titleSpan.innerText = this.serie.name
      }
    }
  }

  async authChangeEvent (x: AuthChangeEvent) {
    if (x.status === AuthStatus.SUDO) {
      this.draggable = true
      this.append(this.actions)
    } else this.actions.remove()
    console.log('CARD AUTH CHANGE')
  }
}
<file_sep>'use strict'

/**
 *
 * @param {string} term
 * @param {string[]} input
 * @returns
 */
async function searchStart (term, input) {
  let start = 0
  while (!input[start].toLocaleLowerCase().startsWith(term)) {
    start++
    console.log(input)
    console.log(start)
    console.log(input[start])
  }
  return start
}

/**
 *
 * @param {string} term
 * @param {string[]} input
 * @returns
 */
async function searchEnd (term, input) {
  let end = input.length - 1
  while (end > 0 && !input[end].toLocaleLowerCase().startsWith(term)) {
    end--
  }
  return end
}
async function searchStartWith (term, input) {
  const results = await Promise.all([searchStart(term, input), searchEnd(term, input)])
  return input.slice(results[0], results[1] + 1)
}

self.onmessage = async (msg) => {
  /** @type string */
  const term = msg.data
  const firstLet = term.charAt(0) + term.charAt(1)
  const link = `/assets/anime_completions/${firstLet.toLowerCase()}.txt`
  if (link === undefined) {
    postMessage('sadge')
    return
  }
  const req = await fetch(link)
  const res = await req.text()
  const toret = await searchStartWith(term.toLocaleLowerCase(), res.split(/\r?\n|\r|\n/g))
  postMessage(toret)
}
<file_sep>import { ContextMenuBuilder } from '../builders/ContextMenu'
import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'
import { Category, Serie } from '../interfaces/Models'

export interface ITab extends Category {
  url: string;
}

@CustomElement('sl-tab')
export class Tab extends IComponent {
  public onActive?: () => void
  public onDropSerie?: (serie: Serie) => void
  /**
   * Fires a delete this category event
   */
  public onDelete?: (categId: string) => Promise<void>
  public onEditCateg?: (categ: Category) => Promise<void>

  static get observedAttributes () {
    return ['active']
  }

  constructor (public tab: ITab) {
    super()
    this.onclick = () => {
      history.pushState(null, '', this.tab._id!)
      this.onActive!()
    }
    this.oncontextmenu = async (ev) => {
      ev.preventDefault()
      const menu = await new ContextMenuBuilder()
        .button('Editar', async () => await this.onEditCateg!(this.tab))
        .button('Eliminar', async () => {
          alert('CHILLING BROOOOOO, CHILLING!!!!!!!!!')
        })
        .build({ mouseX: ev.pageX, mouseY: ev.pageY })
      this.append(menu)
    }
    this.ondrop = (ev: DragEvent) => {
      const data = ev.dataTransfer?.getData('serie')
      if (data === undefined) throw new Error('Serie dragged is undefined')
      const serie = JSON.parse(data) as Serie
      console.log('Serie dragged:', serie)
      this.onDropSerie!(serie)
    }
    this.ondragover = (ev: DragEvent) => {
      ev.preventDefault()
    }
  }

  connectedCallback () {
    this.innerText = this.tab.name
  }

  getData (): ITab {
    return this.tab
  }

  attributeChangedCallback (name: string, lastValue: any, newValue: any) {
    if (name === Tab.observedAttributes[0]) {
      if (newValue === 'true') {
        // this.style.backgroundColor = "#3b3f49";
        this.style.color = '#89ddff'
        this.style.borderBottomColor = '#89ddff'
      } else {
        // this.style.removeProperty("background-color");
        this.style.removeProperty('border-bottom-color')
        this.style.removeProperty('color')
      }
    }
  }
}
<file_sep># Series List

## Configuration

To build the app you need a config file `app.config.ts` and a `.env` with this structure:

> Requires Nodejs >=14.x
> `app.config.ts`

```typescript
export const FIREBASE_KEYS = {
  apiKey: "############################",
  authDomain: "########################",
  databaseURL: "#######################",
  projectId: "#########################",
  storageBucket: "#####################",
  messagingSenderId: "#################",
  appId: "#############################",
};

export const APP_NAME: string = "Series List Next";
export const SERIES_LIMIT: number = 14;
export const SUDO_EMAILS: (string | RegExp)[] =
  isDebug() ? [/@/] : ["<EMAIL>"]; // Use regular expressions or strings
// This way in DEBUG any email is sudo and in PRODUCTION just <EMAIL>

export function isDebug() {
  // this function is just to improve project readibility
  //return process.env.NODE_ENV == "dev"; this was used when I was using Workbox directly :)
  return true; // or false xd
}
```

## Scripts

- `npm run serve`: runs Vite development server with hot reload
- `npm run emulators`: runs Firebase emulators
- `npm run dev` : development server with emulators
- `npm run build` : production build. Optional parameters `compression=null(default)/brotli/gzip`
<file_sep>import AuthStatus from './AuthStatus'

export interface IAuthController {
  onAuthChange?(x: {}): void;
  getStatus(): AuthStatus;
  login(x: { pass: string; url: string }): void;
  logout(): void;
  remember(): void;
}
<file_sep>import NavigationDrawer from '../components/NavigationDrawer'
import { IDbClient } from '../interfaces/DbClient'
import FirebaseClient from '../controllers/db/FirebaseClient'
import '../styles/ManageView.scss'
import { Route } from '../routes'
import View from '../interfaces/View'
import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'
import AuthStatus from '../interfaces/AuthStatus'

export default class ManageView extends View {
  private dbClient: IDbClient
  private navigatorDrawer: NavigationDrawer = new NavigationDrawer({
    hrefRoot: '/manage'
  })

  private viewDiv: HTMLDivElement = document.createElement('div')
  private viewPlaceholder: HTMLElement = document.createElement('div')

  constructor (x: {
    dbClient: IDbClient;
    changeView: (path: Route) => Promise<void>;
  }) {
    super()
    this.dbClient = x.dbClient
  }

  connectedCallback () {
    this.navigatorDrawer.addItems(
      { name: 'Backups', href: '/backups' },
      { name: 'Migrations', href: '/migrations' }
    )
    this.navigatorDrawer.onItemClick = (x) => {
      console.log('Loading new manage view', x)
      this.updateView(x.href)
    }
    this.viewDiv.className = 'manage-view'
    this.viewDiv.append(this.navigatorDrawer, this.viewPlaceholder)
  }

  authChangeEvent = async (x: AuthChangeEvent) => {
    if (x.status === AuthStatus.SUDO) {
      this.append(this.viewDiv)
    } else {
      this.viewDiv.remove()
    }
  }

  async updateView (path: string) {
    console.log('Updating view for', `"${path}"`)
    let view: HTMLElement
    switch (path) {
      case '/backups':{
        const { default: BackupsView } = await import('./manage/BackupsView')
        const { default: BackupController } = await import(
          '../controllers/BackupController'
        )
        view = new BackupsView(new BackupController(this.dbClient))
        break
      }
      case '/migrations':{
        const { default: MigrationsView } = await import(
          './manage/MigrationsView'
        )
        const { migrateOld } = await import(
          '../controllers/db/FirebaseMigrations'
        )
        view = new MigrationsView({
          fromOldMigration: async () => {
            if (!confirm('YOU KNOW I GOT I BANBAN BLOW YOUR MINDDDDD')) return
            if (this.dbClient instanceof FirebaseClient) { await migrateOld({ client: this.dbClient as FirebaseClient }) } else alert('This action requires a Firebase Db')
          }
        })
        break
      }
      default:{
        const placeholder = document.createElement('div')
        placeholder.innerText = 'Elige una opción del panel'
        view = placeholder
      }
    }
    console.log('New view', view)
    view.className = 'view'
    this.viewPlaceholder.replaceWith(view)
    this.viewPlaceholder = view
  }
}

window.customElements.define('sl-manage', ManageView)
<file_sep>import { IDbClient } from '../interfaces/DbClient'
import { Category, Serie } from '../interfaces/Models'

export default class BackupController {
  // eslint-disable-next-line no-useless-constructor
  constructor (private client:IDbClient) {
  }

  async save (): Promise<void> {
    const categs = await this.client.getAllCategories()
    const toSave = new Map<string, Serie[]>()
    for await (const c of categs) {
      toSave.set(c._id!, await this.client.getSeriesByCategoryId(c._id!))
    }
    console.log('Saving', Object.fromEntries(toSave))
    const blob = new Blob(
      [JSON.stringify(Object.assign(Object.fromEntries(toSave), { categs }))],
      {
        type: 'application/json'
      }
    )
    const link = document.createElement('a')
    link.download = 'backup.json'
    link.href = window.URL.createObjectURL(blob)
    link.dataset.downloadurl = [
      'application/json',
      link.download,
      link.href
    ].join(':')
    link.click()
  }

  async load (file: File): Promise<void> {
    console.log('Loading file', file)
    const text = await file.text()
    console.log('Text', text)
    const obj = JSON.parse(text)
    console.log('Obj', obj)
    const categs = obj.categs as Category[]
    console.log(categs)
    console.time('Loading backup')
    for await (const c of categs) {
      await this.client.addCategory(c)
      const series = obj[c._id!] as Serie[]
      for await (const s of series) {
        delete s._id
        await this.client.addSerie(s, c._id!)
      }
    }
    console.timeEnd('Loading backup')
  }
}
<file_sep>import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'
import AuthStatus from '../interfaces/AuthStatus'
import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'
import { Route } from '../routes'
import '../styles/TopBar.scss'

@CustomElement('sl-topbar')
export default class TopBar extends IComponent {
  private static attrTitle: string = 'title'
  private titleSpan: HTMLSpanElement = document.createElement('span')
  private fujiwara: HTMLImageElement = document.createElement('img')

  static get observedAttributes () {
    return ['title']
  }

  constructor (
    private x: {
      authModule: IComponent;
      changeView: (path: Route) => Promise<void>;
    }
  ) {
    super()
  }

  connectedCallback (): void {
    this.append(this.fujiwara, this.titleSpan, this.x.authModule)
    this.titleSpan.innerText = this.getAttribute('title') || ''
    this.fujiwara.src =
      'https://firebasestorage.googleapis.com/v0/b/prueba-d1c99.appspot.com/o/fujiwara-chika.webp?alt=media&token=<PASSWORD>'
    this.fujiwara.alt = 'Fujiwara Chika detective'
  }

  attributeChangedCallback (name: string, lastValue: any, newValue: any) {
    if (name === TopBar.attrTitle) {
      this.titleSpan.innerText = newValue
    }
  }

  authChangeEvent = async (state: AuthChangeEvent) => {
    console.log('Topbar auth change')
    if (state.status === AuthStatus.SUDO) {
      this.fujiwara.onclick = () => {
        if (!window.location.pathname.split('/').includes('manage')) {
          window.history.pushState(null, '', '/manage')
          this.x.changeView(Route.MANAGE)
        }
      }
      this.titleSpan.onclick = () => {
        if (window.location.pathname.split('/').includes('manage')) {
          window.history.pushState(null, '', '/')
          this.x.changeView(Route.SERIES)
        }
      }
    } else {
      this.fujiwara.onclick = () => {}
      this.titleSpan.onclick = () => {}
    }
  }
}
<file_sep>import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'

export default abstract class IComponent extends HTMLElement {
  /**
   * @description Attributes that fire the attributeChangedCallback()
   * @example static get observedAttributes() { return ["enabled", "foo"] }
   * @returns string[]
   */
  observedAttributes?(): string[];

  /**
   * @description It's called when the component is loaded
   * @returns void
   */
  connectedCallback?(): void;

  /**
   * @description It's called when the component is removed
   * @returns void
   */
  disconnectedCallback?(): void;

  /**
   * @description It's called when an attribute in the observedAttributes() is changed
   * @param name Name of the attribute changed
   * @param lastValue Previous value of the attribute
   * @param newValue New value of the attribute
   */
  attributeChangedCallback?(name: string, lastValue: any, newValue: any): void;

  /**
   * @description It's called when the component is moved to a new document
   * @param lastDocument
   * @param newDocument
   */
  adoptedCallback?(lastDocument: HTMLDocument, newDocument: HTMLDocument): void;

  authChangeEvent?(x: AuthChangeEvent): Promise<void>;
}
<file_sep>import WSAuthController from '../../controllers/auth/WSAuthController'
import IComponent from '../../interfaces/Component'
import '../styles/AuthModule.scss'

export class WSAuthModule extends IComponent {
  static readonly URL_STORE: string = 'lastUrl'

  static get observedAttributes (): string[] {
    return ['logged']
  }

  constructor (private auth: WSAuthController) {
    super()
  }

  connectedCallback () {
    this.setAttribute('logged', 'no')
  }

  logged (): void {
    const disconBtn: HTMLButtonElement = document.createElement('button')
    disconBtn.innerText = 'Desconectar'
    disconBtn.onclick = () => {
      this.auth.logout()
    }
    this.append(disconBtn)
  }

  notLogged (): void {
    const urlInput: HTMLInputElement = document.createElement('input')
    const passInput: HTMLInputElement = document.createElement('input')
    const submitBtn: HTMLButtonElement = document.createElement('button')

    urlInput.type = 'text'
    urlInput.placeholder = 'Url'
    passInput.type = '<PASSWORD>'
    passInput.placeholder = 'Password'
    submitBtn.innerText = 'Iniciar'

    urlInput.value = window.localStorage.getItem(WSAuthModule.URL_STORE) || ''

    submitBtn.onclick = () => {
      this.auth.login({
        url: urlInput.value,
        pass: <PASSWORD>Input.value
      })
      window.localStorage.setItem(WSAuthModule.URL_STORE, urlInput.value)
    }

    this.append(urlInput, passInput, submitBtn)
  }

  attributeChangedCallback (name: string, lastValue: any, newValue: any): void {
    console.log('attribute:', name)
    if (name === WSAuthModule.observedAttributes[0]) {
      if (newValue === 'yes') {
        this.textContent = ''
        this.logged()
      } else {
        this.textContent = ''
        this.notLogged()
      }
    }
  }
}

window.customElements.define('sl-wsauth', WSAuthModule)
<file_sep>import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'

@CustomElement('sl-suggestions-list')
export class SuggestionsList extends IComponent {
  private shadowDom: ShadowRoot
  generateList?: (x: { input: HTMLInputElement, worker: Worker }) => Promise<void>

  constructor () {
    super()
    this.shadowDom = this.attachShadow({ mode: 'closed' })
  }

  connectedCallback () {
    const list: HTMLUListElement = document.createElement('ul')
    list.style.listStyleType = 'none'
    list.style.margin = '0'
    list.style.padding = '0'
    list.style.overflowY = 'auto'
    list.style.maxHeight = '250px'
    this.shadowDom.append(list)
    this.generateList = async (x) => {
      list.textContent = ''
      list.append(document.createElement('progress'))
      x.worker.onmessage = async (e) => {
        list.textContent = ''
        const data = e.data as Array<string>
        for await (const item of data) {
          const entry = document.createElement('li')
          entry.innerText = item
          entry.onclick = () => {
            x.input.value = item
            list.textContent = ''
          }
          entry.style.padding = '10px'
          entry.style.border = 'solid 2px #353a4b'
          entry.style.transition = 'background linear 0.3s, color linear 0.3s'
          entry.onmouseenter = () => {
            entry.style.backgroundColor = '#3b3f49'
          }
          entry.onmouseleave = () => {
            entry.style.backgroundColor = '#282c34'
          }
          entry.onmousedown = () => {
            entry.style.borderColor = '#89ddff'
          }
          entry.onmouseup = () => {
            entry.style.borderColor = '#3b3f49'
          }
          list.append(entry)
        }
      }
      x.worker.onerror = (e) => {
        console.log(e)
      }
      x.worker.onmessageerror = (e) => {
        console.log(e)
      }
      x.worker.postMessage(x.input.value)
    }
  }
}
<file_sep>import IComponent from '../interfaces/Component'
import { CustomElement } from '../interfaces/CustomElement'
import '../styles/NavigationDrawer.scss'

interface NavigatorItem {
  href: string;
  name: string;
}

@CustomElement('sl-navigation-drawer')
export default class NavigationDrawer extends IComponent {
  onItemClick?(x: NavigatorItem): void;

  constructor (private params?: { hrefRoot?: string }) {
    super()
  }

  connectedCallback () {}

  addItems (...items: NavigatorItem[]) {
    items.forEach((i) => {
      const a = document.createElement('a')
      a.id = i.name
      a.innerText = i.name
      a.href = i.href
      a.onclick = (ev) => {
        ev.preventDefault()
        window.history.pushState(null, '', this.params?.hrefRoot + i.href)
        this.onItemClick!(i)
      }
      this.append(a)
    })
  }

  removeItem (itemName: string) {
    this.querySelector(`#${itemName}`)?.remove()
  }
}
<file_sep>import { SUDO_EMAILS } from '../../../app.config'

export async function isEmailSudo (email: string): Promise<boolean> {
  for await (const i of SUDO_EMAILS) {
    if (typeof i === 'string') {
      if (email === i) return true
    } else if (i instanceof RegExp) {
      if (i.test(email)) return true
    }
  }
  return false
}
<file_sep>import IComponent from './Component'
// import Modal from "bundle-text:../styles/Modal.scss";

export default abstract class ModalView extends IComponent {
  onSubmit?(data: any): void;
  private shadowDom: ShadowRoot
  protected window: HTMLDivElement = document.createElement('div')
  private main: HTMLElement = document.createElement('main')
  constructor () {
    super()
    this.shadowDom = this.attachShadow({ mode: 'open' })
    const styles = document.createElement('style')
    // styles.textContent = Modal;
    this.shadowDom.append(styles)
    this.shadowDom.innerHTML = `
    <style>
main {
  position: absolute;
  z-index: 3;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  top: 0;
  right: 0;
  display: flex;
  justify-content: center;
  overflow-y: scroll;
}

.separator {
  width: 100%;
  height: 3px;
  background-color: #89ddff;
  margin-bottom: 5px;
  margin-top:5px;
}

.window {
  border: solid 1px #272b3a;
  border-radius: 5px;
  color: white;
  margin-top: 15vh;
  margin-left: 15vw;
  margin-right: 15vw;
  background-color: #2e3345;
  padding: 10px;
  width: 50vw;
  height: min-content;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
@media (max-width: 900px) {
    .window {
  width: 100%;
  margin-left: 2px;
  margin-right: 2px;
  }
 }

input {
margin: 4px;
vertical-align: middle;
padding: 10px;
font-size: 14px;
border-top: none;
border-left: none;
border-right: none;
border-bottom: solid 2px gray;
transition: border-bottom-color linear 0.2s, background-color linear 0.2s;
}

input:hover {
  background-color: #ededed;
}

input:focus {
border-top: none;
border-left: none;
border-right: none;
border-bottom: solid 2px #89ddff;
outline:none;
background-color: #d9d9d9;
}

button {
font-family: Arial, Helvetica, sans-serif;
margin: 5px;
padding: 10px;
text-transform: uppercase;
white-space: nowrap;
letter-spacing: 2px;
font-weight: bold;
color: white;
background-color: transparent;
border: none;
border-radius: 5px;
transition: background-color ease-in-out 0.2s;
}
button:hover {
cursor: pointer;
background-color: #24272e;
}
button:active {
background-color: #4c515c;
}


.title {
  width: 100%;
  margin-top: 0;
  margin-left: 20px;
  display: flex;
  flex-direction: row;
}

span {
  width: 90%;
  text-align: center;
  font-size: 20px;
  font-weight: bold;
  margin-top: 5px;
}

.title-btn {
width: 35px;
height: 35px;
border-radius: 35px;
border: none;
background-color: transparent;
color: white;
font-weight: bold;
font-size: large;
margin-top: 0;
transition: background-color ease-in-out 0.2s;
text-align: center;
padding: 0;
}
.title-btn:hover {
  background-color: #232736;
}

.title-btn:active {
  background-color: #555b73;
}

.all-container {
  display: flex;
  flex-direction: row;
}

.inputs-container {
  display: flex;
  flex-direction: column;
  width: 100%;
}

.img-prev {
  margin: 0 2% 0 2%;
  width: 250px;
  height: 400px;
}

@media (max-width: 900px) {
  .all-container {
    display: flex;
    flex-direction: column-reverse;
  }
  .img-prev {
    align-self: center;
  }
}
    </style>
    `
    this.window.className = 'window'
    this.shadowDom.append(this.main)
    this.main.append(this.window)
  }
}
<file_sep>import FirebaseAuthController from '../../controllers/auth/FirebaseAuthController'
import AuthStatus from '../../interfaces/AuthStatus'
import IComponent from '../../interfaces/Component'

export default class FirebaseAuth extends IComponent {
  private btn = document.createElement('button')

  constructor (private readonly controller: FirebaseAuthController) {
    super()
    this.notLogged()
  }

  connectedCallback () {
    this.append(this.btn)
  }

  notLogged () {
    console.log('FirebaseAuthModule: not logged')
    this.btn.innerText = 'Iniciar con Google'
    this.btn.onclick = async () => {
      await this.controller.login()
    }
    this.append(this.btn)
  }

  logged () {
    console.log('FirebaseAuthModule: logged')
    this.btn.innerText = 'Cerrar sesión'
    this.btn.onclick = async () => {
      await this.controller.logout()
    }
    this.append(this.btn)
  }

  setState (newState: AuthStatus) {
    if (newState === AuthStatus.SUDO) this.logged()
    else this.notLogged()
  }
}

window.customElements.define('sl-firebaseauth', FirebaseAuth)
<file_sep>import { FloatBottomMenu } from '../components/FloatBottomMenu'
import { ITab } from '../components/Tab'
import { TabsMenu } from '../components/TabsMenu'
import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'
import AuthStatus from '../interfaces/AuthStatus'
import { IDbClient } from '../interfaces/DbClient'
import { Category, Serie } from '../interfaces/Models'
import { Route } from '../routes'
import '../styles/SeriesView.scss'
import View from '../interfaces/View'
/// /////////////////////////////////////////////////////////
import { AddSerieModal } from '../components/AddSerieModal'
import { AddCategoryModal } from '../components/AddCategoryModal'
import { SerieCard } from '../components/SerieCard'
import { EditCategoryModal } from '../components/EditCategoryModal'
/// /////////////////////////////////////////////////////////

export default class SeriesView extends View {
  private client: IDbClient

  // Categories
  private actualCategory: Category

  // Components
  private floatBotMenu: FloatBottomMenu
  private tabsMenu: TabsMenu
  private seriesDiv: HTMLDivElement
  private endDiv: HTMLDivElement
  private viewDiv: HTMLDivElement

  private lastAuthEvent: AuthChangeEvent = { status: AuthStatus.ANONYMOUS }
  private endObserver: IntersectionObserver
  private lastSerie?: Serie

  constructor (x: {
    dbClient: IDbClient;
    changeView: (path: Route) => Promise<void>;
  }) {
    super()

    this.client = x.dbClient
    // this.client = new FakeClient();
    this.actualCategory = { name: '' }

    this.tabsMenu = new TabsMenu()

    this.viewDiv = document.createElement('div')
    this.viewDiv.className = 'view-div'
    this.seriesDiv = document.createElement('div')
    this.endDiv = document.createElement('div')
    this.endDiv.className = 'end-div'

    this.floatBotMenu = new FloatBottomMenu()

    this.tabsMenu.onTabsClick = async (tab: ITab) => {
      this.actualCategory = tab as Category
      console.log('actualCategory:', this.actualCategory)
      if (this.actualCategory._id === undefined) {
        throw new Error(
          'Category ' + this.actualCategory.name + ' id is undefined'
        )
      }
      this.endObserver.disconnect()
      this.endDiv.remove()
      await this.getFirstSeries()
    }

    this.tabsMenu.onRequestEditCateg = async (categ) => {
      const modal = new EditCategoryModal(categ)
      this.append(modal)
      modal.onSubmit = async (data) => {
        await this.client.updateCategory(data)
        await this.tabsMenu.updateTab(data)
      }
    }

    this.tabsMenu.onSerieDrop = async (x) => {
      console.log('Serie dropped:', x)
      const oldId = x.serie._id
      await this.findAndDeleteCard(oldId!)
      await this.client.moveSerie(
        this.actualCategory._id!,
        x.categoryId,
        x.serie
      )
    }

    this.tabsMenu.onRequestDelete = async (categId) => {
      await this.client.deleteCategoryById(categId)
    }
    this.endObserver = new IntersectionObserver(
      (entries, observer) => this.handleEnd(entries, observer),
      {
        root: null,
        rootMargin: '0px',
        threshold: 0.25
      }
    )
  }

  async authChangeEvent (x: AuthChangeEvent) {
    console.log('Auth changed!')
    this.lastAuthEvent = x
    if (x.status === AuthStatus.SIGNED || x.status === AuthStatus.SUDO) {
      console.log('Logged')
      const categories = await this.client.getAllCategories()
      if (categories.length > 0) {
        if (window.location.pathname === '/') {
          window.history.pushState(null, '', categories[0]._id!)
          this.actualCategory = categories[0]
        } else {
          let i = 0
          while (
            i < categories.length &&
            categories[i]._id !== window.location.pathname.replace('/', '')
          ) {
            i++
          }
          if (i === categories.length) {
            window.history.pushState(null, '', categories[0]._id!)
            this.actualCategory = categories[0]
          } else {
            this.actualCategory = categories[i]
          }
          console.log('actualCategory:', this.actualCategory)
        }
        await this.updateTabs(categories)
        await this.getFirstSeries()
        x.status === AuthStatus.SUDO
          ? this.append(this.floatBotMenu)
          : this.floatBotMenu.remove()
      }
    } else {
      if (this.floatBotMenu.isConnected) this.floatBotMenu.remove()
    }
    this.tabsMenu.showAddCategTab(x.status === AuthStatus.SUDO)
    for await (const i of this.querySelectorAll<SerieCard>('sl-serie-card')) {
      await i.authChangeEvent(x)
    }
  }

  async connectedCallback (): Promise<void> {
    this.append(this.tabsMenu, this.viewDiv)
    this.viewDiv.append(this.seriesDiv)

    this.seriesDiv.classList.add('series', 'container')

    this.floatBotMenu.onNewSerie = async () => {
      const modal = new AddSerieModal()
      this.append(modal)
      modal.onSubmit = async (serie) => {
        if (this.actualCategory._id === undefined) { throw new Error('Category id is undefined') }
        const id = await this.client.addSerie(serie, this.actualCategory._id)
        this.seriesDiv.insertBefore(
          (
            await Promise.all(
              await this.createCards(Object.assign({ _id: id }, serie))
            )
          )[0],
          this.seriesDiv.children[0]
        )
      }
    }

    this.floatBotMenu.onAltSwitch = (x) => {
      this.seriesDiv.querySelectorAll('sl-serie-card').forEach((i) => {
        i.setAttribute('alt', `${x.alt}`)
      })
    }

    this.tabsMenu.onRequestNewCateg = async () => {
      const modal = new AddCategoryModal()
      this.append(modal)
      modal.onSubmit = async (categ) => {
        if (!this.floatBotMenu.isConnected) this.append(this.floatBotMenu)
        const id = await this.client.addCategory(categ)
        categ._id = id
        this.tabsMenu.addTab((await this.createTabs(categ))[0])
        if (window.location.pathname === '/') {
          window.history.pushState(null, '', id)
          this.actualCategory = categ
          await this.getFirstSeries()
          await this.tabsMenu.setActiveTabByPathname()
        }
      }
    }
  }

  async handleEnd (
    entries: IntersectionObserverEntry[],
    observer: IntersectionObserver
  ): Promise<void> {
    let i = 0
    while (i < entries.length && !entries[i].isIntersecting) {
      i++
    }
    if (i >= entries.length) {
      console.log('Not getting more series')
      return
    }

    console.log('Getting more series!! Last serie:', this.lastSerie)
    if (this.lastSerie !== undefined) {
      console.log('Last serie:', this.lastSerie)
      const moreSeries = await this.client.getSeriesLimitAfter({
        categId: this.actualCategory._id!,
        start: this.lastSerie.timestamp!
      })
      if (moreSeries.length <= 0) {
        observer.disconnect()
        return
      }
      this.lastSerie = moreSeries[moreSeries.length - 1]
      console.log('More series:', moreSeries)
      const cards = await Promise.all(
        await this.createCards.apply(this, moreSeries)
      )
      cards.forEach((i) => this.seriesDiv.append(i))
    }
  }

  /**
   * Creates cards from SeriesCard
   * @param series
   * @returns
   */
  async createCards (...series: Serie[]) {
    console.log('Creating cards')
    return series.map(async (s: Serie) => {
      const card = new SerieCard(s, this.actualCategory._id || '', this.client)
      await card.authChangeEvent(this.lastAuthEvent)
      return card
    })
  }

  async createTabs (...categs: Category[]): Promise<ITab[]> {
    return categs.map((c) => Object.assign(c, { url: '/' + c.name }))
  }

  async updateTabs (categories: Category[]) {
    await this.tabsMenu.clearTabs()
    await this.tabsMenu.addAllTab(
      await this.createTabs.apply(this, categories)
    )
    await this.tabsMenu.setActiveTabByPathname()
  }

  async getFirstSeries () {
    this.viewDiv.scrollTo({ top: 0, left: 0 })
    console.log('Updating series with category:', this.actualCategory)
    if (this.actualCategory._id === undefined) {
      throw new Error('Error with category ' + this.actualCategory._id)
    }

    const series = await this.client.getSeriesLimitFirst({
      categId: this.actualCategory._id
    })

    this.lastSerie = series[series.length - 1]
    this.seriesDiv.textContent = '';
    (await this.createCards.apply(this, series)).forEach(async (s) =>
      this.seriesDiv.append(await s)
    )
    this.viewDiv.append(this.endDiv)
    this.endObserver.observe(this.endDiv)
  }

  async findAndDeleteCard (id: string) {
    const card = this.seriesDiv.querySelector<SerieCard>(`[id='${id}']`)
    if (card === null) { throw new Error('Card to delete with id ' + id + ' is null') }
    card.draggable = false
    card.style.transition = 'visibility 0.3s linear,opacity 0.3s linear'
    card.style.opacity = '0'
    card.style.visibility = 'hidden'
    setTimeout(() => {
      card.remove()
    }, 1000)
  }
}

window.customElements.define('sl-series-view', SeriesView)
<file_sep>import { IAuthController } from '../src/interfaces/IAuthController'

export default class FakeAuth implements IAuthController {
  private sudo: boolean = false
  onAuthChange?(): void;

  isSudo (): boolean {
    return this.sudo
  }

  login (x: { pass: string; url: string }): void {
    this.sudo = true
    this.onAuthChange!()
  }

  logout (): void {
    this.sudo = false
    this.onAuthChange!()
  }

  remember (): void {
    throw new Error('Method not implemented.')
  }
}
<file_sep>import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { isDebug } from "./app.config";

export default defineConfig({
  base: "./",
  root: "./",
  build: {
    outDir: "./dist",
    minify: "terser",
    target: "esnext",
    cssCodeSplit: true,
  },
  plugins: [],
});
<file_sep>import { runCommand } from './util.js'

const parcel = await runCommand('npx vite serve')
const firebase = await runCommand('npx firebase emulators:start')

function killAll () {
  parcel.kill()
  firebase.kill()
}

process.on('exit', killAll)
process.on('SIGINT', killAll)
process.on('SIGUSR1', killAll)
process.on('SIGUSR2', killAll)
process.on('uncaughtException', killAll)
<file_sep>enum AuthStatus {
  ANONYMOUS,
  SIGNED,
  SUDO,
}

export default AuthStatus
<file_sep>import { Serie } from '../interfaces/Models'
import '../styles/Modal.scss'
import ModalView from '../interfaces/ModalView'
import { CustomElement } from '../interfaces/CustomElement'

@CustomElement('sl-edit-serie-modal')
export default class EditSerieModal extends ModalView {
  onSubmit?(serie: Serie): void;

  private imgPrev = document.createElement('img')
  private nameInput = document.createElement('input')
  private altNameInput = document.createElement('input')
  private imgInput = document.createElement('input')
  private urlInput = document.createElement('input')
  private chapterInput = document.createElement('input')

  private titleDiv = document.createElement('div')
  private titleSpan = document.createElement('span')
  private modalClose = document.createElement('button')

  private submitBtn = document.createElement('button')

  constructor (private serie: Serie) {
    super()
    console.log('Editing serie:', serie)
  }

  connectedCallback () {
    const separator = document.createElement('div')
    separator.className = 'separator'
    const separator2 = document.createElement('div')
    separator2.className = 'separator'
    const inputsContainer = document.createElement('div')
    const allContainer = document.createElement('div')
    inputsContainer.className = 'inputs-container'
    allContainer.className = 'all-container'
    this.imgPrev.className = 'img-prev'

    inputsContainer.append(
      this.nameInput,
      this.altNameInput,
      this.imgInput,
      this.urlInput,
      this.chapterInput
    )

    allContainer.append(inputsContainer, this.imgPrev)

    this.window.append(
      this.titleDiv,
      separator,
      allContainer,
      separator2,
      this.submitBtn
    )
    this.titleDiv.append(this.titleSpan, this.modalClose)

    this.titleDiv.className = 'title'

    this.nameInput.type = 'text'
    this.altNameInput.type = 'text'
    this.imgInput.type = 'text'
    this.urlInput.type = 'text'
    this.chapterInput.type = 'number'

    this.nameInput.placeholder = 'Nombre'
    this.altNameInput.placeholder = 'Nombre alternativo'
    this.imgInput.placeholder = 'Link imagen'
    this.urlInput.placeholder = 'Url'
    this.chapterInput.placeholder = 'Capítulo'

    this.titleSpan.innerText = 'Editar serie'
    this.modalClose.innerText = 'X'
    this.modalClose.className = 'title-btn'
    this.modalClose.onclick = () => {
      this.disconnectedCallback!()
      this.remove()
    }

    this.imgInput.oninput = () => {
      this.imgPrev.src = this.imgInput.value
    }

    this.submitBtn.innerText = 'Guardar'
    this.submitBtn.onclick = this.submit
    this.onkeydown = async (ev) => {
      if (ev.key === 'Enter') {
        await this.submit()
      } else if (ev.key === 'Escape') {
        this.remove()
      }
    }

    this.nameInput.value = this.serie.name
    this.altNameInput.value = this.serie.nameAlt
    this.imgPrev.src = this.serie.image
    this.imgInput.value = this.serie.image
    this.urlInput.value = this.serie.url
    this.chapterInput.value = this.serie.chapter.toString()
  }

  private submit = async () => {
    const { runLoading } = await import('./RunLoading')
    await runLoading(async () => {
      await this.onSubmit!({
        _id: this.serie._id,
        name: this.nameInput.value,
        nameAlt: this.altNameInput.value,
        chapter: this.chapterInput.valueAsNumber,
        image: this.imgInput.value,
        url: this.urlInput.value,
        timestamp: new Date()
      })
    }, this.submitBtn)
    this.disconnectedCallback!()
    this.remove()
  }
}
<file_sep>import * as uglify from 'uglify-js'
import { PurgeCSS } from 'purgecss'
import * as fs from 'fs/promises'

const jsfiles = []
const cssfiles = []

for await (const f of await fs.readdir('./dist/')) {
  if (f.endsWith('.js')) jsfiles.push('./dist/' + f)
  else if (f.endsWith('.css')) cssfiles.push('./dist/' + f)
}
for await (const f of await fs.readdir('./dist/assets/')) {
  if (f.endsWith('.js')) jsfiles.push('./dist/assets/' + f)
  else if (f.endsWith('.css')) cssfiles.push('./dist/assets/' + f)
}

console.log('Minifying!')
for await (const js of jsfiles) {
  console.log(js)
  const source = await fs.readFile(js, {
    encoding: 'utf-8'
  })

  const code = uglify.minify(
    { js: source },
    {
      compress: { collapse_vars: true, drop_console: true, unused: true },
      warnings: 'verbose',
      sourceMap: true,
      toplevel: true
    }
  ).code
  await fs.writeFile(js, code, { encoding: 'utf-8' })
  // await fs.writeFile(path.join("./dist/", js), code, "utf-8");
}

const purge = new PurgeCSS()
const res = await purge.purge({
  css: cssfiles.map((i) => `${i}`),
  content: jsfiles.map((i) => `${i}`),
  output: cssfiles.map((i) => `${i}`)
})
console.log(res)
<file_sep>/**
 *
 * @param {string[]} argv
 */
export async function parseArgs (argv) {
  const toret = {}
  while (argv.length > 0) {
    const splitted = argv.shift().split('=')
    if (splitted.length > 1) {
      toret[splitted.shift()] = splitted.join('=')
    } else {
      if (splitted[0].startsWith('!')) {
        toret[splitted[0].replace('!', '')] = false
      } else {
        toret[splitted] = true
      }
    }
  }
  return toret
}
<file_sep>import { Category, Serie } from '../src/interfaces/Models'
import { FakeClient } from './FakeClient'

type PlaceholderInfo = {
  name: string;
  image: string;
};

const SERIES: PlaceholderInfo[] = [
  {
    name: '<NAME>',
    image:
      'https://es.web.img2.acsta.net/c_310_420/pictures/21/06/17/12/59/4847468.jpg'
  },
  {
    name: 'Non Non Biyori Nonstop',
    image:
      'https://ramenparados.com/wp-content/uploads/2020/05/Non-Non-Biyori-Nonstop-key.jpg'
  },
  {
    name: '<NAME> - Isekai Ittara Honki Dasu',
    image:
      'https://pics.filmaffinity.com/Mushoku_Tensei_Isekai_Ittara_Honki_Dasu_Serie_de_TV-104560602-large.jpg'
  },
  {
    name: 'Kanojo mo Kanojo',
    image:
      'https://ramenparados.com/wp-content/uploads/2021/03/kanokano-anime-poster.jpg'
  },
  {
    name: 'Fumetsu no Anata e',
    image:
      'https://www.animefagos.com/wp-content/uploads/2018/01/fumetsuanata01.jpg'
  },
  {
    name: '<NAME> no Cramer',
    image:
      'https://m.media-amazon.com/images/M/MV5BMDQ3NWNlOGUtNTMwMi00MWNjLWFjNWUtNDcxY2FkYTlkYTM0XkEyXkFqcGdeQXVyODMyNTM0MjM@._V1_.jpg'
  },
  {
    name: 'Boku no Hero Academia 5',
    image:
      'https://i.pinimg.com/736x/c8/42/fc/c842fc99774afbeff6c07bb6c6a88536.jpg'
  },
  {
    name: 'Tokyo Revengers',
    image: 'https://images-na.ssl-images-amazon.com/images/I/810blpOrLhL.jpg'
  },
  {
    name: '<NAME> no <NAME>',
    image:
      'https://somoskudasai.com/wp-content/uploads/2021/04/genjitsuyushakv.jpg'
  },
  {
    name: '<NAME>ug Cup mo',
    image:
      'https://freakelitex.com/wp-content/uploads/2020/07/Yakunara-Mug-Cup-Mo-TV.jpg'
  },
  {
    name: 'Non Non Biyori Repeat',
    image:
      'https://images-na.ssl-images-amazon.com/images/I/81-RVZ8GdkL._SL1280_.jpg'
  },
  {
    name: 'Slime Taoshite 300-nen, Shiranai Uchi ni Level Max ni Nattemashita',
    image:
      'https://www.animefagos.com/wp-content/uploads/2019/10/slimetaoshite30002.jpg'
  },
  {
    name: 'Sentouin, Hakenshimasu!',
    image:
      'https://comicvine1.cbsistatic.com/uploads/scale_medium/6/67663/7308803-04.jpg'
  },
  {
    name: 'Majo no Tabitabi',
    image:
      'https://pics.filmaffinity.com/majo_no_tabitabi_tv_series-222822126-large.jpg'
  },
  {
    name: 'Non non biyori',
    image:
      'https://pics.filmaffinity.com/non_non_biyori_tv_series-162638798-mmed.jpg'
  },
  {
    name: '<NAME> (TV)',
    image: 'https://cdn.myanimelist.net/images/anime/1171/109222l.jpg'
  },
  {
    name: 'Ore wo Suki Nano wa Omae Dake ka yo',
    image:
      'https://static.wixstatic.com/media/2b52e5_f60454d8f14b4a02a0fddef7e9cd842e~mv2.jpg/v1/fill/w_628,h_1000,al_c,q_90/2b52e5_f60454d8f14b4a02a0fddef7e9cd842e~mv2.jpg'
  },
  {
    name: '<NAME>',
    image: 'https://pbs.twimg.com/media/Db-tOHHXcAAZAR5.jpg'
  },
  {
    name: 'Mahouka Koukou no Rettousei: Raihousha-hen',
    image:
      'https://www.playerone.vg/wp-content/uploads/2020/10/MV5BYmNmMDIzZjktOTcwMS00N2Q1LWE3NTAtNTUwZGJjOWQ3N2NlXkEyXkFqcGdeQXVyMzgxODM4NjM@._V1_-scaled.jpg'
  },
  {
    name: 'Mahouka Koukou no Rettousei',
    image:
      'https://img1.ak.crunchyroll.com/i/spire3/f2ccbd8ea20320fda0f49b23bb43d2d21396649462_full.jpg'
  },
  {
    name: 'Love Live Sunshine Over the Rainbow',
    image:
      'https://i.pinimg.com/originals/ac/02/98/ac02980ea4846300f953b7eca0c95084.jpg'
  },
  {
    name: 'Nisekoi',
    image: 'https://i.kym-cdn.com/photos/images/original/001/082/371/c57'
  },
  {
    name: 'Fullmetal Alchemist: Brotherhood',
    image:
      'https://img1.ak.crunchyroll.com/i/spire4/fabddf1040abbd18948b9aacc18011b31475523493_main.jpg'
  },
  {
    name: '<NAME>',
    image:
      'https://www.animefagos.com/wp-content/uploads/2018/11/caroletuesday01.jpg'
  },
  {
    name: 'Noragami',
    image: 'https://animerelleno.com/storage/animes/poster/noragami.jpg'
  },
  {
    name: 'Re:Zero k<NAME> <NAME>',
    image:
      'https://img1.ak.crunchyroll.com/i/spire2/95445cd55c37ce2ff04ef1adde79f50c1529088362_main.jpg'
  }
]

const CATEGS = [
  'Viendo',
  'Pendientes',
  'Vistos',
  'Favoritos',
  'Mangas Terminados',
  'Mangas en espera',
  'Simultcasts',
  'Live Actions',
  'Películas',
  'HBO',
  'Netflix',
  'BBC'
]

export default class Placeholders {
  static async getRandomSerie (): Promise<Serie> {
    const s = SERIES[Math.floor(SERIES.length * Math.random())]
    return {
      _id: await this.getRandomText(),
      timestamp: new Date(),
      name: s.name,
      nameAlt: s.name,
      image: s.image,
      url: '',
      chapter: Math.floor(Math.random() * 24)
    }
  }

  static async getRandomCategory (): Promise<Category> {
    const c = CATEGS[Math.floor(CATEGS.length * Math.random())]
    return {
      _id: await this.getRandomText(),
      name: c,
      timestamp: new Date()
    }
  }

  static async getRandomText (length: number = 32): Promise<string> {
    const letters = 'abcdefghijklnmopqrstuvwxyz '
    let toret: string = ''
    for (let i = 0; i < length; i++) {
      toret += letters[Math.floor(Math.random() * letters.length)]
    }
    return toret
  }
}
<file_sep>import BackupController from '../../controllers/BackupController'
import IComponent from '../../interfaces/Component'

export default class BackupsView extends IComponent {
  constructor (private controller: BackupController) {
    super()
  }

  connectedCallback () {
    const list = document.createElement('ul')
    const saveLi = document.createElement('li')
    const loadLi = document.createElement('li')

    const saveBtn = document.createElement('button')
    saveBtn.innerText = 'Guardar copia de seguridad'
    saveBtn.onclick = async () => {
      await this.controller.save()
    }

    const loadLabel = document.createElement('label')
    loadLabel.innerText = 'Cargar copia de seguridad'
    const loadInput = document.createElement('input')
    loadInput.type = 'file'
    loadInput.accept = '.json'
    loadInput.multiple = false
    const loadBtn = document.createElement('button')
    loadBtn.innerText = 'Cargar'
    loadInput.onchange = async () => {
      if (
        loadInput.files!.length > 0 &&
        confirm(
          `${loadInput.files!.item(0)!.name} - ${
            loadInput.files!.item(0)!.size / 1024
          } KB\nLa información de la base de datos no se eliminará ¿Quieres continuar?`
        )
      ) {
        await this.controller.load(loadInput.files?.item(0)!)
        alert('Backup cargado')
      }
    }
    this.append(list)
    loadLabel.append(loadInput)
    list.append(saveLi, loadLi)
    saveLi.append(saveBtn)
    loadLi.append(loadLabel)
  }
}

window.customElements.define('sl-backups-view', BackupsView)
<file_sep>import { AuthChangeEvent } from '../controllers/auth/FirebaseAuthController'
import IComponent from './Component'

export default abstract class View extends IComponent {
  abstract authChangeEvent(x: AuthChangeEvent): Promise<void>;
}
