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