refactor: Implement mobile-friendly control panel toggle and remove keyboard shortcuts hint.
This commit is contained in:
@@ -6,226 +6,336 @@ import TuiButton from "./TuiButton.astro";
|
|||||||
---
|
---
|
||||||
|
|
||||||
<footer id="tui-controls" class="control-panel">
|
<footer id="tui-controls" class="control-panel">
|
||||||
<div class="control-panel-content">
|
<div class="mobile-controls-header">
|
||||||
<!-- Sliders Section -->
|
<span class="mobile-controls-title">CONTROLS</span>
|
||||||
<div class="panel-section sliders-section">
|
<svg
|
||||||
<div class="section-header">ADJUSTMENTS</div>
|
class="mobile-toggle-icon"
|
||||||
<div class="sliders-grid">
|
width="24"
|
||||||
<TuiSlider
|
height="24"
|
||||||
id="exposure"
|
viewBox="0 0 24 24"
|
||||||
label="EXP"
|
fill="none"
|
||||||
min={0}
|
stroke="currentColor"
|
||||||
max={3}
|
stroke-width="2"
|
||||||
step={0.01}
|
stroke-linecap="round"
|
||||||
value={1.0}
|
stroke-linejoin="round"
|
||||||
title="Exposure / Brightness"
|
>
|
||||||
description="Adjusts the overall brightness level of the input image before processing."
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
/>
|
</svg>
|
||||||
<TuiSlider
|
</div>
|
||||||
id="contrast"
|
|
||||||
label="CON"
|
|
||||||
min={0}
|
|
||||||
max={3}
|
|
||||||
step={0.01}
|
|
||||||
value={1.0}
|
|
||||||
title="Contrast"
|
|
||||||
description="Increases or decreases the difference between light and dark areas."
|
|
||||||
/>
|
|
||||||
<TuiSlider
|
|
||||||
id="saturation"
|
|
||||||
label="SAT"
|
|
||||||
min={0}
|
|
||||||
max={3}
|
|
||||||
step={0.01}
|
|
||||||
value={1.2}
|
|
||||||
title="Saturation"
|
|
||||||
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
|
|
||||||
/>
|
|
||||||
<TuiSlider
|
|
||||||
id="gamma"
|
|
||||||
label="GAM"
|
|
||||||
min={0}
|
|
||||||
max={3}
|
|
||||||
step={0.01}
|
|
||||||
value={1.0}
|
|
||||||
title="Gamma Correction"
|
|
||||||
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
|
|
||||||
/>
|
|
||||||
<TuiSlider
|
|
||||||
id="overlayStrength"
|
|
||||||
label="OVL"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={0.3}
|
|
||||||
title="Overlay Blend Strength"
|
|
||||||
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
|
|
||||||
/>
|
|
||||||
<TuiSlider
|
|
||||||
id="resolution"
|
|
||||||
label="RES"
|
|
||||||
min={0.1}
|
|
||||||
max={2}
|
|
||||||
step={0.01}
|
|
||||||
value={1.0}
|
|
||||||
title="Resolution Scale"
|
|
||||||
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
|
|
||||||
/>
|
|
||||||
<TuiSlider
|
|
||||||
id="dither"
|
|
||||||
label="DTH"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={0}
|
|
||||||
title="Dither Strength"
|
|
||||||
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-divider-v"></div>
|
<div class="control-panel-inner">
|
||||||
|
<div class="control-panel-content">
|
||||||
<!-- Toggles & Segments Section -->
|
<!-- Sliders Section -->
|
||||||
<div class="panel-section effects-section">
|
<div class="panel-section sliders-section">
|
||||||
<div class="sub-section">
|
<div class="section-header">ADJUSTMENTS</div>
|
||||||
<div class="section-header">EFFECTS</div>
|
<div class="sliders-grid">
|
||||||
<div class="toggles-row">
|
<TuiSlider
|
||||||
<TuiToggle
|
id="exposure"
|
||||||
id="toggle-color"
|
label="EXP"
|
||||||
label="CLR"
|
min={0}
|
||||||
title="Color Output (HTML)"
|
max={3}
|
||||||
description="Toggles between monochrome text and colored HTML spans."
|
step={0.01}
|
||||||
|
value={1.0}
|
||||||
|
title="Exposure / Brightness"
|
||||||
|
description="Adjusts the overall brightness level of the input image before processing."
|
||||||
/>
|
/>
|
||||||
|
<TuiSlider
|
||||||
<TuiToggle
|
id="contrast"
|
||||||
id="toggle-denoise"
|
label="CON"
|
||||||
label="DNZ"
|
min={0}
|
||||||
title="Denoise Pre-processing"
|
max={3}
|
||||||
description="Applies a bilateral filter to reduce image noise while preserving edges."
|
step={0.01}
|
||||||
|
value={1.0}
|
||||||
|
title="Contrast"
|
||||||
|
description="Increases or decreases the difference between light and dark areas."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="shadows"
|
||||||
|
label="SHD"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.0}
|
||||||
|
title="Shadow Lift"
|
||||||
|
description="Brightens dark areas to recover details in shadows."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="highlights"
|
||||||
|
label="HLT"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.0}
|
||||||
|
title="Highlight Dim"
|
||||||
|
description="Darkens bright areas to recover details in highlights."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="saturation"
|
||||||
|
label="SAT"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={1.2}
|
||||||
|
title="Saturation"
|
||||||
|
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="gamma"
|
||||||
|
label="GAM"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={1.0}
|
||||||
|
title="Gamma Correction"
|
||||||
|
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="sharpen"
|
||||||
|
label="SHP"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={0.0}
|
||||||
|
title="Sharpen"
|
||||||
|
description="Enhances edges and fine details using an unsharp mask filter."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="overlayStrength"
|
||||||
|
label="OVL"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.3}
|
||||||
|
title="Overlay Blend Strength"
|
||||||
|
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="resolution"
|
||||||
|
label="RES"
|
||||||
|
min={0.1}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={1.0}
|
||||||
|
title="Resolution Scale"
|
||||||
|
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="dither"
|
||||||
|
label="DTH"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0}
|
||||||
|
title="Dither Strength"
|
||||||
|
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="edgeThreshold"
|
||||||
|
label="THR"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.5}
|
||||||
|
title="Edge Threshold"
|
||||||
|
description="Sets the sensitivity for edge detection. Higher values detect only strong edges."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="scanlines"
|
||||||
|
label="SCN"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.0}
|
||||||
|
title="Scanlines"
|
||||||
|
description="Adds CRT-style scanline effect."
|
||||||
|
/>
|
||||||
|
<TuiSlider
|
||||||
|
id="vignette"
|
||||||
|
label="VIG"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={0.0}
|
||||||
|
title="Vignette"
|
||||||
|
description="Darkens the corners of the image."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sub-section">
|
<div class="panel-divider-v"></div>
|
||||||
<div class="section-header">OUTPUT</div>
|
|
||||||
<div class="segments-col">
|
<!-- Toggles & Segments Section -->
|
||||||
<TuiSegment
|
<div class="panel-section effects-section">
|
||||||
id="segment-invert"
|
<div class="sub-section">
|
||||||
label="INV"
|
<div class="section-header">EFFECTS</div>
|
||||||
options={["AUTO", "ON", "OFF"]}
|
<div class="toggles-row">
|
||||||
value="AUTO"
|
<TuiToggle
|
||||||
title="Invert Colors"
|
id="toggle-color"
|
||||||
description="Inverts brightness mapping. AUTO detects dark/light mode."
|
label="CLR"
|
||||||
/>
|
title="Color Output (HTML)"
|
||||||
<TuiSegment
|
description="Toggles between monochrome text and colored HTML spans."
|
||||||
id="segment-edge"
|
/>
|
||||||
label="EDG"
|
|
||||||
options={["OFF", "SPL", "SOB", "CNY"]}
|
<!-- Color Picker for Monochrome -->
|
||||||
value="OFF"
|
<div
|
||||||
title="Edge Detection Mode"
|
class="tui-color-btn"
|
||||||
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
|
title="Monochrome Tint Color"
|
||||||
/>
|
>
|
||||||
<TuiSegment
|
<input
|
||||||
id="segment-charset"
|
type="color"
|
||||||
label="SET"
|
id="input-mono-color"
|
||||||
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
|
value="#ffffff"
|
||||||
value="STD"
|
/>
|
||||||
title="Character Set"
|
<span id="color-swatch-display" class="color-swatch"
|
||||||
description="The set of characters used for mapping brightness levels."
|
></span>
|
||||||
/>
|
<span class="label">TINT</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TuiToggle
|
||||||
|
id="toggle-denoise"
|
||||||
|
label="DNZ"
|
||||||
|
title="Denoise Pre-processing"
|
||||||
|
description="Applies a bilateral filter to reduce image noise while preserving edges."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-divider-v"></div>
|
<div class="sub-section">
|
||||||
|
<div class="section-header">OUTPUT</div>
|
||||||
<!-- Right Column: Actions & Export -->
|
<div class="segments-col">
|
||||||
<div class="panel-section actions-section">
|
<TuiSegment
|
||||||
<div class="sub-section">
|
id="segment-invert"
|
||||||
<div class="section-header">ACTIONS</div>
|
label="INV"
|
||||||
<div class="actions-grid">
|
options={["AUTO", "ON", "OFF"]}
|
||||||
<TuiButton
|
value="AUTO"
|
||||||
id="btn-reset"
|
title="Invert Colors"
|
||||||
label="RESET"
|
description="Inverts brightness mapping. AUTO detects dark/light mode."
|
||||||
shortcut="R"
|
/>
|
||||||
title="Reset to Auto-detected Settings"
|
<TuiSegment
|
||||||
description="Resets all sliders and toggles to their default values."
|
id="segment-edge"
|
||||||
/>
|
label="EDG"
|
||||||
|
options={["OFF", "SPL", "SOB", "CNY"]}
|
||||||
<TuiButton
|
value="OFF"
|
||||||
id="btn-next"
|
title="Edge Detection Mode"
|
||||||
label="NEXT"
|
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
|
||||||
shortcut="N"
|
/>
|
||||||
variant="primary"
|
<TuiSegment
|
||||||
title="Load Next Image"
|
id="segment-charset"
|
||||||
description="Discards current image and loads a new one from the queue."
|
label="SET"
|
||||||
/>
|
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
|
||||||
|
value="STD"
|
||||||
<div
|
title="Character Set"
|
||||||
class="queue-display"
|
description="The set of characters used for mapping brightness levels."
|
||||||
data-tooltip-title="Buffered Images"
|
/>
|
||||||
data-tooltip-desc="Number of images pre-loaded in background queue."
|
|
||||||
>
|
|
||||||
<span class="queue-label">Q:</span>
|
|
||||||
<span id="val-queue" class="queue-value">0</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-divider-h"></div>
|
<div class="panel-divider-v"></div>
|
||||||
|
|
||||||
<div class="sub-section">
|
<!-- Right Column: Actions & Export -->
|
||||||
<div class="section-header">IMPORT / EXPORT</div>
|
<div class="panel-section actions-section">
|
||||||
<div class="actions-grid">
|
<div class="sub-section">
|
||||||
<!-- Hidden File Input -->
|
<div class="section-header">ACTIONS</div>
|
||||||
<input
|
<div class="actions-grid">
|
||||||
type="file"
|
<TuiButton
|
||||||
id="file-upload"
|
id="btn-reset"
|
||||||
accept="image/*"
|
label="RESET"
|
||||||
style="display: none;"
|
shortcut="R"
|
||||||
/>
|
title="Reset to Auto-detected Settings"
|
||||||
<TuiButton
|
description="Resets all sliders and toggles to their default values."
|
||||||
id="btn-import"
|
/>
|
||||||
label="IMP"
|
|
||||||
title="Import Image"
|
<TuiButton
|
||||||
description="Upload your own image from your device."
|
id="btn-next"
|
||||||
/>
|
label="NEXT"
|
||||||
<TuiButton
|
shortcut="N"
|
||||||
id="btn-save-png"
|
variant="primary"
|
||||||
label="PNG"
|
title="Load Next Image"
|
||||||
title="Save as Image"
|
description="Discards current image and loads a new one from the queue."
|
||||||
description="Download high-res PNG capture of the current view."
|
/>
|
||||||
/>
|
|
||||||
<TuiButton
|
<div
|
||||||
id="btn-copy-text"
|
class="queue-display"
|
||||||
label="TXT"
|
data-tooltip-title="Buffered Images"
|
||||||
title="Save as Text"
|
data-tooltip-desc="Number of images pre-loaded in background queue."
|
||||||
description="Download raw ASCII text file."
|
>
|
||||||
/>
|
<span class="queue-label">Q:</span>
|
||||||
<TuiButton
|
<span id="val-queue" class="queue-value">0</span>
|
||||||
id="btn-copy-html"
|
</div>
|
||||||
label="HTML"
|
</div>
|
||||||
title="Save as HTML"
|
</div>
|
||||||
description="Download colored HTML file."
|
|
||||||
/>
|
<div class="panel-divider-h"></div>
|
||||||
|
|
||||||
|
<div class="sub-section">
|
||||||
|
<div class="section-header">IMPORT / EXPORT</div>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<!-- Hidden File Input -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
<TuiButton
|
||||||
|
id="btn-import"
|
||||||
|
label="IMP"
|
||||||
|
title="Import Image"
|
||||||
|
description="Upload your own image from your device."
|
||||||
|
/>
|
||||||
|
<TuiButton
|
||||||
|
id="btn-save-png"
|
||||||
|
label="PNG"
|
||||||
|
title="Save as Image"
|
||||||
|
description="Download high-res PNG capture of the current view."
|
||||||
|
/>
|
||||||
|
<TuiButton
|
||||||
|
id="btn-copy-text"
|
||||||
|
label="TXT"
|
||||||
|
title="Save as Text"
|
||||||
|
description="Download raw ASCII text file."
|
||||||
|
/>
|
||||||
|
<TuiButton
|
||||||
|
id="btn-copy-html"
|
||||||
|
label="HTML"
|
||||||
|
title="Save as HTML"
|
||||||
|
description="Download colored HTML file."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Keyboard shortcuts hint -->
|
<!-- Keyboard shortcuts hint -->
|
||||||
<div class="shortcuts-hint">
|
<div class="shortcuts-hint">
|
||||||
<span><kbd>N</kbd> Next</span>
|
<span><kbd>N</kbd> Next</span>
|
||||||
<span><kbd>R</kbd> Reset</span>
|
<span><kbd>R</kbd> Reset</span>
|
||||||
<span><kbd>I</kbd> Invert</span>
|
<span><kbd>I</kbd> Invert</span>
|
||||||
<span><kbd>C</kbd> Color</span>
|
<span><kbd>C</kbd> Color</span>
|
||||||
<span><kbd>D</kbd> Dither</span>
|
<span><kbd>D</kbd> Dither</span>
|
||||||
<span><kbd>E</kbd> Edges</span>
|
<span><kbd>E</kbd> Edges</span>
|
||||||
<span><kbd>S</kbd> Charset</span>
|
<span><kbd>S</kbd> Charset</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const controlPanel = document.getElementById("tui-controls");
|
||||||
|
const toggleBtn = controlPanel?.querySelector(".mobile-controls-header");
|
||||||
|
|
||||||
|
if (controlPanel && toggleBtn) {
|
||||||
|
// Default to collapsed on mobile
|
||||||
|
if (window.innerWidth <= 1200) {
|
||||||
|
controlPanel.classList.add("collapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBtn.addEventListener("click", () => {
|
||||||
|
controlPanel.classList.toggle("collapsed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Main Container matching Sidebar visual style */
|
/* Main Container matching Sidebar visual style */
|
||||||
.control-panel {
|
.control-panel {
|
||||||
@@ -384,10 +494,62 @@ import TuiButton from "./TuiButton.astro";
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-controls-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.control-panel {
|
.control-panel {
|
||||||
|
padding: 0; /* Let inner content handle padding or just reset */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-controls-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #000;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-controls-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel.collapsed .mobile-toggle-icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel-inner {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.4s ease,
|
||||||
|
opacity 0.4s ease,
|
||||||
|
padding 0.4s ease;
|
||||||
|
max-height: 2000px;
|
||||||
|
opacity: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel.collapsed .control-panel-inner {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-panel-content {
|
.control-panel-content {
|
||||||
@@ -408,4 +570,55 @@ import TuiButton from "./TuiButton.astro";
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tui-color-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 29px; /* Match toggle/button height approx */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-color-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-color-btn input[type="color"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-color-btn .label {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
<div class="mobile-header">
|
||||||
|
<span class="mobile-brand">SYNTAXBULLET</span>
|
||||||
|
<svg
|
||||||
|
class="mobile-toggle-icon"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<div class="brand-group">
|
<div class="brand-group">
|
||||||
<a href="/" class="brand-link">
|
<a href="/" class="brand-link">
|
||||||
@@ -10,24 +26,24 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="brand-subtitle">
|
<div class="brand-subtitle">
|
||||||
FULL STACK ENGINEER
|
FULL STACK ENGINEER
|
||||||
<span class="muted">//</span>
|
<span class="muted">|</span>
|
||||||
CREATIVE TECHNOLOGIST
|
CREATIVE TECHNOLOGIST
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="brand-bio">
|
<p class="brand-bio">
|
||||||
Crafting high-performance digital experiences at the intersection of
|
Crafting high-performance digital experiences at the intersection of
|
||||||
code, art, and artificial intelligence.
|
engineering, design, and artificial intelligence.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="sidebar-actions">
|
<div class="sidebar-actions">
|
||||||
<a href="/projects" class="sidebar-link">
|
<a href="/" class="sidebar-link">
|
||||||
<span class="icon">⚡</span> PROJECTS
|
<span class="icon">⚡</span> GENERATE
|
||||||
</a>
|
</a>
|
||||||
<a href="/blog" class="sidebar-link">
|
<a href="/blog" class="sidebar-link">
|
||||||
<span class="icon">📝</span> BLOG
|
<span class="icon">📝</span> BLOG
|
||||||
</a>
|
</a>
|
||||||
<a href="mailto:contact@syntaxbullet.me" class="sidebar-link">
|
<a href="mailto:me@syntaxbullet.com" class="sidebar-link">
|
||||||
<span class="icon">✉️</span> CONTACT
|
<span class="icon">✉️</span> CONTACT
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +132,10 @@
|
|||||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
|
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
padding: 4rem 3rem;
|
padding: 4rem 3rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -224,13 +244,52 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
padding: 3rem 0; /* Increased padding */
|
padding: 0; /* Remove padding from container, move to toggle */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: flex; /* Show on mobile */
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-brand {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .mobile-toggle-icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
padding: 1rem 3rem; /* Increased horizontal padding */
|
padding: 0 3rem 3rem 3rem; /* Adjust padding */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 0.4s ease,
|
||||||
|
opacity 0.4s ease,
|
||||||
|
padding 0.4s ease;
|
||||||
|
max-height: 1000px; /* Arbitrary large height */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-content {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 0 3rem; /* Collapse padding */
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle,
|
.brand-subtitle,
|
||||||
@@ -238,5 +297,29 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide the large title in the content on mobile since we have the header,
|
||||||
|
or keep it? The user might want the full bio etc.
|
||||||
|
Let's keep the content as is, but maybe hide the "SYNTAXBULLET" big text in content if it's redundant?
|
||||||
|
Actually, the design seems to desire the big brand text. I'll leave it. */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sidebar = document.querySelector(".sidebar");
|
||||||
|
const toggleBtn = document.querySelector(".mobile-header");
|
||||||
|
|
||||||
|
if (sidebar && toggleBtn) {
|
||||||
|
// Default to collapsed on mobile initially?
|
||||||
|
// User didn't specify, but usually "collapsible" implies starting collapsed or having the ability.
|
||||||
|
// Let's start expanded or collapsed? "Make... collapsible".
|
||||||
|
// I'll default to collapsed to save space as requested "mobile friendly".
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
sidebar.classList.add("collapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBtn.addEventListener("click", () => {
|
||||||
|
sidebar.classList.toggle("collapsed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { getEntry } from "astro:content";
|
import { getEntry } from "astro:content";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Sidebar from "../../components/Sidebar.astro";
|
||||||
|
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -16,117 +17,243 @@ if (!entry) {
|
|||||||
const { Content } = await entry.render();
|
const { Content } = await entry.render();
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={entry.data.title} showScroll={true}>
|
<Layout title={entry.data.title}>
|
||||||
<main>
|
<div class="split-layout">
|
||||||
<article>
|
<Sidebar />
|
||||||
<section class="h-entry">
|
|
||||||
<header>
|
<main class="content-workspace">
|
||||||
<h1 class="p-name">{entry.data.title}</h1>
|
<div class="content-container">
|
||||||
<div class="metadata">
|
<article class="h-entry">
|
||||||
<time
|
<header class="post-header">
|
||||||
class="dt-published"
|
<a href="/blog" class="back-link">
|
||||||
datetime={entry.data.pubDate.toISOString()}
|
← Back to Blog
|
||||||
>
|
</a>
|
||||||
{entry.data.pubDate.toISOString().slice(0, 10)}
|
|
||||||
</time>
|
<h1 class="p-name">{entry.data.title}</h1>
|
||||||
{
|
|
||||||
entry.data.updatedDate && (
|
<div class="metadata">
|
||||||
<div class="last-updated">
|
<span class="dt-published">
|
||||||
Last updated on{" "}
|
{entry.data.pubDate.toISOString().slice(0, 10)}
|
||||||
<time>
|
</span>
|
||||||
|
{
|
||||||
|
entry.data.updatedDate && (
|
||||||
|
<span class="updated-date">
|
||||||
|
• Updated:{" "}
|
||||||
{entry.data.updatedDate
|
{entry.data.updatedDate
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10)}
|
.slice(0, 10)}
|
||||||
</time>
|
</span>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="e-content">
|
||||||
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</article>
|
||||||
<div class="e-content">
|
</div>
|
||||||
<Content />
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
/* Split Layout */
|
||||||
width: calc(100% - 2em);
|
.split-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Workspace */
|
||||||
|
.content-workspace {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #050505;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0;
|
width: 100%;
|
||||||
padding: 2em;
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
/* Header Styling */
|
||||||
margin-bottom: 2rem;
|
.post-header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
color: var(--text-color);
|
font-family:
|
||||||
opacity: 0.6;
|
system-ui,
|
||||||
font-family: var(--font-mono);
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a:hover {
|
.back-link:hover {
|
||||||
opacity: 1;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
h1 {
|
||||||
font-size: 2em;
|
font-size: 2.5rem;
|
||||||
margin: 0.25em 0 0;
|
font-weight: 800;
|
||||||
}
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.2;
|
||||||
hr {
|
color: #fff;
|
||||||
border-top: 1px solid var(--text-color);
|
letter-spacing: -0.5px;
|
||||||
opacity: 0.3;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata {
|
.metadata {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown Styles */
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Styling (Markdown) */
|
||||||
.e-content {
|
.e-content {
|
||||||
line-height: 1.6;
|
line-height: 1.8;
|
||||||
font-family: var(--font-mono); /* Keep vibe */
|
font-size: 1.1rem;
|
||||||
font-size: 1rem;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif; /* Clean reading font */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography Overrides */
|
||||||
.e-content :global(h1),
|
.e-content :global(h1),
|
||||||
.e-content :global(h2),
|
.e-content :global(h2),
|
||||||
.e-content :global(h3),
|
.e-content :global(h3),
|
||||||
.e-content :global(h4) {
|
.e-content :global(h4) {
|
||||||
margin-top: 2rem;
|
font-family:
|
||||||
margin-bottom: 1rem;
|
system-ui,
|
||||||
color: var(--text-color);
|
-apple-system,
|
||||||
font-weight: bold;
|
sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(h2) {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.e-content :global(h3) {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(p) {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(a) {
|
.e-content :global(a) {
|
||||||
color: var(--text-color);
|
color: #fff;
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(a:hover) {
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(ul),
|
||||||
|
.e-content :global(ol) {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(li) {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(blockquote) {
|
||||||
|
border-left: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px; /* Softer corners */
|
||||||
|
margin: 2rem 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks */
|
||||||
|
.e-content :global(pre) {
|
||||||
|
background: #111 !important; /* Force override */
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px; /* Softer corners */
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(code) {
|
.e-content :global(code) {
|
||||||
background: #111;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(pre) {
|
.e-content :global(pre code) {
|
||||||
background: #111;
|
background: none;
|
||||||
padding: 1rem;
|
padding: 0;
|
||||||
border: 1px solid #333;
|
color: inherit;
|
||||||
overflow-x: auto;
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.split-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-workspace {
|
||||||
|
height: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Sidebar from "../../components/Sidebar.astro";
|
||||||
import { getCollection, type CollectionEntry } from "astro:content";
|
import { getCollection, type CollectionEntry } from "astro:content";
|
||||||
|
|
||||||
const posts = (await getCollection("blog")).sort(
|
const posts = (await getCollection("blog")).sort(
|
||||||
@@ -8,92 +9,195 @@ const posts = (await getCollection("blog")).sort(
|
|||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="System Logs" showScroll={true}>
|
<Layout title="System Logs">
|
||||||
<main>
|
<div class="split-layout">
|
||||||
<section>
|
<Sidebar />
|
||||||
<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>
|
<main class="content-workspace">
|
||||||
<p>END OF STREAM</p>
|
<div class="content-container">
|
||||||
</footer>
|
<header class="page-header">
|
||||||
</main>
|
<h1>Blog Articles</h1>
|
||||||
|
<div class="divider"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="post-list">
|
||||||
|
{
|
||||||
|
posts.map((post: any) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.slug}/`}
|
||||||
|
class="post-link"
|
||||||
|
>
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="date">
|
||||||
|
{post.data.pubDate
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-info">
|
||||||
|
<span class="title">
|
||||||
|
{post.data.title}
|
||||||
|
</span>
|
||||||
|
<span class="desc">
|
||||||
|
{post.data.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© {new Date().getFullYear()} Syntaxbullet</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
/* Split Layout - Consistent with Homepage */
|
||||||
width: 960px;
|
.split-layout {
|
||||||
max-width: calc(100% - 2em);
|
display: flex;
|
||||||
margin: 0 auto;
|
width: 100vw;
|
||||||
padding: 2em 0;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
/* Content Workspace (Right Side) */
|
||||||
|
.content-workspace {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #050505;
|
||||||
|
overflow-y: auto; /* Enable scrolling for content */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post List Styling */
|
||||||
|
.post-list {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
.post-link {
|
||||||
margin-bottom: 1rem;
|
display: flex;
|
||||||
border-left: 2px solid transparent;
|
flex-direction: column; /* Mobile first */
|
||||||
transition: border-left-color 0.2s;
|
gap: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
li:hover {
|
|
||||||
border-left-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 5px 10px;
|
padding: 1.5rem;
|
||||||
color: var(--text-color);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-family: var(--font-mono);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date {
|
.post-link:hover {
|
||||||
color: rgba(255, 103, 0, 0.6);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
margin-right: 1rem;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-size: 1.25rem;
|
||||||
margin-right: 1rem;
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
color: rgba(255, 103, 0, 0.4);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-style: italic;
|
font-family:
|
||||||
}
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
a:hover .title {
|
sans-serif;
|
||||||
text-decoration: underline;
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.3;
|
opacity: 0.5;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.post-link {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.split-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-workspace {
|
||||||
|
height: auto;
|
||||||
|
flex-grow: 1; /* Fill remaining space */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Tooltip from "../components/Tooltip.astro";
|
|||||||
import ControlPanel from "../components/ControlPanel.astro";
|
import ControlPanel from "../components/ControlPanel.astro";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Syntaxbullet - Digital Wizard">
|
<Layout title="Syntaxbullet - Full Stack Engineer">
|
||||||
<div class="split-layout">
|
<div class="split-layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import ControlPanel from "../components/ControlPanel.astro";
|
|||||||
<!-- Canvas Layer -->
|
<!-- Canvas Layer -->
|
||||||
<div class="canvas-layer">
|
<div class="canvas-layer">
|
||||||
<div id="loading">Loading...</div>
|
<div id="loading">Loading...</div>
|
||||||
<pre id="ascii-result">Preparing art...</pre>
|
<pre id="ascii-result">Initializing...</pre>
|
||||||
<canvas id="ascii-canvas"></canvas>
|
<canvas id="ascii-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,10 +104,11 @@ import ControlPanel from "../components/ControlPanel.astro";
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
|
asciiResult.textContent = `Connection lost. Retrying (${retryCount}/${MAX_RETRIES})...`;
|
||||||
setTimeout(loadNewImage, 2000);
|
setTimeout(loadNewImage, 2000);
|
||||||
} else {
|
} else {
|
||||||
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
|
asciiResult.textContent =
|
||||||
|
"Connection failed. Please refresh.";
|
||||||
controller.hideLoading();
|
controller.hideLoading();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +171,7 @@ import ControlPanel from "../components/ControlPanel.astro";
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading {
|
#loading {
|
||||||
@@ -191,11 +193,14 @@ import ControlPanel from "../components/ControlPanel.astro";
|
|||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.split-layout {
|
.split-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||||
|
height: 100dvh; /* Dynamic viewport height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ascii-workspace {
|
.ascii-workspace {
|
||||||
height: 80vh; /* Give space for scroll */
|
height: auto;
|
||||||
|
flex-grow: 1; /* Fill remaining space */
|
||||||
|
overflow-y: auto; /* Allow scrolling inside workspace if needed */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export class AsciiController {
|
|||||||
showMagnifier: false
|
showMagnifier: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Touch state
|
||||||
|
private lastTouchDist = 0;
|
||||||
|
private isDragging = false;
|
||||||
|
private lastTouchPos = { x: 0, y: 0 };
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private onSettingsChange?: () => void;
|
private onSettingsChange?: () => void;
|
||||||
|
|
||||||
@@ -77,6 +83,33 @@ export class AsciiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.startRenderLoop();
|
this.startRenderLoop();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
// Resize handling
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Debounce resize
|
||||||
|
if (this.animFrameId) {
|
||||||
|
// We are in a loop, just toggle a flag or call safely
|
||||||
|
this.handleResize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.canvas.parentElement) {
|
||||||
|
this.resizeObserver.observe(this.canvas.parentElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
||||||
|
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||||
|
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResize(): void {
|
||||||
|
// Re-calculate grid based on new dimensions
|
||||||
|
this.calculateGrid().then(() => {
|
||||||
|
this.requestRender('all');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultSettings(): AsciiSettings {
|
private getDefaultSettings(): AsciiSettings {
|
||||||
@@ -92,7 +125,14 @@ export class AsciiController {
|
|||||||
edgeMode: 0,
|
edgeMode: 0,
|
||||||
overlayStrength: 0.3,
|
overlayStrength: 0.3,
|
||||||
resolution: 1.0,
|
resolution: 1.0,
|
||||||
charSet: 'standard'
|
charSet: 'standard',
|
||||||
|
sharpen: 0,
|
||||||
|
edgeThreshold: 0.5,
|
||||||
|
shadows: 0,
|
||||||
|
highlights: 0,
|
||||||
|
scanlines: 0,
|
||||||
|
vignette: 0,
|
||||||
|
monoColor: '#ffffff'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +253,14 @@ export class AsciiController {
|
|||||||
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
|
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
|
||||||
charSet: validCharSet,
|
charSet: validCharSet,
|
||||||
resolution: this.settings.resolution,
|
resolution: this.settings.resolution,
|
||||||
color: this.settings.color
|
color: this.settings.color,
|
||||||
|
sharpen: suggestions.sharpen ?? this.settings.sharpen,
|
||||||
|
edgeThreshold: suggestions.edgeThreshold ?? this.settings.edgeThreshold,
|
||||||
|
shadows: suggestions.shadows ?? this.settings.shadows,
|
||||||
|
highlights: suggestions.highlights ?? this.settings.highlights,
|
||||||
|
scanlines: suggestions.scanlines ?? this.settings.scanlines,
|
||||||
|
vignette: suggestions.vignette ?? this.settings.vignette,
|
||||||
|
monoColor: suggestions.monoColor ?? this.settings.monoColor
|
||||||
};
|
};
|
||||||
|
|
||||||
this.onSettingsChange?.();
|
this.onSettingsChange?.();
|
||||||
@@ -280,6 +327,9 @@ export class AsciiController {
|
|||||||
|
|
||||||
let widthCols = Math.floor(availW / 6);
|
let widthCols = Math.floor(availW / 6);
|
||||||
widthCols = Math.floor(widthCols * this.settings.resolution);
|
widthCols = Math.floor(widthCols * this.settings.resolution);
|
||||||
|
|
||||||
|
// Cap grid resolution on mobile specifically if needed,
|
||||||
|
// but current logic is mostly fine as long as resolution slider is manageable.
|
||||||
widthCols = Math.max(10, Math.min(1000, widthCols));
|
widthCols = Math.max(10, Math.min(1000, widthCols));
|
||||||
|
|
||||||
const imgEl = await this.resolveImage(this.currentImgUrl);
|
const imgEl = await this.resolveImage(this.currentImgUrl);
|
||||||
@@ -362,7 +412,9 @@ export class AsciiController {
|
|||||||
|
|
||||||
this.canvas.style.width = `${finalW}px`;
|
this.canvas.style.width = `${finalW}px`;
|
||||||
this.canvas.style.height = `${finalH}px`;
|
this.canvas.style.height = `${finalH}px`;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
|
// Cap DPR to improve mobile performance
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2.0);
|
||||||
this.canvas.width = finalW * dpr;
|
this.canvas.width = finalW * dpr;
|
||||||
this.canvas.height = finalH * dpr;
|
this.canvas.height = finalH * dpr;
|
||||||
}
|
}
|
||||||
@@ -375,7 +427,7 @@ export class AsciiController {
|
|||||||
this.requestRender('all');
|
this.requestRender('all');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============= Zoom =============
|
// ============= Zoom & Touch =============
|
||||||
|
|
||||||
handleWheel(e: WheelEvent): void {
|
handleWheel(e: WheelEvent): void {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
@@ -401,13 +453,16 @@ export class AsciiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(e: MouseEvent): void {
|
handleMouseMove(e: MouseEvent): void {
|
||||||
|
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
|
||||||
|
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const mx = (e.clientX - rect.left) / rect.width;
|
const mx = (e.clientX - rect.left) / rect.width;
|
||||||
const my = (e.clientY - rect.top) / rect.height;
|
const my = (e.clientY - rect.top) / rect.height;
|
||||||
|
|
||||||
this.zoomState.mousePos = { x: mx, y: my };
|
this.zoomState.mousePos = { x: mx, y: my };
|
||||||
const wasShowing = this.zoomState.showMagnifier;
|
const wasShowing = this.zoomState.showMagnifier;
|
||||||
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
// Only show magnifier if not zoomed out completely
|
||||||
|
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1 && !('ontouchstart' in window);
|
||||||
|
|
||||||
if (this.zoomState.showMagnifier || wasShowing) {
|
if (this.zoomState.showMagnifier || wasShowing) {
|
||||||
this.requestRender('uniforms');
|
this.requestRender('uniforms');
|
||||||
@@ -421,6 +476,74 @@ export class AsciiController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Touch Support
|
||||||
|
handleTouchStart(e: TouchEvent): void {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.lastTouchDist = this.getTouchDistance(e.touches);
|
||||||
|
this.isDragging = false;
|
||||||
|
} else if (e.touches.length === 1 && this.zoomState.zoom > 1.0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
this.lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchMove(e: TouchEvent): void {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dist = this.getTouchDistance(e.touches);
|
||||||
|
const factor = dist / this.lastTouchDist;
|
||||||
|
this.lastTouchDist = dist;
|
||||||
|
|
||||||
|
const oldZoom = this.zoomState.zoom;
|
||||||
|
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
|
||||||
|
|
||||||
|
// Center zoom between touches
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const cx = ((e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left) / rect.width;
|
||||||
|
const cy = ((e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top) / rect.height;
|
||||||
|
|
||||||
|
if (oldZoom !== this.zoomState.zoom) {
|
||||||
|
const imgX = (cx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
|
||||||
|
const imgY = (cy - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
|
||||||
|
this.zoomState.zoomCenter.x = (imgX - cx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
this.zoomState.zoomCenter.y = (imgY - cy / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
}
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
|
||||||
|
} else if (e.touches.length === 1 && this.isDragging && this.zoomState.zoom > 1.0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const curX = e.touches[0].clientX;
|
||||||
|
const curY = e.touches[0].clientY;
|
||||||
|
|
||||||
|
const dx = (curX - this.lastTouchPos.x) / this.canvas.width;
|
||||||
|
const dy = (curY - this.lastTouchPos.y) / this.canvas.height;
|
||||||
|
|
||||||
|
// Logarithmic pan speed based on zoom?
|
||||||
|
// Simple mapping: move zoomCenter opposite to drag
|
||||||
|
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
|
||||||
|
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
|
||||||
|
|
||||||
|
this.lastTouchPos = { x: curX, y: curY };
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchEnd(e: TouchEvent): void {
|
||||||
|
this.isDragging = false;
|
||||||
|
if (e.touches.length === 0 && this.zoomState.zoom <= 1.0) {
|
||||||
|
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTouchDistance(touches: TouchList): number {
|
||||||
|
const dx = touches[0].clientX - touches[1].clientX;
|
||||||
|
const dy = touches[0].clientY - touches[1].clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Export =============
|
// ============= Export =============
|
||||||
|
|
||||||
savePNG(): void {
|
savePNG(): void {
|
||||||
@@ -499,6 +622,9 @@ export class AsciiController {
|
|||||||
if (this.animFrameId !== null) {
|
if (this.animFrameId !== null) {
|
||||||
cancelAnimationFrame(this.animFrameId);
|
cancelAnimationFrame(this.animFrameId);
|
||||||
}
|
}
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
}
|
||||||
this.renderer?.dispose();
|
this.renderer?.dispose();
|
||||||
this.renderer = null;
|
this.renderer = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export interface AsciiOptions {
|
|||||||
denoise?: boolean;
|
denoise?: boolean;
|
||||||
fontAspectRatio?: number;
|
fontAspectRatio?: number;
|
||||||
onProgress?: (progress: number) => void;
|
onProgress?: (progress: number) => void;
|
||||||
|
sharpen?: number;
|
||||||
|
edgeThreshold?: number;
|
||||||
|
shadows?: number;
|
||||||
|
highlights?: number;
|
||||||
|
scanlines?: number;
|
||||||
|
vignette?: number;
|
||||||
|
monoColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsciiResult {
|
export interface AsciiResult {
|
||||||
@@ -55,6 +62,13 @@ export interface AsciiSettings {
|
|||||||
overlayStrength: number;
|
overlayStrength: number;
|
||||||
resolution: number;
|
resolution: number;
|
||||||
charSet: CharSetKey;
|
charSet: CharSetKey;
|
||||||
|
sharpen: number;
|
||||||
|
edgeThreshold: number;
|
||||||
|
shadows: number;
|
||||||
|
highlights: number;
|
||||||
|
scanlines: number;
|
||||||
|
vignette: number;
|
||||||
|
monoColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============= Constants =============
|
// ============= Constants =============
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export class UIBindings {
|
|||||||
init(): void {
|
init(): void {
|
||||||
this.setupSliders();
|
this.setupSliders();
|
||||||
this.setupToggles();
|
this.setupToggles();
|
||||||
|
this.setupColorInput();
|
||||||
this.setupSegments();
|
this.setupSegments();
|
||||||
this.setupButtons();
|
this.setupButtons();
|
||||||
this.setupKeyboard();
|
this.setupKeyboard();
|
||||||
@@ -81,7 +82,7 @@ export class UIBindings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup Sliders
|
// Cleanup Sliders
|
||||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights'] as const;
|
||||||
sliderIds.forEach(id => {
|
sliderIds.forEach(id => {
|
||||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
const handler = this.sliderHandlers.get(id);
|
const handler = this.sliderHandlers.get(id);
|
||||||
@@ -162,7 +163,7 @@ export class UIBindings {
|
|||||||
// ============= Sliders =============
|
// ============= Sliders =============
|
||||||
|
|
||||||
private setupSliders(): void {
|
private setupSliders(): void {
|
||||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||||
|
|
||||||
sliderIds.forEach(id => {
|
sliderIds.forEach(id => {
|
||||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
@@ -225,6 +226,23 @@ export class UIBindings {
|
|||||||
document.body.addEventListener('toggle-change', this.toggleHandler);
|
document.body.addEventListener('toggle-change', this.toggleHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= Color Input =============
|
||||||
|
|
||||||
|
private setupColorInput(): void {
|
||||||
|
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||||
|
const colorSwatch = document.getElementById('color-swatch-display');
|
||||||
|
|
||||||
|
if (colorInput) {
|
||||||
|
colorInput.addEventListener('input', () => {
|
||||||
|
if (this.isUpdatingUI) return;
|
||||||
|
this.controller.setSetting('monoColor', colorInput.value);
|
||||||
|
if (colorSwatch) {
|
||||||
|
colorSwatch.style.backgroundColor = colorInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Segments =============
|
// ============= Segments =============
|
||||||
|
|
||||||
private setupSegments(): void {
|
private setupSegments(): void {
|
||||||
@@ -475,7 +493,7 @@ export class UIBindings {
|
|||||||
const settings = this.controller.getSettings();
|
const settings = this.controller.getSettings();
|
||||||
|
|
||||||
// Update sliders
|
// Update sliders
|
||||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||||
sliderIds.forEach(id => {
|
sliderIds.forEach(id => {
|
||||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
if (input && settings[id] !== undefined) {
|
if (input && settings[id] !== undefined) {
|
||||||
@@ -509,6 +527,19 @@ export class UIBindings {
|
|||||||
}
|
}
|
||||||
window.updateSegmentValue?.('segment-edge', edgeShort);
|
window.updateSegmentValue?.('segment-edge', edgeShort);
|
||||||
|
|
||||||
|
// Update color input
|
||||||
|
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||||
|
const colorSwatch = document.getElementById('color-swatch-display');
|
||||||
|
|
||||||
|
if (colorInput && settings.monoColor) {
|
||||||
|
if (colorInput.value !== settings.monoColor) {
|
||||||
|
colorInput.value = settings.monoColor;
|
||||||
|
}
|
||||||
|
if (colorSwatch) {
|
||||||
|
colorSwatch.style.backgroundColor = settings.monoColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.updateQueueDisplay();
|
this.updateQueueDisplay();
|
||||||
|
|
||||||
this.isUpdatingUI = false;
|
this.isUpdatingUI = false;
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface RenderOptions {
|
|||||||
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
|
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||||
dither?: number;
|
dither?: number;
|
||||||
denoise?: boolean;
|
denoise?: boolean;
|
||||||
|
sharpen?: number;
|
||||||
|
edgeThreshold?: number;
|
||||||
|
shadows?: number;
|
||||||
|
highlights?: number;
|
||||||
|
scanlines?: number;
|
||||||
|
vignette?: number;
|
||||||
|
monoColor?: string;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
zoomCenter?: { x: number; y: number };
|
zoomCenter?: { x: number; y: number };
|
||||||
mousePos?: { x: number; y: number };
|
mousePos?: { x: number; y: number };
|
||||||
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
|
|||||||
}
|
}
|
||||||
this.gl = gl;
|
this.gl = gl;
|
||||||
|
|
||||||
|
// Enable required extensions for advanced rendering
|
||||||
|
gl.getExtension('OES_standard_derivatives');
|
||||||
|
gl.getExtension('EXT_shader_texture_lod');
|
||||||
|
|
||||||
this.program = null;
|
this.program = null;
|
||||||
this.textures = {};
|
this.textures = {};
|
||||||
this.buffers = {};
|
this.buffers = {};
|
||||||
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
|
|||||||
|
|
||||||
// Fragment Shader
|
// Fragment Shader
|
||||||
const fsSource = `
|
const fsSource = `
|
||||||
|
#extension GL_OES_standard_derivatives : enable
|
||||||
|
#extension GL_EXT_shader_texture_lod : enable
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
varying vec2 v_texCoord;
|
varying vec2 v_texCoord;
|
||||||
|
|
||||||
@@ -102,6 +115,13 @@ export class WebGLAsciiRenderer {
|
|||||||
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
|
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||||
uniform float u_dither; // Dither strength 0.0 - 1.0
|
uniform float u_dither; // Dither strength 0.0 - 1.0
|
||||||
uniform bool u_denoise;
|
uniform bool u_denoise;
|
||||||
|
uniform float u_sharpen;
|
||||||
|
uniform float u_edgeThreshold;
|
||||||
|
uniform float u_shadows;
|
||||||
|
uniform float u_highlights;
|
||||||
|
uniform float u_scanlines;
|
||||||
|
uniform float u_vignette;
|
||||||
|
uniform vec3 u_monoColor;
|
||||||
|
|
||||||
// Zoom & Magnifier
|
// Zoom & Magnifier
|
||||||
uniform float u_zoom;
|
uniform float u_zoom;
|
||||||
@@ -125,11 +145,26 @@ export class WebGLAsciiRenderer {
|
|||||||
// Exposure
|
// Exposure
|
||||||
color *= u_exposure;
|
color *= u_exposure;
|
||||||
|
|
||||||
|
// Shadows / Highlights
|
||||||
|
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
|
||||||
|
// Shadows: lift dark areas
|
||||||
|
if (u_shadows > 0.0) {
|
||||||
|
float shadowFactor = (1.0 - luma) * u_shadows;
|
||||||
|
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlights: dim bright areas
|
||||||
|
if (u_highlights > 0.0) {
|
||||||
|
float highlightFactor = luma * u_highlights;
|
||||||
|
color = color * (1.0 - highlightFactor * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
// Contrast
|
// Contrast
|
||||||
color = (color - 0.5) * u_contrast + 0.5;
|
color = (color - 0.5) * u_contrast + 0.5;
|
||||||
|
|
||||||
// Saturation
|
// Saturation
|
||||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
color = mix(vec3(luma), color, u_saturation);
|
color = mix(vec3(luma), color, u_saturation);
|
||||||
|
|
||||||
// Gamma
|
// Gamma
|
||||||
@@ -231,6 +266,12 @@ export class WebGLAsciiRenderer {
|
|||||||
} else {
|
} else {
|
||||||
color = getAverageColor(sampleUV, cellSize);
|
color = getAverageColor(sampleUV, cellSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sharpening
|
||||||
|
if (u_sharpen > 0.0) {
|
||||||
|
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
|
||||||
|
color = color + (color - blurred) * u_sharpen;
|
||||||
|
}
|
||||||
|
|
||||||
// Edge Detection Logic
|
// Edge Detection Logic
|
||||||
if (u_edgeMode == 1) {
|
if (u_edgeMode == 1) {
|
||||||
@@ -244,14 +285,20 @@ export class WebGLAsciiRenderer {
|
|||||||
|
|
||||||
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
|
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
|
||||||
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
|
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
|
||||||
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
|
||||||
|
// Use Threshold
|
||||||
|
if (edgeLum > u_edgeThreshold * 0.1) {
|
||||||
|
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
} else if (u_edgeMode == 2) {
|
} else if (u_edgeMode == 2) {
|
||||||
// Sobel Gradient
|
// Sobel Gradient
|
||||||
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||||
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
|
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
|
||||||
// Darken edges
|
|
||||||
color = mix(color, vec3(0.0), edgeStr * 0.8);
|
if (edgeStr > u_edgeThreshold * 0.2) {
|
||||||
|
color = mix(color, vec3(0.0), edgeStr * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
} else if (u_edgeMode == 3) {
|
} else if (u_edgeMode == 3) {
|
||||||
// "Canny-like" (Sobel + gradient suppression)
|
// "Canny-like" (Sobel + gradient suppression)
|
||||||
@@ -260,13 +307,12 @@ export class WebGLAsciiRenderer {
|
|||||||
float angle = sobel.y;
|
float angle = sobel.y;
|
||||||
|
|
||||||
// Non-maximum suppression (simplified)
|
// Non-maximum suppression (simplified)
|
||||||
// Check neighbors in gradient direction
|
|
||||||
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
|
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
|
||||||
|
|
||||||
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
|
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
|
||||||
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
|
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
|
||||||
|
|
||||||
if (mag < s1.x || mag < s2.x || mag < 0.15) {
|
// Use edge threshold
|
||||||
|
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold
|
||||||
mag = 0.0;
|
mag = 0.0;
|
||||||
} else {
|
} else {
|
||||||
mag = 1.0; // Strong edge
|
mag = 1.0; // Strong edge
|
||||||
@@ -276,7 +322,7 @@ export class WebGLAsciiRenderer {
|
|||||||
color = mix(color, vec3(0.0), mag);
|
color = mix(color, vec3(0.0), mag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply adjustments
|
// Apply adjustments (Contrast, etc.)
|
||||||
color = adjust(color);
|
color = adjust(color);
|
||||||
|
|
||||||
// Overlay blend-like effect (boost mid-contrast)
|
// Overlay blend-like effect (boost mid-contrast)
|
||||||
@@ -319,18 +365,48 @@ export class WebGLAsciiRenderer {
|
|||||||
uvInCell.y * u_charSizeUV.y
|
uvInCell.y * u_charSizeUV.y
|
||||||
);
|
);
|
||||||
|
|
||||||
float charAlpha = texture2D(u_atlas, atlasUV).r;
|
// --- MIPMAP FIX ---
|
||||||
|
// Use zoomed 'uv' for derivatives to handle zoom correctly.
|
||||||
|
// Multiply by 0.5 to bias towards sharper mipmaps (LOD bias).
|
||||||
|
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||||
|
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||||
|
|
||||||
|
// Use texture2DGradEXT to sample with explicit gradients, resolving cell boundary artifacts
|
||||||
|
float charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
|
||||||
|
|
||||||
// Loup border effect
|
// Loup border effect
|
||||||
if (u_showMagnifier) {
|
if (u_showMagnifier) {
|
||||||
float edgeWidth = 0.005;
|
float edgeWidth = 0.005;
|
||||||
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
||||||
charAlpha = 1.0;
|
charAlpha = 1.0;
|
||||||
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
|
color = vec3(1.0, 1.0, 1.0); // White border for the loupe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0);
|
// Vignette
|
||||||
|
if (u_vignette > 0.0) {
|
||||||
|
float dist = distance(uv, vec2(0.5));
|
||||||
|
// Smoothsoft vignette
|
||||||
|
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist);
|
||||||
|
charAlpha *= vig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanlines
|
||||||
|
if (u_scanlines > 0.0) {
|
||||||
|
// Sine wave based on grid rows
|
||||||
|
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
|
||||||
|
// Blend scanline effect based on strength
|
||||||
|
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
|
||||||
|
charAlpha *= scanEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mono Color
|
||||||
|
vec3 finalColor;
|
||||||
|
if (u_color) {
|
||||||
|
finalColor = color;
|
||||||
|
} else {
|
||||||
|
finalColor = u_monoColor;
|
||||||
|
}
|
||||||
|
|
||||||
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
|
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
|
||||||
}
|
}
|
||||||
@@ -366,6 +442,8 @@ export class WebGLAsciiRenderer {
|
|||||||
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
||||||
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
|
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
|
||||||
'u_dither', 'u_denoise',
|
'u_dither', 'u_denoise',
|
||||||
|
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
|
||||||
|
'u_scanlines', 'u_vignette', 'u_monoColor',
|
||||||
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
||||||
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
||||||
];
|
];
|
||||||
@@ -518,6 +596,19 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
|
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
|
||||||
gl.uniform1f(u['u_dither'], options.dither || 0.0);
|
gl.uniform1f(u['u_dither'], options.dither || 0.0);
|
||||||
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
|
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
|
||||||
|
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
|
||||||
|
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // Default to mid
|
||||||
|
gl.uniform1f(u['u_shadows'], options.shadows || 0.0);
|
||||||
|
gl.uniform1f(u['u_highlights'], options.highlights || 0.0);
|
||||||
|
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
|
||||||
|
gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
|
||||||
|
|
||||||
|
// Parse hex color
|
||||||
|
const hex = options.monoColor || '#ffffff';
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
|
||||||
|
gl.uniform3f(u['u_monoColor'], r, g, b);
|
||||||
|
|
||||||
// Zoom & Magnifier
|
// Zoom & Magnifier
|
||||||
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-color: #000000;
|
--bg-color: #000000;
|
||||||
--text-color: #FF6700;
|
--text-color: #FFFFFF;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user