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`
${n > 1 ? html`
` : null}
`; } export function StatusText({ text }) { return html`
${text || ""}
`; } 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`
${reactions.map((r) => html` `)}
`; } 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`
${actions.map((a) => html` `)}
`; } 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`
Significant risk
` : kink.risk_level === "advanced" ? html`
Advanced
` : null} `; const body = html` ${category ? html`
${category}
` : null}

${displayKinkName(kink.name)}

${def ? html`

${def}

` : 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`
${category}
` : null}

${displayKinkName(kink.name)}

${expanded ? html` ${fullDef ? html`

${fullDef}

` : null} ${notes ? html`
${notes}
` : null} ` : blurb ? html`

${blurb}

` : null} ${safety} ${tappable ? html`` : null} `; const mediaBlock = hasImage ? html`
<${KinkMediaScroll} resetKey=${kink.id} assets=${visibleAssets} imageRevealed=${imageRevealed} kinkName=${kink.name} onReveal=${onRevealImage} onAssetError=${markAssetFailed} />
` : html`
${(kink.name || "?").slice(0, 2).toUpperCase()}
`; const bodyClass = `tinder-card__body${tappable ? " tinder-card__body--tappable" : ""}${expanded ? " tinder-card__body--expanded" : ""}`; const bodyBlock = tappable ? expanded ? html`
onToggleInfo()} onKeyDown=${(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggleInfo(); } }} > ${tinderBody}
` : html` ` : html`
${tinderBody}
`; return html`
${mediaBlock} ${bodyBlock}
`; } return html`
${body} ${hasImage ? imageRevealed ? html`
${visibleAssets.map( (asset, i) => html`
${kink?.name markAssetFailed(asset.asset_url)} />
`, )}
` : html`
${kink?.name markAssetFailed(visibleAssets[0].asset_url)} /> Show
` : null}
`; } export function PlayChip({ name, emoji, expanded, onClick, children, nearDupHint }) { return html`
${emoji} ${displayKinkName(name)} ${nearDupHint ? html`` : null}
${expanded && children ? html`
e.stopPropagation()}>${children}
` : null}
`; } export function DirectionPills({ directions, locked, onToggle, shape, roleA, roleB }) { if (shape === "mutual") { return html`
This is mutual by default — saved as "We both".
`; } const labels = sharedLabelsForShape(shape, roleA, roleB); return html`
${DIRECTION_PILLS.map((d) => html` `)}
`; } 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`
e.stopPropagation()} onKeyDown=${onKeyDown} tabIndex="-1" data-testid="confirm-dialog" >

${title}

${body ? html`

${body}

` : null}
`; } 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 . 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`
e.stopPropagation()}>
onQueryChange(e.target.value)} />
${hint ? html`

${hint}

` : null}
${results.map((item) => html`
onSelect(item.kink)}> ${displayKinkName(item.kink.name)} ${item.kink.summary?.trim() ? html`${item.kink.summary.trim()}` : null}
`)} ${query && !results.length ? html`
No results
` : null}
`; } export function RoleChip({ kink, selected, onToggle }) { return html`
${displayKinkName(kink.name)}
`; }