瀏覽代碼

Refactor code - replacing nested events with direct calls.
Added path to top of list.

dom111 2 年之前
父節點
當前提交
ae519a889a
共有 57 個文件被更改,包括 1840 次插入1518 次删除
  1. 4 0
      Makefile
  2. 2 2
      TODO.md
  3. 0 0
      assets/css/style-min.css
  4. 1 0
      assets/css/style.css
  5. 7 0
      assets/css/webdav.css.map
  6. 0 0
      build/example.generator.js
  7. 1 1
      build/examples-branch.sh
  8. 1 1
      build/examples-tag.sh
  9. 2 2
      build/examples.sh
  10. 8 0
      esbuild.js
  11. 206 51
      package-lock.json
  12. 8 5
      package.json
  13. 9 0
      src/components/Container.ts
  14. 97 0
      src/components/Footer.ts
  15. 34 0
      src/components/Header.ts
  16. 434 0
      src/components/Item.ts
  17. 107 0
      src/components/List.ts
  18. 45 0
      src/components/UI.ts
  19. 29 0
      src/lib/AbstractUI.ts
  20. 87 0
      src/lib/Collection.ts
  21. 136 94
      src/lib/DAV.ts
  22. 0 160
      src/lib/DAV/Collection.ts
  23. 0 52
      src/lib/DAV/Response.ts
  24. 39 12
      src/lib/Entry.ts
  25. 0 45
      src/lib/EventObject.ts
  26. 58 37
      src/lib/HTTP.ts
  27. 37 0
      src/lib/HTTP/RequestFailure.ts
  28. 64 0
      src/lib/Response.ts
  29. 94 0
      src/lib/State.ts
  30. 0 318
      src/lib/UI/NativeDOM.ts
  31. 0 14
      src/lib/UI/NativeDOM/Container.ts
  32. 0 39
      src/lib/UI/NativeDOM/Element.ts
  33. 0 53
      src/lib/UI/NativeDOM/Footer.ts
  34. 0 123
      src/lib/UI/NativeDOM/List.ts
  35. 0 342
      src/lib/UI/NativeDOM/List/Item.ts
  36. 0 69
      src/lib/UI/UI.ts
  37. 0 1
      src/lib/Unimplemented.ts
  38. 75 0
      src/lib/handleFileUpload.ts
  39. 3 2
      src/lib/joinPath.ts
  40. 32 0
      src/lib/previewItems.ts
  41. 16 0
      src/lib/supportsEvent.ts
  42. 3 1
      src/lib/supportsFocusWithin.ts
  43. 1 1
      src/lib/trailingSlash.ts
  44. 4 0
      src/style.scss
  45. 0 0
      src/webdav-min.js
  46. 2 0
      src/webdav.js.map
  47. 42 17
      src/webdav.ts
  48. 60 6
      tests/functional/List.test.ts
  49. 3 1
      tests/lib/getProperties.ts
  50. 27 11
      tests/lib/isReady.ts
  51. 24 25
      tests/unit/DAV.test.ts
  52. 21 20
      tests/unit/DAV/Collection.test.ts
  53. 6 5
      tests/unit/DAV/Entry.test.ts
  54. 2 2
      tests/unit/DAV/Response.test.ts
  55. 3 2
      translations/de.json
  56. 3 2
      translations/en.json
  57. 3 2
      translations/pt.json

+ 4 - 0
Makefile

@@ -4,6 +4,10 @@ SHELL = /bin/bash
 build: node_modules
 	npm run build
 
+.PHONY: watch
+watch: node_modules
+	npm run watch
+
 .PHONY: test
 test: node_modules
 	docker-compose run --rm -e BASE_URL=http://webdav test npm run test

+ 2 - 2
TODO.md

@@ -2,11 +2,11 @@
 - [x] Add end-to-end UI testing
 - [x] Move to TypeScript
 - [x] Support keyboard navigation whilst overlay is visible
+- [x] Maybe a refactor...
+- [x] Add eventMap to `Event` object. - Replaced with `typed-event-emitter`
 - [ ] Add drag and drop tests
 - [ ] Allow uploading of directories ([#48](https://github.com/dom111/webdav-js/issues/48))
 - [ ] Add functionality for copying and moving files and directories
 - [ ] Add progress bar for file uploads
 - [ ] ReactJS implementation
 - [ ] VueJS implementation
-- [ ] Maybe a refactor...
-- [ ] Add eventMap to `Event` object.

文件差異過大導致無法顯示
+ 0 - 0
assets/css/style-min.css


+ 1 - 0
assets/css/style.css

@@ -1,3 +1,4 @@
+@charset "UTF-8";
 .basicLightbox {
   position: fixed;
   display: flex;

+ 7 - 0
assets/css/webdav.css.map

@@ -0,0 +1,7 @@
+{
+  "version": 3,
+  "sources": ["../node_modules/basiclightbox/src/styles/main.scss", "../node_modules/prismjs/themes/prism.css", "../node_modules/melba-toast/dist/Melba.css", "../src/style.scss"],
+  "sourcesContent": [null, null, null, null],
+  "mappings": "iBAOA,eAEC,eACA,aACA,uBACA,mBACA,MACA,OACA,WACA,aACA,0BACA,YACA,4BACA,aACA,oBAEA,wBACC,UAGD,4BACC,eACA,oBACA,8BACA,UACA,sBAEA,0KAGC,cACA,sCAMA,cACA,eAGD,mHAEC,oBAGD,gHAEC,WACA,YAIF,qJAGC,WACA,YACA,oBAGD,oDACC,mBC/DF,6CAEC,WACA,gBACA,wBACA,8DACA,cACA,gBACA,gBACA,oBACA,kBACA,iBACA,gBAEA,gBACA,cACA,WAEA,qBACA,kBACA,iBACA,aAGD,4JAEC,iBACA,mBAGD,wIAEC,iBACA,mBAGD,aACC,6CAEC,kBAKF,gDAGC,cAGD,uDAEC,mBAID,iEAGC,mBAGD,yDAIC,cAGD,mBACC,WAGD,iBACC,WAGD,qGAOC,WAGD,0FAMC,WAGD,0FAKC,cAEA,8BAGD,+CAGC,WAGD,kCAEC,cAGD,8CAGC,WAGD,6BAEC,gBAED,cACC,kBAGD,cACC,YC1IgB,kBAAkB,SAAS,2BAA2B,eAAe,SAAS,UAAU,0BAA0B,kBAAkB,6BAA6B,OAAO,wFAAwF,oDAAoD,yBAAyB,WAAW,YAAY,eAAe,0BAA0B,eAAe,oCAAoC,kBAAkB,2BAA2B,mBAAmB,cAAc,aAAa,WAAW,gBAAgB,iBAAiB,kBAAkB,gBAAgB,oBAAoB,kBAAkB,cAAc,uBAAuB,YAAY,cAAc,eAAe,cAAc,0BAA0B,kBAAkB,UAAU,QAAQ,mBAAmB,qBAAqB,kBAAkB,aAAa,2FAA2F,YAAY,oBAAoB,gBAAgB,gBAAgB,2FAA2F,gBAAgB,uBAAuB,gBAAgB,gBAAgB,2FAA2F,gBAAgB,uBAAuB,gBAAgB,cAAc,2FAA2F,YAAY,qBAAqB,gBAAgB,aAAa,8BAA8B,UAAU,wBAAwB,aAAa,6BAA6B,UAAU,yCCK3mD,KACE,eAGF,aAGE,0DAMF,UAEE,iBAGF,GACE,gBAGF,EACE,cACA,qBAIF,QACE,uBAGF,WACE,UACA,oBACA,kBAGF,SACE,+tIAGA,WACE,WAIJ,QACE,sBAIF,kBAGE,QACE,kEAKA,WACE,sCACA,0BACA,eACA,cACA,sCAGA,iBACE,yBAGF,uBACE,aAGF,kBACE,WAGF,mBACE,wBAEA,qBACE,oBAIJ,iBACE,WACA,mCAIF,8FAKE,wCACA,YACA,yBAEA,gBACA,iBACA,mBACA,WAGF,iBACE,ygCAGF,iBACE,ygCAGF,mBACE,qoBAGF,mBACE,y+BAGF,sBACE,qjCAGF,iBACE,mBAEA,eAIA,4BACE,gvBAGF,2DAEE,aAKF,uBACE,4aAIA,6BACE,w4BAQF,0GACE,w0BAOF,iFACE,4eAKF,0BACE,w2BAKF,2BACE,wyBAKF,4BACE,w/BAKF,2BACE,ovBAKN,qBACE,sBACA,qBACA,WACA,gCAEA,YAEA,4BACE,gBACA,cACA,WACA,QAIJ,0BACE,+BAOR,QACE,wCAEA,WACA,gBACA,6CAGA,kBACA,eAEA,0BACE,WACA,kBACA,0BAGF,8DAEE,aAGF,oBACE,eAKF,2CACE,gBACA,eACA,4BAMA,gGACE,gBAIJ,+DACE,mBAEA,qEAEE,wNAEA",
+  "names": []
+}

+ 0 - 0
src/example.generator.js → build/example.generator.js


+ 1 - 1
build/examples-branch.sh

@@ -1,6 +1,6 @@
 [ ! -d ./tmp ] && mkdir -p ./tmp;
 
-node src/example.generator.js --cdn --version "$(git rev-parse --abbrev-ref HEAD)" > ./tmp/example-cdn.js;
+node build/example.generator.js --cdn --version "$(git rev-parse --abbrev-ref HEAD)" > ./tmp/example-cdn.js;
 npm run --silent terser -- ./tmp/example-cdn.js -c -m -e > ./tmp/example-cdn-min.js;
 
 printf 'javascript:' > ./examples/bookmarklet/source-min.js;

+ 1 - 1
build/examples-tag.sh

@@ -2,7 +2,7 @@
 
 VERSION="$1";
 
-node src/example.generator.js --cdn --version "$VERSION" > ./tmp/example-cdn.js;
+node build/example.generator.js --cdn --version "$VERSION" > ./tmp/example-cdn.js;
 npm run --silent terser -- ./tmp/example-cdn.js -c -m -e > ./tmp/example-cdn-min.js;
 
 printf 'javascript:' > ./examples/bookmarklet/source-min.js;

+ 2 - 2
build/examples.sh

@@ -1,8 +1,8 @@
 [ ! -d ./tmp ] && mkdir -p ./tmp;
 
-node src/example.generator.js > ./tmp/example-local.js;
+node build/example.generator.js > ./tmp/example-local.js;
 npm run --silent terser -- ./tmp/example-local.js -c -m -e > ./tmp/example-local-min.js;
-node src/example.generator.js --cdn > ./tmp/example-cdn.js;
+node build/example.generator.js --cdn > ./tmp/example-cdn.js;
 npm run --silent terser -- ./tmp/example-cdn.js -c -m -e > ./tmp/example-cdn-min.js;
 
 printf 'javascript:' > ./examples/bookmarklet/source-min.js;

+ 8 - 0
esbuild.js

@@ -17,10 +17,18 @@ const { build } = require('esbuild'),
             from: ['./dist/webdav.js'],
             to: ['./src/webdav-min.js'],
           },
+          {
+            from: ['./dist/webdav.js.map'],
+            to: ['./src/webdav.js.map'],
+          },
           {
             from: ['./dist/webdav.css'],
             to: ['./assets/css/style.css', './assets/css/style-min.css'],
           },
+          {
+            from: ['./dist/webdav.css.map'],
+            to: ['./assets/css/webdav.css.map'],
+          },
         ],
       }),
     ],

文件差異過大導致無法顯示
+ 206 - 51
package-lock.json


+ 8 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "webdav-js",
-  "version": "2.1.1",
+  "version": "2.2.0",
   "description": "WebDAV functionality intended for use as a bookmarklet or to make a simple Apache webserver an interactive WebDAV environment.",
   "repository": {
     "type": "git",
@@ -30,12 +30,15 @@
     "watch": "npm run build:watch"
   },
   "devDependencies": {
+    "@types/basiclightbox": "^5.0.1",
     "@types/expect-puppeteer": "^4.4.7",
     "@types/jest": "^27.4.1",
     "@types/jest-environment-puppeteer": "^5.0.1",
+    "@types/prismjs": "^1.26.0",
     "@types/puppeteer": "^5.4.6",
     "@xmldom/xmldom": "^0.8.2",
     "esbuild": "^0.14.38",
+    "esbuild-plugin-copy": "^1.3.0",
     "esbuild-sass-plugin": "^2.2.6",
     "jest": "^27.5.1",
     "jest-puppeteer": "^6.1.0",
@@ -45,12 +48,12 @@
     "ts-node": "^10.7.0"
   },
   "dependencies": {
+    "@dom111/element": "^0.1.0",
+    "@dom111/typed-event-emitter": "^0.1.0",
     "basiclightbox": "^5.0.2",
-    "esbuild-plugin-copy": "^1.3.0",
     "i18next": "^21.9.1",
     "i18next-browser-languagedetector": "^6.1.5",
-    "melba-toast": "^2.0.0",
-    "prismjs": "^1.17.1",
-    "whatwg-fetch": "^3.0.0"
+    "melba-toast": "^3.0.0",
+    "prismjs": "^1.17.1"
   }
 }

+ 9 - 0
src/components/Container.ts

@@ -0,0 +1,9 @@
+import Element, { s } from '@dom111/element';
+
+export default class Container extends Element {
+  constructor() {
+    const template = '<main></main>';
+
+    super(s(template));
+  }
+}

+ 97 - 0
src/components/Footer.ts

@@ -0,0 +1,97 @@
+import Element, { on, s } from '@dom111/element';
+import DAV from '../lib/DAV';
+import Entry from '../lib/Entry';
+import State from '../lib/State';
+import handleFileUpload from '../lib/handleFileUpload';
+import joinPath from '../lib/joinPath';
+import { success } from 'melba-toast';
+import { t } from 'i18next';
+import trailingSlash from '../lib/trailingSlash';
+
+export default class Footer extends Element {
+  #dav: DAV;
+  #state: State;
+
+  constructor(dav: DAV, state: State) {
+    super(
+      s(`<footer class="upload">
+  <span class="droppable">${t('dropFilesAnywhereToUpload')}</span> ${t('or')}
+  <span class="files">${t(
+    'uploadFiles'
+  )} <input type="file" multiple></span> ${t('or')}
+  <a href="#" class="create-directory">${t('createNewDirectory')}</a>
+</footer>`)
+    );
+
+    this.#dav = dav;
+    this.#state = state;
+
+    this.bindEvents();
+  }
+
+  private bindEvents(): void {
+    const input = this.query('input[type="file"]') as HTMLInputElement,
+      createDirectoryLink = this.query(
+        '.create-directory'
+      ) as HTMLAnchorElement;
+
+    on(input, 'change', async (): Promise<void> => {
+      for (const file of input.files) {
+        handleFileUpload(this.#dav, this.#state, file);
+      }
+
+      this.#state.update();
+
+      input.value = null;
+    });
+
+    on(
+      createDirectoryLink,
+      'click',
+      async (event: MouseEvent): Promise<void> => {
+        event.preventDefault();
+
+        const directoryName = prompt('', t('directoryName'));
+
+        if (!directoryName) {
+          return;
+        }
+
+        this.handleCreateDirectory(
+          trailingSlash(joinPath(location.pathname, directoryName)),
+          directoryName
+        );
+      }
+    );
+  }
+
+  async handleCreateDirectory(fullPath: string, directoryName: string) {
+    const result = await this.#dav.mkcol(fullPath);
+
+    if (!result) {
+      return;
+    }
+
+    success(
+      t('successfullyCreated', {
+        interpolation: {
+          escapeValue: false,
+        },
+        directoryName,
+      })
+    );
+
+    const collection = this.#state.getCollection();
+
+    collection.add(
+      new Entry({
+        directory: true,
+        fullPath,
+        modified: Date.now(),
+        collection,
+      })
+    );
+
+    this.#state.update();
+  }
+}

+ 34 - 0
src/components/Header.ts

@@ -0,0 +1,34 @@
+import Element, { s } from '@dom111/element';
+import State from '../lib/State';
+
+const getPath = (state: State): string => decodeURIComponent(state.getPath());
+
+export class Header extends Element {
+  #state: State;
+
+  constructor(state: State) {
+    super(
+      s(`<header>
+  <h1>${getPath(state)}</h1>
+</header>`)
+    );
+
+    this.#state = state;
+
+    this.bindEvents();
+  }
+
+  private bindEvents(): void {
+    this.#state.on('updated', (): void => {
+      if (this.#state.isDirectory()) {
+        this.update();
+      }
+    });
+  }
+
+  private update(): void {
+    this.query('h1').innerHTML = getPath(this.#state);
+  }
+}
+
+export default Header;

+ 434 - 0
src/components/Item.ts

@@ -0,0 +1,434 @@
+import { BasicLightBox, create } from 'basiclightbox';
+import Element, {
+  addClass,
+  emit,
+  off,
+  on,
+  removeClass,
+  s,
+} from '@dom111/element';
+import DAV from '../lib/DAV';
+import Entry from '../lib/Entry';
+import Prism from 'prismjs';
+import State from '../lib/State';
+import joinPath from '../lib/joinPath';
+import previewItems from '../lib/previewItems';
+import { success } from 'melba-toast';
+import { t } from 'i18next';
+
+const template = (entry: Entry): string => `<li tabindex="0" data-full-path="${
+  entry.fullPath
+}" data-type="${entry.type}">
+  <span class="title">${entry.title}</span>
+  <input type="text" name="rename" class="hidden" readonly>
+  <span class="size">${entry.displaySize}</span>
+  <a href="#" title="${t('delete')} (␡)" class="delete"></a>
+  <!--<a href="#" title="Move" class="move"></a>-->
+  <a href="#" title="${t('rename')} (F2)" class="rename"></a>
+  <!--<a href="#" title="Copy" class="copy"></a>-->
+  <a href="${entry.fullPath}" download="${entry.name}" title="${t(
+  'download'
+)} (⇧+⏎)"></a>
+</li>`;
+
+export default class Item extends Element {
+  #dav: DAV;
+  #entry: Entry;
+  #state: State;
+  #templates: {
+    [key: string]: (entry: Entry, content?: string) => string;
+  } = Object.freeze({
+    video: (entry: Entry): string =>
+      `<video autoplay controls><source src="${entry.fullPath}"/></video>`,
+    audio: (entry: Entry): string =>
+      `<audio autoplay controls><source src="${entry.fullPath}"/></audio>`,
+    image: (entry: Entry): string =>
+      `<img alt="${entry.title}" src="${entry.fullPath}"/>`,
+    font: (entry: Entry): string => {
+      const formats = {
+          eot: 'embedded-opentype',
+          otf: 'opentype',
+          ttf: 'truetype',
+        },
+        extension = entry.name.replace(/^.+\.([^.]+)$/, '$1').toLowerCase(),
+        fontName = entry.fullPath.replace(/\W+/g, '_'),
+        demoText = `${t('pangram')} 0123456789<br/>
+        ${t('alphabet')}`;
+
+      return `<style>@font-face{font-family:"${fontName}";src:url("${
+        entry.fullPath
+      }") format("${formats[extension] || extension}")}</style>
+<h1 style="font-family:'${fontName}'">${entry.title}</h1>
+<p style="font-family:'${fontName}';font-size:1.5em">${demoText}</p>
+<p style="font-family:'${fontName}'">${demoText}</p>
+<p style="font-family:'${fontName}'"><strong>${demoText}</strong></p>
+<p style="font-family:'${fontName}'"><em>${demoText}</em></p>`;
+    },
+    text: (entry: Entry, content: string): string =>
+      `<pre><code class="language-${entry.extension}">${content.replace(
+        /[<>]/g,
+        (c: string): string => (c === '<' ? '&lt;' : '&gt;')
+      )}</code></pre>`,
+    pdf: (entry: Entry): string =>
+      `<iframe src="${entry.fullPath}" height="100%" width="100%"></iframe>`,
+  });
+
+  constructor(entry: Entry, dav: DAV, state: State) {
+    super(s(template(entry)));
+
+    this.#dav = dav;
+    this.#state = state;
+    this.#entry = entry;
+
+    this.addClass(
+      ...[
+        entry.directory ? 'directory' : 'file',
+        entry.type ? entry.type : 'unknown',
+      ]
+    );
+
+    if (entry.placeholder) {
+      this.addClass('loading');
+    }
+
+    if (!entry.del) {
+      this.query('.delete').setAttribute('hidden', '');
+    }
+
+    if (!entry.rename) {
+      this.query('.rename').setAttribute('hidden', '');
+    }
+
+    this.bindEvents();
+  }
+
+  private bindEvents(): void {
+    this.on('click', (event: MouseEvent): void => {
+      if (event.ctrlKey || event.button === 1) {
+        window.open(this.#entry.fullPath);
+
+        return;
+      }
+
+      if (event.shiftKey) {
+        this.download();
+
+        return;
+      }
+
+      this.open();
+    });
+
+    const entryUpdatedHandler = (): void => {
+      this.#entry.off('updated', entryUpdatedHandler);
+
+      this.update();
+    };
+
+    this.#entry.on('updated', entryUpdatedHandler);
+
+    on(this.query('[download]'), 'click', (event: MouseEvent): void =>
+      event.stopPropagation()
+    );
+
+    on(this.query('.delete'), 'click', (event: MouseEvent): void => {
+      event.preventDefault();
+      event.stopPropagation();
+
+      this.del();
+    });
+
+    on(this.query('.rename'), 'click', (event: MouseEvent): void => {
+      event.stopPropagation();
+      event.preventDefault();
+
+      this.rename();
+    });
+
+    this.on('keydown', (event: KeyboardEvent): void => {
+      if (['F2', 'Delete', 'Enter'].includes(event.key)) {
+        event.preventDefault();
+
+        if (event.key === 'F2' && this.#entry.rename) {
+          this.rename();
+        }
+
+        if (event.key === 'Delete' && this.#entry.del) {
+          this.del();
+        }
+
+        if (event.key === 'Enter' && !this.#entry.directory && event.shiftKey) {
+          this.download();
+
+          return;
+        }
+
+        if (event.key === 'Enter') {
+          this.open();
+        }
+      }
+    });
+  }
+
+  async del(): Promise<void> {
+    const entry = this.#entry;
+
+    if (!entry.del) {
+      throw new TypeError(`'${entry.name}' is read only.`);
+    }
+
+    this.addClass('loading');
+
+    if (
+      !confirm(
+        t('deleteConfirmation', {
+          file: entry.title,
+        })
+      )
+    ) {
+      this.removeClass('loading');
+
+      return;
+    }
+
+    const response = await this.#dav.del(entry.fullPath);
+
+    if (!response) {
+      return;
+    }
+
+    this.#state.getCollection().remove(this.#entry);
+    this.element().remove();
+
+    success(
+      t('successfullyDeleted', {
+        interpolation: {
+          escapeValue: false,
+        },
+        file: entry.title,
+      })
+    );
+  }
+
+  download(): void {
+    if (this.#entry.directory) {
+      return;
+    }
+
+    emit(this.query<HTMLAnchorElement>('[download]'), new MouseEvent('click'));
+  }
+
+  async open(): Promise<void> {
+    if (this.hasClass('open')) {
+      return;
+    }
+
+    this.addClass('open', 'loading');
+
+    const entry = this.#entry;
+
+    const response = await this.#dav.check(entry.fullPath);
+
+    if (!response) {
+      this.removeClass('open', 'loading');
+
+      return;
+    }
+
+    if (entry.directory) {
+      this.#state.setPath(entry.fullPath);
+      this.removeClass('open', 'loading');
+
+      return;
+    }
+
+    const launchLightbox = (
+      lightboxContent: string,
+      onShow: ((lightbox: BasicLightBox) => any) | null = null
+    ): void => {
+      const close = (): void => lightbox.close(),
+        keyListener = (event: KeyboardEvent): void => {
+          if (!['Escape', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
+            return;
+          }
+
+          const [previous, next] = previewItems(
+            this.element(),
+            'li:not(.directory):not([data-type="unknown"])'
+          );
+
+          close();
+
+          if (event.key === 'ArrowUp' && previous) {
+            emit(previous, new MouseEvent('click'));
+          }
+
+          if (event.key === 'ArrowDown' && next) {
+            emit(next, new MouseEvent('click'));
+          }
+        },
+        lightbox = create(lightboxContent, {
+          className: entry.type,
+          onShow: (): boolean => {
+            this.removeClass('loading');
+
+            on(document, 'keydown', keyListener);
+
+            if (onShow) {
+              onShow(lightbox);
+            }
+
+            this.#state.showPath(entry.fullPath);
+
+            this.#state.on('updated', close);
+
+            return true;
+          },
+          onClose: (): boolean => {
+            off(document, 'keydown', keyListener);
+            this.#state.off('updated', close);
+
+            this.#state.showPath(entry.path);
+
+            this.removeClass('open');
+
+            return true;
+          },
+        });
+
+      lightbox.show();
+    };
+
+    if (['video', 'audio', 'image', 'font', 'pdf'].includes(entry.type)) {
+      launchLightbox(this.#templates[entry.type](entry));
+
+      this.removeClass('loading');
+
+      return;
+    }
+
+    if (entry.type !== 'text') {
+      this.removeClass('open', 'loading');
+
+      this.download();
+
+      return;
+    }
+
+    const content = await this.#dav.get(entry.fullPath);
+
+    if (!content) {
+      this.removeClass('open', 'loading');
+
+      return;
+    }
+
+    launchLightbox(
+      this.#templates.text(entry, content),
+      (lightbox: BasicLightBox): void =>
+        Prism.highlightAllUnder(lightbox.element())
+    );
+
+    this.removeClass('loading');
+  }
+
+  async rename(): Promise<void> {
+    const entry = this.#entry;
+
+    if (!entry.rename) {
+      throw new TypeError(`'${entry.name}' cannot be renamed.`);
+    }
+
+    const node = this.element(),
+      title = this.query<HTMLElement>('.title'),
+      input = this.query<HTMLInputElement>('input'),
+      setInputSize = (): void => {
+        title.innerText = input.value;
+        input.style.setProperty('width', `${title.scrollWidth}px`);
+      },
+      save = async (): Promise<void> => {
+        // don't process if there's no name change
+        if (input.value !== entry.title) {
+          this.addClass('loading');
+
+          unbindListeners();
+
+          const destinationPath = joinPath(entry.path, input.value),
+            result = await this.#dav.move(
+              entry.fullPath,
+              destinationPath,
+              entry
+            );
+
+          if (!result) {
+            return;
+          }
+
+          success(
+            t('successfullyRenamed', {
+              interpolation: {
+                escapeValue: false,
+              },
+              from: entry.title,
+              to: input.value,
+            })
+          );
+
+          entry.name = input.value;
+        }
+
+        revert();
+      },
+      unbindListeners = (): void => {
+        off(input, 'blur', blurListener);
+        off(input, 'keydown', keyDownListener);
+        off(input, 'input', inputListener);
+      },
+      revert = (): void => {
+        removeClass(title, 'invisible');
+        addClass(input, 'hidden');
+        input.value = entry.title;
+        setInputSize();
+        unbindListeners();
+
+        node.focus();
+      },
+      blurListener = async (): Promise<void> => {
+        await save();
+      },
+      keyDownListener = async (event: KeyboardEvent): Promise<void> => {
+        event.stopPropagation();
+
+        if (event.key === 'Enter') {
+          event.preventDefault();
+
+          await save();
+        }
+
+        if (event.key === 'Escape') {
+          revert();
+        }
+      },
+      inputListener = (): void => {
+        return setInputSize();
+      };
+
+    addClass(title, 'invisible');
+    removeClass(input, 'hidden');
+
+    input.value = entry.title;
+
+    setInputSize();
+    input.removeAttribute('readonly');
+    on(input, 'blur', blurListener);
+    on(input, 'keydown', keyDownListener);
+    on(input, 'input', inputListener);
+    input.focus();
+  }
+
+  update(): void {
+    const newItem = new Item(this.#entry, this.#dav, this.#state);
+
+    this.element().replaceWith(newItem.element());
+  }
+}

+ 107 - 0
src/components/List.ts

@@ -0,0 +1,107 @@
+import Element, { emit, on, s } from '@dom111/element';
+import Collection from '../lib/Collection';
+import DAV from '../lib/DAV';
+import Entry from '../lib/Entry';
+import Item from './Item';
+import State from '../lib/State';
+import previewItems from '../lib/previewItems';
+import supportsFocusWithin from '../lib/supportsFocusWithin';
+
+export class List extends Element<HTMLUListElement> {
+  #dav: DAV;
+  #state: State;
+
+  constructor(dav: DAV, state: State) {
+    super(s('<ul class="loading"></ul>'));
+
+    this.#dav = dav;
+    this.#state = state;
+
+    this.load();
+
+    this.bindEvents();
+  }
+
+  private bindEvents(): void {
+    this.#state.on('updated', (bypassCache: boolean): void => {
+      if (this.#state.isDirectory()) {
+        this.load(bypassCache);
+
+        return;
+      }
+
+      const item = this.query<HTMLLIElement>(
+        `[data-full-path="${this.#state.getPath()}"]`
+      );
+
+      if (!item) {
+        return;
+      }
+
+      emit(item, new MouseEvent('click'));
+    });
+
+    this.#state.on('collection-updated', (): void => this.render());
+
+    const arrowHandler = (event: KeyboardEvent): void => {
+      if (!['ArrowUp', 'ArrowDown'].includes(event.key)) {
+        return;
+      }
+
+      event.preventDefault();
+      event.stopPropagation();
+
+      const current = this.query(
+          `li:focus${supportsFocusWithin ? ', li:focus-within' : ''}`
+        ) as HTMLElement,
+        [previous, next] = current
+          ? previewItems(current)
+          : [
+              this.element().firstElementChild as HTMLElement,
+              this.element().firstElementChild as HTMLElement,
+            ];
+
+      if (event.key === 'ArrowUp' && previous) {
+        previous.focus();
+      }
+
+      if (event.key === 'ArrowDown' && next) {
+        next.focus();
+      }
+    };
+
+    on(document, 'keydown', arrowHandler);
+  }
+
+  async load(bypassCache: boolean = false): Promise<void> {
+    const collection = await this.#dav.list(this.#state.getPath(), bypassCache);
+
+    if (!collection) {
+      return;
+    }
+
+    this.update(collection);
+  }
+
+  private render(): void {
+    this.addClass('loading');
+
+    this.empty();
+
+    this.#state
+      .getCollection()
+      .forEach((entry: Entry): void =>
+        this.append(new Item(entry, this.#dav, this.#state))
+      );
+
+    this.removeClass('loading');
+  }
+
+  update(collection: Collection): void {
+    this.#state.setCollection(collection);
+
+    this.render();
+  }
+}
+
+export default List;

+ 45 - 0
src/components/UI.ts

@@ -0,0 +1,45 @@
+import supportsEvent, { supportsEvents } from '../lib/supportsEvent';
+import AbstractUI from '../lib/AbstractUI';
+import handleFileUpload from '../lib/handleFileUpload';
+
+export class UI extends AbstractUI {
+  protected bindEvents(): void {
+    const isTouch = supportsEvent('touchstart'),
+      supportsDragDrop = supportsEvents('dragstart', 'drop');
+
+    // DOM events
+    if (isTouch) {
+      this.addClass('is-touch');
+    }
+
+    if (!supportsDragDrop) {
+      this.addClass('no-drag-drop');
+    }
+
+    if (supportsDragDrop) {
+      this.onEach(['dragenter', 'dragover'], (event: DragEvent): void => {
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.addClass('active');
+      });
+
+      this.onEach(['dragleave', 'drop'], (event: DragEvent): void => {
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.removeClass('active');
+      });
+
+      this.on('drop', async (event: DragEvent): Promise<void> => {
+        const { files } = event.dataTransfer;
+
+        for (const file of files) {
+          await handleFileUpload(this.dav(), this.state(), file);
+        }
+      });
+    }
+  }
+}
+
+export default UI;

+ 29 - 0
src/lib/AbstractUI.ts

@@ -0,0 +1,29 @@
+import DAV from './DAV';
+import Element from '@dom111/element';
+import State from './State';
+
+export abstract class AbstractUI extends Element {
+  #dav: DAV;
+  #state: State;
+
+  constructor(container: HTMLElement, dav: DAV, state: State) {
+    super(container);
+
+    this.#dav = dav;
+    this.#state = state;
+
+    this.bindEvents();
+  }
+
+  protected abstract bindEvents(): void;
+
+  dav(): DAV {
+    return this.#dav;
+  }
+
+  state(): State {
+    return this.#state;
+  }
+}
+
+export default AbstractUI;

+ 87 - 0
src/lib/Collection.ts

@@ -0,0 +1,87 @@
+import Entry from './Entry';
+import { EntryObject } from './Response';
+import EventEmitter from '@dom111/typed-event-emitter/EventEmitter';
+import joinPath from './joinPath';
+
+type CollectionEvents = {
+  updated: [];
+};
+
+type EntryIterator<T = any> = (entry: Entry, index: number) => T;
+
+export default class Collection extends EventEmitter<CollectionEvents> {
+  #path: string;
+  #entries: Entry[];
+  #sortDirectoriesFirst: boolean;
+
+  // don't need to handle equal paths as that's invalid
+  #sort = (): void => {
+    this.#entries.sort(
+      (a, b) =>
+        (this.#sortDirectoriesFirst && this.#sortDirectories(a, b)) ||
+        this.#sortAlphabetically(a, b)
+    );
+  };
+  #sortAlphabetically = (a: Entry, b: Entry): number =>
+    a.fullPath < b.fullPath ? -1 : 1;
+  #sortDirectories = (a: Entry, b: Entry): number =>
+    (b.directory ? 1 : 0) - (a.directory ? 1 : 0);
+
+  constructor(items: EntryObject[], { sortDirectoriesFirst = false } = {}) {
+    super();
+
+    this.#sortDirectoriesFirst = sortDirectoriesFirst;
+
+    this.#entries = items.map(
+      (item: EntryObject): Entry =>
+        new Entry({
+          ...item,
+          collection: this,
+        })
+    );
+
+    // the first entry is a stub for the directory itself, we can remove that for the root path...
+    const parent = this.#entries.shift();
+
+    this.#path = joinPath(parent.fullPath);
+
+    if (parent.fullPath !== '/') {
+      // ...but change the details for all others.
+      this.#entries.unshift(parent.createParentEntry());
+    }
+
+    this.#sort();
+  }
+
+  add(entry: Entry): void {
+    entry.collection = this;
+
+    this.#entries.push(entry);
+
+    this.#sort();
+
+    this.emit('updated');
+  }
+
+  filter(iterator: EntryIterator<boolean>): Entry[] {
+    return this.#entries.filter(iterator);
+  }
+
+  forEach(iterator: EntryIterator<void>): void {
+    this.#entries.forEach(iterator);
+  }
+
+  map(iterator: EntryIterator): any[] {
+    return this.#entries.map(iterator);
+  }
+
+  path(): string {
+    return this.#path;
+  }
+
+  remove(entry: Entry): void {
+    this.#entries = this.#entries.filter((item): boolean => item !== entry);
+
+    this.emit('updated');
+  }
+}

+ 136 - 94
src/lib/DAV.ts

@@ -1,26 +1,104 @@
-import EventObject from './EventObject';
+import Collection from './Collection';
+import DAVResponse from './Response';
+import Entry from './Entry';
 import HTTP from './HTTP';
-import Response from './DAV/Response';
+import RequestFailure from './HTTP/RequestFailure';
+import { error } from 'melba-toast';
 import joinPath from './joinPath';
+import { t } from 'i18next';
 import trailingSlash from './trailingSlash';
-import Entry from './DAV/Entry';
 
 type ConstructorOptions = {
   bypassCheck?: boolean;
   sortDirectoriesFirst?: boolean;
 };
 
-export default class DAV extends EventObject {
-  #bypassCheck;
-  #cache;
-  #http;
-  #sortDirectoriesFirst;
+type CachedRequests = {
+  GET: string;
+  PROPFIND: Collection;
+};
+
+export type RequestCache<
+  K extends keyof CachedRequests = keyof CachedRequests,
+  T extends CachedRequests[K] = CachedRequests[K]
+> = Map<K, Map<string, T>>;
+
+const emptyCache = (): RequestCache => {
+  const cache = new Map<
+    keyof CachedRequests,
+    Map<string, Collection | string>
+  >();
+
+  cache.set('GET', new Map<string, string>());
+  cache.set('PROPFIND', new Map<string, Collection>());
+
+  return cache;
+};
 
-  #validDestination = (destination) => {
+export default class DAV {
+  #bypassCheck: boolean;
+  #cache: RequestCache;
+  #http: HTTP;
+  #sortDirectoriesFirst: boolean;
+
+  #getCache = <K extends keyof CachedRequests = keyof CachedRequests>(
+    type: K,
+    uri: string
+  ): CachedRequests[K] | null => {
+    const lookup = this.#cache.get(type);
+
+    if (!lookup.has(uri)) {
+      return null;
+    }
+
+    return lookup.get(uri) as CachedRequests[K];
+  };
+  #hasCache = <K extends keyof CachedRequests = keyof CachedRequests>(
+    type: K,
+    uri: string
+  ): boolean => {
+    const lookup = this.#cache.get(type);
+
+    return lookup.has(uri);
+  };
+  #setCache = <K extends keyof CachedRequests = keyof CachedRequests>(
+    type: K,
+    uri: string,
+    value: CachedRequests[K]
+  ): void | null => {
+    const lookup = this.#cache.get(type);
+
+    lookup.set(uri, value);
+  };
+  #toastOnFailure = async (
+    func: () => Promise<Response>
+  ): Promise<Response> => {
+    try {
+      return await func();
+    } catch (e) {
+      if (!(e instanceof RequestFailure)) {
+        throw e;
+      }
+
+      error(
+        t('failure', {
+          interpolation: {
+            escapeValue: false,
+          },
+          method: e.method(),
+          url: e.url(),
+          statusText: e.statusText(),
+          status: e.status(),
+        })
+      );
+    }
+  };
+  #validDestination = (destination: string): string => {
     const hostname = `${location.protocol}//${location.hostname}${
         location.port ? `:${location.port}` : ''
       }`,
       hostnameRegExp = new RegExp(`^${hostname}`);
+
     if (!destination.match(hostnameRegExp)) {
       if (destination.match(/^http/)) {
         throw new TypeError(`Invalid destination host: '${destination}'.`);
@@ -32,97 +110,69 @@ export default class DAV extends EventObject {
     return destination;
   };
 
-  #dispatchWithEvents = (func, eventName, ...params) => {
-    this.trigger(`${eventName}:request`, ...params);
-
-    return func()
-      .then((response) => {
-        if (!response) {
-          this.trigger(`${eventName}:failed`, ...params);
-
-          return response;
-        }
-
-        this.trigger(`${eventName}:success`, ...params);
-
-        return response;
-      })
-      .catch(() => {
-        this.trigger(`${eventName}:failed`, ...params);
-      });
-  };
-
   constructor(
     { bypassCheck, sortDirectoriesFirst }: ConstructorOptions,
-    cache = new Map(),
+    cache: RequestCache = emptyCache(),
     http = new HTTP()
   ) {
-    super();
-
     this.#bypassCheck = bypassCheck;
     this.#sortDirectoriesFirst = sortDirectoriesFirst;
     this.#cache = cache;
     this.#http = http;
-
-    this.bindEvents();
-  }
-
-  bindEvents() {
-    this.on('cache:invalidate', (path) => {
-      if (this.#cache.has(path)) {
-        this.#cache.delete(path);
-      }
-    });
   }
 
-  async check(uri) {
+  async check(uri): Promise<{
+    ok: boolean;
+    status: number;
+  }> {
     if (this.#bypassCheck) {
-      return {
+      return Promise.resolve({
         ok: true,
         status: 200,
-      };
+      });
     }
 
-    return this.#http.HEAD(uri);
+    return this.#toastOnFailure((): Promise<Response> => this.#http.HEAD(uri));
   }
 
-  async copy(from, to, entry = null) {
-    return this.#dispatchWithEvents(
-      () =>
+  async copy(from, to): Promise<Response> {
+    return this.#toastOnFailure(
+      (): Promise<Response> =>
         this.#http.COPY(from, {
           headers: {
             Destination: this.#validDestination(to),
           },
-        }),
-      'copy',
-      from,
-      to,
-      entry
+        })
     );
   }
 
-  async del(uri, entry = null) {
-    return this.#dispatchWithEvents(
-      () => this.#http.DELETE(uri),
-      'delete',
-      uri,
-      entry
+  async del(uri): Promise<Response> {
+    return this.#toastOnFailure(
+      (): Promise<Response> => this.#http.DELETE(uri)
     );
   }
 
-  async get(uri) {
-    return this.#dispatchWithEvents(() => this.#http.GET(uri), 'get', uri);
+  async get(uri): Promise<string | null> {
+    if (!this.#hasCache('GET', uri)) {
+      const response = await this.#toastOnFailure(
+        (): Promise<Response> => this.#http.GET(uri)
+      );
+
+      if (!response || !response.ok) {
+        return;
+      }
+
+      this.#setCache('GET', uri, await response.text());
+    }
+
+    return this.#getCache('GET', uri);
   }
 
-  async list(uri, bypassCache = false) {
+  async list(uri, bypassCache = false): Promise<Collection> {
     uri = trailingSlash(uri);
 
-    if (!bypassCache) {
-      const cached = await this.#cache.get(uri);
-
-      if (cached) {
-        return cached;
-      }
+    if (!bypassCache && this.#hasCache('PROPFIND', uri)) {
+      return this.#getCache('PROPFIND', uri);
     }
 
     const check = await this.check(uri);
@@ -131,59 +181,51 @@ export default class DAV extends EventObject {
       return;
     }
 
-    const data = await this.#http.PROPFIND(uri),
-      response = new Response(await data.text()),
+    const data = await this.#toastOnFailure(
+        (): Promise<Response> => this.#http.PROPFIND(uri)
+      ),
+      response = new DAVResponse(await data.text()),
       collection = response.collection({
         sortDirectoriesFirst: this.#sortDirectoriesFirst,
       });
-    this.#cache.set(uri, collection);
+
+    this.#setCache('PROPFIND', uri, collection);
 
     return collection;
   }
 
-  async mkcol(fullPath, directoryName = '', path = '') {
-    return this.#dispatchWithEvents(
-      () => this.#http.MKCOL(fullPath),
-      'mkcol',
-      fullPath,
-      directoryName,
-      path
+  async mkcol(fullPath) {
+    return this.#toastOnFailure(
+      (): Promise<Response> => this.#http.MKCOL(fullPath)
     );
   }
 
-  async move(from: string, to: string, entry: Entry) {
+  async move(from: string, to: string, entry: Entry): Promise<Response> {
     const destination = this.#validDestination(to);
 
-    return this.#dispatchWithEvents(
-      () =>
+    return this.#toastOnFailure(
+      (): Promise<Response> =>
         this.#http.MOVE(from, {
           headers: {
             Destination: entry.directory
               ? trailingSlash(destination)
               : destination,
           },
-        }),
-      'move',
-      from,
-      to,
-      entry
+        })
     );
   }
 
-  async upload(path, file) {
+  async upload(path, file): Promise<Response> {
     const targetFile = joinPath(path, file.name);
 
-    return this.#dispatchWithEvents(
-      () =>
+    return this.#toastOnFailure(
+      (): Promise<Response> =>
         this.#http.PUT(targetFile, {
           headers: {
             'Content-Type': file.type,
           },
           body: file,
-        }),
-      'upload',
-      path,
-      file
+        })
     );
   }
 }

+ 0 - 160
src/lib/DAV/Collection.ts

@@ -1,160 +0,0 @@
-import Entry from './Entry';
-import EventObject from '../EventObject';
-import joinPath from '../joinPath';
-import trailingSlash from '../trailingSlash';
-
-export default class Collection extends EventObject {
-  #path;
-  #entries;
-  #sortDirectoriesFirst;
-
-  // don't need to handle equal paths as that's invalid
-  #sort = () =>
-    this.#entries.sort(
-      (a, b) =>
-        (this.#sortDirectoriesFirst && this.#sortDirectories(a, b)) ||
-        this.#sortAlphabetically(a, b)
-    );
-  #sortAlphabetically = (a, b) => (a.fullPath < b.fullPath ? -1 : 1);
-  #sortDirectories = (a, b) => b.directory - a.directory;
-
-  constructor(items, { sortDirectoriesFirst = false } = {}) {
-    super();
-
-    this.#sortDirectoriesFirst = sortDirectoriesFirst;
-
-    this.#entries = items.map(
-      (item) =>
-        new Entry({
-          ...item,
-          collection: this,
-        })
-    );
-
-    // the first entry is a stub for the directory itself, we can remove that for the root path...
-    const parent = this.#entries.shift();
-
-    this.#path = joinPath(parent.fullPath);
-
-    if (parent.fullPath !== '/') {
-      // ...but change the details for all others.
-      this.#entries.unshift(parent.createParentEntry());
-    }
-
-    this.#sort();
-
-    this.bindEvents();
-  }
-
-  bindEvents() {
-    this.on('upload:request', (path, file) => {
-      if (joinPath(path) === this.#path) {
-        this.add(
-          new Entry({
-            fullPath: joinPath(path, file.name),
-            modified: file.lastModifiedDate,
-            size: file.size,
-            mimeType: file.type,
-            placeholder: true,
-          })
-        );
-      }
-    });
-
-    this.on('upload:success', (path, completedFile) => {
-      const [completedEntry] = this.filter(
-        (entry) => entry.fullPath === joinPath(path, completedFile.name)
-      );
-
-      if (completedEntry) {
-        completedEntry.placeholder = false;
-      }
-    });
-
-    this.on('upload:failed', (path, failedFile) => {
-      const [failedEntry] = this.filter(
-        (entry) => entry.fullPath === joinPath(path, failedFile.name)
-      );
-
-      if (failedEntry) {
-        this.remove(failedEntry);
-      }
-    });
-
-    this.on('delete:success', (path) => {
-      const [deletedFile] = this.filter((entry) => entry.fullPath === path);
-
-      if (deletedFile) {
-        this.remove(deletedFile);
-      }
-    });
-
-    this.on('move:success', (sourceFullPath, destinationFullPath) => {
-      const [entry] = this.filter((entry) => entry.fullPath === sourceFullPath);
-
-      if (!entry) {
-        return;
-      }
-
-      const newEntry = new Entry({
-        directory: entry.directory,
-        fullPath: trailingSlash(destinationFullPath),
-        modified: entry.modified,
-        size: entry.size,
-        mimeType: entry.mimeType,
-        del: entry.del,
-      });
-
-      this.remove(entry);
-
-      if (entry.path === newEntry.path) {
-        return this.add(newEntry);
-      }
-
-      this.trigger('cache:invalidate', newEntry.path);
-    });
-
-    this.on('mkcol:success', (destination, directoryName, path) => {
-      if (joinPath(path) === this.#path) {
-        this.add(
-          new Entry({
-            directory: true,
-            fullPath: destination,
-            modified: new Date(),
-          })
-        );
-      }
-    });
-  }
-
-  add(entry) {
-    entry.collection = this;
-    this.#entries.push(entry);
-
-    this.#sort();
-
-    this.trigger('collection:update', this);
-
-    return this;
-  }
-
-  remove(entry) {
-    this.#entries = this.#entries.filter((item) => item !== entry);
-
-    this.trigger('collection:update', this);
-
-    return this;
-  }
-
-  map(iterator) {
-    return this.#entries.map(iterator);
-  }
-
-  filter(iterator) {
-    return this.#entries.filter(iterator);
-  }
-
-  get path() {
-    return this.#path;
-  }
-}

+ 0 - 52
src/lib/DAV/Response.ts

@@ -1,52 +0,0 @@
-import Collection from './Collection';
-
-export default class Response {
-  #collection;
-  #document;
-  #parser;
-
-  #getTag = (doc: Element, tag: string) => doc.getElementsByTagName(tag)[0];
-
-  #getTagContent = (doc: Element, tag) => {
-    const node = this.#getTag(doc, tag);
-
-    return node ? node.textContent : '';
-  };
-
-  constructor(rawDocument: string, parser: DOMParser = new DOMParser()) {
-    this.#parser = parser;
-    this.#document = parser.parseFromString(rawDocument, 'application/xml');
-  }
-
-  collection({ sortDirectoriesFirst = false } = {}) {
-    if (!this.#collection) {
-      this.#collection = new Collection(
-        this.responseToPrimitives(
-          this.#document.getElementsByTagName('D:response')
-        ),
-        {
-          sortDirectoriesFirst,
-        }
-      );
-    }
-
-    return this.#collection;
-  }
-
-  responseToPrimitives(responses: HTMLCollection) {
-    return Array.from(responses).map((response) => ({
-      directory: !!this.#getTag(response, 'D:collection'),
-      fullPath: this.#getTagContent(response, 'D:href'),
-      modified: Date.parse(
-        this.#getTagContent(response, 'lp1:getlastmodified') ||
-          this.#getTagContent(response, 'D:getlastmodified')
-      ),
-      size: parseInt(
-        this.#getTagContent(response, 'lp1:getcontentlength') ||
-          this.#getTagContent(response, 'D:getcontentlength'),
-        10
-      ),
-      mimeType: this.#getTagContent(response, 'D:getcontenttype'),
-    }));
-  }
-}

+ 39 - 12
src/lib/DAV/Entry.ts → src/lib/Entry.ts

@@ -1,12 +1,13 @@
 import Collection from './Collection';
-import EventObject from '../EventObject';
-import joinPath from '../joinPath';
+import EventEmitter from '@dom111/typed-event-emitter/EventEmitter';
+import joinPath from './joinPath';
+import trailingSlash from './trailingSlash';
 
 type EntryArgs = {
   directory?: boolean;
   fullPath?: string;
   title?: string;
-  modified?: Date;
+  modified?: number;
   size?: number;
   mimeType?: string;
   del?: boolean;
@@ -15,7 +16,11 @@ type EntryArgs = {
   collection?: Collection | null;
 };
 
-export default class Entry extends EventObject {
+type EntryEvents = {
+  updated: [];
+};
+
+export default class Entry extends EventEmitter<EntryEvents> {
   #del: boolean;
   #directory: boolean;
   #displaySize: string;
@@ -49,12 +54,15 @@ export default class Entry extends EventObject {
 
     const [path, name] = this.getFilename(fullPath);
 
+    const modifiedDate = new Date();
+    modifiedDate.setTime(modified);
+
     this.#path = path;
     this.#name = name;
     this.#directory = directory;
     this.#fullPath = fullPath;
     this.#title = title;
-    this.#modified = modified;
+    this.#modified = modifiedDate;
     this.#size = size;
     this.#mimeType = mimeType;
     this.#del = del;
@@ -65,7 +73,7 @@ export default class Entry extends EventObject {
 
   createParentEntry(): Entry {
     return this.update({
-      fullPath: this.path,
+      fullPath: trailingSlash(this.path),
       title: '&larr;',
       del: false,
       rename: false,
@@ -80,11 +88,11 @@ export default class Entry extends EventObject {
   }
 
   update(properties: EntryArgs = {}): Entry {
-    return new Entry({
+    const newEntry = new Entry({
       ...{
         directory: this.directory,
         fullPath: this.fullPath,
-        modified: this.modified,
+        modified: this.modified.getTime(),
         size: this.size,
         mimeType: this.mimeType,
         del: this.del,
@@ -93,6 +101,10 @@ export default class Entry extends EventObject {
       },
       ...properties,
     });
+
+    this.emit('replaced', newEntry);
+
+    return newEntry;
   }
 
   get del(): boolean {
@@ -104,7 +116,7 @@ export default class Entry extends EventObject {
   }
 
   get displaySize(): string {
-    if (this.directory) {
+    if (this.#directory) {
       return '';
     }
 
@@ -156,6 +168,23 @@ export default class Entry extends EventObject {
     return this.#name;
   }
 
+  set name(name: string) {
+    this.#name = encodeURIComponent(name);
+    this.#title = null;
+    this.#type = null;
+    this.#fullPath = joinPath(this.#path, this.#name);
+
+    if (this.directory) {
+      this.#fullPath = trailingSlash(this.#fullPath);
+    }
+
+    // regenerate these:
+    this.title;
+    this.type;
+
+    this.emit('updated');
+  }
+
   get path(): string {
     return this.#path;
   }
@@ -166,8 +195,6 @@ export default class Entry extends EventObject {
 
   set placeholder(value: boolean) {
     this.#placeholder = value;
-
-    this.trigger('entry:update', this);
   }
 
   get rename(): boolean {
@@ -198,7 +225,7 @@ export default class Entry extends EventObject {
       };
 
       for (const [key, value] of Object.entries(types)) {
-        if (this.name.match(value)) {
+        if (this.#name.match(value)) {
           return (this.#type = key);
         }
       }

+ 0 - 45
src/lib/EventObject.ts

@@ -1,45 +0,0 @@
-const events = {};
-
-export default class EventObject {
-  hasEvent(event) {
-    return event in events;
-  }
-
-  on(event, listener) {
-    if (!this.hasEvent(event)) {
-      events[event] = [];
-    }
-
-    events[event].push(listener);
-  }
-
-  off(event, listener = null) {
-    if (!this.hasEvent(event)) {
-      return;
-    }
-
-    if (listener === null) {
-      return (events[event] = []);
-    }
-
-    events[event] = events[event].filter(
-      (eventListener) => eventListener !== listener
-    );
-  }
-
-  trigger(event, ...data) {
-    if (this.hasEvent(event)) {
-      let stopped = false;
-
-      events[event].forEach((listener) => {
-        if (stopped) {
-          return;
-        }
-
-        if (listener(...data) === false) {
-          stopped = true;
-        }
-      });
-    }
-  }
-}

+ 58 - 37
src/lib/HTTP.ts

@@ -1,67 +1,88 @@
-import EventObject from './EventObject';
+import RequestFailure from './HTTP/RequestFailure';
 
-const defaultParams = {
+type HTTPMethods =
+  | 'CONNECT'
+  | 'DELETE'
+  | 'GET'
+  | 'HEAD'
+  | 'OPTIONS'
+  | 'PATCH'
+  | 'POST'
+  | 'PUT'
+  | 'TRACE';
+type WebDAVMethods =
+  | HTTPMethods
+  | 'COPY'
+  | 'LOCK'
+  | 'MKCOL'
+  | 'MOVE'
+  | 'PROPFIND'
+  | 'PROPPATCH'
+  | 'UNLOCK';
+type MethodParams = {
+  [K in WebDAVMethods]?: RequestInit;
+};
+
+const defaultParams: MethodParams = {
   PROPFIND: {
     headers: {
-      Depth: 1,
+      Depth: '1',
     },
   },
 };
 
-const method = (
+const method = async (
   method: string,
   url: RequestInfo,
-  parameters: RequestInit,
-  object: HTTP
-) =>
-  fetch(url, {
-    ...(defaultParams[method] || null),
+  parameters: RequestInit
+): Promise<Response> => {
+  const request = new Request(url, {
+    ...(defaultParams[method] || {}),
     ...parameters,
     method,
-  }).then((response) => {
-    if (!response.ok) {
-      object.trigger('error', {
-        method,
-        url,
-        response,
-      });
+  });
 
-      return;
-    }
+  const response = await fetch(request);
 
-    return response;
-  });
+  if (!response.ok) {
+    throw new RequestFailure(request, response);
+  }
+
+  return response;
+};
 
-export default class HTTP extends EventObject {
-  GET(url: string, parameters: RequestInit = {}) {
-    return method('GET', url, parameters, this);
+export class HTTP {
+  GET(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('GET', url, parameters);
   }
 
-  HEAD(url: string, parameters: RequestInit = {}) {
-    return method('HEAD', url, parameters, this);
+  HEAD(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('HEAD', url, parameters);
   }
 
-  PUT(url: string, parameters: RequestInit = {}) {
-    return method('PUT', url, parameters, this);
+  PUT(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('PUT', url, parameters);
   }
 
-  PROPFIND(url: string, parameters: RequestInit = {}) {
-    return method('PROPFIND', url, parameters, this);
+  PROPFIND(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('PROPFIND', url, parameters);
   }
 
-  DELETE(url: string, parameters: RequestInit = {}) {
-    return method('DELETE', url, parameters, this);
+  DELETE(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('DELETE', url, parameters);
   }
 
-  MKCOL(url: string, parameters: RequestInit = {}) {
-    return method('MKCOL', url, parameters, this);
+  MKCOL(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('MKCOL', url, parameters);
   }
 
-  COPY(url: string, parameters: RequestInit = {}) {
-    return method('COPY', url, parameters, this);
+  COPY(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('COPY', url, parameters);
   }
 
-  MOVE(url: string, parameters: RequestInit = {}) {
-    return method('MOVE', url, parameters, this);
+  MOVE(url: string, parameters: RequestInit = {}): Promise<Response> {
+    return method('MOVE', url, parameters);
   }
 }
+
+export default HTTP;

+ 37 - 0
src/lib/HTTP/RequestFailure.ts

@@ -0,0 +1,37 @@
+export class RequestFailure extends Error {
+  #request: Request;
+  #response: Response;
+
+  constructor(request: Request, response: Response) {
+    super('Request failure');
+
+    this.#request = request;
+    this.#response = response;
+  }
+
+  request(): Request {
+    return this.#request;
+  }
+
+  response(): Response {
+    return this.#response;
+  }
+
+  method(): string {
+    return this.#request.method;
+  }
+
+  url(): string {
+    return this.#request.url;
+  }
+
+  statusText(): string {
+    return this.#response.statusText;
+  }
+
+  status(): number {
+    return this.#response.status;
+  }
+}
+
+export default RequestFailure;

+ 64 - 0
src/lib/Response.ts

@@ -0,0 +1,64 @@
+import Collection from './Collection';
+
+export type EntryObject = {
+  directory: boolean;
+  fullPath: string;
+  modified: number;
+  size?: number;
+  mimeType?: string;
+};
+
+const getTag = (doc: Element, tag: string): Element =>
+    doc.getElementsByTagName(tag)[0],
+  getTagContent = (doc: Element, tag: string): string => {
+    const node = getTag(doc, tag);
+
+    return node ? node.textContent : '';
+  };
+
+export class Response {
+  #collection: Collection;
+  #document: Document;
+  #parser: DOMParser;
+
+  constructor(rawDocument: string, parser: DOMParser = new DOMParser()) {
+    this.#parser = parser;
+    this.#document = parser.parseFromString(rawDocument, 'application/xml');
+  }
+
+  collection({ sortDirectoriesFirst = false } = {}): Collection {
+    if (!this.#collection) {
+      this.#collection = new Collection(
+        this.responseToPrimitives(
+          this.#document.getElementsByTagName('D:response')
+        ),
+        {
+          sortDirectoriesFirst,
+        }
+      );
+    }
+
+    return this.#collection;
+  }
+
+  responseToPrimitives(responses: HTMLCollection): EntryObject[] {
+    return Array.from(responses).map(
+      (response): EntryObject => ({
+        directory: !!getTag(response, 'D:collection'),
+        fullPath: getTagContent(response, 'D:href'),
+        modified: Date.parse(
+          getTagContent(response, 'lp1:getlastmodified') ||
+            getTagContent(response, 'D:getlastmodified')
+        ),
+        size: parseInt(
+          getTagContent(response, 'lp1:getcontentlength') ||
+            getTagContent(response, 'D:getcontentlength'),
+          10
+        ),
+        mimeType: getTagContent(response, 'D:getcontenttype'),
+      })
+    );
+  }
+}
+
+export default Response;

+ 94 - 0
src/lib/State.ts

@@ -0,0 +1,94 @@
+import Collection from './Collection';
+import EventEmitter from '@dom111/typed-event-emitter/EventEmitter';
+import { t } from 'i18next';
+import { on } from '@dom111/element';
+
+export class State extends EventEmitter<{
+  'collection-updated': [];
+  updated: [boolean?];
+}> {
+  #collection: Collection;
+  #location: Location;
+  #history: History;
+  #document: Document;
+  #window: Window;
+
+  #collectionUpdatedListener = (): void => this.emit('collection-updated');
+
+  constructor(document: Document, window: Window) {
+    super();
+
+    this.#document = document;
+    this.#window = window;
+    this.#location = window.location;
+    this.#history = window.history;
+
+    this.bindEvents();
+    this.setTitle(this.getPath());
+  }
+
+  private bindEvents(): void {
+    on(window, 'popstate', () => this.update());
+  }
+
+  getCollection(): Collection {
+    return this.#collection;
+  }
+
+  getPath(): string {
+    return this.#location.pathname;
+  }
+
+  getTitleForPath(path: string): string {
+    return t('title', {
+      interpolation: {
+        escapeValue: false,
+      },
+      path: decodeURIComponent(path),
+    });
+  }
+
+  isDirectory(): boolean {
+    return this.getPath().endsWith('/');
+  }
+
+  setCollection(collection: Collection): void {
+    if (this.#collection) {
+      this.#collection.off('updated', this.#collectionUpdatedListener);
+    }
+
+    this.#collection = collection;
+
+    collection.on('updated', this.#collectionUpdatedListener);
+  }
+
+  setPath(path: string): void {
+    if (this.#location.pathname !== path) {
+      this.showPath(path);
+
+      this.emit('updated');
+    }
+  }
+
+  private setTitle(path: string): void {
+    const title = this.getTitleForPath(path);
+
+    if (this.#document.title !== title) {
+      this.#document.title = title;
+    }
+  }
+
+  showPath(path: string): void {
+    if (this.#location.pathname !== path) {
+      this.#history.pushState({ path }, this.getTitleForPath(path), path);
+
+      this.setTitle(path);
+    }
+  }
+
+  update(bypassCache: boolean = false): void {
+    this.emit('updated', bypassCache);
+  }
+}
+
+export default State;

+ 0 - 318
src/lib/UI/NativeDOM.ts

@@ -1,318 +0,0 @@
-import Container from './NativeDOM/Container';
-import Footer from './NativeDOM/Footer';
-import Melba from 'melba-toast';
-import UI from './UI';
-import i18next from 'i18next';
-import trailingSlash from '../trailingSlash';
-
-export default class NativeDOM extends UI {
-  render(container = new Container(), footer = new Footer()) {
-    this.container.appendChild(container.element);
-    this.container.appendChild(footer.element);
-
-    this.bindEvents();
-
-    this.trigger('go');
-  }
-
-  bindEvents(element = this.container) {
-    const supportsEvent = (eventName) => {
-        const element = document.createElement('span');
-
-        element.setAttribute(`on${eventName}`, '');
-
-        return typeof element[`on${eventName}`] === 'function';
-      },
-      isTouch = supportsEvent('touchstart'),
-      supportsDragDrop = supportsEvent('dragstart') && supportsEvent('drop'),
-      updateTitle = (title) => {
-        if (document.title !== title) {
-          document.title = title;
-        }
-      },
-      updatePath = (path) => {
-        if (location.pathname !== path) {
-          history.pushState(history.state, path, path);
-        }
-      };
-
-    // DOM events
-    if (isTouch) {
-      this.container.classList.add('is-touch');
-    }
-
-    if (!supportsDragDrop) {
-      this.container.classList.add('no-drag-drop');
-    }
-
-    window.addEventListener('popstate', () => {
-      const url = location.pathname;
-
-      element.dispatchEvent(
-        new CustomEvent('preview:close', {
-          bubbles: true,
-          detail: {
-            preview: true,
-          },
-        })
-      );
-
-      if (url.endsWith('/')) {
-        return this.trigger('go');
-      }
-
-      const path = url.replace(/[^/]+$/, '');
-
-      this.trigger('go', path, {
-        bypassPushState: true,
-        success: () =>
-          this.container
-            .querySelector(`main ul li[data-full-path="${url}"]`)
-            ?.dispatchEvent(new CustomEvent('click')),
-      });
-
-      // trigger opening file
-    });
-
-    if (supportsDragDrop) {
-      ['dragenter', 'dragover'].forEach((eventName) => {
-        element.addEventListener(eventName, (event) => {
-          event.preventDefault();
-          event.stopPropagation();
-
-          element.classList.add('active');
-        });
-      });
-
-      ['dragleave', 'drop'].forEach((eventName) => {
-        element.addEventListener(eventName, (event) => {
-          event.preventDefault();
-          event.stopPropagation();
-
-          element.classList.remove('active');
-        });
-      });
-
-      element.addEventListener('drop', async (event) => {
-        const { files } = event.dataTransfer;
-
-        for (const file of files) {
-          this.trigger('upload', location.pathname, file);
-        }
-      });
-    }
-
-    // global listeners
-    this.on('error', ({ method, url, response }) => {
-      new Melba({
-        content: i18next.t('failure', {
-          interpolation: {
-            escapeValue: false,
-          },
-          method,
-          url,
-          statusText: response.statusText,
-          status: response.status,
-        }),
-        type: 'error',
-      });
-    });
-
-    // local events
-    this.on('upload', async (path, file) => {
-      const collection = await this.dav.list(path),
-        [existingFile] = collection.filter((entry) => entry.name === file.name);
-
-      if (existingFile) {
-        // TODO: nicer notification
-        // TODO: i18m
-        if (
-          !confirm(
-            i18next.t('overwriteFileConfirmation', {
-              file: existingFile.title,
-            })
-          )
-        ) {
-          return false;
-        }
-      }
-
-      await this.dav.upload(path, file);
-    });
-
-    this.on('upload:success', async (path, file) => {
-      new Melba({
-        content: i18next.t('successfullyUploaded', {
-          interpolation: {
-            escapeValue: false,
-          },
-          file: file.name,
-        }),
-        type: 'success',
-        hide: 5,
-      });
-    });
-
-    this.on('move', async (source, destination, entry) => {
-      await this.dav.move(source, destination, entry);
-    });
-
-    this.on('move:success', (source, destination, entry) => {
-      const [, destinationUrl, destinationFile] =
-          destination.match(/^(.*)\/([^/]+\/?)$/),
-        destinationPath =
-          destinationUrl &&
-          destinationUrl.replace(
-            `${location.protocol}//${location.hostname}${
-              location.port ? `:${location.port}` : ''
-            }`,
-            ''
-          );
-
-      if (entry.path === destinationPath || entry.directory) {
-        return new Melba({
-          content: i18next.t('successfullyRenamed', {
-            interpolation: {
-              escapeValue: false,
-            },
-            from: entry.title,
-            to: decodeURIComponent(destinationFile),
-          }),
-          type: 'success',
-          hide: 5,
-        });
-      }
-
-      new Melba({
-        content: i18next.t('successfullyMoved', {
-          interpolation: {
-            escapeValue: false,
-          },
-          from: entry.title,
-          to: decodeURIComponent(destinationPath),
-        }),
-        type: 'success',
-        hide: 5,
-      });
-    });
-
-    this.on('delete', async (path, entry) => {
-      await this.dav.del(path, entry);
-    });
-
-    this.on('delete:success', (path, entry) => {
-      new Melba({
-        content: i18next.t('successfullyDeleted', {
-          interpolation: {
-            escapeValue: false,
-          },
-          file: entry.title,
-        }),
-        type: 'success',
-        hide: 5,
-      });
-    });
-
-    this.on('get', async (file, callback) => {
-      const response = await this.dav.get(file);
-
-      callback(response && (await response.text()));
-    });
-
-    this.on('check', async (uri, callback, failure) => {
-      const response = await this.dav.check(uri);
-
-      if (response && response.ok && callback) {
-        callback(response);
-
-        return;
-      }
-
-      if (failure) {
-        failure();
-      }
-    });
-
-    this.on('create-directory', async (fullPath, directoryName, path) => {
-      await this.dav.mkcol(fullPath, directoryName, path);
-    });
-
-    this.on('mkcol:success', (fullPath, directoryName) => {
-      new Melba({
-        content: i18next.t('successfullyCreated', {
-          interpolation: {
-            escapeValue: false,
-          },
-          directoryName,
-        }),
-        type: 'success',
-        hide: 5,
-      });
-    });
-
-    this.on(
-      'go',
-      async (
-        path = location.pathname,
-        {
-          bypassCache = false,
-          bypassPushState = false,
-          failure = null,
-          success = null,
-        } = {}
-      ) => {
-        const prevPath = location.pathname;
-
-        this.trigger('list:update:request', path);
-
-        // TODO: store the collection to allow manipulation
-        const collection = await this.dav.list(path, bypassCache);
-
-        if (!collection) {
-          this.trigger('list:update:failed');
-
-          if (failure) {
-            failure();
-          }
-
-          return;
-        }
-
-        this.trigger('list:update:success', collection);
-
-        if (!bypassPushState) {
-          updatePath(path);
-        }
-
-        updateTitle(`${decodeURIComponent(path)} | WebDAV`);
-
-        if (success) {
-          success(collection);
-        }
-      }
-    );
-
-    this.on('preview:opened', (entry) => {
-      document.body.classList.add('preview-open');
-      this.container
-        .querySelector(`[data-full-path="${entry.fullPath}"]`)
-        ?.focus();
-
-      updatePath(entry.fullPath);
-      updateTitle(`${decodeURIComponent(entry.fullPath)} | WebDAV`);
-    });
-
-    this.on('preview:closed', (entry, { preview = false } = {}) => {
-      if (preview) {
-        return;
-      }
-
-      const path = trailingSlash(entry.path);
-
-      document.body.classList.remove('preview-open');
-
-      updatePath(path);
-      updateTitle(`${decodeURIComponent(path)} | WebDAV`);
-    });
-  }
-}

+ 0 - 14
src/lib/UI/NativeDOM/Container.ts

@@ -1,14 +0,0 @@
-import Element from './Element';
-import List from './List';
-
-export default class Container extends Element {
-  constructor() {
-    const template = '<main></main>';
-
-    super(template);
-
-    const list = new List();
-
-    this.element.appendChild(list.element);
-  }
-}

+ 0 - 39
src/lib/UI/NativeDOM/Element.ts

@@ -1,39 +0,0 @@
-import EventObject from '../../EventObject';
-
-export default class Element extends EventObject {
-  #element;
-
-  constructor(template = null) {
-    super();
-
-    if (template !== null) {
-      this.#element = this.createNodeFromString(template);
-    }
-  }
-
-  get element() {
-    return this.#element;
-  }
-
-  createNodesFromString(html) {
-    const container = document.createElement('div'),
-      fragment = document.createDocumentFragment();
-    container.innerHTML = html;
-
-    for (const childNode of container.childNodes) {
-      fragment.appendChild(childNode);
-    }
-
-    return fragment;
-  }
-
-  createNodeFromString(html) {
-    return this.createNodesFromString(html).firstChild;
-  }
-
-  emptyNode() {
-    while (this.element.firstChild) {
-      this.element.removeChild(this.element.firstChild);
-    }
-  }
-}

+ 0 - 53
src/lib/UI/NativeDOM/Footer.ts

@@ -1,53 +0,0 @@
-import Element from './Element';
-import i18next from 'i18next';
-import joinPath from '../../joinPath';
-import trailingSlash from '../../trailingSlash';
-
-export default class Footer extends Element {
-  constructor() {
-    const template = `<footer class="upload">
-  <span class="droppable">${i18next.t(
-    'dropFilesAnywhereToUpload'
-  )}</span> ${i18next.t('or')}
-  <span class="files">${i18next.t(
-    'uploadFiles'
-  )} <input type="file" multiple></span> ${i18next.t('or')}
-  <a href="#" class="create-directory">${i18next.t('createNewDirectory')}</a>
-</footer>`;
-
-    super(template);
-
-    this.bindEvents();
-  }
-
-  bindEvents(element = this.element) {
-    element
-      .querySelector('input[type="file"]')
-      .addEventListener('change', async (event) => {
-        for (const file of event.target.files) {
-          this.trigger('upload', location.pathname, file);
-        }
-
-        element.value = null;
-      });
-
-    element
-      .querySelector('.create-directory')
-      .addEventListener('click', async (event) => {
-        event.preventDefault();
-
-        const directoryName = prompt('', i18next.t('directoryName'));
-
-        if (!directoryName) {
-          return;
-        }
-
-        this.trigger(
-          'create-directory',
-          trailingSlash(joinPath(location.pathname, directoryName)),
-          directoryName,
-          location.pathname
-        );
-      });
-  }
-}

+ 0 - 123
src/lib/UI/NativeDOM/List.ts

@@ -1,123 +0,0 @@
-import Element from './Element';
-import Item from './List/Item';
-import supportsFocusWithin from '../supportsFocusWithin';
-
-export default class List extends Element {
-  #collection;
-  #items;
-
-  constructor() {
-    super('<ul class="loading"></ul>');
-
-    this.bindEvents();
-  }
-
-  bindEvents() {
-    this.on('list:update:request', () => this.loading());
-    this.on('list:update:success', (collection) => this.update(collection));
-    this.on('list:update:failed', () => this.loading(false));
-
-    this.on('collection:update', (collection) => {
-      if (collection === this.#collection) {
-        this.update();
-      }
-    });
-
-    this.on('entry:update', (entry) => {
-      if (entry.collection === this.#collection) {
-        this.update();
-      }
-    });
-
-    const arrowHandler = (event) => {
-      if (!['ArrowUp', 'ArrowDown'].includes(event.key)) {
-        return;
-      }
-
-      event.preventDefault();
-      event.stopPropagation();
-
-      const current = this.element.querySelector(
-          `li:focus${supportsFocusWithin ? ', li:focus-within' : ''}`
-        ),
-        isPreview = document.body.classList.contains('preview-open'),
-        previewItems = [
-          ...this.element.querySelectorAll(
-            'li:not(.directory):not([data-type="unknown"])'
-          ),
-        ],
-        currentItemIndex = previewItems.indexOf(current),
-        next = isPreview
-          ? currentItemIndex > -1
-            ? previewItems.slice(currentItemIndex + 1).shift()
-            : null
-          : current
-          ? current.nextElementSibling
-          : this.element.querySelector('li:first-child'),
-        previous = isPreview
-          ? currentItemIndex > -1
-            ? previewItems.slice(0, currentItemIndex).pop()
-            : null
-          : current
-          ? current.previousElementSibling
-          : null;
-
-      if (event.key === 'ArrowUp' && previous) {
-        previous.focus();
-
-        if (isPreview) {
-          this.element.dispatchEvent(
-            new CustomEvent('preview:close', {
-              bubbles: true,
-              detail: {
-                preview: true,
-              },
-            })
-          );
-
-          previous.dispatchEvent(new CustomEvent('click'));
-        }
-      } else if (event.key === 'ArrowDown' && next) {
-        next.focus();
-
-        if (isPreview) {
-          this.element.dispatchEvent(
-            new CustomEvent('preview:close', {
-              bubbles: true,
-              detail: {
-                preview: true,
-              },
-            })
-          );
-
-          next.dispatchEvent(new CustomEvent('click'));
-        }
-      }
-    };
-
-    document.addEventListener('keydown', arrowHandler);
-    this.element.addEventListener('keydown', arrowHandler);
-  }
-
-  loading(loading = true) {
-    if (loading) {
-      return this.element.classList.add('loading');
-    }
-
-    this.element.classList.remove('loading');
-  }
-
-  update(collection = this.#collection) {
-    this.emptyNode();
-
-    this.#items = collection.map((entry) => new Item(entry));
-
-    [...this.#items.map((item) => item.element)].forEach((element) =>
-      this.element.appendChild(element)
-    );
-
-    this.loading(false);
-
-    this.#collection = collection;
-  }
-}

+ 0 - 342
src/lib/UI/NativeDOM/List/Item.ts

@@ -1,342 +0,0 @@
-import * as BasicLightbox from 'basiclightbox';
-import Element from '../Element';
-import Prism from 'prismjs';
-import i18next from 'i18next';
-import joinPath from '../../../joinPath';
-
-export default class Item extends Element {
-  #base64Encoder;
-  #entry;
-  #templates = Object.freeze({
-    video: (entry) =>
-      `<video autoplay controls><source src="${entry.fullPath}"/></video>`,
-    audio: (entry) =>
-      `<audio autoplay controls><source src="${entry.fullPath}"/></audio>`,
-    image: (entry) => `<img alt="${entry.title}" src="${entry.fullPath}"/>`,
-    font: (entry) => {
-      const formats = {
-          eot: 'embedded-opentype',
-          otf: 'opentype',
-          ttf: 'truetype',
-        },
-        extension = entry.name.replace(/^.+\.([^.]+)$/, '$1').toLowerCase(),
-        fontName = entry.fullPath.replace(/\W+/g, '_'),
-        demoText = `${i18next.t('pangram')} 0123456789<br/>
-        ${i18next.t('alphabet')}`;
-      return `<style type="text/css">@font-face{font-family:"${fontName}";src:url("${
-        entry.fullPath
-      }") format("${formats[extension] || extension}")}</style>
-<h1 style="font-family:'${fontName}'">${entry.title}</h1>
-<p style="font-family:'${fontName}';font-size:1.5em">${demoText}</p>
-<p style="font-family:'${fontName}'">${demoText}</p>
-<p style="font-family:'${fontName}'"><strong>${demoText}</strong></p>
-<p style="font-family:'${fontName}'"><em>${demoText}</em></p>`;
-    },
-    text: (entry, content) =>
-      `<pre><code class="language-${entry.extension}">${content.replace(
-        /[<>]/g,
-        (c) => ({ '<': '&lt;', '>': '&gt;' }[c])
-      )}</code></pre>`,
-    pdf: (entry) =>
-      `<iframe src="${entry.fullPath}" frameborder="0" border="0" height="100%" width="100%"></iframe>`,
-  });
-
-  constructor(entry, base64Encoder = btoa) {
-    super(`<li tabindex="0" data-full-path="${entry.fullPath}" data-type="${
-      entry.type
-    }">
-  <span class="title">${entry.title}</span>
-  <input type="text" name="rename" class="hidden" readonly>
-  <span class="size">${entry.displaySize}</span>
-  <a href="#" title="${i18next.t('delete')} (␡)" class="delete"></a>
-  <!--<a href="#" title="Move" class="move"></a>-->
-  <a href="#" title="${i18next.t('rename')} (F2)" class="rename"></a>
-  <!--<a href="#" title="Copy" class="copy"></a>-->
-  <a href="${entry.fullPath}" download="${entry.name}" title="${i18next.t(
-      'download'
-    )} (⇧+⏎)"></a>
-</li>`);
-
-    this.#base64Encoder = base64Encoder;
-    this.#entry = entry;
-
-    this.element.classList.add(
-      ...[
-        entry.directory ? 'directory' : 'file',
-        entry.type ? entry.type : 'unknown',
-      ]
-    );
-
-    if (entry.placeholder) {
-      this.element.classList.add('loading');
-    }
-
-    if (!entry.del) {
-      this.element.querySelector('.delete').setAttribute('hidden', '');
-    }
-
-    if (!entry.rename) {
-      this.element.querySelector('.rename').setAttribute('hidden', '');
-    }
-
-    this.bindEvents();
-  }
-
-  bindEvents(element = this.element) {
-    this.on('entry:update', (entry) => {
-      if (entry === this.#entry) {
-        this.update();
-      }
-    });
-
-    this.on('move:failed', (sourcePath) => {
-      if (sourcePath === this.#entry.fullPath) {
-        this.loading(false);
-      }
-    });
-
-    this.on('delete:failed', (sourcePath) => {
-      if (sourcePath === this.#entry.fullPath) {
-        this.loading(false);
-      }
-    });
-
-    element.addEventListener('click', () => this.open());
-
-    element
-      .querySelector('[download]')
-      .addEventListener('click', (event) => event.stopPropagation());
-
-    element.querySelector('.delete').addEventListener('click', (event) => {
-      event.preventDefault();
-      event.stopPropagation();
-
-      this.del();
-    });
-
-    element.querySelector('.rename').addEventListener('click', (event) => {
-      event.stopPropagation();
-      event.preventDefault();
-
-      this.rename();
-    });
-
-    element.addEventListener('keydown', (event) => {
-      if (['F2', 'Delete', 'Enter'].includes(event.key)) {
-        event.preventDefault();
-
-        if (event.key === 'F2' && this.#entry.rename) {
-          this.rename();
-        } else if (event.key === 'Delete' && this.#entry.del) {
-          this.del();
-        } else if (event.key === 'Enter' && !this.#entry.directory) {
-          if (event.shiftKey) {
-            return this.download();
-          }
-
-          this.open();
-        }
-      }
-    });
-  }
-
-  del() {
-    const entry = this.#entry;
-
-    if (!entry.del) {
-      throw new TypeError(`'${entry.name}' is read only.`);
-    }
-
-    this.loading();
-
-    if (
-      !confirm(
-        i18next.t('deleteConfirm', {
-          file: entry.title,
-        })
-      )
-    ) {
-      return this.loading(false);
-    }
-
-    this.trigger('delete', entry.fullPath, entry);
-  }
-
-  download() {
-    if (this.#entry.directory) {
-      return;
-    }
-
-    this.element
-      .querySelector('[download]')
-      .dispatchEvent(new CustomEvent('click'));
-  }
-
-  loading(loading = true) {
-    if (loading) {
-      return this.element.classList.add('loading');
-    }
-
-    this.element.classList.remove('loading');
-  }
-
-  open() {
-    const entry = this.#entry;
-
-    this.loading();
-
-    if (entry.directory) {
-      return this.trigger('go', entry.fullPath, {
-        failure: () => this.loading(false),
-      });
-    }
-
-    const launchLightbox = (lightboxContent, onShow = null) => {
-      const close = () => lightbox.close(),
-        escapeListener = (event) => {
-          if (event.key === 'Escape') {
-            close();
-          }
-        },
-        lightbox = BasicLightbox.create(lightboxContent, {
-          className: entry.type,
-          onShow: () => {
-            this.loading(false);
-
-            document.addEventListener('keydown', escapeListener);
-            document.addEventListener('preview:close', (event: CustomEvent) => {
-              lightbox.preview = event.detail?.preview;
-
-              close();
-            });
-
-            if (onShow) {
-              onShow(lightbox);
-            }
-          },
-          onClose: () => {
-            document.removeEventListener('keydown', escapeListener);
-            document.removeEventListener('preview:close', close);
-
-            this.trigger('preview:closed', this.#entry, {
-              preview: lightbox.preview,
-            });
-          },
-        });
-      lightbox.show();
-
-      this.trigger('preview:opened', this.#entry);
-    };
-
-    if (['video', 'audio', 'image', 'font', 'pdf'].includes(entry.type)) {
-      this.trigger(
-        'check',
-        entry.fullPath,
-        () => {
-          launchLightbox(this.#templates[entry.type](entry));
-        },
-        () => this.loading(false)
-      );
-    } else {
-      this.trigger('get', entry.fullPath, (content) => {
-        if (!content) {
-          return this.loading(false);
-        }
-
-        if (entry.type !== 'text') {
-          return this.download();
-        }
-
-        launchLightbox(this.#templates.text(entry, content), (lightbox) =>
-          Prism.highlightAllUnder(lightbox.element())
-        );
-      });
-
-      this.loading(false);
-    }
-
-    event.preventDefault();
-  }
-
-  rename() {
-    const entry = this.#entry;
-
-    if (!entry.rename) {
-      throw new TypeError(`'${entry.name}' cannot be renamed.`);
-    }
-
-    const node = this.element,
-      title = node.querySelector('.title'),
-      input = node.querySelector('input'),
-      setInputSize = () => {
-        title.innerText = input.value;
-        input.style.setProperty('width', `${title.scrollWidth}px`);
-      },
-      save = () => {
-        // don't process if there's no name change
-        if (input.value !== entry.title) {
-          this.loading();
-
-          unbindListeners();
-
-          return this.trigger(
-            'move',
-            entry.fullPath,
-            joinPath(entry.path, input.value),
-            entry
-          );
-        }
-
-        revert();
-      },
-      unbindListeners = () => {
-        input.removeEventListener('blur', blurListener);
-        input.removeEventListener('keydown', keyDownListener);
-        input.removeEventListener('input', inputListener);
-      },
-      revert = () => {
-        title.classList.remove('invisible');
-        input.classList.add('hidden');
-        input.value = entry.title;
-        setInputSize();
-        unbindListeners();
-
-        return node.focus();
-      },
-      blurListener = () => {
-        save();
-      },
-      keyDownListener = (event) => {
-        if (event.key === 'Enter') {
-          event.stopPropagation();
-          event.preventDefault();
-
-          save();
-        } else if (event.key === 'Escape') {
-          revert();
-        }
-      },
-      inputListener = () => {
-        return setInputSize();
-      };
-    title.classList.add('invisible');
-
-    input.classList.remove('hidden');
-    input.value = entry.title;
-    setInputSize();
-    input.removeAttribute('readonly');
-    input.addEventListener('blur', blurListener);
-    input.addEventListener('keydown', keyDownListener);
-    input.addEventListener('input', inputListener);
-    input.focus();
-  }
-
-  update() {
-    if (
-      this.#entry.placeholder &&
-      this.element.classList.contains('placeholder')
-    ) {
-      this.element.classList.remove('placeholder');
-    }
-  }
-}

+ 0 - 69
src/lib/UI/UI.ts

@@ -1,69 +0,0 @@
-import DAV from '../DAV';
-import EventObject from '../EventObject';
-import LanguageDetector from 'i18next-browser-languagedetector';
-import Unimplemented from '../Unimplemented';
-import de from '../../../translations/de.json';
-import en from '../../../translations/en.json';
-import i18next from 'i18next';
-import pt from '../../../translations/pt.json';
-
-type UIOptions = {
-  bypassCheck?: boolean;
-  sortDirectoriesFirst?: boolean;
-};
-
-export default class UI extends EventObject {
-  #container;
-  #dav;
-  #options;
-
-  constructor(
-    container,
-    options: UIOptions = {},
-    dav = new DAV({
-      bypassCheck: options.bypassCheck ?? false,
-      sortDirectoriesFirst: options.sortDirectoriesFirst ?? false,
-    })
-  ) {
-    super();
-
-    if (!(container instanceof HTMLElement)) {
-      throw new TypeError(`Invalid container element: '${container}'.`);
-    }
-
-    this.#container = container;
-    this.#dav = dav;
-    this.#options = options;
-
-    i18next.use(LanguageDetector).init({
-      detection: {
-        caches: [],
-      },
-      fallbackLng: 'en',
-      resources: {
-        de,
-        en,
-        pt,
-      },
-    });
-  }
-
-  get options() {
-    // return a clone so these cannot be changed
-    return {
-      ...this.#options,
-    };
-  }
-
-  get dav(): DAV {
-    return this.#dav;
-  }
-
-  get container() {
-    return this.#container;
-  }
-
-  render() {
-    throw new Unimplemented("'render' must be implemented in the child class.");
-  }
-}

+ 0 - 1
src/lib/Unimplemented.ts

@@ -1 +0,0 @@
-export default class Unimplemented extends Error {}

+ 75 - 0
src/lib/handleFileUpload.ts

@@ -0,0 +1,75 @@
+import DAV from './DAV';
+import Entry from './Entry';
+import State from './State';
+import joinPath from './joinPath';
+import { success } from 'melba-toast';
+import { t } from 'i18next';
+
+export const handleFileUpload = async (
+  dav: DAV,
+  state: State,
+  file: File
+): Promise<void> => {
+  const collection = await dav.list(state.getPath(), true);
+
+  if (!collection) {
+    return;
+  }
+
+  state.setCollection(collection);
+
+  const [existingFile] = collection.filter(
+    (entry: Entry): boolean => entry.name === file.name
+  );
+
+  if (existingFile) {
+    // TODO: nicer notification
+    if (
+      !confirm(
+        t('overwriteFileConfirmation', {
+          file: existingFile.title,
+        })
+      )
+    ) {
+      return;
+    }
+
+    collection.remove(existingFile);
+  }
+
+  const placeholder = new Entry({
+    fullPath: joinPath(state.getPath(), file.name),
+    modified: Date.now(),
+    size: file.size,
+    mimeType: file.type,
+    placeholder: true,
+    collection,
+  });
+
+  collection.add(placeholder);
+
+  const result = await dav.upload(location.pathname, file);
+
+  if (!result) {
+    collection.remove(placeholder);
+
+    state.update();
+
+    return;
+  }
+
+  placeholder.placeholder = false;
+
+  state.update();
+
+  success(
+    t('successfullyUploaded', {
+      interpolation: {
+        escapeValue: false,
+      },
+      file: file.name,
+    })
+  );
+};
+
+export default handleFileUpload;

+ 3 - 2
src/lib/joinPath.ts

@@ -1,6 +1,7 @@
-export const trimSlashes = (piece: string) => piece.replace(/^\/+|\/+$/g, '');
+export const trimSlashes = (piece: string): string =>
+  piece.replace(/^\/+|\/+$/g, '');
 
-export const joinPath = (...pieces: string[]) =>
+export const joinPath = (...pieces: string[]): string =>
   `/${pieces
     .map(trimSlashes)
     .filter((piece) => piece)

+ 32 - 0
src/lib/previewItems.ts

@@ -0,0 +1,32 @@
+export const previewItems = (
+  currentItem: HTMLElement,
+  selector: string = 'li'
+): [HTMLElement | null, HTMLElement | null] => {
+  if (!currentItem) {
+    return [null, null];
+  }
+
+  const list = currentItem.parentElement as HTMLElement;
+
+  if (!list) {
+    return [null, null];
+  }
+
+  const previewItems = Array.from(list.querySelectorAll(selector)),
+    currentIndex = previewItems.indexOf(currentItem);
+
+  if (currentIndex === -1) {
+    return [null, null];
+  }
+
+  const previous =
+      currentIndex > 0 ? (previewItems[currentIndex - 1] as HTMLElement) : null,
+    next =
+      currentIndex < previewItems.length - 1
+        ? (previewItems[currentIndex + 1] as HTMLElement)
+        : null;
+
+  return [previous, next];
+};
+
+export default previewItems;

+ 16 - 0
src/lib/supportsEvent.ts

@@ -0,0 +1,16 @@
+const testElement = document.createElement('span');
+
+export const supportsEvents = (...eventNames: string[]): boolean =>
+  eventNames.every((eventName) => supportsEvent(eventName));
+
+export const supportsEvent = (eventName: string): boolean => {
+  const attributeName = `on${eventName}`;
+
+  if (!testElement.hasAttribute(attributeName)) {
+    testElement.setAttribute(attributeName, '');
+  }
+
+  return typeof testElement[`on${eventName}`] === 'function';
+};
+
+export default supportsEvent;

+ 3 - 1
src/lib/UI/supportsFocusWithin.ts → src/lib/supportsFocusWithin.ts

@@ -1,4 +1,4 @@
-export default (() => {
+export const supportsFocusWithin: boolean = (() => {
   try {
     document.querySelector(':focus-within');
 
@@ -7,3 +7,5 @@ export default (() => {
     return false;
   }
 })();
+
+export default supportsFocusWithin;

+ 1 - 1
src/lib/trailingSlash.ts

@@ -1,4 +1,4 @@
-export const trailingSlash = (text: string) =>
+export const trailingSlash = (text: string): string =>
   text.endsWith('/') ? text : `${text}/`;
 
 export default trailingSlash;

+ 4 - 0
assets/scss/style.scss → src/style.scss

@@ -1,3 +1,7 @@
+@use '../node_modules/basiclightbox/src/styles/main';
+@use '../node_modules/prismjs/themes/prism.css';
+@use '../node_modules/melba-toast/dist/Melba.css';
+
 // Mini reset
 html {
   font-size: 16px;

文件差異過大導致無法顯示
+ 0 - 0
src/webdav-min.js


文件差異過大導致無法顯示
+ 2 - 0
src/webdav.js.map


+ 42 - 17
src/webdav.ts

@@ -1,19 +1,44 @@
-import 'basiclightbox/src/styles/main.scss';
-import 'prismjs/themes/prism.css';
-import 'melba-toast/dist/Melba.css';
-import '../assets/scss/style.scss';
-import 'whatwg-fetch'; // IE11 compatibility
-import NativeDOM from './lib/UI/NativeDOM';
+import './style.scss';
 
-const ui = new NativeDOM(document.body, {
-  bypassCheck: !!document.querySelector('[data-disable-check]'),
-  sortDirectoriesFirst: !!document.querySelector(
-    '[data-sort-directories-first]'
-  ),
-});
+import Container from './components/Container';
+import DAV from './lib/DAV';
+import Footer from './components/Footer';
+import Header from './components/Header';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import List from './components/List';
+import State from './lib/State';
+import UI from './components/UI';
+import de from '../translations/de.json';
+import en from '../translations/en.json';
+import pt from '../translations/pt.json';
+import { use } from 'i18next';
 
-if (document.readyState === 'loading') {
-  document.addEventListener('DOMContentLoaded', () => ui.render());
-} else {
-  ui.render();
-}
+use(LanguageDetector)
+  .init({
+    detection: {
+      caches: [],
+    },
+    fallbackLng: 'en',
+    resources: {
+      de,
+      en,
+      pt,
+    },
+  })
+  .then((): void => {
+    const state = new State(document, window),
+      dav = new DAV({
+        bypassCheck: !!document.querySelector('[data-disable-check]'),
+        sortDirectoriesFirst: !!document.querySelector(
+          '[data-sort-directories-first]'
+        ),
+      }),
+      container = new Container(),
+      header = new Header(state),
+      list = new List(dav, state),
+      footer = new Footer(dav, state),
+      ui = new UI(document.body, dav, state);
+
+    container.append(header, list);
+    ui.append(container, footer);
+  });

+ 60 - 6
tests/functional/List.test.ts

@@ -1,3 +1,4 @@
+import { ConsoleMessage, ElementHandle } from 'puppeteer';
 import {
   isLightboxClosed,
   isPageReady,
@@ -6,12 +7,55 @@ import {
   isElementGone,
   isElementThere,
 } from '../lib/isReady';
-import { ElementHandle } from 'puppeteer';
+import trailingSlash from '../../src/lib/trailingSlash';
 import * as fs from 'fs';
 
 const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080/',
   DESTINATION_FONT_FILE = '/tmp/BlackAndWhitePicture-Regular.ttf';
 
+if (process.argv.some((flag) => flag.match(/-debug/))) {
+  page.on('console', (message: ConsoleMessage): void => {
+    if (message.type() === 'error') {
+      console.error(message.text());
+
+      const trace = message.stackTrace();
+
+      if (trace.length) {
+        console.error(
+          [
+            `Stack trace:`,
+            ...trace.map(
+              (message): string =>
+                message.url +
+                ' (' +
+                message.lineNumber +
+                ':' +
+                message.columnNumber +
+                ')'
+            ),
+          ].join('\n')
+        );
+      }
+
+      return;
+    }
+
+    if (message.type() === 'warning') {
+      console.warn(message.text());
+
+      return;
+    }
+
+    if (message.type() === 'info') {
+      console.info(message.text());
+
+      return;
+    }
+
+    console.log(message.text());
+  });
+}
+
 describe('WebDAV.js', () => {
   describe('List', () => {
     it('should be possible to preview items', async () => {
@@ -148,7 +192,9 @@ describe('WebDAV.js', () => {
 
       await expectToastShown(
         page,
-        'HEAD /inaccessible-dir/ failed: Forbidden (403)',
+        `HEAD ${trailingSlash(
+          BASE_URL
+        )}inaccessible-dir/ failed: Forbidden (403)`,
         'error'
       );
 
@@ -156,7 +202,9 @@ describe('WebDAV.js', () => {
 
       await expectToastShown(
         page,
-        'GET /inaccessible-file failed: Forbidden (403)',
+        `HEAD ${trailingSlash(
+          BASE_URL
+        )}inaccessible-file failed: Forbidden (403)`,
         'error'
       );
 
@@ -164,7 +212,9 @@ describe('WebDAV.js', () => {
 
       await expectToastShown(
         page,
-        'HEAD /inaccessible-image.jpg failed: Forbidden (403)',
+        `HEAD ${trailingSlash(
+          BASE_URL
+        )}inaccessible-image.jpg failed: Forbidden (403)`,
         'error'
       );
 
@@ -172,7 +222,9 @@ describe('WebDAV.js', () => {
 
       await expectToastShown(
         page,
-        'GET /inaccessible-text-file.txt failed: Forbidden (403)',
+        `HEAD ${trailingSlash(
+          BASE_URL
+        )}inaccessible-text-file.txt failed: Forbidden (403)`,
         'error'
       );
     });
@@ -221,7 +273,9 @@ describe('WebDAV.js', () => {
 
       await page.click('[data-full-path="/package.json"]');
 
-      await page.once('dialog', async (dialog) => await dialog.accept());
+      await page.once('dialog', async (dialog) => {
+        await dialog.accept();
+      });
 
       await page.click('[data-full-path="/package.json"] .delete');
 

+ 3 - 1
tests/lib/getProperties.ts

@@ -7,7 +7,9 @@ export const getRawProperty = async <
   element: Promise<ElementHandle<T>> | ElementHandle<T>,
   property: K
 ): Promise<T[K]> =>
-  await (await (await element)?.getProperty(property as string))?.jsonValue();
+  (await (
+    await (await element)?.getProperty(property as string)
+  )?.jsonValue()) as T[K];
 
 export const getRawProperties = async <
   T extends HTMLElement = HTMLElement,

+ 27 - 11
tests/lib/isReady.ts

@@ -6,17 +6,29 @@ export const isPageReady = async (page: Page, url: string) => {
   await isElementThere(page, 'main ul li');
 };
 
-export const isElementThere = async (page: Page, selector: string) =>
+export const isElementThere = async (
+  page: Page,
+  selector: string,
+  timeout: number = 4000
+) =>
   await page.waitForFunction(
     (selector) => !!document.querySelector(selector),
-    {},
+    {
+      timeout,
+    },
     selector
   );
 
-export const isElementGone = async (page: Page, selector: string) =>
+export const isElementGone = async (
+  page: Page,
+  selector: string,
+  timeout: number = 4000
+) =>
   await page.waitForFunction(
     (selector) => !document.querySelector(selector),
-    {},
+    {
+      timeout,
+    },
     selector
   );
 
@@ -45,22 +57,26 @@ export const expectToastShown = async (
   text: string,
   type: string
 ) => {
-  await page.waitForTimeout(100);
+  let i = 0;
+  await isElementThere(page, '.toast__container .toast');
 
   const toast = await page.$('.toast__container .toast');
 
-  expect(toast).not.toBeNull();
+  await expect(toast).not.toBeNull();
 
-  await expect(
-    await toast.evaluate((toast) => toast.childNodes[1].textContent)
-  ).toEqual(text);
+  const toastText = await toast.evaluate(
+    (toast: HTMLElement) => toast.childNodes[1].textContent
+  );
+
+  await expect(toastText).toEqual(text);
 
   await expect(
     await toast.evaluate(
-      (toast, type: string) => toast.classList.contains(`toast--${type}`),
+      (toast: HTMLElement, type: string) =>
+        toast.classList.contains(`toast--${type}`),
       type
     )
   ).toBeTruthy();
 
-  await toast.evaluate((toast) => toast.remove());
+  await toast.evaluate((toast: HTMLElement) => toast.remove());
 };

+ 24 - 25
tests/unit/DAV.test.ts

@@ -1,13 +1,17 @@
-import Collection from '../../src/lib/DAV/Collection';
-import DAV from '../../src/lib/DAV';
+/**
+ * @jest-environment jsdom
+ */
+import Collection from '../../src/lib/Collection';
+import DAV, { RequestCache } from '../../src/lib/DAV';
 import { DOMParser } from '@xmldom/xmldom';
+import Entry from '../../src/lib/Entry';
 import HTTP from '../../src/lib/HTTP';
 
 describe('DAV', () => {
   const getSpies = (
     SpyHTTPReturns = {},
     SpyCacheReturns = {}
-  ): [HTTP, Map<string, Collection>] => {
+  ): [HTTP, RequestCache, Map<string, string | Collection>] => {
     const SpyHTTP = new HTTP(),
       SpyCache = new Map();
 
@@ -37,7 +41,12 @@ describe('DAV', () => {
         ))
     );
 
-    return [SpyHTTP, SpyCache];
+    const cache = new Map();
+
+    cache.set('GET', SpyCache);
+    cache.set('PROPFIND', SpyCache);
+
+    return [SpyHTTP, cache, SpyCache];
   };
 
   if (typeof window === 'undefined') {
@@ -90,7 +99,7 @@ describe('DAV', () => {
   });
 
   it('should fire a PROPFIND request and store cache on list', async () => {
-    const [SpyHTTP, SpyCache] = getSpies(
+    const [SpyHTTP, cache, SpyCache] = getSpies(
         {
           HEAD: {
             ok: true,
@@ -104,10 +113,10 @@ describe('DAV', () => {
           get: false,
         }
       ),
-      dav = new DAV({}, SpyCache, SpyHTTP),
+      dav = new DAV({}, cache, SpyHTTP),
       collection = await dav.list('/checkPropfindRequest');
 
-    expect(SpyCache.get).toHaveBeenCalledWith('/checkPropfindRequest/');
+    expect(SpyCache.has).toHaveBeenCalledWith('/checkPropfindRequest/');
     expect(SpyHTTP.HEAD).toHaveBeenCalledWith('/checkPropfindRequest/');
     expect(SpyHTTP.PROPFIND).toHaveBeenCalledWith('/checkPropfindRequest/');
     expect(collection).toBeInstanceOf(Collection);
@@ -130,7 +139,14 @@ describe('DAV', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
 
-    dav.move('/moveSource', '/moveDestination');
+    dav.move(
+      '/moveSource',
+      '/moveDestination',
+      new Entry({
+        fullPath: '/moveSource',
+        directory: false,
+      })
+    );
 
     expect(SpyHTTP.MOVE).toHaveBeenCalledWith('/moveSource', {
       headers: {
@@ -141,23 +157,6 @@ describe('DAV', () => {
     });
   });
 
-  test.todo('should fire a PUT request on upload - functional only');
-  // it('should fire a PUT request on upload', () => {
-  //   const [SpyHTTP, SpyCache] = getSpies(),
-  //     dav = new DAV({}, SpyCache, SpyHTTP),
-  //     file = new File([''], 'uploadTest', {
-  //       type: 'text/plain',
-  //     });
-  //
-  //   dav.upload('/path/', file);
-  //   expect(SpyHTTP.PUT).toHaveBeenCalledWith('/path/uploadTest', {
-  //     headers: {
-  //       'Content-Type': file.type,
-  //     },
-  //     body: file,
-  //   });
-  // });
-
   it('should not fire a HEAD request on list when `bypassCheck` is set', async () => {
     const [SpyHTTP, SpyCache] = getSpies(
         {

+ 21 - 20
tests/unit/DAV/Collection.test.ts

@@ -1,16 +1,16 @@
-import Entry from './../../../src/lib/DAV/Entry';
-import Collection from './../../../src/lib/DAV/Collection';
+import Entry from '../../../src/lib/Entry';
+import Collection from '../../../src/lib/Collection';
 
 describe('Collection', () => {
   const directory = {
       directory: true,
       fullPath: '/path/to/',
-      modified: new Date(),
+      modified: Date.now(),
     },
     file1 = {
       directory: false,
       fullPath: '/path/to/file2.txt',
-      modified: new Date(),
+      modified: Date.now(),
       size: 54321,
       mimeType: 'text/plain',
     },
@@ -20,7 +20,7 @@ describe('Collection', () => {
       {
         directory: false,
         fullPath: '/path/to/file1.txt',
-        modified: new Date(),
+        modified: Date.now(),
         size: 12345,
         mimeType: 'text/plain',
       },
@@ -28,7 +28,7 @@ describe('Collection', () => {
     collection = new Collection(entries),
     collectionEntries = collection.map((entry) => entry);
   it('should the expected path from the first entry', () => {
-    expect(collection.path).toBe('/path/to');
+    expect(collection.path()).toBe('/path/to');
   });
 
   it('should create a new parent entry from the original first item', () => {
@@ -59,26 +59,27 @@ describe('Collection', () => {
   });
 
   it('should be sort the entries alphabetically after adding a new Entry', () => {
-    collection
-      .add(
-        new Entry({
-          ...file1,
-          fullPath: '/path/to/file0.txt',
-        })
-      )
-      .filter((entry, i) => {
-        if (entry.name === 'file0.txt') {
-          expect(i).toBe(1);
-        }
-      });
+    collection.add(
+      new Entry({
+        ...file1,
+        fullPath: '/path/to/file0.txt',
+      })
+    );
+
+    collection.forEach((entry, i) => {
+      if (entry.name === 'file0.txt') {
+        expect(i).toBe(1);
+      }
+    });
   });
 
   it('should be possible to remove an Entry', () => {
     const [file0] = collection.filter((entry) => entry.name === 'file0.txt');
 
+    collection.remove(file0);
+
     expect(
-      collection.remove(file0).filter((entry) => entry.name === 'file0.txt')
-        .length
+      collection.filter((entry) => entry.name === 'file0.txt').length
     ).toBe(0);
   });
 });

+ 6 - 5
tests/unit/DAV/Entry.test.ts

@@ -1,20 +1,21 @@
-import Entry from './../../../src/lib/DAV/Entry';
+import Entry from '../../../src/lib/Entry';
+import trailingSlash from '../../../src/lib/trailingSlash';
 
 describe('Entry', () => {
   const directory = new Entry({
       directory: true,
       fullPath: '/path/to/',
-      modified: new Date(),
+      modified: Date.now(),
     }),
     file = new Entry({
       fullPath: '/path/to/file.txt',
-      modified: new Date(),
+      modified: Date.now(),
       size: 54321,
       mimeType: 'text/plain',
     }),
     atFile = new Entry({
       fullPath: '/%40',
-      modified: new Date(),
+      modified: Date.now(),
       size: 54321,
       mimeType: 'text/plain',
     });
@@ -39,7 +40,7 @@ describe('Entry', () => {
   it('should create the expected parent object', () => {
     const parent = directory.createParentEntry();
 
-    expect(parent.fullPath).toBe(directory.path);
+    expect(parent.fullPath).toBe(trailingSlash(directory.path));
     expect(parent.title).toBe('&larr;');
   });
 

+ 2 - 2
tests/unit/DAV/Response.test.ts

@@ -1,5 +1,5 @@
-import Collection from '../../../src/lib/DAV/Collection';
-import Response from '../../../src/lib/DAV/Response';
+import Collection from '../../../src/lib/Collection';
+import Response from '../../../src/lib/Response';
 
 describe('Response', () => {
   test.todo('No DOMParser implementation in Node');

+ 3 - 2
translations/de.json

@@ -10,12 +10,13 @@
     "rename": "Umbenennen",
     "download": "Herunterladen",
     "deleteConfirmation": "Willst du wirklich die Datei '%s' löschen?",
-    "overwriteFileConfimation": "Die Datei '{{file}}' existiert bereits, willst du sie überschreiben?",
+    "overwriteFileConfirmation": "Die Datei '{{file}}' existiert bereits, willst du sie überschreiben?",
     "failure": "{{method}} {{url}} fehlgeschlagen: {{statusText}} ({{status}})",
     "successfullyUploaded": "'{{file}}' wurde erfolgreich hochgeladen.",
     "successfullyRenamed": "'{{from}}' wurde erfolgreich umbenannt in '{{to}}'.",
     "successfullyMoved": "'{{from}}' wurde erfolgreich verschoben nach '{{to}}'.",
     "successfullyDeleted": "'{{file}}' wurde gelöscht.",
-    "successfullyCreated": "'{{directoryName}}' wurde erstellt."
+    "successfullyCreated": "'{{directoryName}}' wurde erstellt.",
+    "title": "{{path}} | WebDAV"
   }
 }

+ 3 - 2
translations/en.json

@@ -11,12 +11,13 @@
     "rename": "Rename",
     "download": "Download",
     "deleteConfirmation": "Are you sure you want to delete '{{file}}'?",
-    "overwriteFileConfimation": "A file called '{{file}}' already exists, would you like to overwrite it?",
+    "overwriteFileConfirmation": "A file called '{{file}}' already exists, would you like to overwrite it?",
     "failure": "{{method}} {{url}} failed: {{statusText}} ({{status}})",
     "successfullyUploaded": "'{{file}}' has been successfully uploaded.",
     "successfullyRenamed": "'{{from}}' successfully renamed to '{{to}}'.",
     "successfullyMoved": "'{{from}}' successfully moved to '{{to}}'.",
     "successfullyDeleted": "'{{file}}' has been deleted.",
-    "successfullyCreated": "'{{directoryName}}' has been created."
+    "successfullyCreated": "'{{directoryName}}' has been created.",
+    "title": "{{path}} | WebDAV"
   }
 }

+ 3 - 2
translations/pt.json

@@ -10,12 +10,13 @@
     "rename": "Renomear",
     "download": "Descarregar",
     "deleteConfirmation": "Quer mesmo apagar o arquivo \"{{file}}\"?",
-    "overwriteFileConfimation": "O arquivo \"{{file}}\" já existe, você quer sobrescrevê-lo?",
+    "overwriteFileConfirmation": "O arquivo \"{{file}}\" já existe, você quer sobrescrevê-lo?",
     "failure": "{{method}} {{url}} falhou: {{statusText}} ({{status}})",
     "successfullyUploaded": "\"{{file}}\" foi carregada com êxito.",
     "successfullyRenamed": "\"{{from}}\" foi alterado para \"{{to}}\" com êxito.",
     "successfullyMoved": "\"{{from}}\" foi transferida com êxito para \"{{to}}\".",
     "successfullyDeleted": "\"{{file}}\" foi suprimido.",
-    "successfullyCreated": "\"{{directoryName}}\" foi criada."
+    "successfullyCreated": "\"{{directoryName}}\" foi criada.",
+    "title": "{{path}} | WebDAV"
   }
 }

部分文件因文件數量過多而無法顯示