Spaces:
Sleeping
Sleeping
| import React, { useState, useCallback, useEffect } from "react"; | |
| import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js"; | |
| import { kinkCategoryLabel, displayKinkName } from "./ui-labels.js"; | |
| import { labelsForShape as sharedLabelsForShape } from "./direction-shape-labels.js"; | |
| /** One photo at a time; tap left/right edges to change. */ | |
| function KinkMediaScroll({ assets, imageRevealed, kinkName, onReveal, onAssetError, resetKey }) { | |
| const [idx, setIdx] = useState(0); | |
| const list = (Array.isArray(assets) ? assets : []).filter((a) => a && typeof a.asset_url === "string" && a.asset_url); | |
| const n = list.length; | |
| useEffect(() => { | |
| setIdx(0); | |
| }, [resetKey]); | |
| useEffect(() => { | |
| setIdx((i) => (n ? Math.min(Math.max(0, i), n - 1) : 0)); | |
| }, [n]); | |
| const go = useCallback( | |
| (delta, e) => { | |
| e?.preventDefault?.(); | |
| e?.stopPropagation?.(); | |
| if (n < 2) return; | |
| setIdx((i) => (i + delta + n) % n); | |
| }, | |
| [n], | |
| ); | |
| if (!n) return null; | |
| const safeIdx = Math.min(Math.max(0, idx), n - 1); | |
| const cur = list[safeIdx]; | |
| if (!cur?.asset_url) return null; | |
| return html` | |
| <div className="tinder-card__media-inner"> | |
| <div className="tinder-card__carousel"> | |
| ${n > 1 | |
| ? html` | |
| <button | |
| type="button" | |
| className="tinder-edge tinder-edge--left" | |
| aria-label="Previous photo" | |
| onClick=${(e) => go(-1, e)} | |
| /> | |
| <button | |
| type="button" | |
| className="tinder-edge tinder-edge--right" | |
| aria-label="Next photo" | |
| onClick=${(e) => go(1, e)} | |
| /> | |
| ` | |
| : null} | |
| <div className="tinder-card__slide tinder-card__slide--solo"> | |
| <img | |
| loading="eager" | |
| decoding="async" | |
| className=${imageRevealed ? "" : "tinder-card__img-blur"} | |
| src=${cur.asset_url} | |
| alt=${kinkName ? `Photo for ${kinkName}` : "Kink illustration"} | |
| draggable=${false} | |
| onError=${() => onAssetError?.(cur.asset_url)} | |
| /> | |
| </div> | |
| ${n > 1 | |
| ? html` | |
| <div className="tinder-dots" aria-hidden="true"> | |
| ${list.map((_, i) => html`<span key=${`d-${i}`} className=${i === safeIdx ? "on" : ""} />`)} | |
| </div> | |
| ` | |
| : null} | |
| ${n > 1 | |
| ? html`<div className="tinder-card__photo-badge">${safeIdx + 1} / ${n}</div>` | |
| : null} | |
| ${!imageRevealed | |
| ? html` | |
| <div className="tinder-card__reveal-row"> | |
| <button | |
| type="button" | |
| className="tinder-card__reveal" | |
| onClick=${(e) => { | |
| e.stopPropagation(); | |
| onReveal?.(); | |
| }} | |
| >Show</button> | |
| </div> | |
| ` | |
| : null} | |
| <div className="tinder-card__gradient"></div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export function StatusText({ text }) { | |
| return html`<div className="status" data-testid="status-text">${text || ""}</div>`; | |
| } | |
| export function ReactionBar({ current, onRate, size = "normal", disabled = false }) { | |
| const reactions = size === "compact" | |
| ? UI_REACTIONS.filter((r) => r.ui !== "not_interested") | |
| : UI_REACTIONS; | |
| return html` | |
| <div className="reaction-bar"> | |
| ${reactions.map((r) => html` | |
| <button key=${r.ui} | |
| className=${`react-btn ${current === r.ui ? "active" : ""}`} | |
| title=${r.label} | |
| disabled=${disabled} | |
| onClick=${(e) => { e.stopPropagation(); onRate(r.api); }}> | |
| ${r.icon} | |
| </button> | |
| `)} | |
| </div> | |
| `; | |
| } | |
| export function ActionBar({ onRate }) { | |
| const actions = [ | |
| { api: "love", icon: "\u2764\uFE0F", label: "Love", cls: "love" }, | |
| { api: "like", icon: "\uD83D\uDC4D", label: "Like", cls: "like" }, | |
| { api: "curious", icon: "\uD83E\uDD14", label: "Maybe", cls: "maybe" }, | |
| { api: "not_interested", icon: "\u27A1\uFE0F", label: "Skip", cls: "skip" }, | |
| { api: "hard_no", icon: "\uD83D\uDEAB", label: "No-go", cls: "nogo" }, | |
| ]; | |
| return html` | |
| <div className="action-bar" data-testid="discover-action-bar"> | |
| ${actions.map((a) => html` | |
| <button key=${a.api} className=${`action-btn ${a.cls}`} | |
| data-testid=${`discover-rate-${a.api}`} | |
| onClick=${() => onRate(a.api)}> | |
| <span className="action-icon">${a.icon}</span> | |
| <span>${a.label}</span> | |
| </button> | |
| `)} | |
| </div> | |
| `; | |
| } | |
| function definitionText(kink) { | |
| const t = (kink.detail_summary || kink.definition || kink.summary || "").trim(); | |
| return t; | |
| } | |
| const DISCOVER_BLURB_MAX = 140; | |
| function discoverCollapsedBlurb(kink) { | |
| const fullText = discoverFullDefinition(kink); | |
| if (!fullText) return ""; | |
| if (fullText.length <= DISCOVER_BLURB_MAX) return fullText; | |
| const cut = fullText.slice(0, DISCOVER_BLURB_MAX); | |
| const lastSpace = cut.lastIndexOf(" "); | |
| const trimmed = lastSpace > DISCOVER_BLURB_MAX - 30 ? cut.slice(0, lastSpace) : cut; | |
| return `${trimmed.replace(/[\s.,;:!?-]+$/, "")}…`; | |
| } | |
| function discoverFullDefinition(kink) { | |
| const d = (kink.definition || "").trim(); | |
| if (d) return d; | |
| return (kink.detail_summary || kink.summary || "").trim(); | |
| } | |
| export function KinkCard({ | |
| kink, | |
| onRevealImage, | |
| imageRevealed, | |
| photosEnabled = true, | |
| layout = "default", | |
| infoExpanded = false, | |
| onToggleInfo, | |
| }) { | |
| const assets = (kink.assets || []).filter((a) => a && typeof a.asset_url === "string" && a.asset_url); | |
| const [failedAssetUrls, setFailedAssetUrls] = useState(() => new Set()); | |
| useEffect(() => { | |
| setFailedAssetUrls(new Set()); | |
| }, [kink.id]); | |
| const markAssetFailed = useCallback((assetUrl) => { | |
| if (!assetUrl) return; | |
| setFailedAssetUrls((prev) => { | |
| if (prev.has(assetUrl)) return prev; | |
| const next = new Set(prev); | |
| next.add(assetUrl); | |
| return next; | |
| }); | |
| }, []); | |
| const visibleAssets = assets.filter((a) => !failedAssetUrls.has(a.asset_url)); | |
| const hasImage = photosEnabled && visibleAssets.length > 0; | |
| const category = kinkCategoryLabel(kink); | |
| const def = definitionText(kink); | |
| const safety = html` | |
| ${kink.is_extreme ? html`<div className="safety-badge extreme">Significant risk</div>` | |
| : kink.risk_level === "advanced" ? html`<div className="safety-badge advanced">Advanced</div>` | |
| : null} | |
| `; | |
| const body = html` | |
| ${category ? html`<div className="kink-pill">${category}</div>` : null} | |
| <h2 className="card-title">${displayKinkName(kink.name)}</h2> | |
| ${def ? html`<p className="card-definition">${def}</p>` : null} | |
| ${safety} | |
| `; | |
| if (layout === "tinder") { | |
| const expanded = Boolean(infoExpanded); | |
| const notes = (kink.notes || "").trim(); | |
| const blurb = discoverCollapsedBlurb(kink); | |
| const fullDef = discoverFullDefinition(kink); | |
| /** No "Tap for full info" when collapsed blurb, full text, and notes are all empty (or full text equals blurb). */ | |
| const tinderExpandable = | |
| Boolean(notes) || (Boolean(fullDef) && (!blurb || fullDef !== blurb)); | |
| const tappable = typeof onToggleInfo === "function" && tinderExpandable; | |
| const tinderBody = html` | |
| ${category ? html`<div className="kink-pill">${category}</div>` : null} | |
| <h2 className="card-title">${displayKinkName(kink.name)}</h2> | |
| ${expanded | |
| ? html` | |
| ${fullDef ? html`<p className="card-definition card-definition--discover-full">${fullDef}</p>` : null} | |
| ${notes ? html`<div className="tinder-card__notes">${notes}</div>` : null} | |
| ` | |
| : blurb | |
| ? html`<p className="card-definition">${blurb}</p>` | |
| : null} | |
| ${safety} | |
| ${tappable | |
| ? html`<div className="tinder-card__info-cue" aria-hidden="true">${expanded ? "Tap to collapse" : "Tap for full info"}</div>` | |
| : null} | |
| `; | |
| const mediaBlock = hasImage | |
| ? html` | |
| <div className=${`tinder-card__media ${imageRevealed ? "revealed" : ""}`}> | |
| <${KinkMediaScroll} | |
| resetKey=${kink.id} | |
| assets=${visibleAssets} | |
| imageRevealed=${imageRevealed} | |
| kinkName=${kink.name} | |
| onReveal=${onRevealImage} | |
| onAssetError=${markAssetFailed} | |
| /> | |
| </div> | |
| ` | |
| : html` | |
| <div className="tinder-card__media tinder-card__media--placeholder"> | |
| <div className="tinder-card__placeholder-letters">${(kink.name || "?").slice(0, 2).toUpperCase()}</div> | |
| <div className="tinder-card__gradient"></div> | |
| </div> | |
| `; | |
| const bodyClass = `tinder-card__body${tappable ? " tinder-card__body--tappable" : ""}${expanded ? " tinder-card__body--expanded" : ""}`; | |
| const bodyBlock = tappable | |
| ? expanded | |
| ? html` | |
| <div | |
| className=${bodyClass} | |
| data-testid="discover-card-body" | |
| role="button" | |
| tabIndex=${0} | |
| aria-expanded=${true} | |
| aria-label="Hide full kink description" | |
| onClick=${() => onToggleInfo()} | |
| onKeyDown=${(e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| onToggleInfo(); | |
| } | |
| }} | |
| > | |
| ${tinderBody} | |
| </div> | |
| ` | |
| : html` | |
| <button | |
| type="button" | |
| className=${bodyClass} | |
| data-testid="discover-card-body" | |
| aria-expanded=${false} | |
| aria-label="Show full kink description" | |
| onClick=${() => onToggleInfo()} | |
| > | |
| ${tinderBody} | |
| </button> | |
| ` | |
| : html`<div className=${bodyClass} data-testid="discover-card-body">${tinderBody}</div>`; | |
| return html` | |
| <div className="tinder-card" data-testid="discover-card"> | |
| ${mediaBlock} | |
| ${bodyBlock} | |
| </div> | |
| `; | |
| } | |
| return html` | |
| <div className="card card-lg"> | |
| ${body} | |
| ${hasImage | |
| ? imageRevealed | |
| ? html` | |
| <div className="card-image-scroll"> | |
| ${visibleAssets.map( | |
| (asset, i) => html` | |
| <div key=${asset.asset_url || `c-${i}`} className="card-image-slide"> | |
| <img | |
| loading=${i < 2 ? "eager" : "lazy"} | |
| src=${asset.asset_url} | |
| alt=${kink?.name ? `Photo for ${kink.name}` : "Kink illustration"} | |
| onError=${() => markAssetFailed(asset.asset_url)} | |
| /> | |
| </div> | |
| `, | |
| )} | |
| </div> | |
| ` | |
| : html` | |
| <div className="card-image-area card-image-area--preview" onClick=${onRevealImage}> | |
| <img | |
| className="card-image-blur" | |
| src=${visibleAssets[0].asset_url} | |
| alt=${kink?.name ? `Blurred preview for ${kink.name}` : "Blurred preview"} | |
| draggable=${false} | |
| onError=${() => markAssetFailed(visibleAssets[0].asset_url)} | |
| /> | |
| <span className="card-image-cta">Show</span> | |
| </div> | |
| ` | |
| : null} | |
| </div> | |
| `; | |
| } | |
| export function PlayChip({ name, emoji, expanded, onClick, children, nearDupHint }) { | |
| return html` | |
| <div className=${`play-chip ${expanded ? "expanded" : ""}`} title=${name} onClick=${onClick}> | |
| <div className="chip-top-row"> | |
| <span className="chip-emoji">${emoji}</span> | |
| <span className="chip-name">${displayKinkName(name)}</span> | |
| ${nearDupHint | |
| ? html`<span className="chip-near-title" title=${nearDupHint}>≈</span>` | |
| : null} | |
| </div> | |
| ${expanded && children ? html`<div className="inline-detail" onClick=${(e) => e.stopPropagation()}>${children}</div>` : null} | |
| </div> | |
| `; | |
| } | |
| export function DirectionPills({ directions, locked, onToggle, shape, roleA, roleB }) { | |
| if (shape === "mutual") { | |
| return html` | |
| <div className="dir-pills dir-pills--mutual" data-testid="direction-pills"> | |
| <span className="tiny muted">This is mutual by default — saved as "We both".</span> | |
| </div> | |
| `; | |
| } | |
| const labels = sharedLabelsForShape(shape, roleA, roleB); | |
| return html` | |
| <div className="dir-pills" data-testid="direction-pills"> | |
| ${DIRECTION_PILLS.map((d) => html` | |
| <button key=${d.key} | |
| className=${`dir-pill ${directions.includes(d.key) ? "active" : ""}`} | |
| disabled=${locked} | |
| data-testid=${`direction-pill-${d.key}`} | |
| onClick=${(e) => { e.stopPropagation(); onToggle(d.key); }}> | |
| ${directions.includes(d.key) ? d.icon : "\uD83E\uDD14"} ${(labels && labels[d.key]) || d.label} | |
| </button> | |
| `)} | |
| </div> | |
| `; | |
| } | |
| export function ConfirmDialog({ open, title, body, confirmLabel, cancelLabel, onConfirm, onCancel, destructive }) { | |
| if (!open) return null; | |
| const onKeyDown = (e) => { | |
| if (e.key === "Escape") { | |
| e.preventDefault(); | |
| onCancel?.(); | |
| } | |
| }; | |
| return html` | |
| <div className="confirm-dialog-backdrop" onClick=${onCancel} data-testid="confirm-dialog-backdrop"> | |
| <div | |
| className=${`confirm-dialog ${destructive ? "confirm-dialog--destructive" : ""}`} | |
| role="alertdialog" | |
| aria-modal="true" | |
| aria-labelledby="confirm-dialog-title" | |
| onClick=${(e) => e.stopPropagation()} | |
| onKeyDown=${onKeyDown} | |
| tabIndex="-1" | |
| data-testid="confirm-dialog" | |
| > | |
| <h3 id="confirm-dialog-title" className="confirm-dialog-title">${title}</h3> | |
| ${body ? html`<p className="confirm-dialog-body">${body}</p>` : null} | |
| <div className="confirm-dialog-actions"> | |
| <button type="button" className="ghost sm" data-testid="confirm-dialog-cancel" onClick=${onCancel}>${cancelLabel || "Cancel"}</button> | |
| <button | |
| type="button" | |
| className=${destructive ? "secondary sm" : "sm"} | |
| data-testid="confirm-dialog-confirm" | |
| autoFocus | |
| onClick=${onConfirm} | |
| >${confirmLabel || "Confirm"}</button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export function SearchOverlay({ query, onQueryChange, results, onSelect, onClose, hint }) { | |
| // htm/React's ref-prop pass-through is unreliable here, so look elements up by testid in | |
| // the keydown handler. Capture the trigger on mount; restore focus on unmount so keyboard | |
| // and screen-reader users land back where they were instead of at <body>. | |
| useEffect(() => { | |
| const previouslyFocused = document.activeElement; | |
| return () => { | |
| if (previouslyFocused && typeof previouslyFocused.focus === "function") { | |
| try { previouslyFocused.focus(); } catch { /* trigger detached */ } | |
| } | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| const onKey = (e) => { | |
| if (e.key === "Escape") { | |
| e.preventDefault(); | |
| onClose?.(); | |
| return; | |
| } | |
| // Focus trap: only the search input + close button are focusable. Cycle Tab between them. | |
| if (e.key === "Tab") { | |
| const input = document.querySelector('[data-testid="search-overlay-input"]'); | |
| const close = document.querySelector('[data-testid="search-overlay-close"]'); | |
| if (!input || !close) return; | |
| const active = document.activeElement; | |
| if (e.shiftKey && active === input) { | |
| e.preventDefault(); | |
| close.focus(); | |
| } else if (!e.shiftKey && active === close) { | |
| e.preventDefault(); | |
| input.focus(); | |
| } | |
| } | |
| }; | |
| document.addEventListener("keydown", onKey); | |
| return () => document.removeEventListener("keydown", onKey); | |
| }, [onClose]); | |
| return html` | |
| <div className="search-overlay" onClick=${onClose} role="dialog" aria-modal="true" aria-label="Search kinks"> | |
| <div className="search-inner" onClick=${(e) => e.stopPropagation()}> | |
| <div style=${{ display: "flex", gap: 8, alignItems: "center" }}> | |
| <input | |
| data-testid="search-overlay-input" | |
| autoFocus | |
| placeholder="Search kinks..." | |
| value=${query} | |
| aria-label="Search kinks" | |
| onInput=${(e) => onQueryChange(e.target.value)} | |
| /> | |
| <button type="button" className="ghost sm" data-testid="search-overlay-close" onClick=${onClose} aria-label="Close search">Close</button> | |
| </div> | |
| ${hint ? html`<p className="tiny muted">${hint}</p>` : null} | |
| <div className="stack-sm"> | |
| ${results.map((item) => html` | |
| <div key=${item.kink.id} className="play-chip" | |
| onClick=${() => onSelect(item.kink)}> | |
| <span className="chip-name">${displayKinkName(item.kink.name)}</span> | |
| ${item.kink.summary?.trim() | |
| ? html`<span className="tiny">${item.kink.summary.trim()}</span>` | |
| : null} | |
| </div> | |
| `)} | |
| ${query && !results.length ? html`<div className="empty">No results</div>` : null} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export function RoleChip({ kink, selected, onToggle }) { | |
| return html` | |
| <div className=${`play-chip ${selected ? "" : ""}`}> | |
| <span className="chip-name">${displayKinkName(kink.name)}</span> | |
| <button className=${`dir-pill sm ${selected ? "active" : ""}`} | |
| onClick=${(e) => { e.stopPropagation(); onToggle(kink.id, !selected); }}> | |
| ${selected ? "Selected" : "Add"} | |
| </button> | |
| </div> | |
| `; | |
| } | |