Initial commit

This commit is contained in:
syntaxbullet
2026-02-09 12:54:10 +01:00
commit bfefaa0055
23 changed files with 9076 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Build output
dist/
.astro/
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# VS Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS runtime
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

9
astro.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
});

9
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
web:
build: .
volumes:
- .:/app
- /app/node_modules
command: npm run dev -- --host
environment:
- NODE_ENV=development

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
web:
build: .
ports:
- "4321:4321"
environment:
- PORT=4321
- HOST=0.0.0.0

6100
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "website",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "astro dev",
"start": "node ./dist/server/entry.mjs",
"build": "astro check && astro build",
"preview": "astro preview",
"docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.2",
"astro": "^5.17.1",
"gifuct-js": "^2.1.2",
"typescript": "^5.9.3"
}
}

155
src/components/Navbar.astro Normal file
View File

@@ -0,0 +1,155 @@
---
const { pathname } = Astro.url;
---
<div class="system-status-bar">
<div class="status-left">
<div class="status-item brand">SYNTAXBULLET</div>
<a
href="/"
class:list={[
"status-item",
"nav-link",
{ active: pathname === "/" },
]}
>
HOME
</a>
<a
href="/blog"
class:list={[
"status-item",
"nav-link",
{
active:
pathname === "/blog" || pathname.startsWith("/blog/"),
},
]}
>
BLOG
</a>
<a
href="https://github.com"
target="_blank"
class="status-item nav-link"
>
GIT
</a>
</div>
<div class="status-right">
<div class="status-item">
<span class="prefix">UTC:</span>
<span id="clock">00:00:00</span>
</div>
<div class="status-item">
<span id="system-status-label">SYS:</span>
<span id="system-status">OK</span>
</div>
</div>
</div>
<style>
.system-status-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 24px;
background: #000;
border-bottom: 1px solid var(--text-color);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
font-size: 11px;
font-family: var(--font-mono);
z-index: 9999;
box-sizing: border-box;
}
.status-left,
.status-right {
display: flex;
align-items: center;
height: 100%;
}
.status-item {
padding: 0 12px;
height: 100%;
display: flex;
align-items: center;
color: var(--text-color);
text-decoration: none;
border-right: 1px solid rgba(255, 103, 0, 0.2);
transition: all 0.1s;
}
.nav-link:hover {
background: var(--text-color);
color: #000;
text-decoration: none;
}
.nav-link:hover .nav-index {
color: #000;
opacity: 1;
}
.status-item.active {
background: var(--text-color);
color: #000;
font-weight: bold;
}
.status-item.brand {
background: rgba(255, 103, 0, 0.1);
font-weight: 900;
}
.nav-index {
font-size: 9px;
opacity: 0.5;
margin-right: 6px;
border: 1px solid currentColor;
padding: 0 3px;
line-height: 1;
}
.status-item.active .nav-index {
opacity: 1;
}
.status-right .status-item {
border-right: none;
border-left: 1px solid rgba(255, 103, 0, 0.2);
}
.prefix {
opacity: 0.6;
margin-right: 6px;
font-weight: bold;
}
#system-status {
color: #0f0;
font-weight: bold;
}
</style>
<script>
function updateClock() {
const clock = document.getElementById("clock");
if (clock) {
const now = new Date();
clock.textContent =
now.getUTCHours().toString().padStart(2, "0") +
":" +
now.getUTCMinutes().toString().padStart(2, "0") +
":" +
now.getUTCSeconds().toString().padStart(2, "0");
}
}
setInterval(updateClock, 1000);
updateClock();
</script>

View File

@@ -0,0 +1,90 @@
---
interface Props {
id: string;
label: string;
shortcut?: string;
variant?: "default" | "primary" | "subtle";
title?: string;
}
const { id, label, shortcut, variant = "default", title = "" } = Astro.props;
---
<button
type="button"
class:list={["tui-button", `tui-button--${variant}`]}
id={id}
title={title}
>
{shortcut && <span class="tui-button-shortcut">{shortcut}</span>}
<span class="tui-button-label">{label}</span>
</button>
<style>
.tui-button {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid rgba(255, 103, 0, 0.4);
color: var(--text-color);
font-family: inherit;
font-size: 11px;
padding: 3px 10px;
cursor: pointer;
opacity: 0.8;
transition: all 0.15s;
user-select: none;
}
.tui-button:hover {
opacity: 1;
border-color: var(--text-color);
background: rgba(255, 103, 0, 0.1);
}
.tui-button:active {
background: rgba(255, 103, 0, 0.2);
}
.tui-button--primary {
border-color: var(--text-color);
background: rgba(255, 103, 0, 0.1);
}
.tui-button--primary:hover {
background: var(--text-color);
color: #000;
}
.tui-button--subtle {
border-color: transparent;
opacity: 0.6;
}
.tui-button--subtle:hover {
border-color: rgba(255, 103, 0, 0.3);
opacity: 1;
}
.tui-button-shortcut {
font-size: 9px;
opacity: 0.6;
padding: 0 3px;
border: 1px solid currentColor;
line-height: 1.2;
border-radius: 2px;
}
.tui-button:hover .tui-button-shortcut {
opacity: 1;
}
.tui-button--primary:hover .tui-button-shortcut {
border-color: #000;
}
.tui-button-label {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,152 @@
---
interface Props {
id: string;
label: string;
options: string[];
value?: string;
title?: string;
}
const { id, label, options, value = options[0], title = "" } = Astro.props;
---
<div class="tui-segment" data-segment-id={id} title={title}>
<span class="tui-segment-label">{label}</span>
<div class="tui-segment-options" id={id} data-value={value}>
{
options.map((opt, i) => (
<button
type="button"
class:list={[
"tui-segment-option",
{ active: opt === value },
]}
data-value={opt}
>
{opt}
</button>
))
}
</div>
</div>
<style>
.tui-segment {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
user-select: none;
}
.tui-segment-label {
min-width: 3ch;
font-weight: bold;
opacity: 0.7;
}
.tui-segment-options {
display: flex;
border: 1px solid rgba(255, 103, 0, 0.3);
}
.tui-segment-option {
background: none;
border: none;
border-right: 1px solid rgba(255, 103, 0, 0.2);
color: var(--text-color);
font-family: inherit;
font-size: inherit;
padding: 2px 8px;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
min-width: 3ch;
text-align: center;
}
.tui-segment-option:last-child {
border-right: none;
}
.tui-segment-option:hover {
opacity: 0.8;
background: rgba(255, 103, 0, 0.1);
}
.tui-segment-option.active {
background: var(--text-color);
color: #000;
opacity: 1;
font-weight: bold;
}
/* Hover the whole group */
.tui-segment:hover .tui-segment-label {
opacity: 1;
}
.tui-segment:hover .tui-segment-options {
border-color: var(--text-color);
}
</style>
<script>
function initSegments() {
document
.querySelectorAll(".tui-segment")
.forEach((segmentContainer) => {
const optionsContainer = segmentContainer.querySelector(
".tui-segment-options",
) as HTMLElement;
const buttons = segmentContainer.querySelectorAll(
".tui-segment-option",
);
if (!optionsContainer) return;
buttons.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const value = (btn as HTMLElement).dataset.value;
// Update active state
buttons.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// Update data attribute
optionsContainer.dataset.value = value;
// Dispatch custom event
optionsContainer.dispatchEvent(
new CustomEvent("segment-change", {
detail: { value },
bubbles: true,
}),
);
});
});
});
}
document.addEventListener("DOMContentLoaded", initSegments);
initSegments();
// Expose update function globally
(window as any).updateSegmentValue = function (
segmentId: string,
newValue: string,
) {
const container = document.getElementById(segmentId) as HTMLElement;
if (container) {
const buttons = container.querySelectorAll(".tui-segment-option");
buttons.forEach((btn) => {
btn.classList.toggle(
"active",
(btn as HTMLElement).dataset.value === newValue,
);
});
container.dataset.value = newValue;
}
};
</script>

View File

@@ -0,0 +1,205 @@
---
interface Props {
id: string;
label: string;
min?: number;
max?: number;
step?: number;
value?: number;
title?: string;
}
const {
id,
label,
min = 0,
max = 5,
step = 0.1,
value = 1.0,
title = "",
} = Astro.props;
// Generate slider visual (12 segments for better resolution)
const segments = 12;
---
<div class="tui-slider" data-slider-id={id} title={title}>
<span class="tui-slider-label">{label}</span>
<div class="tui-slider-track-wrapper">
<div class="tui-slider-visual">
<span class="tui-slider-track" data-for={id}>
{
Array(segments)
.fill(null)
.map((_, i) => (
<span class="tui-slider-segment" data-index={i}>
-
</span>
))
}
</span>
</div>
<input
type="range"
id={id}
class="tui-slider-input"
min={min}
max={max}
step={step}
value={value}
/>
</div>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(1)}</span>
</div>
<style>
.tui-slider {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
user-select: none;
}
.tui-slider-label {
min-width: 3ch;
font-weight: bold;
opacity: 0.7;
}
.tui-slider-track-wrapper {
position: relative;
display: flex;
align-items: center;
}
.tui-slider-visual {
display: flex;
align-items: center;
pointer-events: none;
z-index: 1;
}
.tui-slider-track {
display: flex;
letter-spacing: -1px;
font-family: monospace;
}
.tui-slider-segment {
transition: color 0.1s;
color: rgba(255, 103, 0, 0.25);
}
.tui-slider-segment.filled {
color: var(--text-color);
}
.tui-slider-segment.thumb {
color: #fff;
text-shadow: 0 0 4px var(--text-color);
}
.tui-slider-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: ew-resize;
margin: 0;
z-index: 2;
}
.tui-slider-value {
min-width: 3ch;
text-align: right;
font-weight: bold;
opacity: 0.9;
}
/* Hover effect */
.tui-slider:hover .tui-slider-label {
opacity: 1;
}
.tui-slider:hover .tui-slider-segment {
color: rgba(255, 103, 0, 0.4);
}
.tui-slider:hover .tui-slider-segment.filled {
color: var(--text-color);
}
.tui-slider:hover .tui-slider-segment.thumb {
color: #fff;
}
</style>
<script>
// Initialize all sliders
function initSliders() {
document.querySelectorAll(".tui-slider").forEach((sliderContainer) => {
const input = sliderContainer.querySelector(
".tui-slider-input",
) as HTMLInputElement;
const track = sliderContainer.querySelector(
".tui-slider-track",
) as HTMLElement;
const valueDisplay = sliderContainer.querySelector(
".tui-slider-value",
) as HTMLElement;
if (!input || !track || !valueDisplay) return;
const segments = track.querySelectorAll(".tui-slider-segment");
const segmentCount = segments.length;
function updateVisual() {
const min = parseFloat(input.min);
const max = parseFloat(input.max);
const val = parseFloat(input.value);
const percent = (val - min) / (max - min);
const thumbIndex = Math.round(percent * (segmentCount - 1));
segments.forEach((seg, i) => {
seg.classList.remove("filled", "thumb");
if (i < thumbIndex) {
// Filled portion uses = characters
seg.textContent = "=";
seg.classList.add("filled");
} else if (i === thumbIndex) {
// Thumb/handle is a pipe character
seg.textContent = "|";
seg.classList.add("thumb");
} else {
// Unfilled portion uses - characters
seg.textContent = "-";
}
});
valueDisplay.textContent = val.toFixed(1);
}
input.addEventListener("input", updateVisual);
updateVisual(); // Initial render
});
}
// Run on load and expose for dynamic re-init
document.addEventListener("DOMContentLoaded", initSliders);
initSliders();
// Expose update function globally for external value changes
(window as any).updateSliderVisual = function (
sliderId: string,
newValue: number,
) {
const input = document.getElementById(sliderId) as HTMLInputElement;
if (input) {
input.value = String(newValue);
input.dispatchEvent(new Event("input"));
}
};
</script>

View File

@@ -0,0 +1,109 @@
---
interface Props {
id: string;
label: string;
checked?: boolean;
title?: string;
}
const { id, label, checked = false, title = "" } = Astro.props;
---
<button
type="button"
class:list={["tui-toggle", { active: checked }]}
id={id}
data-checked={checked ? "true" : "false"}
title={title}
>
<span class="tui-toggle-label">{label}</span>
</button>
<style>
.tui-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid rgba(255, 103, 0, 0.3);
color: var(--text-color);
font-family: inherit;
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
user-select: none;
min-width: 3ch;
text-align: center;
}
.tui-toggle:hover {
opacity: 0.8;
background: rgba(255, 103, 0, 0.1);
}
.tui-toggle.active {
background: var(--text-color);
color: #000;
opacity: 1;
font-weight: bold;
border-color: var(--text-color);
}
.tui-toggle-label {
font-weight: bold;
}
</style>
<script>
// Use a WeakSet to track initialized toggles (prevents duplicate listeners)
const initializedToggles = new WeakSet<Element>();
function initToggles() {
document.querySelectorAll(".tui-toggle").forEach((toggle) => {
// Skip if already initialized
if (initializedToggles.has(toggle)) return;
initializedToggles.add(toggle);
const btn = toggle as HTMLButtonElement;
btn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
const isChecked = this.dataset.checked === "true";
const newState = !isChecked;
// Update visual state
this.dataset.checked = String(newState);
this.classList.toggle("active", newState);
// Dispatch custom event that bubbles
this.dispatchEvent(
new CustomEvent("toggle-change", {
detail: { checked: newState },
bubbles: true,
composed: true,
}),
);
});
});
}
// Initialize immediately and on DOMContentLoaded
initToggles();
document.addEventListener("DOMContentLoaded", initToggles);
// Expose update function globally
(window as any).updateToggleState = function (
toggleId: string,
newState: boolean,
) {
const btn = document.getElementById(toggleId) as HTMLButtonElement;
if (btn) {
btn.dataset.checked = String(newState);
btn.classList.toggle("active", newState);
}
};
</script>

View File

@@ -0,0 +1,24 @@
---
title: 'System Initialization'
description: 'Bootstrapping the Neko ASCII Generator.'
pubDate: '2026-02-08'
heroImage: '/blog/boot.png'
---
## Initializing Core Systems...
The Neko ASCII Auto-Generator has been successfully migrated to the Astro framework.
### Features
- Real-time image processing
- CLI-inspired controls
- Dynamic font scaling
- Automatic parameter tuning based on image histogram
### Changelog v2.0
- Migrated from vanilla HTML/JS to Astro
- Added Blog module
- Improved mobile responsiveness
Running diagnostics... **OK**
Systems online.

15
src/content/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
});
export const collections = { blog };

43
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,43 @@
---
import Navbar from '../components/Navbar.astro';
interface Props {
title: string;
showScroll?: boolean;
}
const { title, showScroll = false } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"
rel="stylesheet">
<title>{title}</title>
<style is:global>
@import "../styles/global.css";
</style>
<style define:vars={{ overflow: showScroll ? 'auto' : 'hidden' }}>
body {
overflow: var(--overflow);
height: 100vh;
width: 100vw;
padding-top: 24px;
box-sizing: border-box;
}
</style>
</head>
<body>
<Navbar />
<slot />
</body>
</html>

View File

@@ -0,0 +1,48 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params, request }) => {
const path = params.path;
if (!path) {
return new Response('Missing path', { status: 400 });
}
const targetUrl = path.startsWith('http') ? path : `https://${path}`;
const url = new URL(request.url);
const search = url.search; // keep query params
try {
const response = await fetch(`${targetUrl}${search}`, {
headers: {
'User-Agent': 'Mozilla/5.0',
// Optional: forward other safe headers if needed
}
});
const newHeaders = new Headers(response.headers);
// Remove hop-by-hop headers and specific ones we don't want
const banned = ['content-encoding', 'transfer-encoding', 'content-length', 'connection', 'access-control-allow-origin'];
banned.forEach(h => newHeaders.delete(h));
// Add CORS headers just in case we hit it externally or from debug tools
newHeaders.set('Access-Control-Allow-Origin', '*');
newHeaders.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
} catch (err) {
return new Response(String(err), { status: 500 });
}
}
export const OPTIONS: APIRoute = async () => {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, Content-Type'
}
});
}

132
src/pages/blog/[slug].astro Normal file
View File

@@ -0,0 +1,132 @@
---
import { getEntry } from "astro:content";
import Layout from "../../layouts/Layout.astro";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/404");
}
const entry = await getEntry("blog", slug);
if (!entry) {
return Astro.redirect("/404");
}
const { Content } = await entry.render();
---
<Layout title={entry.data.title} showScroll={true}>
<main>
<article>
<section class="h-entry">
<header>
<h1 class="p-name">{entry.data.title}</h1>
<div class="metadata">
<time
class="dt-published"
datetime={entry.data.pubDate.toISOString()}
>
{entry.data.pubDate.toISOString().slice(0, 10)}
</time>
{
entry.data.updatedDate && (
<div class="last-updated">
Last updated on{" "}
<time>
{entry.data.updatedDate
.toISOString()
.slice(0, 10)}
</time>
</div>
)
}
</div>
</header>
<div class="e-content">
<Content />
</div>
</section>
</article>
</main>
</Layout>
<style>
main {
width: calc(100% - 2em);
max-width: 800px;
margin: 0;
padding: 2em;
}
header {
margin-bottom: 2rem;
}
header a {
display: inline-block;
margin-bottom: 1rem;
color: var(--text-color);
opacity: 0.6;
font-family: var(--font-mono);
}
header a:hover {
opacity: 1;
}
.title {
font-size: 2em;
margin: 0.25em 0 0;
}
hr {
border-top: 1px solid var(--text-color);
opacity: 0.3;
margin: 1rem 0;
}
.metadata {
font-family: var(--font-mono);
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem;
}
/* Markdown Styles */
.e-content {
line-height: 1.6;
font-family: var(--font-mono); /* Keep vibe */
font-size: 1rem;
}
.e-content :global(h1),
.e-content :global(h2),
.e-content :global(h3),
.e-content :global(h4) {
margin-top: 2rem;
margin-bottom: 1rem;
color: var(--text-color);
font-weight: bold;
}
.e-content :global(a) {
color: var(--text-color);
text-decoration: underline;
}
.e-content :global(code) {
background: #111;
padding: 2px 5px;
border-radius: 2px;
font-family: var(--font-mono);
color: #fff;
}
.e-content :global(pre) {
background: #111;
padding: 1rem;
border: 1px solid #333;
overflow-x: auto;
}
</style>

View File

@@ -0,0 +1,99 @@
---
import Layout from "../../layouts/Layout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
const posts = (await getCollection("blog")).sort(
(a: CollectionEntry<"blog">, b: CollectionEntry<"blog">) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<Layout title="System Logs" showScroll={true}>
<main>
<section>
<ul>
{
posts.map((post: any) => (
<li>
<a href={`/blog/${post.slug}/`}>
<span class="date">
[
{post.data.pubDate
.toISOString()
.slice(0, 10)}
]
</span>
<span class="title">{post.data.title}</span>
<span class="desc">
// {post.data.description}
</span>
</a>
</li>
))
}
</ul>
</section>
<footer>
<p>END OF STREAM</p>
</footer>
</main>
</Layout>
<style>
main {
width: 960px;
max-width: calc(100% - 2em);
margin: 0 auto;
padding: 2em 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin-bottom: 1rem;
border-left: 2px solid transparent;
transition: border-left-color 0.2s;
}
li:hover {
border-left-color: var(--text-color);
}
a {
display: block;
text-decoration: none;
padding: 5px 10px;
color: var(--text-color);
font-family: var(--font-mono);
}
.date {
color: rgba(255, 103, 0, 0.6);
margin-right: 1rem;
}
.title {
font-weight: bold;
margin-right: 1rem;
}
.desc {
color: rgba(255, 103, 0, 0.4);
font-style: italic;
}
a:hover .title {
text-decoration: underline;
}
footer {
margin-top: 4rem;
text-align: center;
opacity: 0.3;
font-size: 0.8rem;
}
</style>

1004
src/pages/index.astro Normal file

File diff suppressed because it is too large Load Diff

132
src/scripts/anime-api.js Normal file
View File

@@ -0,0 +1,132 @@
/**
* @typedef {Object} AnimeImage
* @property {string} url - The URL of the image.
* @property {string} [artist] - The artist name if available.
* @property {string} [sourceUrl] - The source URL of the artwork.
* @property {Object} [meta] - Original metadata object from the API.
*/
/**
* Available categories from nekos.best API.
* All images are SFW.
* @type {readonly string[]}
*/
export const CATEGORIES = [
'waifu', 'neko', 'kitsune', 'husbando'
];
/**
* Fetches a random anime image from the nekos.best API.
* All images from this API are guaranteed SFW.
*
* @param {Object} options - Fetch options.
* @param {string} [options.category='waifu'] - Image category ('waifu', 'neko', 'kitsune', 'husbando').
* @param {number} [options.amount=1] - Number of images to fetch (1-20).
* @returns {Promise<AnimeImage>} The fetched image data.
*/
export async function fetchRandomAnimeImage(options = {}) {
const {
category = 'waifu',
amount = 1
} = options;
// Validate amount (API allows 1-20)
const validAmount = Math.max(1, Math.min(20, amount));
// nekos.best API base URL
const apiBase = 'https://nekos.best/api/v2';
// Construct URL with category and optional amount
let url = `${apiBase}/${category}`;
if (validAmount > 1) {
url += `?amount=${validAmount}`;
}
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Validate response structure
// Expected: { results: [{ url, artist_name, artist_href, source_url }] }
if (!data.results || !Array.isArray(data.results) || data.results.length === 0) {
throw new Error('Invalid API response format: No results found.');
}
const image = data.results[0];
if (!image.url) {
throw new Error('Invalid API response format: No image URL found.');
}
return {
url: image.url,
artist: image.artist_name || undefined,
sourceUrl: image.source_url || undefined,
meta: image
};
} catch (error) {
console.error('Failed to fetch anime image:', error);
throw error;
}
}
/**
* Fetches multiple random anime images from the nekos.best API.
* All images from this API are guaranteed SFW.
*
* @param {Object} options - Fetch options.
* @param {string} [options.category='waifu'] - Image category ('waifu', 'neko', 'kitsune', 'husbando').
* @param {number} [options.amount=5] - Number of images to fetch (1-20).
* @returns {Promise<AnimeImage[]>} Array of fetched image data.
*/
export async function fetchMultipleAnimeImages(options = {}) {
const {
category = 'waifu',
amount = 5
} = options;
// Validate amount (API allows 1-20)
const validAmount = Math.max(1, Math.min(20, amount));
const apiBase = 'https://nekos.best/api/v2';
const url = `${apiBase}/${category}?amount=${validAmount}`;
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data.results || !Array.isArray(data.results)) {
throw new Error('Invalid API response format: No results found.');
}
return data.results.map(image => ({
url: image.url,
artist: image.artist_name || undefined,
sourceUrl: image.source_url || undefined,
meta: image
}));
} catch (error) {
console.error('Failed to fetch anime images:', error);
throw error;
}
}

615
src/scripts/ascii.js Normal file
View File

@@ -0,0 +1,615 @@
/**
* @typedef {Object} AsciiOptions
* @property {number} [width] - Width of the ASCII output in characters.
* @property {number} [height] - Height of the ASCII output.
* @property {number} [contrast=1.0] - Contrast adjustment (0.0 to 5.0).
* @property {number} [exposure=1.0] - Exposure/Brightness adjustment (0.0 to 5.0).
* @property {boolean} [invert=false] - Whether to invert the colors.
* @property {number} [saturation=1.2] - Saturation adjustment (0.0 to 5.0).
* @property {number} [gamma=1.0] - Gamma correction value.
* @property {string} [charSet='standard'] - Key of CHAR_SETS or custom string.
* @property {boolean} [color=false] - If true, returns HTML with color spans.
* @property {boolean} [dither=false] - If true, applies Floyd-Steinberg dithering for smoother gradients.
* @property {boolean} [enhanceEdges=false] - If true, applies edge enhancement for line art.
* @property {boolean} [autoStretch=true] - If true, stretches histogram to use full character range.
* @property {number} [overlayStrength=0.3] - Strength of the overlay blend effect (0.0 to 1.0).
* @property {'fit'|'fill'|'stretch'} [aspectMode='fit'] - How to handle aspect ratio.
* @property {boolean} [denoise=false] - If true, applies a slight blur to reduce noise.
* @property {number} [fontAspectRatio=0.55] - Custom font aspect ratio for height calculation.
* @property {function} [onProgress] - Optional callback for progress updates (0-100).
*/
/**
* @typedef {Object} AsciiResult
* @property {string} output - The ASCII art string (plain text or HTML).
* @property {boolean} isHtml - Whether the output contains HTML color spans.
* @property {number} width - Width in characters.
* @property {number} height - Height in characters.
*/
export const CHAR_SETS = {
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ',
simple: '@%#*+=-:. ',
blocks: '█▓▒░ ',
minimal: '#+-. ',
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ '
};
/** Aspect mode options */
export const ASPECT_MODES = {
fit: 'fit', // Fits within given width/height (default)
fill: 'fill', // Fills the area, may crop
stretch: 'stretch' // Ignores aspect ratio
};
export class AsciiGenerator {
constructor() {
this.ctx = null;
this.canvas = null;
this.sharpCanvas = null;
this.sharpCtx = null;
this.denoiseCanvas = null;
this.denoiseCtx = null;
this.colorData = null; // Store color data for color output
}
/**
* Dispose of canvas resources.
* Call this when you're done using the generator to free memory.
*/
dispose() {
this.ctx = null;
this.sharpCtx = null;
this.denoiseCtx = null;
this.colorData = null;
if (this.canvas) {
this.canvas.width = 0;
this.canvas.height = 0;
this.canvas = null;
}
if (this.sharpCanvas) {
this.sharpCanvas.width = 0;
this.sharpCanvas.height = 0;
this.sharpCanvas = null;
}
if (this.denoiseCanvas) {
this.denoiseCanvas.width = 0;
this.denoiseCanvas.height = 0;
this.denoiseCanvas = null;
}
}
/**
* Converts an image to ASCII art.
* @param {string|HTMLImageElement} imageSource
* @param {AsciiOptions} options
* @returns {Promise<string|AsciiResult>} ASCII art string, or AsciiResult if color=true
*/
async generate(imageSource, options = {}) {
if (typeof document === 'undefined') {
throw new Error('AsciiGenerator requires a browser environment.');
}
const onProgress = options.onProgress || (() => { });
onProgress(0);
const img = await this.resolveImage(imageSource);
onProgress(10);
// Configuration
const requestedWidth = options.width || 100;
const fontAspectRatio = options.fontAspectRatio || 0.55;
const imgRatio = this.getImageRatio(img);
const aspectMode = options.aspectMode || 'fit';
// Calculate dimensions based on aspect mode
let width, height;
if (aspectMode === 'stretch') {
width = requestedWidth;
height = options.height || Math.floor(requestedWidth / 2);
} else if (aspectMode === 'fill') {
width = requestedWidth;
const naturalHeight = Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
height = options.height || naturalHeight;
// For fill, we'll handle cropping in the draw phase
} else {
// fit (default)
width = requestedWidth;
height = options.height || Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
}
// Resolve CharSet
let charSet = options.charSet || 'standard';
if (CHAR_SETS[charSet]) {
charSet = CHAR_SETS[charSet];
}
// Initialize Canvas
if (!this.canvas) {
this.canvas = document.createElement('canvas');
}
this.canvas.width = width;
this.canvas.height = height;
this.ctx = this.canvas.getContext('2d');
// Reuse offscreen canvas for memory efficiency
if (!this.sharpCanvas) {
this.sharpCanvas = document.createElement('canvas');
}
this.sharpCanvas.width = width;
this.sharpCanvas.height = height;
this.sharpCtx = this.sharpCanvas.getContext('2d');
const exposure = options.exposure ?? 1.0;
const contrast = options.contrast ?? 1.0;
const saturation = options.saturation ?? 1.2;
const gamma = options.gamma ?? 1.0;
const dither = options.dither ?? false;
const enhanceEdges = options.enhanceEdges ?? false;
const autoStretch = options.autoStretch !== false; // default true
const overlayStrength = options.overlayStrength ?? 0.3;
const denoise = options.denoise ?? false;
const colorOutput = options.color ?? false;
onProgress(20);
// Denoise pre-processing (slight blur to reduce noise)
let sourceImage = img;
if (denoise) {
if (!this.denoiseCanvas) {
this.denoiseCanvas = document.createElement('canvas');
}
this.denoiseCanvas.width = width;
this.denoiseCanvas.height = height;
this.denoiseCtx = this.denoiseCanvas.getContext('2d');
this.denoiseCtx.filter = 'blur(0.5px)';
this.denoiseCtx.drawImage(img, 0, 0, width, height);
sourceImage = this.denoiseCanvas;
}
// Calculate draw parameters for fill mode (center crop)
let sx = 0, sy = 0, sw = img.width, sh = img.height;
if (aspectMode === 'fill' && options.height) {
const targetRatio = width / (options.height * fontAspectRatio);
if (imgRatio > targetRatio) {
// Image is wider, crop sides
sw = img.height * targetRatio;
sx = (img.width - sw) / 2;
} else {
// Image is taller, crop top/bottom
sh = img.width / targetRatio;
sy = (img.height - sh) / 2;
}
}
this.sharpCtx.filter = `brightness(${exposure}) contrast(${contrast}) saturate(${saturation})`;
if (denoise && sourceImage === this.denoiseCanvas) {
this.sharpCtx.drawImage(sourceImage, 0, 0, width, height);
} else {
this.sharpCtx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
}
// Optional edge enhancement for line art (Laplacian-like sharpening)
if (enhanceEdges) {
this.sharpCtx.filter = 'none';
this.sharpCtx.globalCompositeOperation = 'source-over';
const edgeCanvas = document.createElement('canvas');
edgeCanvas.width = width;
edgeCanvas.height = height;
const edgeCtx = edgeCanvas.getContext('2d');
edgeCtx.filter = 'contrast(2) brightness(0.8)';
edgeCtx.drawImage(this.sharpCanvas, 0, 0);
this.sharpCtx.globalAlpha = 0.4;
this.sharpCtx.globalCompositeOperation = 'multiply';
this.sharpCtx.drawImage(edgeCanvas, 0, 0);
this.sharpCtx.globalCompositeOperation = 'source-over';
this.sharpCtx.globalAlpha = 1.0;
}
onProgress(40);
/**
* Filter stacking with overlay blend:
* This technique boosts mid-contrast by overlaying the image on itself.
* The strength is configurable via overlayStrength option.
*/
this.ctx.globalAlpha = 1.0;
this.ctx.drawImage(this.sharpCanvas, 0, 0);
if (overlayStrength > 0) {
this.ctx.globalCompositeOperation = 'overlay';
this.ctx.globalAlpha = overlayStrength;
this.ctx.drawImage(this.sharpCanvas, 0, 0);
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.globalAlpha = 1.0;
}
const imageData = this.ctx.getImageData(0, 0, width, height);
const pixels = imageData.data;
onProgress(50);
// Build luminance matrix for processing
const lumMatrix = new Float32Array(width * height);
let minLum = 1.0, maxLum = 0.0;
// Store color data if color output is requested
if (colorOutput) {
this.colorData = new Uint8Array(width * height * 3);
}
for (let i = 0; i < width * height; i++) {
const offset = i * 4;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
let lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
// Store original colors for color output
if (colorOutput) {
this.colorData[i * 3] = r;
this.colorData[i * 3 + 1] = g;
this.colorData[i * 3 + 2] = b;
}
// Gamma correction
if (gamma !== 1.0) {
lum = Math.pow(lum, gamma);
}
// Invert
if (options.invert) {
lum = 1 - lum;
}
lumMatrix[i] = lum;
if (lum < minLum) minLum = lum;
if (lum > maxLum) maxLum = lum;
}
onProgress(60);
// Histogram auto-stretch: normalize to use full character range
const lumRange = maxLum - minLum;
if (autoStretch && lumRange > 0.01) {
for (let i = 0; i < lumMatrix.length; i++) {
lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange;
}
}
// Floyd-Steinberg dithering (optional)
if (dither) {
const levels = charSet.length;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const oldVal = lumMatrix[i];
const newVal = Math.round(oldVal * (levels - 1)) / (levels - 1);
lumMatrix[i] = newVal;
const error = oldVal - newVal;
// Distribute error to neighboring pixels
if (x + 1 < width) lumMatrix[i + 1] += error * 7 / 16;
if (y + 1 < height) {
if (x > 0) lumMatrix[(y + 1) * width + (x - 1)] += error * 3 / 16;
lumMatrix[(y + 1) * width + x] += error * 5 / 16;
if (x + 1 < width) lumMatrix[(y + 1) * width + (x + 1)] += error * 1 / 16;
}
}
}
}
onProgress(80);
// Build output string
let output = '';
if (colorOutput) {
// HTML color output with spans
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const brightness = Math.max(0, Math.min(1, lumMatrix[i]));
const charIndex = Math.floor(brightness * (charSet.length - 1));
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
const char = charSet[safeIndex];
const r = this.colorData[i * 3];
const g = this.colorData[i * 3 + 1];
const b = this.colorData[i * 3 + 2];
// Escape HTML special characters
const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char;
output += `<span style="color:rgb(${r},${g},${b})">${safeChar}</span>`;
}
output += '\n';
}
} else {
// Plain text output
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const brightness = Math.max(0, Math.min(1, lumMatrix[y * width + x]));
const charIndex = Math.floor(brightness * (charSet.length - 1));
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
output += charSet[safeIndex];
}
output += '\n';
}
}
onProgress(100);
if (colorOutput) {
return {
output,
isHtml: true,
width,
height
};
}
return output;
}
getImageRatio(img) {
if (img.width && img.height) {
return img.width / img.height;
}
return 1;
}
resolveImage(src) {
return new Promise((resolve, reject) => {
if (src instanceof HTMLImageElement) {
if (src.complete) return resolve(src);
src.onload = () => resolve(src);
src.onerror = reject;
return;
}
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = src;
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
});
}
}
// Backward compatibility wrapper
export async function imageToAscii(imageSource, options = {}) {
const generator = new AsciiGenerator();
return generator.generate(imageSource, options);
}
/**
* Analyzes an image and returns suggested options (auto-tune).
* @param {HTMLImageElement} img
* @returns {AsciiOptions}
*/
/**
* Analyzes an image and returns suggested options (auto-tune).
* @param {HTMLImageElement} img
* @param {Object} [meta] - Optional metadata from API (dominant color, palette).
* @returns {AsciiOptions}
*/
export function autoTuneImage(img, meta = null) {
if (typeof document === 'undefined') return {};
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const size = 100;
canvas.width = size;
canvas.height = size;
ctx.drawImage(img, 0, 0, size, size);
const imageData = ctx.getImageData(0, 0, size, size);
const pixels = imageData.data;
const histogram = new Array(256).fill(0);
let totalLum = 0;
for (let i = 0; i < pixels.length; i += 4) {
const lum = Math.round(0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]);
histogram[lum]++;
totalLum += lum;
}
const pixelCount = pixels.length / 4;
const avgLum = totalLum / pixelCount;
let p5 = null, p95 = 255, count = 0;
for (let i = 0; i < 256; i++) {
count += histogram[i];
if (p5 === null && count > pixelCount * 0.05) p5 = i;
if (count > pixelCount * 0.95) { p95 = i; break; }
}
p5 = p5 ?? 0; // Ensure p5 has a value (handles edge case where luminance 0 is the 5th percentile)
const midPoint = (p5 + p95) / 2;
let exposure = 128 / Math.max(midPoint, 10);
exposure = Math.max(0.4, Math.min(2.8, exposure));
const activeRange = p95 - p5;
let contrast = 1.1;
if (activeRange < 50) contrast = 2.5;
else if (activeRange < 100) contrast = 1.8;
else if (activeRange < 150) contrast = 1.4;
let invert = false;
let saturation = 1.2;
let useEdgeDetection = true;
// improved via Metadata if available
if (meta) {
const { color_dominant, color_palette } = meta;
// 1. Invert based on Dominant Color (more reliable than edges for anime art)
if (color_dominant) {
const [r, g, b] = color_dominant;
const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
// If background/dominant color is bright, we likely need to invert
// so that dark lines become characters (white background -> black ink)
if (domLum > 140) {
invert = true;
useEdgeDetection = false;
}
}
// 2. Saturation based on Palette vibrancy
if (color_palette && Array.isArray(color_palette) && color_palette.length > 0) {
let totalSat = 0;
for (const [r, g, b] of color_palette) {
const max = Math.max(r, g, b);
const delta = max - Math.min(r, g, b);
const s = max === 0 ? 0 : delta / max;
totalSat += s;
}
const avgSat = totalSat / color_palette.length;
if (avgSat > 0.4) saturation = 1.6;
else if (avgSat < 0.1) saturation = 0.0;
else saturation = 1.2;
}
}
// Fallback to edge detection if metadata didn't decide inversion
if (useEdgeDetection) {
let edgeLumSum = 0;
let edgeCount = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (x < 5 || x >= size - 5 || y < 5 || y >= size - 5) {
const i = (y * size + x) * 4;
edgeLumSum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
edgeCount++;
}
}
}
const bgLum = edgeLumSum / edgeCount;
if (bgLum > 160) {
invert = true;
}
}
// Gamma < 1 lifts shadows (for dark images), gamma = 1 keeps bright images neutral
const gamma = avgLum < 80 ? 0.75 : 1.0;
// === SMART CHARSET RECOMMENDATION ===
// Analyze image characteristics to recommend the best charset
let recommendedCharSet = 'standard';
let denoise = false;
let enhanceEdges = false;
let overlayStrength = 0.3;
// Detect image type based on histogram distribution
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
const isHighContrast = activeRange > 180;
const isLowContrast = activeRange < 80;
const isBimodal = histogramPeaks <= 3;
// Check for line art characteristics (bimodal histogram, few colors)
if (isBimodal && activeRange > 150) {
recommendedCharSet = 'minimal';
enhanceEdges = true;
overlayStrength = 0.1; // Less overlay for line art
}
// High contrast images work well with blocks
else if (isHighContrast) {
recommendedCharSet = 'blocks';
overlayStrength = 0.2;
}
// Low contrast, possibly noisy images
else if (isLowContrast) {
recommendedCharSet = 'simple';
denoise = true;
overlayStrength = 0.5; // More overlay to boost contrast
}
// Photos with good tonal range
else if (activeRange > 100 && activeRange <= 180) {
recommendedCharSet = 'standard';
// Check for noise by looking at high-frequency variation
const noiseLevel = estimateNoiseLevel(pixels, size);
if (noiseLevel > 20) {
denoise = true;
}
}
// Use dots charset for images with lots of fine detail
if (meta && meta.has_fine_detail) {
recommendedCharSet = 'dots';
}
return {
exposure: parseFloat(exposure.toFixed(2)),
contrast,
invert,
gamma,
saturation: parseFloat(saturation.toFixed(1)),
charSet: recommendedCharSet,
denoise,
enhanceEdges,
overlayStrength
};
}
/**
* Count significant peaks in histogram for image type detection.
* @param {number[]} histogram - 256-bin luminance histogram
* @param {number} pixelCount - Total pixel count
* @returns {number} Number of significant peaks
*/
function countHistogramPeaks(histogram, pixelCount) {
const threshold = pixelCount * 0.02; // 2% of pixels
let peaks = 0;
let inPeak = false;
for (let i = 1; i < 255; i++) {
const isPeak = histogram[i] > histogram[i - 1] && histogram[i] > histogram[i + 1];
const isSignificant = histogram[i] > threshold;
if (isPeak && isSignificant && !inPeak) {
peaks++;
inPeak = true;
} else if (histogram[i] < threshold / 2) {
inPeak = false;
}
}
return peaks;
}
/**
* Estimate noise level in image by measuring local variance.
* @param {Uint8ClampedArray} pixels - Image pixel data
* @param {number} size - Image dimension
* @returns {number} Estimated noise level (0-100)
*/
function estimateNoiseLevel(pixels, size) {
let totalVariance = 0;
const samples = 100;
for (let s = 0; s < samples; s++) {
const x = Math.floor(Math.random() * (size - 2)) + 1;
const y = Math.floor(Math.random() * (size - 2)) + 1;
const i = (y * size + x) * 4;
// Get center and neighbor luminances
const center = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
const neighbors = [
(y - 1) * size + x,
(y + 1) * size + x,
y * size + (x - 1),
y * size + (x + 1)
].map(idx => {
const offset = idx * 4;
return 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2];
});
const avgNeighbor = neighbors.reduce((a, b) => a + b, 0) / 4;
totalVariance += Math.abs(center - avgNeighbor);
}
return totalVariance / samples;
}

59
src/styles/global.css Normal file
View File

@@ -0,0 +1,59 @@
:root {
--bg-color: #000000;
--text-color: #FF6700;
--font-mono: 'JetBrains Mono', monospace;
}
body {
font-family: var(--font-mono);
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Button styles that match vibe */
button {
background: none;
border: 1px solid var(--text-color);
color: var(--text-color);
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
opacity: 0.8;
transition: opacity 0.2s, background 0.2s, color 0.2s;
}
button:hover {
opacity: 1;
background: rgba(255, 103, 0, 0.1);
}
a {
color: var(--text-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Scrollbar styling for blog */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111;
}
::-webkit-scrollbar-thumb {
background: #333;
border: 1px solid var(--text-color);
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}