ecklf 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/App.js +86 -0
- package/dist/Greeting.js +62 -0
- package/dist/Knot.js +9 -0
- package/dist/cli.js +35 -0
- package/dist/data.js +92 -0
- package/dist/hooks.js +26 -0
- package/dist/knot-renderer.js +104 -0
- package/dist/open.js +34 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ecklf
|
|
2
|
+
|
|
3
|
+
A terminal UI for [ecklf.com](https://ecklf.com). Browse Florentin's projects and open
|
|
4
|
+
them on the web or GitHub — complete with a spinning ASCII wireframe and typed
|
|
5
|
+
multi‑language greetings, just like the website.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Run it without installing:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx ecklf
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install globally:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm i -g ecklf
|
|
19
|
+
# then
|
|
20
|
+
ecklf
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Controls
|
|
24
|
+
|
|
25
|
+
| Key | Action |
|
|
26
|
+
| -------------- | ---------------------------------------- |
|
|
27
|
+
| `↑` / `↓`, `j` / `k` | Navigate projects |
|
|
28
|
+
| `↵` | Open menu → choose **Website** / **GitHub** |
|
|
29
|
+
| `w` | Open ecklf.com in your browser |
|
|
30
|
+
| `g` | Open the selected project on GitHub |
|
|
31
|
+
| `←` / `→` | Switch choice (in the open menu) |
|
|
32
|
+
| `esc` | Back |
|
|
33
|
+
| `q` | Quit |
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
- Project data is fetched **live** from [ecklf.com](https://ecklf.com) at launch and
|
|
38
|
+
parsed from the server-rendered markup. If the site can't be reached it falls back
|
|
39
|
+
to a bundled snapshot, so it still works offline.
|
|
40
|
+
- The header features two animations mirroring the site:
|
|
41
|
+
- a rotating 3D wireframe **torus-knot** rendered with braille characters, and
|
|
42
|
+
- a **typewriter** cycling greetings in different languages.
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
This project uses [pnpm](https://pnpm.io).
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pnpm install
|
|
50
|
+
pnpm build # compile TypeScript -> dist/
|
|
51
|
+
pnpm start # run the built CLI
|
|
52
|
+
pnpm dev # tsc --watch
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT © [Florentin Eckl](https://ecklf.com)
|
package/dist/App.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { Greeting } from "./Greeting.js";
|
|
5
|
+
import { Knot } from "./Knot.js";
|
|
6
|
+
import { openUrl } from "./open.js";
|
|
7
|
+
import { fetchSiteData, SITE_URL } from "./data.js";
|
|
8
|
+
const ACCENT = "cyanBright";
|
|
9
|
+
export const App = () => {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [status, setStatus] = useState("loading");
|
|
12
|
+
const [data, setData] = useState(null);
|
|
13
|
+
const [selected, setSelected] = useState(0);
|
|
14
|
+
const [mode, setMode] = useState("list");
|
|
15
|
+
const [actionChoice, setActionChoice] = useState(0); // 0 = website, 1 = github
|
|
16
|
+
const [opened, setOpened] = useState(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let active = true;
|
|
19
|
+
fetchSiteData().then((d) => {
|
|
20
|
+
if (!active)
|
|
21
|
+
return;
|
|
22
|
+
setData(d);
|
|
23
|
+
setStatus("ready");
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
active = false;
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
useInput((input, key) => {
|
|
30
|
+
if (input === "q" || (key.ctrl && input === "c")) {
|
|
31
|
+
exit();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (status !== "ready" || !data)
|
|
35
|
+
return;
|
|
36
|
+
const projects = data.projects;
|
|
37
|
+
if (mode === "list") {
|
|
38
|
+
if (key.upArrow || input === "k") {
|
|
39
|
+
setSelected((s) => (s - 1 + projects.length) % projects.length);
|
|
40
|
+
setOpened(null);
|
|
41
|
+
}
|
|
42
|
+
else if (key.downArrow || input === "j") {
|
|
43
|
+
setSelected((s) => (s + 1) % projects.length);
|
|
44
|
+
setOpened(null);
|
|
45
|
+
}
|
|
46
|
+
else if (key.return || input === " ") {
|
|
47
|
+
setMode("action");
|
|
48
|
+
setActionChoice(1); // default highlight GitHub
|
|
49
|
+
}
|
|
50
|
+
else if (input === "g") {
|
|
51
|
+
openUrl(projects[selected].github);
|
|
52
|
+
setOpened(projects[selected].github);
|
|
53
|
+
}
|
|
54
|
+
else if (input === "w") {
|
|
55
|
+
openUrl(SITE_URL);
|
|
56
|
+
setOpened(SITE_URL);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// action mode
|
|
61
|
+
if (key.leftArrow || key.rightArrow || input === "h" || input === "l" || key.tab) {
|
|
62
|
+
setActionChoice((c) => (c === 0 ? 1 : 0));
|
|
63
|
+
}
|
|
64
|
+
else if (key.return) {
|
|
65
|
+
const p = projects[selected];
|
|
66
|
+
const url = actionChoice === 0 ? SITE_URL : p.github;
|
|
67
|
+
openUrl(url);
|
|
68
|
+
setOpened(url);
|
|
69
|
+
setMode("list");
|
|
70
|
+
}
|
|
71
|
+
else if (key.escape) {
|
|
72
|
+
setMode("list");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, { isActive: process.stdin.isTTY === true });
|
|
76
|
+
if (status === "loading") {
|
|
77
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Text, { color: "gray", children: "Connecting to ecklf.com\u2026" }) }));
|
|
78
|
+
}
|
|
79
|
+
const d = data;
|
|
80
|
+
const projects = d.projects;
|
|
81
|
+
const current = projects[selected];
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsx(Box, { children: _jsx(Greeting, {}) }), _jsx(Text, { bold: true, color: "whiteBright", children: d.name }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: d.tagline }), _jsx(Text, { color: "gray", children: d.subtitle })] })] }), _jsx(Box, { width: 28, height: 14, justifyContent: "flex-end", children: _jsx(Knot, { cols: 26, rows: 14 }) })] }), _jsxs(Box, { marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "whiteBright", children: "Projects" }), _jsxs(Text, { color: "gray", children: [" ", d.source === "live" ? "● live" : "○ offline"] })] }), _jsx(Box, { flexDirection: "column", children: projects.map((p, i) => {
|
|
83
|
+
const active = i === selected;
|
|
84
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: active ? ACCENT : "gray", children: active ? "❯ " : " " }), _jsx(Text, { bold: true, color: active ? "whiteBright" : "white", children: p.title })] }), active && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", dimColor: true, children: p.description }) }))] }, p.github));
|
|
85
|
+
}) }), mode === "action" && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Open ", _jsx(Text, { color: "whiteBright", children: current.title }), " \u2192"] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: actionChoice === 0 ? "black" : ACCENT, backgroundColor: actionChoice === 0 ? ACCENT : undefined, children: " Website " }), _jsx(Text, { children: " " }), _jsx(Text, { color: actionChoice === 1 ? "black" : ACCENT, backgroundColor: actionChoice === 1 ? ACCENT : undefined, children: " GitHub " })] })] })), opened && mode === "list" && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "greenBright", children: "\u2197 opened " }), _jsx(Text, { color: "gray", children: opened })] })), _jsx(Box, { marginTop: 1, children: mode === "list" ? (_jsx(Text, { color: "gray", dimColor: true, children: "\u2191/\u2193 navigate \u00B7 \u21B5 open\u2026 \u00B7 w website \u00B7 g github \u00B7 q quit" })) : (_jsx(Text, { color: "gray", dimColor: true, children: "\u2190/\u2192 choose \u00B7 \u21B5 confirm \u00B7 esc back" })) })] }));
|
|
86
|
+
};
|
package/dist/Greeting.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
// Multi-language greetings, cycled with a typewriter effect — mirrors the
|
|
5
|
+
// animated headline on ecklf.com.
|
|
6
|
+
const GREETINGS = [
|
|
7
|
+
"Hallo",
|
|
8
|
+
"Hello",
|
|
9
|
+
"Bonjour",
|
|
10
|
+
"Hola",
|
|
11
|
+
"Ciao",
|
|
12
|
+
"こんにちは",
|
|
13
|
+
"안녕하세요",
|
|
14
|
+
"你好",
|
|
15
|
+
"Olá",
|
|
16
|
+
"Привет",
|
|
17
|
+
"Hej",
|
|
18
|
+
"Merhaba",
|
|
19
|
+
];
|
|
20
|
+
export const Greeting = () => {
|
|
21
|
+
const [index, setIndex] = useState(0);
|
|
22
|
+
const [text, setText] = useState("");
|
|
23
|
+
const [phase, setPhase] = useState("typing");
|
|
24
|
+
const [cursorOn, setCursorOn] = useState(true);
|
|
25
|
+
const timer = useRef();
|
|
26
|
+
// Blinking cursor.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const id = setInterval(() => setCursorOn((c) => !c), 450);
|
|
29
|
+
return () => clearInterval(id);
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const word = GREETINGS[index];
|
|
33
|
+
const schedule = (fn, ms) => {
|
|
34
|
+
timer.current = setTimeout(fn, ms);
|
|
35
|
+
};
|
|
36
|
+
if (phase === "typing") {
|
|
37
|
+
if (text.length < word.length) {
|
|
38
|
+
schedule(() => setText(word.slice(0, text.length + 1)), 90);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
setPhase("holding");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (phase === "holding") {
|
|
45
|
+
schedule(() => setPhase("deleting"), 1400);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (text.length > 0) {
|
|
49
|
+
schedule(() => setText(word.slice(0, text.length - 1)), 45);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
setIndex((i) => (i + 1) % GREETINGS.length);
|
|
53
|
+
setPhase("typing");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return () => {
|
|
57
|
+
if (timer.current)
|
|
58
|
+
clearTimeout(timer.current);
|
|
59
|
+
};
|
|
60
|
+
}, [text, phase, index]);
|
|
61
|
+
return (_jsxs(Text, { bold: true, color: "white", children: [text, _jsx(Text, { color: "cyanBright", children: cursorOn ? "▋" : " " })] }));
|
|
62
|
+
};
|
package/dist/Knot.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { useClock } from "./hooks.js";
|
|
4
|
+
import { renderKnot } from "./knot-renderer.js";
|
|
5
|
+
export const Knot = ({ cols = 26, rows = 14 }) => {
|
|
6
|
+
const t = useClock(24);
|
|
7
|
+
const frame = renderKnot(t, cols, rows);
|
|
8
|
+
return _jsx(Text, { color: "gray", children: frame });
|
|
9
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { App } from "./App.js";
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
7
|
+
console.log(`
|
|
8
|
+
ecklf — a terminal UI for ecklf.com
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
$ ecklf
|
|
12
|
+
|
|
13
|
+
Controls
|
|
14
|
+
↑/↓ or j/k navigate projects
|
|
15
|
+
↵ open menu (Website / GitHub)
|
|
16
|
+
w open ecklf.com
|
|
17
|
+
g open selected project on GitHub
|
|
18
|
+
q quit
|
|
19
|
+
`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
23
|
+
console.log("1.0.0");
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
if (!process.stdin.isTTY) {
|
|
27
|
+
console.error("ecklf is an interactive terminal app — please run it directly in a terminal.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
// Enter alternate screen buffer so the TUI doesn't clutter scrollback.
|
|
31
|
+
process.stdout.write("\x1b[?1049h");
|
|
32
|
+
const { waitUntilExit } = render(_jsx(App, {}));
|
|
33
|
+
waitUntilExit().then(() => {
|
|
34
|
+
process.stdout.write("\x1b[?1049l");
|
|
35
|
+
});
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const SITE_URL = "https://ecklf.com";
|
|
2
|
+
/**
|
|
3
|
+
* Fallback data (last known good snapshot of ecklf.com) used when the
|
|
4
|
+
* site cannot be reached. Keeps the TUI useful offline.
|
|
5
|
+
*/
|
|
6
|
+
const FALLBACK = {
|
|
7
|
+
name: "I'm Florentin",
|
|
8
|
+
tagline: "Full Stack Developer from Germany",
|
|
9
|
+
subtitle: "Compute Infrastructure ▲ Vercel",
|
|
10
|
+
source: "fallback",
|
|
11
|
+
projects: [
|
|
12
|
+
{ title: "tailwindcss-radix", description: "Utilities and variants for styling Radix state", github: "https://github.com/ecklf/tailwindcss-radix" },
|
|
13
|
+
{ title: "tailwindcss-base-ui", description: "Utilities and variants for styling Base UI state", github: "https://github.com/ecklf/tailwindcss-base-ui" },
|
|
14
|
+
{ title: "tailwindcss-figma-kit", description: "Design Kit for Utility-First CSS", github: "https://github.com/ecklf/tailwindcss-figma-kit" },
|
|
15
|
+
{ title: "tailwindcss-figma-plugin", description: "Style importer for Figma", github: "https://github.com/ecklf/tailwindcss-figma-plugin" },
|
|
16
|
+
{ title: "flutters", description: "Flappy Bird + Doodle Jump", github: "https://github.com/ecklf/flutters" },
|
|
17
|
+
{ title: "coingecko-rs", description: "Rust client for the CoinGecko API", github: "https://github.com/ecklf/coingecko-rs" },
|
|
18
|
+
{ title: "macos-tags", description: "Rust library for modifying macOS tags", github: "https://github.com/ecklf/macos-tags" },
|
|
19
|
+
{ title: "reddit-clawler", description: "A simple Reddit Crawler written in Rust", github: "https://github.com/ecklf/reddit-clawler" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
function decodeEntities(input) {
|
|
23
|
+
return input
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">")
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/'|'/g, "'")
|
|
29
|
+
.replace(/'/g, "'")
|
|
30
|
+
.replace(/ /g, " ");
|
|
31
|
+
}
|
|
32
|
+
function stripTags(input) {
|
|
33
|
+
return decodeEntities(input.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse the server-rendered ecklf.com markup. The project list lives inside
|
|
37
|
+
* <li> elements that each contain an <h3> title, a <p> description and an
|
|
38
|
+
* <a href="…github…"> link.
|
|
39
|
+
*/
|
|
40
|
+
export function parseSite(html) {
|
|
41
|
+
const projects = [];
|
|
42
|
+
const liRegex = /<li\b[^>]*>([\s\S]*?)<\/li>/gi;
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = liRegex.exec(html)) !== null) {
|
|
45
|
+
const block = match[1];
|
|
46
|
+
const hrefMatch = block.match(/href="(https:\/\/github\.com\/ecklf\/[^"]+)"/i);
|
|
47
|
+
if (!hrefMatch)
|
|
48
|
+
continue;
|
|
49
|
+
const titleMatch = block.match(/<h3\b[^>]*>([\s\S]*?)<\/h3>/i);
|
|
50
|
+
const descMatch = block.match(/<p\b[^>]*>([\s\S]*?)<\/p>/i);
|
|
51
|
+
if (!titleMatch)
|
|
52
|
+
continue;
|
|
53
|
+
const title = stripTags(titleMatch[1]);
|
|
54
|
+
const description = descMatch ? stripTags(descMatch[1]) : "";
|
|
55
|
+
const github = hrefMatch[1];
|
|
56
|
+
if (title && !projects.some((p) => p.github === github)) {
|
|
57
|
+
projects.push({ title, description, github });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return projects;
|
|
61
|
+
}
|
|
62
|
+
export async function fetchSiteData(timeoutMs = 6000) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(SITE_URL, {
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
headers: { "user-agent": "ecklf-cli" },
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
throw new Error(`HTTP ${res.status}`);
|
|
72
|
+
const html = await res.text();
|
|
73
|
+
const projects = parseSite(html);
|
|
74
|
+
if (projects.length === 0)
|
|
75
|
+
return FALLBACK;
|
|
76
|
+
return {
|
|
77
|
+
name: FALLBACK.name,
|
|
78
|
+
tagline: FALLBACK.tagline,
|
|
79
|
+
subtitle: FALLBACK.subtitle,
|
|
80
|
+
projects,
|
|
81
|
+
source: "live",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return FALLBACK;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export const websiteUrl = (project) => SITE_URL;
|
|
92
|
+
export { SITE_URL, FALLBACK };
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Drives a continuously increasing clock (seconds) using setInterval,
|
|
4
|
+
* suitable for animation. `fps` controls update frequency.
|
|
5
|
+
*/
|
|
6
|
+
export function useClock(fps = 20) {
|
|
7
|
+
const [t, setT] = useState(0);
|
|
8
|
+
const start = useRef(Date.now());
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const id = setInterval(() => {
|
|
11
|
+
setT((Date.now() - start.current) / 1000);
|
|
12
|
+
}, 1000 / fps);
|
|
13
|
+
return () => clearInterval(id);
|
|
14
|
+
}, [fps]);
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
export function useInterval(callback, ms) {
|
|
18
|
+
const saved = useRef(callback);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
saved.current = callback;
|
|
21
|
+
}, [callback]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const id = setInterval(() => saved.current(), ms);
|
|
24
|
+
return () => clearInterval(id);
|
|
25
|
+
}, [ms]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 3D torus-knot wireframe renderer -> ASCII braille grid.
|
|
2
|
+
// Mirrors the spinning wireframe on ecklf.com.
|
|
3
|
+
// Generate vertices along a (p,q) torus knot.
|
|
4
|
+
function torusKnot(samples, p = 2, q = 3, R = 1, r = 0.4) {
|
|
5
|
+
const pts = [];
|
|
6
|
+
for (let i = 0; i < samples; i++) {
|
|
7
|
+
const u = (i / samples) * Math.PI * 2;
|
|
8
|
+
const cu = Math.cos(q * u);
|
|
9
|
+
const x = (R + r * cu) * Math.cos(p * u);
|
|
10
|
+
const y = (R + r * cu) * Math.sin(p * u);
|
|
11
|
+
const z = r * Math.sin(q * u);
|
|
12
|
+
pts.push({ x, y, z });
|
|
13
|
+
}
|
|
14
|
+
return pts;
|
|
15
|
+
}
|
|
16
|
+
function rotate(p, ax, ay, az) {
|
|
17
|
+
// X
|
|
18
|
+
let { x, y, z } = p;
|
|
19
|
+
let cy = Math.cos(ax), sy = Math.sin(ax);
|
|
20
|
+
let ny = y * cy - z * sy;
|
|
21
|
+
let nz = y * sy + z * cy;
|
|
22
|
+
y = ny;
|
|
23
|
+
z = nz;
|
|
24
|
+
// Y
|
|
25
|
+
cy = Math.cos(ay);
|
|
26
|
+
sy = Math.sin(ay);
|
|
27
|
+
let nx = x * cy + z * sy;
|
|
28
|
+
nz = -x * sy + z * cy;
|
|
29
|
+
x = nx;
|
|
30
|
+
z = nz;
|
|
31
|
+
// Z
|
|
32
|
+
cy = Math.cos(az);
|
|
33
|
+
sy = Math.sin(az);
|
|
34
|
+
nx = x * cy - y * sy;
|
|
35
|
+
ny = x * sy + y * cy;
|
|
36
|
+
x = nx;
|
|
37
|
+
y = ny;
|
|
38
|
+
return { x, y, z };
|
|
39
|
+
}
|
|
40
|
+
// Braille dot bit layout (2 cols x 4 rows per cell).
|
|
41
|
+
const BRAILLE_OFFSETS = [
|
|
42
|
+
[0x01, 0x08],
|
|
43
|
+
[0x02, 0x10],
|
|
44
|
+
[0x04, 0x20],
|
|
45
|
+
[0x40, 0x80],
|
|
46
|
+
];
|
|
47
|
+
const SAMPLES = 240;
|
|
48
|
+
const KNOT = torusKnot(SAMPLES);
|
|
49
|
+
/**
|
|
50
|
+
* Render the rotating knot to a string. `cols`/`rows` are character cell
|
|
51
|
+
* dimensions; the braille grid is 2x wider and 4x taller in dots.
|
|
52
|
+
*/
|
|
53
|
+
export function renderKnot(t, cols, rows) {
|
|
54
|
+
const dotW = cols * 2;
|
|
55
|
+
const dotH = rows * 4;
|
|
56
|
+
const grid = new Uint8Array(cols * rows);
|
|
57
|
+
const ax = t * 0.7;
|
|
58
|
+
const ay = t * 1.0;
|
|
59
|
+
const az = t * 0.3;
|
|
60
|
+
const scale = Math.min(dotW, dotH) * 0.32;
|
|
61
|
+
const cx = dotW / 2;
|
|
62
|
+
const cy = dotH / 2;
|
|
63
|
+
const persp = 3.2;
|
|
64
|
+
const projected = new Array(SAMPLES);
|
|
65
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
66
|
+
const r = rotate(KNOT[i], ax, ay, az);
|
|
67
|
+
const depth = persp / (persp + r.z);
|
|
68
|
+
const px = cx + r.x * scale * depth;
|
|
69
|
+
// terminal cells ~2x taller than wide -> compress vertical a touch
|
|
70
|
+
const py = cy + r.y * scale * depth * 1.0;
|
|
71
|
+
projected[i] = { x: px, y: py };
|
|
72
|
+
}
|
|
73
|
+
const plot = (x, y) => {
|
|
74
|
+
const xi = Math.round(x);
|
|
75
|
+
const yi = Math.round(y);
|
|
76
|
+
if (xi < 0 || xi >= dotW || yi < 0 || yi >= dotH)
|
|
77
|
+
return;
|
|
78
|
+
const cellX = xi >> 1;
|
|
79
|
+
const cellY = yi >> 2;
|
|
80
|
+
const idx = cellY * cols + cellX;
|
|
81
|
+
const bit = BRAILLE_OFFSETS[yi & 3][xi & 1];
|
|
82
|
+
grid[idx] |= bit;
|
|
83
|
+
};
|
|
84
|
+
// Draw line segments between consecutive (looping) knot points.
|
|
85
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
86
|
+
const a = projected[i];
|
|
87
|
+
const b = projected[(i + 1) % SAMPLES];
|
|
88
|
+
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)));
|
|
89
|
+
for (let s = 0; s <= steps; s++) {
|
|
90
|
+
const f = s / steps;
|
|
91
|
+
plot(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let out = "";
|
|
95
|
+
for (let row = 0; row < rows; row++) {
|
|
96
|
+
for (let col = 0; col < cols; col++) {
|
|
97
|
+
const v = grid[row * cols + col];
|
|
98
|
+
out += v === 0 ? " " : String.fromCharCode(0x2800 + v);
|
|
99
|
+
}
|
|
100
|
+
if (row < rows - 1)
|
|
101
|
+
out += "\n";
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Open a URL in the user's default browser. Cross-platform, dependency-free.
|
|
4
|
+
*/
|
|
5
|
+
export function openUrl(url) {
|
|
6
|
+
const platform = process.platform;
|
|
7
|
+
let command;
|
|
8
|
+
let args;
|
|
9
|
+
if (platform === "darwin") {
|
|
10
|
+
command = "open";
|
|
11
|
+
args = [url];
|
|
12
|
+
}
|
|
13
|
+
else if (platform === "win32") {
|
|
14
|
+
command = "cmd";
|
|
15
|
+
args = ["/c", "start", "", url];
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
command = "xdg-open";
|
|
19
|
+
args = [url];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn(command, args, {
|
|
23
|
+
stdio: "ignore",
|
|
24
|
+
detached: true,
|
|
25
|
+
});
|
|
26
|
+
child.on("error", () => {
|
|
27
|
+
/* swallow — nothing we can do in a TUI */
|
|
28
|
+
});
|
|
29
|
+
child.unref();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ecklf",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A terminal UI for ecklf.com — browse Florentin's projects and open them on the web or GitHub, with a spinning ASCII wireframe and typed greetings.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ecklf",
|
|
7
|
+
"cli",
|
|
8
|
+
"tui",
|
|
9
|
+
"ink",
|
|
10
|
+
"portfolio",
|
|
11
|
+
"terminal"
|
|
12
|
+
],
|
|
13
|
+
"author": "Florentin Eckl <hi@ecklf.com> (https://ecklf.com)",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://ecklf.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/ecklf/ecklf.git"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"packageManager": "pnpm@10.33.0",
|
|
22
|
+
"bin": {
|
|
23
|
+
"ecklf": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"dev": "tsc --watch",
|
|
34
|
+
"start": "node dist/cli.js",
|
|
35
|
+
"prepublishOnly": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"ink": "^5.0.1",
|
|
39
|
+
"react": "^18.3.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^26.0.1",
|
|
43
|
+
"@types/react": "^18.3.12",
|
|
44
|
+
"typescript": "^5.6.3"
|
|
45
|
+
}
|
|
46
|
+
}
|