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 +175 -128
- package/mevebu/app/mevebu.js +0 -27
- package/mevebu/app/tailwind.css +53 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/icons.js +2 -1
- package/mevebu/libs/js/registry.js +19 -0
- package/mevebu/libs/js/tools/image-converter.js +643 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,61 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# mevebu
|
|
2
4
|
|
|
3
|
-
**
|
|
5
|
+
**187 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
|
|
4
6
|
|
|
5
|
-
A
|
|
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
|
-
|
|
11
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/mevebu)
|
|
10
|
+
[](https://www.npmjs.com/package/mevebu)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
[](https://github.com/mevebu/mevebu/stargazers)
|
|
13
|
+

|
|
14
|
+

|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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:  -->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
##
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
206
|
+
You can also [open an issue](https://github.com/mevebu/mevebu/issues) directly, or send a pull request.
|
|
166
207
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
-
|
|
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.
|
package/mevebu/app/mevebu.js
CHANGED
|
@@ -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
|
),
|
package/mevebu/app/tailwind.css
CHANGED
|
@@ -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
|
}
|
package/mevebu/libs/js/core.js
CHANGED
|
@@ -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.
|
|
753
|
+
export const VERSION = "1.8.1";
|
|
754
754
|
export const PACKAGE_NAME = "mevebu";
|
|
755
755
|
export const REPO_SLUG = "mevebu/mevebu";
|
|
756
756
|
|
package/mevebu/libs/js/icons.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// GENERATED FILE, DO NOT EDIT.
|
|
2
|
-
// Source: lucide v1.59.0 (ISC), icons/
|
|
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 };
|