nodejs-fs-explorer-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # nodejs-fs-explorer-ui
2
+
3
+ File Explorer Svelte component for the [NodeJS](https://nodejs.org) [filesystem](https://nodejs.org/docs/latest/api/fs.html) API for using in the browser, i.e. for browser libraries that implement the NodeJS FS API such as [zenfs](https://github.com/zen-fs/) and [memfs](https://github.com/streamich/memfs).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install nodejs-fs-explorer-ui
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```svelte
14
+ <script>
15
+ import FileExplorer from "nodejs-fs-explorer-ui";
16
+ import fs from "@zenfs/core";
17
+ </script>
18
+
19
+ <FileExplorer {fs} />
20
+ ```
21
+
22
+ ## Developing
23
+
24
+ Once you've installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
25
+
26
+ ```sh
27
+ pnpm run dev
28
+ ```
29
+
30
+ Everything inside `src/lib` is part of the library, everything inside `src/routes` can be used as a showcase or preview app.
31
+
32
+ ## Building
33
+
34
+ To build and pack:
35
+
36
+ ```sh
37
+ pnpm pack
38
+ ```
39
+
40
+ To create a production version of the library:
41
+
42
+ ```sh
43
+ pnpm run build
44
+ ```
45
+
46
+ You can preview the production build with `pnpm run preview`.
47
+
48
+ ## Publishing
49
+
50
+ ```sh
51
+ pnpm publish
52
+ ```
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { ArrowLeftIcon, SaveIcon } from "@lucide/svelte";
3
+ import { editorOpened } from "../state.svelte";
4
+ import type { NodeJsFs } from "../types.js";
5
+ import { onMount } from "svelte";
6
+
7
+ let { fs }: { fs: NodeJsFs } = $props();
8
+ let text = $state("");
9
+ let saving = $state(false);
10
+
11
+ onMount(async () => {
12
+ text = await fs.promises.readFile(editorOpened.path!, { encoding: "utf8" });
13
+ });
14
+
15
+ async function save() {
16
+ saving = true;
17
+ try {
18
+ await fs.promises.writeFile(editorOpened.path!, text);
19
+ } catch (err) {
20
+ console.log(err);
21
+ } finally {
22
+ saving = false;
23
+ }
24
+ }
25
+
26
+ function close() {
27
+ editorOpened.path = null;
28
+ }
29
+ </script>
30
+
31
+ <div class="container">
32
+ <section role="toolbar">
33
+ <button onclick={close} title="Close" aria-label="Close">
34
+ <ArrowLeftIcon />
35
+ </button>
36
+ <button disabled={saving} onclick={save}>
37
+ <SaveIcon />
38
+ {#if saving}
39
+ Saving...
40
+ {:else}
41
+ Save
42
+ {/if}
43
+ </button>
44
+ </section>
45
+ <textarea aria-label="File contents" bind:value={text}></textarea>
46
+ </div>
47
+
48
+ <style>
49
+ textarea {
50
+ flex: 1;
51
+ resize: none;
52
+ font-family: "Courier New", Courier, monospace;
53
+ padding: 10px;
54
+ }
55
+
56
+ button {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 5px;
60
+ }
61
+
62
+ .container {
63
+ display: flex;
64
+ flex-direction: column;
65
+ height: 100%;
66
+ gap: 5px;
67
+ }
68
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { NodeJsFs } from "../types.ts";
2
+ type $$ComponentProps = {
3
+ fs: NodeJsFs;
4
+ };
5
+ declare const Editor: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Editor = ReturnType<typeof Editor>;
7
+ export default Editor;
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import {
3
+ DownloadIcon,
4
+ FileIcon,
5
+ FileSymlink,
6
+ FolderIcon,
7
+ MoreVerticalIcon,
8
+ TrashIcon,
9
+ XIcon,
10
+ } from "@lucide/svelte";
11
+ import { dirPath, editorOpened } from "../state.svelte";
12
+ import type { NodeJsFs } from "../types.js";
13
+ import type { Dirent } from "fs";
14
+ import { resolve } from "path-unified/posix";
15
+
16
+ let {
17
+ fs,
18
+ entry,
19
+ loadDirData,
20
+ }: { fs: NodeJsFs; entry: Dirent; loadDirData: () => void } = $props();
21
+ // svelte-ignore state_referenced_locally
22
+ let path = resolve(dirPath.path!, entry.name);
23
+
24
+ let actionsDialog: HTMLDialogElement;
25
+ let deleteDialog: HTMLDialogElement;
26
+
27
+ function onclick() {
28
+ if (entry.isDirectory()) {
29
+ dirPath.path = path;
30
+ }
31
+ if (entry.isFile()) {
32
+ editorOpened.path = path;
33
+ }
34
+ }
35
+
36
+ async function download() {
37
+ const file = new Blob([await fs.promises.readFile(path)]);
38
+
39
+ const link = document.createElement("a");
40
+ link.href = URL.createObjectURL(file);
41
+ link.download = entry.name;
42
+
43
+ document.body.appendChild(link);
44
+ link.click();
45
+
46
+ setTimeout(() => {
47
+ URL.revokeObjectURL(link.href);
48
+ document.body.removeChild(link);
49
+ }, 1000);
50
+ }
51
+
52
+ async function remove() {
53
+ try {
54
+ await fs.promises.unlink(path);
55
+ } catch (err) {
56
+ console.log(err);
57
+ }
58
+ loadDirData();
59
+ deleteDialog.close();
60
+ }
61
+ </script>
62
+
63
+ <dialog class="actions" bind:this={actionsDialog}>
64
+ <div class="container">
65
+ <div class="header">
66
+ <b>{entry.name}</b>
67
+ <button aria-label="Close" onclick={() => actionsDialog.close()}>
68
+ <XIcon />
69
+ </button>
70
+ </div>
71
+ <div class="buttons">
72
+ <button onclick={download}>
73
+ <DownloadIcon />
74
+ Download
75
+ </button>
76
+ <button
77
+ onclick={() => {
78
+ actionsDialog.close();
79
+ deleteDialog.showModal();
80
+ }}
81
+ >
82
+ <TrashIcon />
83
+ Delete
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </dialog>
88
+
89
+ <dialog class="delete-dialog" bind:this={deleteDialog}>
90
+ <div>
91
+ Are you sure you want to delete "{entry.name}" ?
92
+ </div>
93
+ <br />
94
+ <div style="display: flex; justify-content: space-between;">
95
+ <button onclick={() => deleteDialog.close()}>Cancel</button>
96
+ <button onclick={remove}>Delete</button>
97
+ </div>
98
+ </dialog>
99
+
100
+ <div class="container">
101
+ <button {onclick}>
102
+ {#if entry.isDirectory()}
103
+ <FolderIcon aria-label="Directory" />
104
+ {:else if entry.isFile()}
105
+ <FileIcon aria-label="File" />
106
+ {:else if entry.isSymbolicLink()}
107
+ <FileSymlink aria-label="Symlink" />
108
+ {/if}
109
+ {entry.name}
110
+ </button>
111
+ {#if entry.isFile()}
112
+ <button aria-label="More" onclick={() => actionsDialog.showModal()}>
113
+ <MoreVerticalIcon />
114
+ </button>
115
+ {/if}
116
+ </div>
117
+
118
+ <style>
119
+ .container {
120
+ display: grid;
121
+ grid-template-columns: 1fr auto;
122
+ gap: 5px;
123
+ }
124
+ button {
125
+ display: inline-flex;
126
+ gap: 5px;
127
+ align-items: center;
128
+ height: 30px;
129
+ }
130
+ dialog {
131
+ min-width: 300px;
132
+ min-height: 300px;
133
+ max-width: 500px;
134
+ padding: 5px;
135
+ border: 2px solid;
136
+ border-radius: 5px;
137
+ }
138
+ .actions .container,
139
+ .actions .buttons {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 5px;
143
+ }
144
+ .actions .header {
145
+ display: flex;
146
+ gap: 5px;
147
+ justify-content: space-between;
148
+ align-items: center;
149
+ }
150
+ .actions .buttons button {
151
+ width: 100%;
152
+ }
153
+
154
+ .delete-dialog button {
155
+ width: 40%;
156
+ text-align: center;
157
+ display: inline-block;
158
+ }
159
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { NodeJsFs } from "../types.ts";
2
+ import type { Dirent } from "fs";
3
+ type $$ComponentProps = {
4
+ fs: NodeJsFs;
5
+ entry: Dirent;
6
+ loadDirData: () => void;
7
+ };
8
+ declare const Entry: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type Entry = ReturnType<typeof Entry>;
10
+ export default Entry;
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import Entry from "./Entry.svelte";
3
+ import { onMount } from "svelte";
4
+ import { addFiles } from "../lib.js";
5
+ import { dirPath } from "../state.svelte";
6
+ import type { NodeJsFs } from "../types.js";
7
+ import type { Dirent } from "fs";
8
+
9
+ let {
10
+ fs,
11
+ dirData,
12
+ loadDirData,
13
+ }: { fs: NodeJsFs; dirData: Dirent[]; loadDirData: () => void } = $props();
14
+
15
+ let dropZone: HTMLElement;
16
+ onMount(() => {
17
+ ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
18
+ dropZone.addEventListener(eventName, preventDefault, false);
19
+ });
20
+
21
+ function preventDefault(e: Event) {
22
+ e.preventDefault();
23
+ e.stopPropagation();
24
+ }
25
+
26
+ dropZone.addEventListener("drop", onDrop);
27
+ });
28
+
29
+ async function onDrop(e: DragEvent) {
30
+ if (!e.dataTransfer) return;
31
+ const files = Array.from(e.dataTransfer.files);
32
+ if (!files.length) return;
33
+ let dir = dirPath.path!;
34
+ if (!dir.endsWith("/")) dir += "/";
35
+ try {
36
+ await addFiles(fs, dir, files);
37
+ loadDirData();
38
+ } catch (err) {
39
+ console.log(err);
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <section
45
+ class="folder-content"
46
+ aria-label="Folder content"
47
+ bind:this={dropZone}
48
+ >
49
+ {#if dirData.length > 0}
50
+ {#each dirData as entry (entry.name)}
51
+ <Entry {fs} {entry} {loadDirData} />
52
+ {/each}
53
+ {:else}
54
+ <div class="empty-info">
55
+ <div>This folder is empty</div>
56
+ </div>
57
+ {/if}
58
+ </section>
59
+
60
+ <style>
61
+ .folder-content {
62
+ height: 100%;
63
+ display: flex;
64
+ flex-direction: column;
65
+ row-gap: 5px;
66
+ }
67
+ .empty-info {
68
+ height: 100%;
69
+ color: gray;
70
+ display: flex;
71
+ justify-content: center;
72
+ align-items: center;
73
+ }
74
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { NodeJsFs } from "../types.ts";
2
+ import type { Dirent } from "fs";
3
+ type $$ComponentProps = {
4
+ fs: NodeJsFs;
5
+ dirData: Dirent[];
6
+ loadDirData: () => void;
7
+ };
8
+ declare const FolderContent: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type FolderContent = ReturnType<typeof FolderContent>;
10
+ export default FolderContent;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import Toolbar from "./Toolbar.svelte";
3
+ import FolderContent from "./FolderContent.svelte";
4
+ import Editor from "./Editor.svelte";
5
+ import { dirPath, editorOpened } from "../state.svelte.js";
6
+ import type { NodeJsFs } from "../types.js";
7
+ import type { Dirent } from "fs";
8
+
9
+ let {
10
+ fs,
11
+ options = {},
12
+ }: { fs: NodeJsFs; options?: { initialDir?: string } } = $props();
13
+ // svelte-ignore state_referenced_locally
14
+ let initialDir = options.initialDir || "/";
15
+ dirPath.path = initialDir;
16
+ let dirDataPromise: Promise<Dirent[]> | undefined = $state.raw();
17
+
18
+ function loadDirData() {
19
+ dirDataPromise = fs.promises.readdir(dirPath.path!, {
20
+ withFileTypes: true,
21
+ });
22
+ }
23
+
24
+ $effect(() => {
25
+ loadDirData();
26
+ });
27
+ </script>
28
+
29
+ <div class="container">
30
+ {#if editorOpened.path !== null}
31
+ <Editor {fs} />
32
+ {:else}
33
+ <Toolbar {fs} {loadDirData} />
34
+ {#if dirDataPromise}
35
+ {#await dirDataPromise then dirData}
36
+ <FolderContent {fs} {dirData} {loadDirData} />
37
+ {/await}
38
+ {/if}
39
+ {/if}
40
+ </div>
41
+
42
+ <style>
43
+ .container {
44
+ padding: 5px;
45
+ height: calc(100% - 10px);
46
+ }
47
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { NodeJsFs } from "../types.ts";
2
+ type $$ComponentProps = {
3
+ fs: NodeJsFs;
4
+ options?: {
5
+ initialDir?: string;
6
+ };
7
+ };
8
+ declare const Main: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type Main = ReturnType<typeof Main>;
10
+ export default Main;
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import {
3
+ ArrowLeftIcon,
4
+ CheckIcon,
5
+ FileUpIcon,
6
+ FolderPlusIcon,
7
+ XIcon,
8
+ } from "@lucide/svelte";
9
+ import { addFiles } from "../lib.js";
10
+ import { tick } from "svelte";
11
+ import { dirPath } from "../state.svelte";
12
+ import type { NodeJsFs } from "../types.js";
13
+ import { dirname, resolve } from "path-unified/posix";
14
+
15
+ let { fs, loadDirData }: { fs: NodeJsFs; loadDirData: () => void } = $props();
16
+
17
+ let newFolderNameEl: HTMLInputElement | undefined = $state(undefined);
18
+ let showNewFolderDiv = $state(false);
19
+ let folderName = $state("");
20
+ let canCreateFolder = $derived(folderName.trim() !== "");
21
+
22
+ let folderCreationError: null | string = $state(null);
23
+
24
+ function goBack() {
25
+ dirPath.path = dirname(dirPath.path!);
26
+ }
27
+
28
+ function importFiles() {
29
+ const fileInput = document.createElement("input");
30
+ fileInput.type = "file";
31
+ fileInput.hidden = true;
32
+ fileInput.multiple = true;
33
+ fileInput.onchange = async () => {
34
+ const files = Array.from(fileInput.files!);
35
+ if (files.length) {
36
+ let dir = dirPath.path!;
37
+ if (!dir.endsWith("/")) {
38
+ dir += "/";
39
+ }
40
+ try {
41
+ await addFiles(fs, dir, files);
42
+ loadDirData();
43
+ } catch (err) {
44
+ console.log(err);
45
+ }
46
+ }
47
+ fileInput.remove();
48
+ };
49
+ document.body.append(fileInput);
50
+ fileInput.click();
51
+ }
52
+
53
+ async function createFolder() {
54
+ try {
55
+ await fs.promises.mkdir(resolve(dirPath.path!, folderName));
56
+ loadDirData();
57
+ folderCreationError = null;
58
+ folderName = "";
59
+ showNewFolderDiv = false;
60
+ } catch (err) {
61
+ folderCreationError = String(err);
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <section role="toolbar" class="toolbar">
67
+ <button onclick={goBack} aria-label="Go back" title="Go back">
68
+ <ArrowLeftIcon />
69
+ </button>
70
+ <button onclick={importFiles} aria-label="Import files" title="Import files">
71
+ <FileUpIcon />
72
+ </button>
73
+ <button
74
+ onclick={() => {
75
+ showNewFolderDiv = true;
76
+ tick().then(() => newFolderNameEl!.focus());
77
+ }}
78
+ aria-label="New folder"
79
+ title="New folder"
80
+ >
81
+ <FolderPlusIcon />
82
+ </button>
83
+ <input
84
+ type="text"
85
+ bind:value={dirPath.path}
86
+ disabled
87
+ aria-label="Current directory"
88
+ title="Current directory"
89
+ />
90
+ </section>
91
+ {#if showNewFolderDiv}
92
+ <form
93
+ class="new-folder"
94
+ onsubmit={(e) => {
95
+ e.preventDefault();
96
+ createFolder();
97
+ }}
98
+ >
99
+ <input
100
+ type="text"
101
+ placeholder="Folder name"
102
+ required
103
+ bind:value={folderName}
104
+ bind:this={newFolderNameEl}
105
+ />
106
+ <button
107
+ type="submit"
108
+ disabled={!canCreateFolder}
109
+ aria-label="Create folder"
110
+ title="Create folder"
111
+ >
112
+ <CheckIcon />
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onclick={() => {
117
+ showNewFolderDiv = false;
118
+ folderCreationError = null;
119
+ }}
120
+ aria-label="Cancel"
121
+ title="Cancel"
122
+ >
123
+ <XIcon />
124
+ </button>
125
+ </form>
126
+ <div role="alert">
127
+ {#if folderCreationError}
128
+ <!-- `key` ensures that the error is re-announced,
129
+ even if the error text is the same. -->
130
+ {#key folderCreationError}
131
+ <div class="error">
132
+ {folderCreationError}
133
+ </div>
134
+ {/key}
135
+ {/if}
136
+ </div>
137
+ {/if}
138
+ <hr />
139
+
140
+ <style>
141
+ .toolbar {
142
+ display: flex;
143
+ gap: 5px;
144
+ margin-bottom: 5px;
145
+ }
146
+
147
+ .toolbar input {
148
+ flex-grow: 1;
149
+ }
150
+
151
+ .new-folder {
152
+ display: flex;
153
+ gap: 5px;
154
+ }
155
+
156
+ .error {
157
+ color: red;
158
+ }
159
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { NodeJsFs } from "../types.ts";
2
+ type $$ComponentProps = {
3
+ fs: NodeJsFs;
4
+ loadDirData: () => void;
5
+ };
6
+ declare const Toolbar: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Toolbar = ReturnType<typeof Toolbar>;
8
+ export default Toolbar;
@@ -0,0 +1,2 @@
1
+ import FileExplorer from "./components/Main.svelte";
2
+ export default FileExplorer;
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import FileExplorer from "./components/Main.svelte";
2
+ export default FileExplorer;
package/dist/lib.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { NodeJsFs } from "./types.ts";
2
+ export declare function addFiles(fs: NodeJsFs, dir: string, files: File[]): Promise<void>;
package/dist/lib.js ADDED
@@ -0,0 +1,10 @@
1
+ async function addFile(fs, dir, file) {
2
+ const path = dir + file.name;
3
+ const uint8arr = new Uint8Array(await file.arrayBuffer());
4
+ await fs.promises.writeFile(path, uint8arr);
5
+ }
6
+ export async function addFiles(fs, dir, files) {
7
+ await Promise.all(files.map(async (file) => {
8
+ await addFile(fs, dir, file);
9
+ }));
10
+ }
@@ -0,0 +1,6 @@
1
+ export declare const dirPath: {
2
+ path: string | undefined;
3
+ };
4
+ export declare const editorOpened: {
5
+ path: string | null;
6
+ };
@@ -0,0 +1,4 @@
1
+ export const dirPath = $state({
2
+ path: undefined,
3
+ });
4
+ export const editorOpened = $state({ path: null });
@@ -0,0 +1,2 @@
1
+ import type * as FS from "node:fs";
2
+ export type NodeJsFs = typeof FS;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "nodejs-fs-explorer-ui",
3
+ "version": "0.1.0",
4
+ "files": [
5
+ "dist",
6
+ "!dist/**/*.test.*",
7
+ "!dist/**/*.spec.*"
8
+ ],
9
+ "sideEffects": [
10
+ "**/*.css"
11
+ ],
12
+ "svelte": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "svelte": "./dist/index.js"
19
+ }
20
+ },
21
+ "peerDependencies": {
22
+ "svelte": "^5.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/compat": "^2.0.2",
26
+ "@eslint/js": "^9.39.2",
27
+ "@sveltejs/adapter-auto": "^7.0.0",
28
+ "@sveltejs/kit": "^2.50.2",
29
+ "@sveltejs/package": "^2.5.7",
30
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
31
+ "@types/node": "^24",
32
+ "@zenfs/core": "^2.5.2",
33
+ "eslint": "^9.39.2",
34
+ "eslint-config-prettier": "^10.1.8",
35
+ "eslint-plugin-svelte": "^3.14.0",
36
+ "globals": "^17.3.0",
37
+ "prettier": "^3.8.1",
38
+ "prettier-plugin-svelte": "^3.4.1",
39
+ "publint": "^0.3.17",
40
+ "svelte": "^5.51.0",
41
+ "svelte-check": "^4.4.2",
42
+ "typescript": "^5.9.3",
43
+ "typescript-eslint": "^8.54.0",
44
+ "vite": "^7.3.1"
45
+ },
46
+ "keywords": [
47
+ "svelte",
48
+ "nodejs",
49
+ "fs",
50
+ "filesystem",
51
+ "browser",
52
+ "explorer",
53
+ "ui"
54
+ ],
55
+ "dependencies": {
56
+ "@lucide/svelte": "^0.577.0",
57
+ "path-unified": "^0.2.0"
58
+ },
59
+ "scripts": {
60
+ "dev": "vite dev",
61
+ "build": "vite build && npm run prepack",
62
+ "preview": "vite preview",
63
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
64
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
65
+ "lint": "prettier --check . && eslint .",
66
+ "format": "prettier --write ."
67
+ }
68
+ }