330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
|
import { ShirtOptionsPanel } from './ShirtOptionsPanel';
|
|
import { UploadTab } from './UploadTab';
|
|
import { StickersTab } from './StickersTab';
|
|
import { EmojiTab } from './EmojiTab';
|
|
import { TextTab } from './TextTab';
|
|
import { LayersPanel } from '../panels/LayersPanel';
|
|
import { formatUsd } from '../../constants/shirt';
|
|
import '../../styles/Sidebar.css';
|
|
// Tab nav for the right rail. Templates are deliberately absent — templates
|
|
// are loaded exclusively via the `?template=X` URL parameter (see
|
|
// `readTemplateFromUrl` in App.jsx). The Layers panel used to be a tab here
|
|
// too; it's now a permanent section rendered beneath the tab card so the
|
|
// user can browse / reorder / delete elements without losing access to the
|
|
// upload / stickers / text tools.
|
|
const TABS = [
|
|
{ id: 'upload', label: 'Upload Photo', icon: UploadIcon },
|
|
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
|
|
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
|
|
{ id: 'text', label: 'Text', icon: TextIcon },
|
|
];
|
|
|
|
/**
|
|
* Right-rail panel.
|
|
*
|
|
* Top: Shirt Options card (color/size/price)
|
|
* Middle: Tab nav + active tab content
|
|
* Bottom (sticky): Preview-on-Model toggle + Add to Cart button
|
|
*/
|
|
export function Sidebar({
|
|
// Editor wiring
|
|
onAddImage, onAddSticker, onAddText,
|
|
recentUploads,
|
|
onRemoveUpload,
|
|
// Selection-aware editing surface for the text tab. NOTE:
|
|
// `selectedElement` is used by the layers panel and the mobile
|
|
// bottom-sheet compact mode. The Text tab now reads from a
|
|
// SEPARATE prop (`editingTextElement`) so that *selecting* a text
|
|
// element on the canvas no longer pulls the Text tab into edit
|
|
// mode — only an explicit edit gesture (pencil affordance on
|
|
// desktop, Edit-text toolbar button on mobile) sets that prop.
|
|
// See the Change 7 follow-up in [[Refinements_2026-05-20_Part2]].
|
|
selectedElement, editingTextElement, onUpdateSelected, onCommit,
|
|
// Shirt config
|
|
shirtColorId, onShirtColorChange,
|
|
shirtSize, onShirtSizeChange,
|
|
totalPrice, basePrice,
|
|
// Cart
|
|
onAddToCart,
|
|
cartDisabled = false,
|
|
cartDisabledReason = null,
|
|
// Color helpers for the text tab
|
|
recentColors,
|
|
onPickColor,
|
|
// Font helpers for the text tab (recent fonts dropdown)
|
|
recentFonts,
|
|
onPickFont,
|
|
// Layers tab — needs the full elements list and a delete callback. We
|
|
// already have selectedElement; add elements + onSelectElement +
|
|
// onDeleteElement so the LayersPanel can show the list and act on it.
|
|
elements = [],
|
|
onSelectElement,
|
|
onDeleteElement,
|
|
// Layers-panel per-row actions (Change 4). Optional so existing
|
|
// callers continue to work — the buttons just don't render when
|
|
// the callback is missing.
|
|
onDuplicateElement,
|
|
onUpdateElement,
|
|
// S3 multi-select wiring. LayersPanel uses these to render the
|
|
// ✓ chip per row, the bulk-delete affordance, and the drag-reorder
|
|
// grip. All optional — the panel falls back to single-selection
|
|
// rendering when they aren't provided.
|
|
selectedIds,
|
|
onToggleInSelection,
|
|
onDeleteMany,
|
|
onReorderElement,
|
|
// Controlled active-tab API (Change 7 follow-up). Sidebar used to
|
|
// own its tab state internally; lifting it to App lets the pencil
|
|
// affordance / Edit-text gesture set the tab directly (“clicking
|
|
// the pencil should switch to the Text tab and populate the
|
|
// form”). Both props are optional so existing call sites that
|
|
// didn't pass them fall back to internal state — uncontrolled.
|
|
activeTab: controlledActiveTab,
|
|
onActiveTabChange,
|
|
// Desktop focus signal for the Text tab. Threaded straight through
|
|
// to TextTab — see its docblock for how it's consumed. Optional;
|
|
// when omitted, TextTab simply never auto-focuses (which is fine
|
|
// for any caller that doesn't need the pencil-click behaviour).
|
|
textEditFocusToken,
|
|
}) {
|
|
// Controlled or uncontrolled. When the parent supplies `activeTab`
|
|
// and `onActiveTabChange`, those drive the visible tab; otherwise
|
|
// we keep our own `useState` and behave as before. Detecting via
|
|
// `!== undefined` so a deliberate `null` from the parent (= reset
|
|
// to default) is still respected.
|
|
const [internalActiveTab, setInternalActiveTab] = useState('upload');
|
|
const isControlled = controlledActiveTab !== undefined && typeof onActiveTabChange === 'function';
|
|
const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
|
|
const setActiveTab = isControlled ? onActiveTabChange : setInternalActiveTab;
|
|
|
|
// ---------------------------------------------------------------
|
|
// Pencil-click focus plumbing (Change 7 follow-up).
|
|
//
|
|
// The textarea-focus logic LIVES HERE — in Sidebar — rather than
|
|
// inside TextTab, on purpose. TextTab is mounted/unmounted as the
|
|
// user navigates between tabs; pressing the pencil flips the tab
|
|
// to 'text' AND bumps the focus token in the same React batch,
|
|
// which means TextTab is mounting FRESH at the moment the token
|
|
// first changes. Any `useRef(focusToken)` initializer inside
|
|
// TextTab would capture the new value at mount and the
|
|
// "transition detection" effect would immediately bail out
|
|
// (`1 === 1`) — the user has to click the pencil a second time
|
|
// before TextTab's ref carries an old value.
|
|
//
|
|
// Sidebar, by contrast, is persistently mounted on desktop
|
|
// (App.jsx wraps it in an <aside> that lives for the lifetime of
|
|
// the editor). Its refs survive every tab switch. The ref
|
|
// initializer captures the very-first token value (0) at app
|
|
// boot, before any pencil click can have happened — so the next
|
|
// bump is correctly detected as a transition.
|
|
//
|
|
// textareaRef is forwarded down to TextTab via the `textareaRef`
|
|
// prop. TextTab attaches it to its <textarea>. When this effect
|
|
// fires, the next-frame callback runs after React has mounted
|
|
// TextTab and committed its ref — so textareaRef.current is
|
|
// populated by the time we call .focus().
|
|
// ---------------------------------------------------------------
|
|
const textareaRef = useRef(null);
|
|
const lastFocusTokenRef = useRef(textEditFocusToken ?? 0);
|
|
useEffect(() => {
|
|
if (textEditFocusToken == null) return;
|
|
if (textEditFocusToken === lastFocusTokenRef.current) return;
|
|
lastFocusTokenRef.current = textEditFocusToken;
|
|
// requestAnimationFrame defers past the current commit — if the
|
|
// same pencil click that bumped this token ALSO switched
|
|
// activeTab to 'text', TextTab is mounting on the same render
|
|
// and its textarea ref isn't attached yet at the moment React
|
|
// fires this effect. Waiting one frame guarantees the textarea
|
|
// is in the DOM and the ref is set.
|
|
const rafId = window.requestAnimationFrame(() => {
|
|
textareaRef.current?.focus();
|
|
});
|
|
return () => window.cancelAnimationFrame(rafId);
|
|
}, [textEditFocusToken]);
|
|
|
|
// The Text tab's edit mode is gated on `editingTextElement` (NOT
|
|
// `selectedElement`) so it only activates when the user has
|
|
// explicitly entered text-editing mode via the pencil affordance
|
|
// (desktop) or the Edit-text toolbar button (mobile). Selection
|
|
// alone is no longer enough — see the Change 7 follow-up in
|
|
// [[Refinements_2026-05-20_Part2]] for the rationale.
|
|
const selectedTextElement = editingTextElement?.type === 'text' ? editingTextElement : null;
|
|
|
|
// NOTE: the auto-switch-to-Text-tab effect that used to live here
|
|
// moved up to App.jsx — the pencil/Edit-text handler now sets the
|
|
// active tab directly via the controlled API. The behaviour is
|
|
// identical from the user's perspective (Text tab pops to the
|
|
// front on edit gesture, stays put when selection alone changes)
|
|
// but the routing is one place instead of two. See Change 7
|
|
// follow-up in [[Refinements_2026-05-20_Part2]].
|
|
|
|
const renderTabContent = () => {
|
|
switch (activeTab) {
|
|
case 'upload': return <UploadTab onAddImage={onAddImage} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
|
|
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
|
|
case 'emoji': return <EmojiTab onAddSticker={onAddSticker} />;
|
|
case 'text': return (
|
|
<TextTab
|
|
onAddText={onAddText}
|
|
selectedTextElement={selectedTextElement}
|
|
onUpdateSelected={onUpdateSelected}
|
|
onCommit={onCommit}
|
|
recentColors={recentColors}
|
|
onPickColor={onPickColor}
|
|
recentFonts={recentFonts}
|
|
onPickFont={onPickFont}
|
|
textareaRef={textareaRef}
|
|
/* Bug 3 fix — forward the full canvas elements list so the
|
|
* TextTab can derive sensible draft defaults from any text
|
|
* already on the canvas. The tab filters to text-typed
|
|
* elements and checks for a shared style; when found, the
|
|
* draft inherits that style's font / fill / outline. */
|
|
elements={elements}
|
|
/>
|
|
);
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<aside className="rp" aria-label="Customization options">
|
|
<div className="rp__scroll">
|
|
<ShirtOptionsPanel
|
|
selectedColorId={shirtColorId}
|
|
onColorChange={onShirtColorChange}
|
|
selectedSize={shirtSize}
|
|
onSizeChange={onShirtSizeChange}
|
|
price={basePrice}
|
|
/>
|
|
|
|
<section className="rp__tools" aria-labelledby="rp-tools-heading">
|
|
<h2 id="rp-tools-heading" className="visually-hidden">Design tools</h2>
|
|
|
|
<div className="rp__tabs" role="tablist" aria-label="Design tools">
|
|
{TABS.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`tab-panel-${tab.id}`}
|
|
id={`tab-${tab.id}`}
|
|
className={`rp__tab${isActive ? ' is-active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
>
|
|
<Icon />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
role="tabpanel"
|
|
id={`tab-panel-${activeTab}`}
|
|
aria-labelledby={`tab-${activeTab}`}
|
|
className="rp__tabpanel"
|
|
>
|
|
{renderTabContent()}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Layers section — permanent surface beneath the tools card. The
|
|
LayersPanel renders its own visible "Layers (N)" titlebar and
|
|
handles the empty-state message internally, so this section
|
|
just provides a matching card chrome and a screen-reader
|
|
heading. */}
|
|
<section className="rp__layers" aria-labelledby="rp-layers-heading">
|
|
<h2 id="rp-layers-heading" className="visually-hidden">Layers</h2>
|
|
<LayersPanel
|
|
elements={elements}
|
|
selectedId={selectedElement?.id ?? null}
|
|
selectedIds={selectedIds}
|
|
onSelect={onSelectElement}
|
|
onToggleInSelection={onToggleInSelection}
|
|
onDelete={onDeleteElement}
|
|
onDeleteMany={onDeleteMany}
|
|
onReorder={onReorderElement}
|
|
onDuplicate={onDuplicateElement}
|
|
onUpdate={onUpdateElement}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="rp__cart-bar">
|
|
{/* Reason text for the disabled cart, when present. Rendered as a
|
|
sibling of the button rather than a tooltip because (a) the
|
|
primary surface for the warning is already the canvas-frame
|
|
chip, this is a follow-up affordance for the user who has
|
|
scrolled to the cart, and (b) tooltips are unreliable on
|
|
touch. The button's `disabled` and `aria-disabled` attributes
|
|
give assistive tech the actionable signal. */}
|
|
{cartDisabled && cartDisabledReason && (
|
|
<p className="rp__cart-reason" role="status">
|
|
{cartDisabledReason}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className={`rp__add-to-cart${cartDisabled ? ' is-disabled' : ''}`}
|
|
onClick={onAddToCart}
|
|
disabled={cartDisabled}
|
|
aria-disabled={cartDisabled}
|
|
title={cartDisabled ? cartDisabledReason || undefined : undefined}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
|
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
|
|
</svg>
|
|
<span>Add to Cart</span>
|
|
<span className="rp__add-to-cart-price">{formatUsd(totalPrice)}</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/* ─── Inline icon set — kept here so each tab definition stays declarative ─── */
|
|
|
|
function UploadIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<path d="M17 8l-5-5-5 5" />
|
|
<path d="M12 3v12" />
|
|
</svg>
|
|
);
|
|
}
|
|
function StickersIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14.5 2.5a8 8 0 1 1-12 12L14.5 2.5z" />
|
|
<path d="M14.5 2.5L21.5 9.5a8 8 0 0 1-7 7" />
|
|
</svg>
|
|
);
|
|
}
|
|
function EmojiIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
<path d="M9 9h.01" />
|
|
<path d="M15 9h.01" />
|
|
</svg>
|
|
);
|
|
}
|
|
function TextIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 7V5h16v2" />
|
|
<path d="M9 5v14" />
|
|
<path d="M15 5v14" />
|
|
<path d="M7 19h4" />
|
|
<path d="M13 19h4" />
|
|
</svg>
|
|
);
|
|
}
|