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`
go(-1, e)}
/>
go(1, e)}
/>
`
: null}
onAssetError?.(cur.asset_url)}
/>
${n > 1
? html`
${list.map((_, i) => html` `)}
`
: null}
${n > 1
? html`${safeIdx + 1} / ${n}
`
: null}
${!imageRevealed
? html`
{
e.stopPropagation();
onReveal?.();
}}
>Show
`
: 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`
{ e.stopPropagation(); onRate(r.api); }}>
${r.icon}
`)}
`;
}
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`
onRate(a.api)}>
${a.icon}
${a.label}
`)}
`;
}
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`${expanded ? "Tap to collapse" : "Tap for full info"}
`
: 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`
onToggleInfo()}
>
${tinderBody}
`
: html`${tinderBody}
`;
return html`
${mediaBlock}
${bodyBlock}
`;
}
return html`
${body}
${hasImage
? imageRevealed
? html`
${visibleAssets.map(
(asset, i) => html`
markAssetFailed(asset.asset_url)}
/>
`,
)}
`
: html`
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`
{ e.stopPropagation(); onToggle(d.key); }}>
${directions.includes(d.key) ? d.icon : "\uD83E\uDD14"} ${(labels && labels[d.key]) || d.label}
`)}
`;
}
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}
${cancelLabel || "Cancel"}
${confirmLabel || "Confirm"}
`;
}
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)}
/>
Close
${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)}
{ e.stopPropagation(); onToggle(kink.id, !selected); }}>
${selected ? "Selected" : "Add"}
`;
}