kink-discovery / frontend /components.js
Perplexed7675's picture
Sync from kink_cli (Docker Space)
90f1f6e verified
Raw
History Blame Contribute Delete
17.7 kB
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>
`;
}