Initial commit
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"git.ignoreLimitWarning": true
|
||||||
|
}
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
9
astro.config.mjs
Normal 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
9
docker-compose.dev.yml
Normal 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
8
docker-compose.yml
Normal 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
6100
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
155
src/components/Navbar.astro
Normal 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>
|
||||||
90
src/components/TuiButton.astro
Normal file
90
src/components/TuiButton.astro
Normal 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>
|
||||||
152
src/components/TuiSegment.astro
Normal file
152
src/components/TuiSegment.astro
Normal 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>
|
||||||
205
src/components/TuiSlider.astro
Normal file
205
src/components/TuiSlider.astro
Normal 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>
|
||||||
109
src/components/TuiToggle.astro
Normal file
109
src/components/TuiToggle.astro
Normal 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>
|
||||||
24
src/content/blog/first-post.md
Normal file
24
src/content/blog/first-post.md
Normal 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
15
src/content/config.ts
Normal 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
43
src/layouts/Layout.astro
Normal 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>
|
||||||
48
src/pages/api-proxy/[...path].ts
Normal file
48
src/pages/api-proxy/[...path].ts
Normal 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
132
src/pages/blog/[slug].astro
Normal 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>
|
||||||
99
src/pages/blog/index.astro
Normal file
99
src/pages/blog/index.astro
Normal 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
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
132
src/scripts/anime-api.js
Normal 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
615
src/scripts/ascii.js
Normal 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 === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : 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
59
src/styles/global.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user