mevebu 1.7.3 → 1.8.1

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 CHANGED
@@ -1,36 +1,61 @@
1
+ <div align="center">
2
+
1
3
  # mevebu
2
4
 
3
- **186 small tools, each on its own page, all running in your browser.**
5
+ **187 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
4
6
 
5
- A box of browser tools for the jobs that do not deserve an app: convert a value,
6
- hash a text, sign a token, format JSON, pick a color, run a timer, count words,
7
- build a cron line, check a JWT, read a QR. Every tool opens on its own page and
8
- most of them update as you type, so there is no submit button and no waiting.
7
+ A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
9
8
 
10
- Nothing leaves your tab. There is no account to make, nothing to upload, and
11
- nothing saved to a server.
9
+ [![npm version](https://img.shields.io/npm/v/mevebu?color=2ea44f&label=npm)](https://www.npmjs.com/package/mevebu)
10
+ [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
+ [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
+ [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
+ ![Tools](https://img.shields.io/badge/tools-187-brightgreen)
14
+ ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
12
15
 
13
- ```
14
- tools 186
15
- categories 20
16
- followers 1 (one tab, no account)
17
- uploads 0
18
- ```
16
+ [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Host it yourself](#host-it-yourself) · [Development](#development) · [FAQ](#faq)
17
+
18
+ </div>
19
+
20
+ <!-- Tip: add a screenshot here, for example: ![mevebu home page](docs/screenshot.png) -->
21
+
22
+ ---
23
+
24
+ ## What is mevebu?
25
+
26
+ mevebu is a collection of **187 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
+
28
+ Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
+
30
+ You can run it on your own computer with one command, or copy the folder to any static host and use it from anywhere.
31
+
32
+ ## Why you will like it
19
33
 
20
- ## Install and run
34
+ - **Private by design.** Nothing you paste ever leaves your browser tab.
35
+ - **No account, no sign up.** Open a tool and use it.
36
+ - **Fast.** Plain HTML and vanilla JavaScript. No heavy framework, no loading spinners.
37
+ - **One tool, one page.** Every tool has its own clean page you can bookmark.
38
+ - **Instant search.** Press `Ctrl+K` and find any tool by name, keyword, or what it does.
39
+ - **Works offline.** Once installed, it runs fully on your machine.
40
+ - **Easy to host.** It is a plain static site. Drop it on any file host.
41
+ - **Free and open source.** MIT licensed.
21
42
 
22
- ```sh
43
+ ## Quick start
44
+
45
+ You need [Node.js](https://nodejs.org) installed. Then run:
46
+
47
+ ```bash
23
48
  npm install -g mevebu
24
49
  mevebu
25
50
  ```
26
51
 
27
- `mevebu` starts a local server (it tries port 5555, then 5678, then 5432, then
28
- any free port), opens your browser to the home page, and serves the static
29
- folder bundled with the package. Press `Ctrl+C` in the terminal to stop it.
52
+ That is it. mevebu starts a small local server, opens your browser on the home page, and serves the tools that ship with the package. Press `Ctrl+C` in the terminal to stop it.
30
53
 
31
- ### CLI
54
+ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free port. The first three are stable between restarts, so your bookmarks keep working.
32
55
 
33
- ```
56
+ ### CLI options
57
+
58
+ ```text
34
59
  -p, --port <number> Try this port first (5555, 5678, 5432 otherwise).
35
60
  -d, --dir <path> Folder to serve. Defaults to the bundled tools.
36
61
  --no-open Do not open the browser.
@@ -38,98 +63,86 @@ folder bundled with the package. Press `Ctrl+C` in the terminal to stop it.
38
63
  -h, --help Print this help and exit.
39
64
  ```
40
65
 
41
- The first three ports are stable across restarts, so the bookmark to your tools
42
- page keeps working.
43
-
44
66
  ## What's inside
45
67
 
46
- A tool is a small page that does one job. The registry lists them all; here is
47
- the shape of it.
48
-
49
- | Category | Count |
50
- | :--- | ---: |
51
- | Random | 3 |
52
- | Passwords and keys | 10 |
53
- | Developer | 21 |
54
- | Convert | 3 |
55
- | Text and data | 14 |
56
- | Daily use | 8 |
57
- | Math | 17 |
58
- | Date and time | 7 |
59
- | Data and lists | 8 |
60
- | Design | 10 |
61
- | Web and SEO | 12 |
62
- | Encoding | 8 |
63
- | Academic | 15 |
64
- | Study | 7 |
65
- | Business | 10 |
66
- | Locale | 7 |
67
- | Content | 8 |
68
- | GitHub | 8 |
69
- | Units | 8 |
70
- | QR codes | 2 |
71
- | **Total** | **186** |
72
-
73
- The counts are kept honest by the registry, so the page and the README never
74
- drift apart. Press `Ctrl+K` in the app to search the full list by name, by
75
- keyword, or by what the tool does.
76
-
77
- A short tour of the most reached-for tools, one from each side of the catalog:
78
-
79
- - **Password generator**: make a password, copy it, and the page never talks
80
- to anything but your clipboard.
81
- - **JSON formatter**: paste, and the formatted version fills in as you type.
82
- - **JWT generator and decoder**: sign a token with HS256, RS256 or ES256, or
83
- paste a token and read its header and payload.
84
- - **Color palette**: pick one color, get a matching set in analogous,
85
- complementary or monochrome schemes.
86
- - **Countdown timer**: a quiet, fullscreen timer that keeps running when the
87
- tab is hidden.
88
- - **QR code generator**: turn a URL, a Wi-Fi config or any text into a scannable
89
- image right in the tab.
90
- - **Slug converter**: keep a sentence readable while you turn it into a URL.
91
-
92
- The full list is at the home page and under each category.
93
-
94
- ## How it stays private
95
-
96
- Every tool is a static HTML file, a tiny JavaScript module, and a Lucide icon.
97
- The HTML and the JavaScript are served from your machine when you run `mevebu`,
98
- or from the file you host when you copy the `mevebu/` folder somewhere. There is
99
- no server-side code, no analytics, and no API key.
68
+ mevebu has **187 tools in 20 categories**. Here is the full breakdown:
69
+
70
+ | Category | Tools |
71
+ | ------------------ | ----: |
72
+ | Random | 3 |
73
+ | Passwords and keys | 10 |
74
+ | Developer | 21 |
75
+ | Convert | 4 |
76
+ | Text and data | 14 |
77
+ | Daily use | 8 |
78
+ | Math | 17 |
79
+ | Date and time | 7 |
80
+ | Data and lists | 8 |
81
+ | Design | 10 |
82
+ | Web and SEO | 12 |
83
+ | Encoding | 8 |
84
+ | Academic | 15 |
85
+ | Study | 7 |
86
+ | Business | 10 |
87
+ | Locale | 7 |
88
+ | Content | 8 |
89
+ | GitHub | 8 |
90
+ | Units | 8 |
91
+ | QR codes | 2 |
92
+ | **Total** | **187** |
93
+
94
+ The counts come straight from the tool registry, so this table and the app never drift apart.
95
+
96
+ ### Popular tools
97
+
98
+ | Tool | What it does |
99
+ | ---- | ------------ |
100
+ | **Password generator** | Make a strong password and copy it. The page only talks to your clipboard. |
101
+ | **JSON formatter** | Paste your JSON and get a clean, formatted version as you type. |
102
+ | **JWT generator and decoder** | Sign a token with HS256, RS256, or ES256, or paste one and read its header and payload. |
103
+ | **Color palette** | Pick one color and get matching analogous, complementary, or monochrome schemes. |
104
+ | **Countdown timer** | A quiet, fullscreen timer that keeps running when the tab is hidden. |
105
+ | **QR code generator** | Turn a URL, a Wi-Fi config, or any text into a scannable image. |
106
+ | **Slug converter** | Turn a sentence into a clean, readable URL slug. |
107
+
108
+ Browse everything from the home page or by category, or just press `Ctrl+K` and start typing.
109
+
110
+ ## Privacy by design
111
+
112
+ mevebu is built so that your data has nowhere to go. Every tool is a static HTML file, a tiny JavaScript module, and a Lucide icon. There is no server side code, no analytics, and no API key.
100
113
 
101
114
  When you paste a password, a token, or a CSV into a tool:
102
115
 
103
- - the text is processed in the browser tab, by the file that the page loaded
104
- from, using the standard Web Crypto API or the standard browser APIs the
105
- tool needs
106
- - nothing is sent to a remote server, because there is no remote server in
107
- the loop
108
- - nothing is saved to disk by the tool, because the tool does not have a way
109
- to write to your file system
110
- - the only network request the page makes is to load itself, and to read the
111
- GitHub star count for the footer chip (and that request fails silently when
112
- you are offline)
113
-
114
- You can verify this by opening the network tab in your browser's dev tools and
115
- watching a tool do its work. There is no upload. The same tool, loaded the same
116
- way, behaves the same in a hotel Wi-Fi as it does on a server room.
117
-
118
- ## Hosting the static folder
119
-
120
- If you would rather not run the CLI, the whole app is a plain static site. Copy
121
- the `mevebu/` folder from this repo, or from the npm package, to any host that
122
- serves files: a static site host, a CDN, an object store, a USB drive. Routes
123
- are hash routes (`#/security/password-generator`), so no server rewrites are
124
- needed.
125
-
126
- The shipped folder is plain files, the CSS is one committed stylesheet that
127
- Tailwind built from one source file, and there is no separate build step. The
128
- hosting layer never runs code on your behalf.
116
+ - It is processed in your browser tab, using the standard Web Crypto API or other built-in browser APIs.
117
+ - Nothing is sent to a remote server, because there is no remote server in the loop.
118
+ - Nothing is saved to disk by the tool.
119
+ - The only network request the page makes is to load itself and to read the GitHub star count for the footer chip. That request fails quietly when you are offline.
120
+
121
+ Do not just take our word for it. Open the Network tab in your browser dev tools and watch a tool work. You will not see an upload.
122
+
123
+ | | mevebu | Typical online tool sites |
124
+ | --- | :---: | :---: |
125
+ | Account needed | No | Often |
126
+ | Your data uploaded | Never | Sometimes |
127
+ | Works offline | Yes | Rarely |
128
+ | Open source | Yes (MIT) | Rarely |
129
+ | Self-hostable | Yes | Rarely |
130
+
131
+ ## Host it yourself
132
+
133
+ Prefer not to use the CLI? mevebu is a plain static site, so you can host it anywhere files can be served: a static site host, a CDN, an object store, or even a USB drive.
134
+
135
+ 1. Copy the `mevebu/` folder from this repo (or from the npm package).
136
+ 2. Upload it to your host.
137
+ 3. Open `index.html`.
138
+
139
+ Routes are hash routes, like `#/security/password-generator`, so you do not need any server rewrites. The CSS is one committed stylesheet built by Tailwind, and there is no extra build step. Your host never has to run code.
129
140
 
130
141
  ## Development
131
142
 
132
- ```sh
143
+ Want to hack on mevebu or add your own tool? Here is how to get started:
144
+
145
+ ```bash
133
146
  git clone https://github.com/mevebu/mevebu
134
147
  cd mevebu
135
148
  npm install
@@ -138,38 +151,72 @@ npm run icons # regenerate the vendored Lucide icon set
138
151
  npm start # serve the static folder on http://localhost:5555
139
152
  ```
140
153
 
141
- The app is a few hundred lines of vanilla JavaScript (ESM) in
142
- `mevebu/app/mevebu.js` and a small shared vocabulary in `mevebu/libs/js/core.js`.
143
- Every tool is a single file in `mevebu/libs/js/tools/`, exports a `mount(slot)`
144
- function and (optionally) an `unmount()` to stop timers, cameras and observers.
145
- The registry in `mevebu/libs/js/registry.js` is the single source of truth: add
146
- an entry to `TOOLS` and a file, and the catalog, search, breadcrumb, and
147
- category page pick it up.
154
+ ### Project layout
155
+
156
+ ```text
157
+ bin/ The mevebu command line entry
158
+ mevebu/ The static app that gets served
159
+ app/ App shell (mevebu.js) and the Tailwind source (tailwind.src.css)
160
+ libs/js/ Shared core.js, the registry, and one file per tool in tools/
161
+ scripts/ Helper scripts for the project
162
+ ```
163
+
164
+ ### How it works
165
+
166
+ - The app is a few hundred lines of vanilla JavaScript (ESM) in `mevebu/app/mevebu.js`, plus a small shared helper set in `mevebu/libs/js/core.js`.
167
+ - The registry in `mevebu/libs/js/registry.js` is the single source of truth for all tools.
168
+ - The styling is Tailwind v4, built from one source file. The built stylesheet is committed, so the shipped folder stays plain static files.
169
+ - The design system (type scale, layout, color, and motion) is documented in `AGENTS.md` and `DESIGN.md`.
170
+
171
+ ### Add a new tool
172
+
173
+ 1. Create one file in `mevebu/libs/js/tools/`.
174
+ 2. Export a `mount(slot)` function. Optionally export `unmount()` to clean up timers, cameras, and observers.
175
+ 3. Add one entry to `TOOLS` in `mevebu/libs/js/registry.js`.
176
+
177
+ That is all. The catalog, search, breadcrumbs, and category pages pick it up automatically.
178
+
179
+ ## FAQ
180
+
181
+ **What is mevebu?**
182
+ mevebu is a free, open source collection of 187 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
148
183
 
149
- The CSS is Tailwind v4, built from one source file, with the stylesheet output
150
- committed so the shipped folder is still plain static files. There is no
151
- component layer and no `mvb-` class: every utility is written at the call
152
- site, and the design tokens live in `mevebu/app/tailwind.src.css`.
184
+ **Is mevebu really free?**
185
+ Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
153
186
 
154
- Layout and color decisions, the type scale, the orbit lines, the "energy 1,
155
- rhythm 2, motion 1" dials, and the rest of the visual system, are recorded in
156
- `AGENTS.md` and `DESIGN.md`, the two docs an agent reads first.
187
+ **Does mevebu upload or store my data?**
188
+ No. Your input is processed in your browser tab and never sent to a server.
189
+
190
+ **Can I use mevebu offline?**
191
+ Yes. After you install it with npm, it runs on your own machine without needing the internet.
192
+
193
+ **Can I host mevebu on my own site?**
194
+ Yes. Copy the `mevebu/` folder to any static host. No server side setup is needed.
195
+
196
+ **How do I find a tool quickly?**
197
+ Press `Ctrl+K` and search by tool name, keyword, or what the tool does.
198
+
199
+ **Can I add my own tool?**
200
+ Yes. Add one file and one registry entry. See [Add a new tool](#add-a-new-tool).
157
201
 
158
202
  ## Report a problem
159
203
 
160
- Every tool page has a small "Report a problem" link at the bottom, alongside
161
- the tool's actions. It opens a GitHub issue with the tool name, the page URL
162
- and the mevebu version already filled in, so all you have to do is describe
163
- what happened and what you expected.
204
+ Every tool page has a small **Report a problem** link at the bottom. It opens a GitHub issue with the tool name, the page URL, and the mevebu version already filled in. You only need to describe what happened and what you expected.
164
205
 
165
- ## License
206
+ You can also [open an issue](https://github.com/mevebu/mevebu/issues) directly, or send a pull request.
166
207
 
167
- MIT. The whole list, the whole source, the whole thing is in the repository.
168
- If a tool here saved you time, a coffee keeps the next one coming:
169
- <https://buymeacoffee.com/fastro>.
208
+ ## Follow
209
+
210
+ Star the repo if you like it, and follow mevebu for updates:
211
+
212
+ [GitHub](https://github.com/mevebu) · [TikTok](https://www.tiktok.com/@mevebu01) · [Instagram](https://www.instagram.com/mevebu01) · [Facebook](https://www.facebook.com/mevebu) · [YouTube](https://www.youtube.com/@mevebu) · [Threads](https://www.threads.net/@mevebu01)
170
213
 
171
214
  ## Links
172
215
 
173
216
  - Repository: <https://github.com/mevebu/mevebu>
174
- - npm: <https://www.npmjs.com/package/mevebu>
175
- - Donate: <https://buymeacoffee.com/fastro>
217
+ - npm package: <https://www.npmjs.com/package/mevebu>
218
+ - Issues: <https://github.com/mevebu/mevebu/issues>
219
+
220
+ ## License
221
+
222
+ [MIT](./LICENSE). The whole list, the whole source, the whole thing is right here in the repository.
@@ -10,7 +10,6 @@ import {
10
10
  textInput, textArea, panel,
11
11
  } from "../libs/js/core.js";
12
12
 
13
- const DONATE_URL = "https://buymeacoffee.com/fastro";
14
13
  const REPO_URL = "https://github.com/mevebu/mevebu";
15
14
 
16
15
  const NAV = [
@@ -161,22 +160,6 @@ function openHelp(tool) {
161
160
  });
162
161
  }
163
162
 
164
- function openDonate() {
165
- const close = modal({
166
- label: "Support mevebu",
167
- body: [
168
- modalHead("coffee", "Keep mevebu flying"),
169
- el("p", {}, "mevebu is free and runs entirely in your browser. No accounts, no ads, nothing to sell."),
170
- el("p", {}, "If a tool here saved you time, a coffee keeps the next one coming."),
171
- ],
172
- actions: [
173
- button("Not now", { onclick: () => close() }),
174
- el("a", { class: LINK_FILL, href: DONATE_URL, target: "_blank", rel: "noopener" },
175
- icon("coffee"), el("span", {}, "Buy me a coffee")),
176
- ],
177
- });
178
- }
179
-
180
163
  // The footer chip opens this. `latest` is the published version, or null when the
181
164
  // check has not answered; opening the modal retries it, so a registry hiccup on
182
165
  // the first load is not a dead end. The body and the actions repaint in place
@@ -294,15 +277,6 @@ async function startVersionChip() {
294
277
  }
295
278
 
296
279
  function footer() {
297
- const donate = button("Donate", {
298
- icon: "coffee",
299
- kind: "quiet",
300
- onclick: (event) => {
301
- event.preventDefault();
302
- openDonate();
303
- },
304
- });
305
-
306
280
  // A finger needs 44px on a phone, so the whole list is a comfortable target
307
281
  // there. The desktop row stays as it was, at text height.
308
282
  const FOOT_LINK = "inline-flex items-center text-sm text-ink-2 no-underline hover:text-jade max-[45rem]:min-h-11";
@@ -341,7 +315,6 @@ function footer() {
341
315
  group("Project", [
342
316
  el("a", { class: FOOT_LINK, href: "#/about" }, "About"),
343
317
  el("a", { class: FOOT_LINK, href: REPO_URL, target: "_blank", rel: "noopener" }, "GitHub"),
344
- donate,
345
318
  ])
346
319
  )
347
320
  ),
@@ -211,12 +211,18 @@
211
211
  .inset-0 {
212
212
  inset: 0px;
213
213
  }
214
+ .end-1 {
215
+ inset-inline-end: calc(var(--spacing) * 1);
216
+ }
214
217
  .end-3 {
215
218
  inset-inline-end: calc(var(--spacing) * 3);
216
219
  }
217
220
  .top-0\.5 {
218
221
  top: calc(var(--spacing) * 0.5);
219
222
  }
223
+ .top-1 {
224
+ top: var(--spacing);
225
+ }
220
226
  .top-3 {
221
227
  top: calc(var(--spacing) * 3);
222
228
  }
@@ -283,6 +289,9 @@
283
289
  .me-2 {
284
290
  margin-inline-end: calc(var(--spacing) * 2);
285
291
  }
292
+ .mt-0\.5 {
293
+ margin-top: calc(var(--spacing) * 0.5);
294
+ }
286
295
  .mt-1 {
287
296
  margin-top: var(--spacing);
288
297
  }
@@ -370,10 +379,17 @@
370
379
  .table {
371
380
  display: table;
372
381
  }
382
+ .aspect-\[4\/3\] {
383
+ aspect-ratio: 4/3;
384
+ }
373
385
  .size-4 {
374
386
  width: calc(var(--spacing) * 4);
375
387
  height: calc(var(--spacing) * 4);
376
388
  }
389
+ .size-6 {
390
+ width: calc(var(--spacing) * 6);
391
+ height: calc(var(--spacing) * 6);
392
+ }
377
393
  .size-8 {
378
394
  width: calc(var(--spacing) * 8);
379
395
  height: calc(var(--spacing) * 8);
@@ -628,6 +644,9 @@
628
644
  .\[grid-template-columns\:minmax\(90px\,34\%\)_minmax\(0\,1fr\)\] {
629
645
  grid-template-columns: minmax(90px,34%) minmax(0,1fr);
630
646
  }
647
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(160px\,1fr\)\)\] {
648
+ grid-template-columns: repeat(auto-fill,minmax(160px,1fr));
649
+ }
631
650
  .\[grid-template-columns\:repeat\(auto-fit\,minmax\(96px\,1fr\)\)\] {
632
651
  grid-template-columns: repeat(auto-fit,minmax(96px,1fr));
633
652
  }
@@ -872,6 +891,12 @@
872
891
  .bg-deck-2 {
873
892
  background-color: var(--deck-2);
874
893
  }
894
+ .bg-deck\/90 {
895
+ background-color: var(--deck);
896
+ @supports (color: color-mix(in lab, red, red)) {
897
+ background-color: color-mix(in oklab, var(--deck) 90%, transparent);
898
+ }
899
+ }
875
900
  .bg-ink {
876
901
  background-color: var(--ink);
877
902
  }
@@ -914,6 +939,9 @@
914
939
  .bg-no-repeat {
915
940
  background-repeat: no-repeat;
916
941
  }
942
+ .object-contain {
943
+ object-fit: contain;
944
+ }
917
945
  .p-0 {
918
946
  padding: 0px;
919
947
  }
@@ -1282,6 +1310,9 @@
1282
1310
  .opacity-0 {
1283
1311
  opacity: 0%;
1284
1312
  }
1313
+ .opacity-50 {
1314
+ opacity: 50%;
1315
+ }
1285
1316
  .shadow {
1286
1317
  --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
1287
1318
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -1437,6 +1468,9 @@
1437
1468
  border-color: var(--jade);
1438
1469
  }
1439
1470
  @media (hover: hover) {
1471
+ .hover\:border-danger:hover {
1472
+ border-color: var(--danger);
1473
+ }
1440
1474
  .hover\:border-danger-deep:hover {
1441
1475
  border-color: var(--danger-deep);
1442
1476
  }
@@ -1472,6 +1506,9 @@
1472
1506
  .hover\:bg-jade-soft:hover {
1473
1507
  background-color: var(--jade-soft);
1474
1508
  }
1509
+ .hover\:text-danger:hover {
1510
+ color: var(--danger);
1511
+ }
1475
1512
  .hover\:text-ink:hover {
1476
1513
  color: var(--ink);
1477
1514
  }
@@ -1502,6 +1539,22 @@
1502
1539
  .focus-visible\:border-jade:focus-visible {
1503
1540
  border-color: var(--jade);
1504
1541
  }
1542
+ .focus-visible\:outline-2:focus-visible {
1543
+ outline-style: var(--tw-outline-style);
1544
+ outline-width: 2px;
1545
+ }
1546
+ .focus-visible\:outline-offset-1:focus-visible {
1547
+ outline-offset: 1px;
1548
+ }
1549
+ .focus-visible\:outline-offset-2:focus-visible {
1550
+ outline-offset: 2px;
1551
+ }
1552
+ .focus-visible\:outline-danger:focus-visible {
1553
+ outline-color: var(--danger);
1554
+ }
1555
+ .focus-visible\:outline-jade:focus-visible {
1556
+ outline-color: var(--jade);
1557
+ }
1505
1558
  .active\:scale-\[0\.88\]:active {
1506
1559
  scale: 0.88;
1507
1560
  }
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "1.7.3";
753
+ export const VERSION = "1.8.1";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -1,5 +1,5 @@
1
1
  // GENERATED FILE, DO NOT EDIT.
2
- // Source: lucide v1.59.0 (ISC), icons/173 names from scripts/vendor-icons.mjs
2
+ // Source: lucide v1.59.0 (ISC), icons/174 names from scripts/vendor-icons.mjs
3
3
  // Regenerate with: npm run icons
4
4
 
5
5
  const D = {
@@ -176,6 +176,7 @@ const D = {
176
176
  "clipboard-check": [ ["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }], ["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }], ["path", { d: "m9 14 2 2 4-4" }] ],
177
177
  "circle-check-big": [ ["path", { d: "M21.801 10A10 10 0 1 1 17 3.335" }], ["path", { d: "m9 11 3 3L22 4" }] ],
178
178
  "square": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }]],
179
+ "chevron-right": [["path", { d: "m9 18 6-6-6-6" }]],
179
180
  };
180
181
 
181
182
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -357,6 +357,25 @@ export const TOOLS = [
357
357
  keywords: "uppercase lowercase camel snake kebab pascal title",
358
358
  load: () => import("./tools/case-converter.js"),
359
359
  },
360
+ {
361
+ id: "image-converter",
362
+ category: "convert",
363
+ name: "Image Converter",
364
+ icon: "image-up",
365
+ note: "Convert images to another format, in batch.",
366
+ about: "JPEG, PNG, WebP, TIFF and BMP, written on this device.",
367
+ tutorial: {
368
+ intro: "Drop one image or a whole set, pick the format, and every file converts on this device.",
369
+ steps: [
370
+ "Drop images on the box or choose files. Add as many as you like.",
371
+ "Pick the format. Quality applies to JPEG and WebP; PNG, TIFF and BMP are lossless.",
372
+ "Drag the quality slider or type a number. Every file updates as you change it.",
373
+ "Save one file, or Download all for a ZIP. The x on a thumbnail removes that image.",
374
+ ],
375
+ },
376
+ keywords: "convert jpeg jpg png webp tiff bmp image batch compress quality",
377
+ load: () => import("./tools/image-converter.js"),
378
+ },
360
379
 
361
380
  {
362
381
  id: "lorem-ipsum",
@@ -0,0 +1,643 @@
1
+ // Image to image conversion, in batch and entirely on the device. Canvas writes
2
+ // JPEG, PNG, WebP and AVIF; TIFF and BMP come from the two small encoders below,
3
+ // because no browser can write either of them.
4
+ import {
5
+ el, icon, toast, download, bench, stack, panel, selectInput, rangeInput, numberInput,
6
+ button, actions, toolcard, blank, notice,
7
+ } from "../core.js";
8
+
9
+ const FORMATS = [
10
+ { id: "jpeg", label: "JPEG", mime: "image/jpeg", ext: "jpg", quality: true },
11
+ { id: "png", label: "PNG", mime: "image/png", ext: "png", quality: false },
12
+ { id: "webp", label: "WebP", mime: "image/webp", ext: "webp", quality: true },
13
+ { id: "avif", label: "AVIF", mime: "image/avif", ext: "avif", quality: true },
14
+ { id: "tiff", label: "TIFF", mime: "image/tiff", ext: "tiff", quality: false, encode: encodeTiff },
15
+ { id: "bmp", label: "BMP", mime: "image/bmp", ext: "bmp", quality: false, encode: encodeBmp },
16
+ ];
17
+
18
+ const MAX_SIDE = 16384;
19
+ const MAX_PIXELS = 80_000_000;
20
+
21
+ function writable(mime) {
22
+ const canvas = document.createElement("canvas");
23
+ canvas.width = 1;
24
+ canvas.height = 1;
25
+ return canvas.toDataURL(mime).startsWith(`data:${mime}`);
26
+ }
27
+
28
+ const AVAILABLE = FORMATS.filter((format) => format.encode || writable(format.mime));
29
+
30
+ function formatBytes(bytes) {
31
+ if (!bytes) return "0 B";
32
+ if (bytes < 1024) return `${bytes} B`;
33
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
34
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
35
+ }
36
+
37
+ function baseName(name) {
38
+ return name.replace(/\.[^.]+$/, "") || "image";
39
+ }
40
+
41
+ function inputFormat(file) {
42
+ const type = (file.type || "").split("/")[1];
43
+ if (type) return type.replace("svg+xml", "SVG").replace("vnd.microsoft.icon", "ICO").toUpperCase();
44
+ const ext = (file.name.split(".").pop() || "").toUpperCase();
45
+ return ext || "Image";
46
+ }
47
+
48
+ function looksLikeImage(file) {
49
+ if (file.type.startsWith("image/")) return true;
50
+ return /\.(jpe?g|png|webp|avif|gif|bmp|tiff?|svg|ico)$/i.test(file.name);
51
+ }
52
+
53
+ async function decode(file) {
54
+ if (typeof createImageBitmap === "function") {
55
+ try {
56
+ return await createImageBitmap(file, { imageOrientation: "from-image" });
57
+ } catch {
58
+ // Some formats, SVG among them, only decode through an <img>.
59
+ }
60
+ }
61
+ const url = URL.createObjectURL(file);
62
+ try {
63
+ const image = new Image();
64
+ image.decoding = "sync";
65
+ await new Promise((resolve, reject) => {
66
+ image.onload = resolve;
67
+ image.onerror = () => reject(new Error("This file is not an image the browser can read."));
68
+ image.src = url;
69
+ });
70
+ return image;
71
+ } finally {
72
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
73
+ }
74
+ }
75
+
76
+ async function convert(file, format, quality) {
77
+ const source = await decode(file);
78
+ const width = source.width || source.naturalWidth;
79
+ const height = source.height || source.naturalHeight;
80
+ if (!width || !height) throw new Error("This image has no pixel size to read.");
81
+ if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
82
+ throw new Error("This image is too large to convert here.");
83
+ }
84
+
85
+ const canvas = document.createElement("canvas");
86
+ canvas.width = width;
87
+ canvas.height = height;
88
+ const context = canvas.getContext("2d", { alpha: true });
89
+ // JPEG and BMP have no usable alpha, so a transparent pixel would turn black.
90
+ if (format.id === "jpeg" || format.id === "bmp") {
91
+ context.fillStyle = "#ffffff";
92
+ context.fillRect(0, 0, width, height);
93
+ }
94
+ context.drawImage(source, 0, 0);
95
+ if (source.close) source.close();
96
+
97
+ if (format.encode) {
98
+ return format.encode(context.getImageData(0, 0, width, height));
99
+ }
100
+ return new Promise((resolve, reject) => {
101
+ canvas.toBlob(
102
+ (blob) => (blob ? resolve(blob) : reject(new Error("The browser could not encode this image."))),
103
+ format.mime,
104
+ format.quality ? quality / 100 : undefined
105
+ );
106
+ });
107
+ }
108
+
109
+ // Baseline TIFF, uncompressed RGBA. Every tag is written by hand because the
110
+ // format needs strip offsets and rational resolutions that a canvas never gives.
111
+ function encodeTiff(image) {
112
+ const { data, width, height } = image;
113
+ const samples = 4;
114
+ const pixels = new Uint8Array(width * height * samples);
115
+ for (let i = 0, j = 0; i < data.length; i += 4, j += samples) {
116
+ pixels[j] = data[i];
117
+ pixels[j + 1] = data[i + 1];
118
+ pixels[j + 2] = data[i + 2];
119
+ pixels[j + 3] = data[i + 3];
120
+ }
121
+
122
+ const entries = [
123
+ [256, 4, 1, width],
124
+ [257, 4, 1, height],
125
+ [258, 3, samples, 0],
126
+ [259, 3, 1, 1],
127
+ [262, 3, 1, 2],
128
+ [273, 4, 1, 0],
129
+ [277, 3, 1, samples],
130
+ [278, 4, 1, height],
131
+ [279, 4, 1, pixels.length],
132
+ [282, 5, 1, 0],
133
+ [283, 5, 1, 0],
134
+ [296, 3, 1, 2],
135
+ [338, 3, 1, 1],
136
+ ];
137
+
138
+ const ifdSize = 2 + entries.length * 12 + 4;
139
+ const bitsAt = 8 + ifdSize;
140
+ const xResAt = bitsAt + samples * 2;
141
+ const yResAt = xResAt + 8;
142
+ const pixelsAt = yResAt + 8;
143
+ const buffer = new ArrayBuffer(pixelsAt + pixels.length);
144
+ const view = new DataView(buffer);
145
+ const bytes = new Uint8Array(buffer);
146
+
147
+ view.setUint16(0, 0x4949, true);
148
+ view.setUint16(2, 42, true);
149
+ view.setUint32(4, 8, true);
150
+ view.setUint16(8, entries.length, true);
151
+
152
+ let at = 10;
153
+ for (const [tag, type, count, value] of entries) {
154
+ view.setUint16(at, tag, true);
155
+ view.setUint16(at + 2, type, true);
156
+ view.setUint32(at + 4, count, true);
157
+ let inline = value;
158
+ if (tag === 258) inline = bitsAt;
159
+ else if (tag === 273) inline = pixelsAt;
160
+ else if (tag === 282) inline = xResAt;
161
+ else if (tag === 283) inline = yResAt;
162
+ if (type === 3 && count === 1) view.setUint16(at + 8, inline, true);
163
+ else view.setUint32(at + 8, inline, true);
164
+ at += 12;
165
+ }
166
+ view.setUint32(at, 0, true);
167
+
168
+ for (let i = 0; i < samples; i++) view.setUint16(bitsAt + i * 2, 8, true);
169
+ view.setUint32(xResAt, 72, true);
170
+ view.setUint32(xResAt + 4, 1, true);
171
+ view.setUint32(yResAt, 72, true);
172
+ view.setUint32(yResAt + 4, 1, true);
173
+ bytes.set(pixels, pixelsAt);
174
+
175
+ return new Blob([buffer], { type: "image/tiff" });
176
+ }
177
+
178
+ // 24-bit BMP, bottom up, rows padded to four bytes.
179
+ function encodeBmp(image) {
180
+ const { data, width, height } = image;
181
+ const rowSize = Math.floor((24 * width + 31) / 32) * 4;
182
+ const pixelSize = rowSize * height;
183
+ const buffer = new ArrayBuffer(54 + pixelSize);
184
+ const view = new DataView(buffer);
185
+ const bytes = new Uint8Array(buffer);
186
+
187
+ bytes[0] = 0x42;
188
+ bytes[1] = 0x4d;
189
+ view.setUint32(2, buffer.byteLength, true);
190
+ view.setUint32(10, 54, true);
191
+ view.setUint32(14, 40, true);
192
+ view.setInt32(18, width, true);
193
+ view.setInt32(22, height, true);
194
+ view.setUint16(26, 1, true);
195
+ view.setUint16(28, 24, true);
196
+ view.setUint32(34, pixelSize, true);
197
+ view.setInt32(38, 2835, true);
198
+ view.setInt32(42, 2835, true);
199
+
200
+ for (let y = 0; y < height; y++) {
201
+ const from = y * width * 4;
202
+ const to = 54 + (height - 1 - y) * rowSize;
203
+ for (let x = 0; x < width; x++) {
204
+ const i = from + x * 4;
205
+ const o = to + x * 3;
206
+ bytes[o] = data[i + 2];
207
+ bytes[o + 1] = data[i + 1];
208
+ bytes[o + 2] = data[i];
209
+ }
210
+ }
211
+ return new Blob([buffer], { type: "image/bmp" });
212
+ }
213
+
214
+ const CRC_TABLE = (() => {
215
+ const table = new Uint32Array(256);
216
+ for (let n = 0; n < 256; n++) {
217
+ let c = n;
218
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
219
+ table[n] = c >>> 0;
220
+ }
221
+ return table;
222
+ })();
223
+
224
+ function crc32(bytes) {
225
+ let c = 0xffffffff;
226
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
227
+ return (c ^ 0xffffffff) >>> 0;
228
+ }
229
+
230
+ // Store only. The images are already compressed, so deflating them again would
231
+ // cost time and save nothing.
232
+ function zip(files) {
233
+ const now = new Date();
234
+ const time = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
235
+ const date = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
236
+ const chunks = [];
237
+ const central = [];
238
+ let offset = 0;
239
+
240
+ for (const file of files) {
241
+ const name = new TextEncoder().encode(file.name);
242
+ const data = new Uint8Array(file.bytes);
243
+ const crc = crc32(data);
244
+
245
+ const local = new Uint8Array(30 + name.length);
246
+ const lv = new DataView(local.buffer);
247
+ lv.setUint32(0, 0x04034b50, true);
248
+ lv.setUint16(4, 20, true);
249
+ lv.setUint16(6, 0x0800, true);
250
+ lv.setUint16(8, 0, true);
251
+ lv.setUint16(10, time, true);
252
+ lv.setUint16(12, date, true);
253
+ lv.setUint32(14, crc, true);
254
+ lv.setUint32(18, data.length, true);
255
+ lv.setUint32(22, data.length, true);
256
+ lv.setUint16(26, name.length, true);
257
+ local.set(name, 30);
258
+
259
+ const entry = new Uint8Array(46 + name.length);
260
+ const cv = new DataView(entry.buffer);
261
+ cv.setUint32(0, 0x02014b50, true);
262
+ cv.setUint16(4, 20, true);
263
+ cv.setUint16(6, 20, true);
264
+ cv.setUint16(8, 0x0800, true);
265
+ cv.setUint16(10, 0, true);
266
+ cv.setUint16(12, time, true);
267
+ cv.setUint16(14, date, true);
268
+ cv.setUint32(16, crc, true);
269
+ cv.setUint32(20, data.length, true);
270
+ cv.setUint32(24, data.length, true);
271
+ cv.setUint16(28, name.length, true);
272
+ cv.setUint32(42, offset, true);
273
+ entry.set(name, 46);
274
+
275
+ chunks.push(local, data);
276
+ central.push(entry);
277
+ offset += local.length + data.length;
278
+ }
279
+
280
+ const centralSize = central.reduce((total, entry) => total + entry.length, 0);
281
+ const end = new Uint8Array(22);
282
+ const ev = new DataView(end.buffer);
283
+ ev.setUint32(0, 0x06054b50, true);
284
+ ev.setUint16(8, files.length, true);
285
+ ev.setUint16(10, files.length, true);
286
+ ev.setUint32(12, centralSize, true);
287
+ ev.setUint32(16, offset, true);
288
+
289
+ return new Blob([...chunks, ...central, end], { type: "application/zip" });
290
+ }
291
+
292
+ export function mount(slot) {
293
+ const entries = [];
294
+ let nextId = 1;
295
+ let runToken = 0;
296
+ let timer = null;
297
+ let busy = false;
298
+
299
+ const picker = el("input", { type: "file", accept: "image/*", multiple: true, class: "hidden" });
300
+ const drop = el(
301
+ "div",
302
+ {
303
+ class:
304
+ "grid cursor-pointer justify-items-center gap-1.5 rounded-r2 border border-dashed border-orbit-hard bg-deck-2 px-4 py-7 text-center transition-[border-color,background-color] duration-150 hover:border-jade focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-jade data-[drag]:border-jade data-[drag]:bg-jade-soft",
305
+ role: "button",
306
+ tabindex: "0",
307
+ "aria-label": "Add images",
308
+ },
309
+ icon("image-up", "size-6 text-ink-3"),
310
+ el("p", { class: "text-sm font-semibold" }, "Drop images here"),
311
+ el("p", { class: "text-2xs text-ink-3" }, "or choose files")
312
+ );
313
+
314
+ const target = selectInput(
315
+ AVAILABLE.map((format) => ({ value: format.id, label: format.label })),
316
+ { value: AVAILABLE.some((format) => format.id === "webp") ? "webp" : "jpeg", "aria-label": "Convert to" }
317
+ );
318
+
319
+ const qualityRange = rangeInput({ min: "10", max: "100", step: "1", value: "85", "aria-label": "Quality percent" });
320
+ const qualityNumber = numberInput({ min: "10", max: "100", step: "1", value: "85", "aria-label": "Quality percent" });
321
+ const qualityRow = el(
322
+ "div",
323
+ { class: "flex min-w-0 items-center gap-3" },
324
+ el("div", { class: "min-w-0 flex-1" }, qualityRange),
325
+ qualityNumber
326
+ );
327
+ const qualityHint = el("span", { class: "text-2xs text-ink-3" });
328
+ const qualityPanel = el(
329
+ "div",
330
+ { class: "grid min-w-0 content-start gap-1.5" },
331
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Quality"),
332
+ qualityRow,
333
+ qualityHint
334
+ );
335
+
336
+ const error = notice("warn");
337
+ const summary = el("p", { class: "text-sm text-ink-2" });
338
+ const grid = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(160px,1fr))]" });
339
+ const empty = blank("image-up", "Add an image to convert it.");
340
+
341
+ const clearAll = button("Clear all", { icon: "trash" });
342
+ const downloadAll = button("Download all", { icon: "download", kind: "fill" });
343
+
344
+ function currentFormat() {
345
+ return AVAILABLE.find((format) => format.id === target.value) ?? AVAILABLE[0];
346
+ }
347
+
348
+ function syncQuality() {
349
+ const format = currentFormat();
350
+ const lossy = Boolean(format.quality);
351
+ qualityRange.disabled = !lossy;
352
+ qualityNumber.disabled = !lossy;
353
+ qualityPanel.classList.toggle("opacity-50", !lossy);
354
+ qualityHint.textContent = lossy
355
+ ? `Applies to ${format.label}. Lower means a smaller file.`
356
+ : `${format.label} is lossless, so there is no quality to set.`;
357
+ }
358
+
359
+ function schedule() {
360
+ clearTimeout(timer);
361
+ timer = setTimeout(run, 220);
362
+ }
363
+
364
+ function outputName(entry, format) {
365
+ const base = baseName(entry.file.name);
366
+ const used = new Set(entries.filter((other) => other !== entry && other.outName).map((other) => other.outName));
367
+ let name = `${base}.${format.ext}`;
368
+ let index = 2;
369
+ while (used.has(name)) {
370
+ name = `${base}-${index}.${format.ext}`;
371
+ index++;
372
+ }
373
+ return name;
374
+ }
375
+
376
+ async function run() {
377
+ if (!entries.length) return;
378
+ const token = ++runToken;
379
+ const format = currentFormat();
380
+ const quality = Math.max(10, Math.min(100, parseInt(qualityNumber.value || "85", 10) || 85));
381
+ busy = true;
382
+ error.write("");
383
+ downloadAll.disabled = true;
384
+ clearAll.disabled = true;
385
+
386
+ for (const entry of entries) {
387
+ if (token !== runToken) return;
388
+ entry.status = "working";
389
+ entry.blob = null;
390
+ entry.error = "";
391
+ entry.outName = "";
392
+ }
393
+ render();
394
+
395
+ for (const [index, entry] of entries.entries()) {
396
+ if (token !== runToken) return;
397
+ summary.textContent = `Converting ${index + 1} of ${entries.length}`;
398
+ try {
399
+ const blob = await convert(entry.file, format, quality);
400
+ if (token !== runToken) return;
401
+ entry.blob = blob;
402
+ entry.outName = outputName(entry, format);
403
+ entry.status = "done";
404
+ } catch (caught) {
405
+ if (token !== runToken) return;
406
+ entry.error = caught?.message || "Could not convert this file.";
407
+ entry.status = "error";
408
+ }
409
+ render();
410
+ }
411
+
412
+ if (token !== runToken) return;
413
+ busy = false;
414
+ downloadAll.disabled = false;
415
+ clearAll.disabled = false;
416
+ render();
417
+ }
418
+
419
+ // Two lines, so a unit never breaks in half at the card edge.
420
+ function metaLines(entry) {
421
+ const format = currentFormat();
422
+ const from = `${inputFormat(entry.file)} · ${formatBytes(entry.file.size)}`;
423
+ if (entry.status === "error") return [from, entry.error];
424
+ if (entry.status === "working") return [from, `Converting to ${format.label}`];
425
+ if (entry.status === "done") {
426
+ return [from, `to ${format.label} · ${formatBytes(entry.blob.size)}`];
427
+ }
428
+ return [from, `to ${format.label}`];
429
+ }
430
+
431
+ function fileCard(entry) {
432
+ const remove = button("", {
433
+ icon: "x",
434
+ kind: "quiet",
435
+ class:
436
+ "absolute end-1 top-1 size-8 rounded-full border border-orbit bg-deck/90 text-ink-2 hover:border-danger hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-danger",
437
+ "aria-label": `Remove ${entry.file.name}`,
438
+ });
439
+ remove.addEventListener("click", () => removeEntry(entry));
440
+
441
+ const save = button("Save", { icon: "download", class: "w-full" });
442
+ save.disabled = entry.status !== "done";
443
+ save.addEventListener("click", () => {
444
+ if (entry.blob) download(entry.outName, entry.blob, entry.blob.type);
445
+ });
446
+
447
+ return el(
448
+ "div",
449
+ { class: "grid content-start gap-2 rounded-r2 border border-orbit bg-deck-2 p-2" },
450
+ el(
451
+ "div",
452
+ { class: "relative" },
453
+ el("img", {
454
+ src: entry.url,
455
+ alt: "",
456
+ class: "aspect-[4/3] w-full rounded-r1 bg-deck object-contain",
457
+ }),
458
+ remove
459
+ ),
460
+ el(
461
+ "div",
462
+ { class: "min-w-0" },
463
+ el("p", { class: "truncate text-sm font-semibold", title: entry.file.name }, entry.file.name),
464
+ el(
465
+ "p",
466
+ { class: `mt-0.5 grid gap-0.5 text-2xs tabular-nums ${entry.status === "error" ? "text-danger" : "text-ink-3"}` },
467
+ ...metaLines(entry).map((line) => el("span", { class: "truncate" }, line))
468
+ )
469
+ ),
470
+ save
471
+ );
472
+ }
473
+
474
+ function render() {
475
+ grid.replaceChildren(...entries.map(fileCard));
476
+ const done = entries.filter((entry) => entry.status === "done");
477
+ const before = done.reduce((total, entry) => total + entry.file.size, 0);
478
+ const after = done.reduce((total, entry) => total + entry.blob.size, 0);
479
+ if (!entries.length) {
480
+ summary.textContent = "";
481
+ grid.hidden = true;
482
+ empty.hidden = false;
483
+ } else {
484
+ grid.hidden = false;
485
+ empty.hidden = true;
486
+ if (busy) {
487
+ // The loop writes the counter itself.
488
+ } else {
489
+ const failed = entries.length - done.length;
490
+ const size = done.length ? ` · ${formatBytes(before)} → ${formatBytes(after)}` : "";
491
+ const trouble = failed ? ` · ${failed} failed` : "";
492
+ summary.textContent = `${entries.length} image${entries.length === 1 ? "" : "s"}${size}${trouble}`;
493
+ }
494
+ }
495
+ downloadAll.disabled = busy || !done.length;
496
+ clearAll.disabled = busy || !entries.length;
497
+ }
498
+
499
+ function removeEntry(entry) {
500
+ const index = entries.indexOf(entry);
501
+ if (index < 0) return;
502
+ entries.splice(index, 1);
503
+ URL.revokeObjectURL(entry.url);
504
+ if (!entries.length) {
505
+ runToken++;
506
+ busy = false;
507
+ summary.textContent = "";
508
+ }
509
+ render();
510
+ if (entries.length) schedule();
511
+ }
512
+
513
+ function addFiles(files) {
514
+ const images = [];
515
+ let skipped = 0;
516
+ for (const file of files) {
517
+ if (looksLikeImage(file)) images.push(file);
518
+ else skipped++;
519
+ }
520
+ error.write(skipped ? `${skipped} file${skipped === 1 ? "" : "s"} skipped. Only images can be converted.` : "");
521
+ if (!images.length) return;
522
+ for (const file of images) {
523
+ entries.push({
524
+ id: nextId++,
525
+ file,
526
+ url: URL.createObjectURL(file),
527
+ status: "queued",
528
+ blob: null,
529
+ error: "",
530
+ outName: "",
531
+ });
532
+ }
533
+ render();
534
+ schedule();
535
+ }
536
+
537
+ picker.addEventListener("change", () => {
538
+ addFiles(Array.from(picker.files ?? []));
539
+ picker.value = "";
540
+ });
541
+
542
+ drop.addEventListener("click", () => picker.click());
543
+ drop.addEventListener("keydown", (event) => {
544
+ if (event.key === "Enter" || event.key === " ") {
545
+ event.preventDefault();
546
+ picker.click();
547
+ }
548
+ });
549
+
550
+ let dragDepth = 0;
551
+ drop.addEventListener("dragenter", (event) => {
552
+ event.preventDefault();
553
+ dragDepth++;
554
+ drop.dataset.drag = "";
555
+ });
556
+ drop.addEventListener("dragover", (event) => event.preventDefault());
557
+ drop.addEventListener("dragleave", () => {
558
+ dragDepth = Math.max(0, dragDepth - 1);
559
+ if (!dragDepth) delete drop.dataset.drag;
560
+ });
561
+ drop.addEventListener("drop", (event) => {
562
+ event.preventDefault();
563
+ dragDepth = 0;
564
+ delete drop.dataset.drag;
565
+ addFiles(Array.from(event.dataTransfer?.files ?? []));
566
+ });
567
+
568
+ target.addEventListener("change", () => {
569
+ syncQuality();
570
+ schedule();
571
+ });
572
+ qualityRange.addEventListener("input", () => {
573
+ qualityNumber.value = qualityRange.value;
574
+ schedule();
575
+ });
576
+ qualityNumber.addEventListener("input", () => {
577
+ const value = parseInt(qualityNumber.value || "", 10);
578
+ if (Number.isFinite(value)) qualityRange.value = String(Math.max(10, Math.min(100, value)));
579
+ schedule();
580
+ });
581
+ qualityNumber.addEventListener("change", () => {
582
+ const value = Math.max(10, Math.min(100, parseInt(qualityNumber.value || "85", 10) || 85));
583
+ qualityNumber.value = String(value);
584
+ qualityRange.value = String(value);
585
+ schedule();
586
+ });
587
+
588
+ clearAll.addEventListener("click", () => {
589
+ runToken++;
590
+ clearTimeout(timer);
591
+ busy = false;
592
+ for (const entry of entries) URL.revokeObjectURL(entry.url);
593
+ entries.length = 0;
594
+ error.write("");
595
+ render();
596
+ });
597
+
598
+ downloadAll.addEventListener("click", () => {
599
+ const done = entries.filter((entry) => entry.status === "done" && entry.blob);
600
+ if (!done.length) return toast("Nothing to save yet");
601
+ if (done.length === 1) {
602
+ download(done[0].outName, done[0].blob, done[0].blob.type);
603
+ return;
604
+ }
605
+ Promise.all(done.map(async (entry) => ({ name: entry.outName, bytes: await entry.blob.arrayBuffer() })))
606
+ .then((files) => download(`mevebu-images-${currentFormat().ext}.zip`, zip(files), "application/zip"))
607
+ .catch(() => toast("Could not build the ZIP"));
608
+ });
609
+
610
+ syncQuality();
611
+ render();
612
+
613
+ const shell = toolcard({
614
+ body: bench(
615
+ stack(
616
+ drop,
617
+ picker,
618
+ panel("Convert to", target),
619
+ qualityPanel,
620
+ error
621
+ ),
622
+ stack(
623
+ summary,
624
+ empty,
625
+ grid
626
+ )
627
+ ),
628
+ foot: actions(clearAll, downloadAll),
629
+ });
630
+
631
+ slot.append(shell.node);
632
+
633
+ return {
634
+ unmount() {
635
+ runToken++;
636
+ clearTimeout(timer);
637
+ for (const entry of entries) URL.revokeObjectURL(entry.url);
638
+ entries.length = 0;
639
+ },
640
+ };
641
+ }
642
+
643
+ export default { mount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.7.3",
3
+ "version": "1.8.1",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",