feat: implement a responsive mobile control panel with tabbed navigation and dynamic control staging, and refactor the desktop layout for mobile responsiveness.

This commit is contained in:
syntaxbullet
2026-02-10 21:49:44 +01:00
parent cabf963e94
commit f4a0e2a82b
2 changed files with 505 additions and 106 deletions

View File

@@ -3,17 +3,32 @@ import TuiSlider from "./TuiSlider.astro";
import TuiSegment from "./TuiSegment.astro";
import TuiToggle from "./TuiToggle.astro";
import TuiButton from "./TuiButton.astro";
import { ChevronDown } from "@lucide/astro";
import {
ChevronDown,
SlidersHorizontal,
Wand2,
Download,
RotateCcw,
SkipForward,
Layers,
} from "@lucide/astro";
---
<footer id="tui-controls" class="control-panel">
<!-- Desktop Header (Hidden on Mobile) -->
<div class="desktop-header">
<!-- Optional: keeps the old structure if needed, or purely CSS hidden -->
</div>
<!-- Mobile Header (Hidden on Desktop) -->
<div class="mobile-controls-header">
<span class="mobile-controls-title">CONTROLS</span>
<ChevronDown class="mobile-toggle-icon" size={24} />
</div>
<div class="control-panel-inner">
<div class="control-panel-content">
<!-- DESKTOP LAYOUT (Hidden on Mobile via CSS) -->
<div class="control-panel-content desktop-only">
<!-- Sliders Section -->
<div class="panel-section sliders-section">
<div class="section-header">ADJUSTMENTS</div>
@@ -183,6 +198,7 @@ import { ChevronDown } from "@lucide/astro";
<div
class="tui-color-btn"
title="Monochrome Tint Color"
id="btn-color-tint"
>
<input
type="color"
@@ -195,7 +211,11 @@ import { ChevronDown } from "@lucide/astro";
</div>
<!-- Background Color Picker -->
<div class="tui-color-btn" title="Background Color">
<div
class="tui-color-btn"
title="Background Color"
id="btn-color-bg"
>
<input
type="color"
id="input-bg-color"
@@ -333,8 +353,8 @@ import { ChevronDown } from "@lucide/astro";
</div>
</div>
<!-- Keyboard shortcuts hint -->
<div class="shortcuts-hint">
<!-- KEYBOARD HINTS (Desktop Only) -->
<div class="shortcuts-hint desktop-only">
<span><kbd>N</kbd> Next</span>
<span><kbd>R</kbd> Reset</span>
<span><kbd>I</kbd> Invert</span>
@@ -343,37 +363,331 @@ import { ChevronDown } from "@lucide/astro";
<span><kbd>E</kbd> Edges</span>
<span><kbd>S</kbd> Charset</span>
</div>
<!-- MOBILE NAV CONTAINER (Hidden on Desktop) -->
<div class="mobile-nav-container mobile-only">
<!-- 1. ACTIVE VIEW (Where the selected control appears) -->
<div class="mobile-active-view" id="mobile-control-stage">
<div class="mobile-placeholder">Select a Control</div>
<!-- Logic will move elements here -->
</div>
<!-- 2. PARAMETER LIST (Horizontal Scroll) -->
<div class="mobile-param-list" id="mobile-param-list">
<!-- Populated via JS based on Active Tab -->
</div>
<!-- 3. TABS (Categories) -->
<div class="mobile-tabs">
<button class="mobile-tab-btn active" data-tab="adjust">
<SlidersHorizontal size={18} />
<span>ADJUST</span>
</button>
<button class="mobile-tab-btn" data-tab="effects">
<Wand2 size={18} />
<span>EFFECTS</span>
</button>
<button class="mobile-tab-btn" data-tab="export">
<Download size={18} />
<span>EXPORT</span>
</button>
</div>
<!-- 4. FOOTER (High Freq Actions) -->
<div class="mobile-footer">
<button class="mobile-footer-btn" id="mobile-btn-reset">
<RotateCcw size={16} /> RESET
</button>
<div class="mobile-queue-badge">
<Layers size={14} />
<span id="mobile-val-queue">0</span>
</div>
<button class="mobile-footer-btn primary" id="mobile-btn-next">
NEXT <SkipForward size={16} />
</button>
</div>
</div>
</div>
</footer>
<script>
const controlPanel = document.getElementById("tui-controls");
const toggleBtn = controlPanel?.querySelector(".mobile-controls-header");
import {
SlidersHorizontal,
Wand2,
Download,
RotateCcw,
SkipForward,
Layers,
} from "@lucide/astro";
if (controlPanel && toggleBtn) {
// Default to collapsed on mobile
if (window.innerWidth <= 1200) {
controlPanel.classList.add("collapsed");
// --- MOBILE COMPACT MODE LOGIC ---
// Configuration for mapping controls to tabs
const TABS: Record<string, string[]> = {
adjust: [
"exposure",
"contrast",
"saturation",
"gamma",
"shadows",
"highlights",
"sharpen",
"resolution",
"dither",
"scanlines",
"vignette",
"overlayStrength",
"edgeThreshold", // Actually belongs slightly to effects but fits slider format
],
effects: [
"toggle-color",
"btn-color-tint",
"btn-color-bg",
"toggle-denoise",
"segment-invert",
"segment-edge",
"segment-charset",
],
export: [
"btn-import",
"btn-save-png",
"btn-copy-text",
"btn-copy-html",
],
};
// Dictionary to store original parents to restore them if needed resizing back to desktop
const originalParents = new Map<string, Element | null>();
// Store current mode to prevent redundant operations
let isMobileMode = false;
function initMobileMode() {
// Init happens once, but state changes on resize
const stage = document.getElementById("mobile-control-stage");
const list = document.getElementById("mobile-param-list");
const tabs = document.querySelectorAll(".mobile-tab-btn");
// Helper to get element by ID (handling some component specific wrappings if needed)
const getEl = (id: string) => {
// Try direct ID first
let el = document.getElementById(id);
if (el) {
// For TuiSlider, the top level div has the ID in data-slider-id but not id attribute itself sometimes?
// Wait, TuiSlider: <div class="tui-slider" data-slider-id={id}> ... <input id={id}>
// We want the container.
const container =
el.closest(".tui-slider") ||
el.closest(".tui-toggle") ||
el.closest(".tui-segment") ||
el.closest(".tui-btn") ||
el.closest(".tui-color-btn") ||
el;
return container;
}
// Fallback for sliders where ID is on input
const input = document.getElementById(id);
if (input) {
return (
input.closest(".tui-slider") ||
input.closest(".tui-color-btn")
);
}
return null;
};
const renderTab = (tabName: string) => {
if (!list) return;
list.innerHTML = ""; // Clear list
const items = TABS[tabName];
if (!items) return;
items.forEach((id) => {
const el = getEl(id);
if (!el) return;
// Create a Nav Pill
const btn = document.createElement("button");
btn.className = "mobile-list-item";
btn.id = `nav-pill-${id}`; // Help with tracking
// Get Label Text
// Try various selectors based on component type
let labelText = id;
const labelEl = el.querySelector(
".tui-slider-label .full, .tui-toggle-label .full, .tui-segment-label .full, .tui-btn-label .full, .label",
);
if (labelEl && labelEl.textContent)
labelText = labelEl.textContent;
// Abbr fallback
const abbrEl = el.querySelector(".abbr");
if (
(!labelEl || !labelEl.textContent) &&
abbrEl &&
abbrEl.textContent
)
labelText = abbrEl.textContent;
btn.textContent = labelText;
btn.onclick = () => {
// Highlight active logic
document
.querySelectorAll(".mobile-list-item")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// Move component to stage
if (stage) {
// Clean stage first - return any existing child to its home
const currentInStage = stage.firstElementChild;
if (
currentInStage &&
currentInStage.className !== "mobile-placeholder" &&
currentInStage !== el
) {
// Return to original home
// The ID might be on the element itself or a child (input)
// We use data-origin-id tagged during move
const originId = (currentInStage as HTMLElement)
.dataset.originTabId;
if (originId) {
const orig = originalParents.get(originId);
if (orig) orig.appendChild(currentInStage);
}
}
// Remove placeholder if present
const placeholder = stage.querySelector(
".mobile-placeholder",
);
if (placeholder) placeholder.remove();
// Prepare element for move
if (!originalParents.has(id)) {
// Store parent for restoration
originalParents.set(id, el.parentElement);
}
(el as HTMLElement).dataset.originTabId = id; // Tag it
stage.appendChild(el);
}
};
list.appendChild(btn);
});
// Auto-select first item?
if (list.firstElementChild) {
(list.firstElementChild as HTMLElement).click();
}
};
// --- State Management ---
const checkMode = () => {
// Match CSS breakpoint
const isMobile = window.innerWidth <= 1024;
if (isMobile && !isMobileMode) {
// Entering Mobile
isMobileMode = true;
console.log("📱 Entering Mobile Mode");
// Init tabs listeners if not already
tabs.forEach((tab) => {
// Check if already bound
if ((tab as HTMLElement).dataset.hasListener) return;
tab.addEventListener("click", () => {
const t = tab as HTMLElement;
tabs.forEach((x) => x.classList.remove("active"));
t.classList.add("active");
if (t.dataset.tab) renderTab(t.dataset.tab);
});
(tab as HTMLElement).dataset.hasListener = "true";
});
// Render default tab
// Find active tab or default to adjust
const activeTab = document.querySelector(
".mobile-tab-btn.active",
) as HTMLElement;
renderTab(activeTab?.dataset.tab || "adjust");
} else if (!isMobile && isMobileMode) {
// Exiting Mobile - RESTORE EVERYTHING
isMobileMode = false;
console.log("🖥️ Entering Desktop Mode");
// 1. Move everything back from stage
if (stage) {
Array.from(stage.children).forEach((child) => {
if (child.className === "mobile-placeholder") return;
const originId = (child as HTMLElement).dataset
.originTabId;
if (originId) {
const orig = originalParents.get(originId);
if (orig) orig.appendChild(child);
}
});
// Clear stage
stage.innerHTML =
'<div class="mobile-placeholder">Select a Control</div>';
}
}
};
// Listen for resize
window.addEventListener("resize", checkMode);
// Initial check
checkMode();
// --- FOOTER PROXY BUTTONS ---
const bindFooterProxy = (proxyId: string, originalId: string) => {
const proxy = document.getElementById(proxyId);
const original = document.getElementById(originalId);
if (proxy && original) {
if (proxy.dataset.bound) return;
proxy.addEventListener("click", () => original.click());
proxy.dataset.bound = "true";
}
};
bindFooterProxy("mobile-btn-reset", "btn-reset");
bindFooterProxy("mobile-btn-next", "btn-next");
// Queue Sync
const qVal = document.getElementById("val-queue");
const mQVal = document.getElementById("mobile-val-queue");
if (qVal && mQVal) {
// Observer to sync text
const obs = new MutationObserver(() => {
if (qVal.textContent) mQVal.textContent = qVal.textContent;
});
obs.observe(qVal, {
childList: true,
subtree: true,
characterData: true,
});
if (qVal.textContent) mQVal.textContent = qVal.textContent;
}
toggleBtn.addEventListener("click", () => {
controlPanel.classList.toggle("collapsed");
});
}
// Run on load
document.addEventListener("DOMContentLoaded", initMobileMode);
</script>
<style>
/* Main Container matching Sidebar visual style */
/* DESKTOP STYLES (Existing) */
.control-panel {
flex-shrink: 0;
background: #000; /* Matching Sidebar */
background: #000;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem 3rem; /* Matching Sidebar padding style */
padding: 2rem 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
z-index: 20;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); /* Inverted shadow */
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
position: relative;
}
@@ -381,7 +695,7 @@ import { ChevronDown } from "@lucide/astro";
display: flex;
justify-content: space-between;
gap: 2rem;
flex-wrap: nowrap; /* Prevent wrapping on large screens */
flex-wrap: nowrap;
align-items: stretch;
}
@@ -423,7 +737,7 @@ import { ChevronDown } from "@lucide/astro";
/* Headers */
.section-header {
font-size: 0.75rem; /* Matched to sidebar subtitle size approx */
font-size: 0.75rem;
font-weight: 700;
opacity: 0.5;
letter-spacing: 1px;
@@ -437,7 +751,7 @@ import { ChevronDown } from "@lucide/astro";
.sliders-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem 1.5rem; /* More airy gap */
gap: 1rem 1.5rem;
}
.toggles-row {
@@ -483,7 +797,7 @@ import { ChevronDown } from "@lucide/astro";
color: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
height: 32px; /* Match button height */
height: 32px;
font-family: var(--font-mono);
}
@@ -520,88 +834,15 @@ import { ChevronDown } from "@lucide/astro";
color: #fff;
}
/* Helper Classes */
.mobile-only {
display: none !important; /* Force hidden on desktop */
}
.mobile-controls-header {
display: none;
}
/* Responsive Design */
@media (max-width: 1200px) {
.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;
overflow-y: auto;
transition:
max-height 0.4s ease,
opacity 0.4s ease,
padding 0.4s ease;
max-height: 70dvh;
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;
overflow-y: hidden;
}
.control-panel-content {
flex-wrap: wrap;
}
.panel-divider-v {
display: none;
}
.sliders-section,
.effects-section,
.actions-section {
flex: 1 1 100%; /* Stack on smaller screens */
}
.sliders-grid {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
.shortcuts-hint {
display: none;
}
}
/* Color Btn Styles (Shared) */
.tui-color-btn {
position: relative;
display: inline-flex;
@@ -616,16 +857,14 @@ import { ChevronDown } from "@lucide/astro";
cursor: pointer;
border-radius: 2px;
transition: all 0.2s;
height: 29px; /* Match toggle/button height approx */
height: 29px;
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;
@@ -638,7 +877,6 @@ import { ChevronDown } from "@lucide/astro";
border: none;
z-index: 2;
}
.color-swatch {
width: 12px;
height: 12px;
@@ -647,9 +885,167 @@ import { ChevronDown } from "@lucide/astro";
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;
}
/* --- MOBILE STYLES --- */
@media (max-width: 1024px) {
.control-panel {
padding: 0;
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
border-top: 1px solid #222;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex !important;
}
/* Container */
.mobile-nav-container {
flex-direction: column;
width: 100%;
height: 240px; /* Fixed compact height */
background: #000;
}
/* 1. Active View */
.mobile-active-view {
flex: 1;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #222;
overflow: hidden;
background: #080808;
}
.mobile-placeholder {
color: #444;
font-size: 0.8rem;
font-family: var(--font-mono);
}
/* When components are moved here, they need to stretch */
.mobile-active-view > :global(*) {
width: 100%;
max-width: 300px;
}
/* 2. Param List */
.mobile-param-list {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
overflow-x: auto;
white-space: nowrap;
background: #000;
border-bottom: 1px solid #1a1a1a;
/* Hide scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
}
.mobile-param-list::-webkit-scrollbar {
display: none;
}
.mobile-param-list :global(.mobile-list-item) {
background: #1a1a1a;
border: 1px solid #333;
color: #fff;
opacity: 0.8;
padding: 4px 10px;
font-size: 11px;
font-family: var(--font-mono);
border-radius: 2px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.mobile-param-list :global(.mobile-list-item.active) {
border-color: var(--accent-color);
color: var(--accent-color);
background: color-mix(
in srgb,
var(--accent-color),
transparent 90%
);
opacity: 1;
}
/* 3. Tabs */
.mobile-tabs {
display: flex;
border-top: 1px solid #222;
height: 50px;
}
.mobile-tab-btn {
flex: 1;
background: #000;
border: none;
color: #555;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 9px;
font-family: var(--font-mono);
cursor: pointer;
transition: color 0.2s;
}
.mobile-tab-btn span {
margin-top: 2px;
}
.mobile-tab-btn.active {
color: var(--accent-color);
background: #0a0a0a;
}
/* 4. Footer */
.mobile-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
height: 44px;
background: #050505;
border-top: 1px solid #1a1a1a;
}
.mobile-footer-btn {
background: transparent;
border: none;
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
display: flex;
align-items: center;
gap: 6px;
opacity: 0.7;
padding: 8px;
}
.mobile-footer-btn.primary {
color: var(--accent-color);
font-weight: 700;
opacity: 1;
}
.mobile-queue-badge {
display: flex;
align-items: center;
gap: 4px;
color: #444;
font-size: 10px;
font-family: var(--font-mono);
}
}
</style>

View File

@@ -89,6 +89,9 @@
};
const showTooltip = (target: Element, e: MouseEvent) => {
// Only show on devices with hover capability (mouse)
if (!window.matchMedia("(hover: hover)").matches) return;
const title = target.getAttribute("data-tooltip-title");
const desc = target.getAttribute("data-tooltip-desc");