Perplexed7675's picture
Sync from kink_cli (Docker Space)
bdab097 verified
Raw
History Blame Contribute Delete
36.6 kB
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { createRoot } from "react-dom/client";
import {
QueryClient,
QueryClientProvider,
useQueryClient,
} from "@tanstack/react-query";
import { html, VIEWS } from "./constants.js";
import { currentDirectionsFor } from "./api.js";
import { directionsAfterColumnDrop } from "./direction-logic.js";
import { StatusText, RoleChip } from "./components.js";
import {
useUserQuery,
useGroupsQuery,
useRolesQuery,
useRoleSearchQuery,
} from "./queries.js";
import {
useLoginMutation,
useCreateMutation,
useSavePlayMutation,
useDeletePlayMutation,
useSaveRoleMutation,
usePrefMutation,
useScenarioPreferenceMutation,
useLinkMutation,
useCreateGroupMutation,
useRemoveGroupMemberMutation,
useShareToggleMutation,
usePartnerRequestMutations,
} from "./mutations.js";
import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js";
import { DiscoverView } from "./discover.js";
import { normalizeDiscoverSheetDirections } from "./discover-flow.js";
import { ScenariosView } from "./scenarios.js";
import { MyPlaysView } from "./my-plays.js";
import { TogetherView } from "./together.js";
import { SettingsView } from "./settings.js";
import { initTelemetry } from "./telemetry.js";
import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
import { nextStarterCompleted, starterViewState } from "./starter-flow.js";
const queryClient = new QueryClient();
const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
const STARTER_TARGET = 12;
const PENDING_PARTNER_KEY = "kink_pending_partner";
function loadStoredAuth() {
try {
const raw = localStorage.getItem("kink_auth");
if (raw) {
const payload = JSON.parse(raw);
if (payload.userId && payload.token) return payload;
}
} catch {}
return { userId: "", token: "" };
}
function loadPendingPartnerIntent() {
try {
const url = new URL(window.location.href);
const fromLink = (url.searchParams.get("link") || "").trim();
if (fromLink) return fromLink;
return (localStorage.getItem(PENDING_PARTNER_KEY) || "").trim();
} catch {
return "";
}
}
function persistPendingPartnerIntent(partnerId) {
try {
if (partnerId) localStorage.setItem(PENDING_PARTNER_KEY, partnerId);
else localStorage.removeItem(PENDING_PARTNER_KEY);
} catch {}
try {
const url = new URL(window.location.href);
if (partnerId) url.searchParams.set("link", partnerId);
else url.searchParams.delete("link");
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState({}, "", next || "/");
} catch {}
}
const GRAPH_DEBUG_UI_KEY = "kink_graph_debug_ui";
function readGraphDebugUi() {
try {
return localStorage.getItem(GRAPH_DEBUG_UI_KEY) === "1";
} catch {
return false;
}
}
function writeGraphDebugUi(value) {
try {
localStorage.setItem(GRAPH_DEBUG_UI_KEY, value ? "1" : "0");
} catch {}
}
function GraphDebugBar({ enabled, onToggle }) {
return html`
<div className="app-global-bar" data-testid="app-graph-debug-bar">
<button
type="button"
className=${`ghost sm app-graph-debug-toggle ${enabled ? "app-graph-debug-toggle--on" : ""}`}
data-testid="app-graph-debug-toggle"
aria-pressed=${enabled}
title="Show graph hop / bridge details on Together → Worth Exploring"
onClick=${() => onToggle(!enabled)}
>${enabled ? "Graph debug on" : "Graph debug"}</button>
</div>
`;
}
function AppInner() {
const qc = useQueryClient();
const [auth, setAuthRaw] = useState(loadStoredAuth);
const [authStep, setAuthStep] = useState(auth.userId ? null : "welcome");
const [pendingPartnerId, setPendingPartnerIdRaw] = useState(loadPendingPartnerIntent);
const [status, setStatus] = useState("");
const [view, setView] = useState(VIEWS.DISCOVER);
const [loginForm, setLoginForm] = useState({ user_id: "", private_token: "" });
const [createResult, setCreateResult] = useState(null);
const [roleSearchInput, setRoleSearchInput] = useState("");
const [roleSearchDebounced, setRoleSearchDebounced] = useState("");
const [roleDebounceTimer, setRoleDebounceTimer] = useState(null);
const [activeGroupId, setActiveGroupId] = useState("");
// True when the user has explicitly clicked "Just me" — discover should run solo even if the
// user has partner groups. Without this flag the auto-pick effect below would silently snap
// activeGroupId back to a preferred group on every render.
const [partnerContextOptOut, setPartnerContextOptOut] = useState(false);
const [playsFocusKinkId, setPlaysFocusKinkId] = useState("");
const [scenarioFocus, setScenarioFocus] = useState({ parentKinkId: "", scenarioKinkId: "" });
const [discoverPositiveDirections, setDiscoverPositiveDirections] = useState(["together"]);
const [pendingPlaySaveCount, setPendingPlaySaveCount] = useState(0);
const [starterCompleted, setStarterCompleted] = useState(false);
const [graphDebugUi, setGraphDebugUi] = useState(readGraphDebugUi);
const pendingPlayCommitRef = useRef("");
const autoLinkAttemptRef = useRef("");
const toggleGraphDebugUi = useCallback((next) => {
setGraphDebugUi(next);
writeGraphDebugUi(next);
}, []);
const setAuth = useCallback((value) => {
setAuthRaw(value);
if (value.userId && value.token) localStorage.setItem("kink_auth", JSON.stringify(value));
else localStorage.removeItem("kink_auth");
}, []);
const setPendingPartnerIntent = useCallback((partnerId) => {
const next = String(partnerId || "").trim();
setPendingPartnerIdRaw(next);
persistPendingPartnerIntent(next);
}, []);
const logout = useCallback(() => {
setAuth({ userId: "", token: "" });
qc.clear();
setView(VIEWS.DISCOVER);
setAuthStep("welcome");
setActiveGroupId("");
setPlaysFocusKinkId("");
setScenarioFocus({ parentKinkId: "", scenarioKinkId: "" });
setDiscoverPositiveDirections(["together"]);
setCreateResult(null);
setPendingPlaySaveCount(0);
setStarterCompleted(false);
setStatus("");
}, [qc, setAuth]);
const userQuery = useUserQuery(auth, { pauseUserRefetch: pendingPlaySaveCount > 0 });
const groupsQuery = useGroupsQuery(auth);
const rolesQuery = useRolesQuery(auth);
const roleSearchQuery = useRoleSearchQuery(roleSearchDebounced);
const user = userQuery.data || null;
const currentPlays = user?.plays || {};
const scenarioPreferences = user?.scenario_preferences || {};
const showImages = user?.preferences?.show_images ?? true;
const savedCount = Object.keys(currentPlays).length;
const selectedRoles = rolesQuery.data?.items || [];
const selectedRoleIds = new Set(selectedRoles.map((role) => role.id));
const visibleRoleSearchItems = (roleSearchQuery.data?.items || []).filter((item) => !selectedRoleIds.has(item.kink.id));
const partnerGroups = resolvedPartnerGroups(user, groupsQuery.data);
const partnerGroupsLoaded = !auth.userId || !auth.token || groupsQuery.isSuccess || groupsQuery.isError;
const groupSelectionReady = Boolean(auth.userId && auth.token) && partnerGroupsLoaded && (!partnerGroups.length || Boolean(activeGroupId) || partnerContextOptOut);
const starterState = starterViewState({
hasAuth: Boolean(auth.userId && auth.token),
hasCreateResult: Boolean(createResult),
userLoaded: userQuery.isSuccess,
savedCount,
starterTarget: STARTER_TARGET,
pendingSaveCount: pendingPlaySaveCount,
starterCompleted,
});
const needsStarterFlow = starterState === "starter" || starterState === "finalizing";
/** Starter Discover always queries recs with group id ""; keep save/recs cache in sync if partner groups load mid-flow. */
const recsCacheGroupId = needsStarterFlow ? "" : activeGroupId;
useEffect(() => {
if (createResult) return;
if (!auth.userId || !auth.token) return;
if (!userQuery.isError || userQuery.isFetching) return;
const statusCode = userQuery.error?.status;
if (statusCode === 401 || statusCode === 404) {
setStatus("That profile is gone. Create a new one or log in again.");
logout();
}
}, [createResult, auth.userId, auth.token, userQuery.isError, userQuery.isFetching, userQuery.error, logout]);
useEffect(() => {
if (createResult) return;
if (!auth.userId || !auth.token) return;
const bad = (q) => q.isError && !q.isFetching && (q.error?.status === 401 || q.error?.status === 404);
if (bad(groupsQuery) || bad(rolesQuery)) {
setStatus("That profile is gone. Create a new one or log in again.");
logout();
}
}, [
createResult,
auth.userId,
auth.token,
groupsQuery.isError,
groupsQuery.isFetching,
groupsQuery.error,
rolesQuery.isError,
rolesQuery.isFetching,
rolesQuery.error,
logout,
]);
useEffect(() => {
if (!auth.userId || !auth.token) {
setPendingPlaySaveCount(0);
setStarterCompleted(false);
return;
}
if (createResult || !userQuery.isSuccess) return;
setStarterCompleted((current) => nextStarterCompleted({
starterCompleted: current,
savedCount,
starterTarget: STARTER_TARGET,
pendingSaveCount: pendingPlaySaveCount,
}));
}, [auth.userId, auth.token, createResult, userQuery.isSuccess, savedCount, pendingPlaySaveCount]);
useEffect(() => {
if (!partnerGroups.length) {
if (activeGroupId) setActiveGroupId("");
if (partnerContextOptOut) setPartnerContextOptOut(false);
return;
}
// Respect an explicit "Just me" choice — only auto-pick a group when the user has not
// opted out of partner context. Otherwise re-typing "Just me" each render would defeat
// the toggle.
if (partnerContextOptOut) return;
const preferred = preferredPartnerGroupId(partnerGroups, activeGroupId);
if (preferred !== activeGroupId) setActiveGroupId(preferred);
}, [activeGroupId, partnerGroups, partnerContextOptOut]);
function replacePartnerGroup(nextGroup) {
qc.setQueryData(["partner-groups", auth.userId], (old) => (
old?.items
? { ...old, items: old.items.map((group) => (group.id === nextGroup.id ? { ...group, ...nextGroup } : group)) }
: old
));
qc.setQueryData(["user", auth.userId], (old) => (
old?.partner_groups
? { ...old, partner_groups: old.partner_groups.map((group) => (group.id === nextGroup.id ? { ...group, ...nextGroup } : group)) }
: old
));
}
function removeMemberFromGroupCache(groupId, memberId) {
const patch = (group) => group.id !== groupId
? group
: {
...group,
member_ids: (group.member_ids || []).filter((id) => id !== memberId),
participant_ids: (group.participant_ids || []).filter((id) => id !== memberId),
sharing_member_ids: (group.sharing_member_ids || []).filter((id) => id !== memberId),
};
qc.setQueryData(["partner-groups", auth.userId], (old) => (old?.items ? { ...old, items: old.items.map(patch) } : old));
qc.setQueryData(["user", auth.userId], (old) => (old?.partner_groups ? { ...old, partner_groups: old.partner_groups.map(patch) } : old));
}
function syncPostSaveCaches(userPayload, kinkId, interestState, directions) {
qc.setQueryData(["user", auth.userId], userPayload);
qc.setQueryData(["board", auth.userId], (old) => optimisticBoard(old || EMPTY_BOARD, kinkId, interestState, directions, qc, auth));
qc.setQueryData(["recs", auth.userId, recsCacheGroupId], (old) => removeKinkFromItemsPayload(old, kinkId));
qc.invalidateQueries({ queryKey: ["scenario-parents", auth.userId] });
if (activeGroupId) qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, activeGroupId] });
}
const loginMutation = useLoginMutation({ setAuth, setCreateResult, setAuthOpen: () => {}, setStatus });
const createMutation = useCreateMutation({ setAuth, setCreateResult, setLoginForm, setStatus });
const savePlayMutation = useSavePlayMutation({
auth,
activeGroupId: recsCacheGroupId,
selectedKinkId: "",
setSelectedKinkId: () => {},
setStatus,
pendingPlayCommitRef,
playCommitKey,
syncPostSaveCaches,
onSaveStart: () => setPendingPlaySaveCount((count) => count + 1),
onSaveSettled: () => setPendingPlaySaveCount((count) => Math.max(0, count - 1)),
});
const deletePlayMutation = useDeletePlayMutation({ auth, setStatus });
const handleDeletePlay = useCallback((kinkId) => {
if (!kinkId) return;
deletePlayMutation.mutate(kinkId);
}, [deletePlayMutation]);
const saveRoleMutation = useSaveRoleMutation({ auth, setStatus });
const prefMutation = usePrefMutation({ auth, setStatus });
const scenarioPreferenceMutation = useScenarioPreferenceMutation({ auth, activeGroupId, setStatus });
const linkMutation = useLinkMutation({ auth, setGroupDraftMembers: () => {}, setGroupNameInput: () => {}, setStatus });
const partnerRequestMutations = usePartnerRequestMutations({ auth, setStatus });
const createGroupMutation = useCreateGroupMutation({ auth, setActiveGroupId, setGroupNameInput: () => {}, setGroupDraftMembers: () => {}, setStatus });
const removeGroupMemberMutation = useRemoveGroupMemberMutation({ auth, removeMemberFromGroupCache, setStatus });
const shareToggleMutation = useShareToggleMutation({ auth, replacePartnerGroup, setStatus });
useEffect(() => {
if (!pendingPartnerId || !auth.userId || !auth.token || !user) return;
if (pendingPartnerId === auth.userId) {
setPendingPartnerIntent("");
setStatus("You cannot connect a profile to itself.");
return;
}
if ((user.partners || []).includes(pendingPartnerId)) {
setPendingPartnerIntent("");
setStatus("That partner is already connected.");
return;
}
if ((user.outgoing_partner_requests || []).includes(pendingPartnerId)) {
setPendingPartnerIntent("");
setStatus("Connection request already sent.");
return;
}
if ((user.incoming_partner_requests || []).includes(pendingPartnerId)) {
setPendingPartnerIntent("");
setStatus("They already requested a connection. Open Together to accept.");
return;
}
if (autoLinkAttemptRef.current === pendingPartnerId || linkMutation.isPending) return;
autoLinkAttemptRef.current = pendingPartnerId;
linkMutation.mutate(pendingPartnerId, {
onSettled: () => {
autoLinkAttemptRef.current = "";
setPendingPartnerIntent("");
},
});
}, [pendingPartnerId, auth.userId, auth.token, user, setPendingPartnerIntent, linkMutation]);
function normalizeDirectionsForSave(interestState, directions = []) {
const cleaned = [...new Set((directions || []).filter(Boolean))].sort();
if (cleaned.length) return cleaned;
return ["love", "like", "curious"].includes(interestState)
? [...new Set(normalizeDiscoverSheetDirections(discoverPositiveDirections).filter(Boolean))].sort()
: [];
}
const handleDiscoverDirectionsChange = useCallback((update) => {
setDiscoverPositiveDirections((prev) => {
const prevNorm = normalizeDiscoverSheetDirections(prev);
const next = typeof update === "function" ? update(prevNorm) : update;
return normalizeDiscoverSheetDirections(next);
});
}, []);
const handleRate = useCallback((kinkId, interestState, directions = null) => {
const kid = String(kinkId);
const prevState = currentPlays[kid] || null;
const nextDirections = normalizeDirectionsForSave(interestState, directions ?? currentDirectionsFor(currentPlays, kid));
const normalized = [...nextDirections].sort();
const existing = [...(prevState?.directions || [])].sort();
if (
!needsStarterFlow
&& prevState?.interest_state === interestState
&& JSON.stringify(existing) === JSON.stringify(normalized)
) {
return Promise.resolve(null);
}
const commitKey = playCommitKey(kid, interestState, normalized);
if (!needsStarterFlow && pendingPlayCommitRef.current === commitKey) return Promise.resolve(null);
pendingPlayCommitRef.current = commitKey;
return savePlayMutation.mutateAsync({ kinkId: kid, interestState, directions: normalized });
}, [currentPlays, discoverPositiveDirections, needsStarterFlow, savePlayMutation]);
const handleDirectionToggle = useCallback((kinkId, direction) => {
const play = currentPlays[kinkId];
if (!play?.interest_state) {
setStatus("Rate this play first.");
return;
}
const current = play.directions || [];
let next;
if (current.includes(direction)) next = current.filter((value) => value !== direction);
else if (current.length === 1 && current[0] === "together" && (direction === "to_me" || direction === "by_me")) next = [direction];
else next = [...current, direction];
if (!next.length) {
setStatus("Choose at least one direction.");
return;
}
const interestState = play.interest_state === "not_interested" ? "curious" : play.interest_state;
handleRate(kinkId, interestState, next);
}, [currentPlays, handleRate]);
const handleDirectionColumnChange = useCallback((kinkId, column) => {
const play = currentPlays[kinkId];
if (!play?.interest_state) {
setStatus("Rate this play first.");
return;
}
if (!["to_me", "by_me", "together"].includes(column)) return;
const next = directionsAfterColumnDrop(play.directions, column);
const prevSorted = [...new Set((play.directions || []).filter(Boolean))].sort();
const nextSorted = [...next].sort();
const dirsUnchanged = JSON.stringify(prevSorted) === JSON.stringify(nextSorted);
if (dirsUnchanged && play.interest_state !== "not_interested") return;
const interestState = play.interest_state === "not_interested" ? "curious" : play.interest_state;
handleRate(kinkId, interestState, next);
}, [currentPlays, handleRate]);
const handleScenarioRate = useCallback((parentKinkId, scenarioKinkId, interestState, directions = null) => {
const existing = scenarioPreferences[scenarioKinkId] || null;
const parentDirections = currentPlays[parentKinkId]?.directions || [];
const nextDirections = normalizeDirectionsForSave(
interestState,
directions ?? existing?.directions ?? parentDirections,
);
return scenarioPreferenceMutation.mutateAsync({
parentKinkId,
scenarioKinkId,
interestState,
directions: nextDirections,
});
}, [scenarioPreferences, currentPlays, scenarioPreferenceMutation]);
const handleOpenScenarios = useCallback((parentKinkId, scenarioKinkId = "") => {
setScenarioFocus({ parentKinkId: parentKinkId || "", scenarioKinkId: scenarioKinkId || "" });
setView(VIEWS.SCENARIOS);
}, []);
const handleNavigate = useCallback((targetView, kinkId) => {
setView(targetView);
if (kinkId) setPlaysFocusKinkId(kinkId);
}, []);
const handleRoleSearch = useCallback((value) => {
setRoleSearchInput(value);
if (roleDebounceTimer) clearTimeout(roleDebounceTimer);
setRoleDebounceTimer(setTimeout(() => setRoleSearchDebounced(value.trim()), 300));
}, [roleDebounceTimer]);
if (!auth.userId || !auth.token || createResult) {
return html`
<${Onboarding}
step=${authStep || "welcome"}
setStep=${setAuthStep}
loginForm=${loginForm}
setLoginForm=${setLoginForm}
createResult=${createResult}
status=${status}
loginMutation=${loginMutation}
createMutation=${createMutation}
saveRoleMutation=${saveRoleMutation}
roleSearchInput=${roleSearchInput}
onRoleSearch=${handleRoleSearch}
roleSearchItems=${visibleRoleSearchItems}
selectedRoles=${selectedRoles}
pendingPartnerId=${pendingPartnerId}
onFinish=${() => {
const uid = auth.userId;
setCreateResult(null);
setAuthStep(null);
if (uid) {
qc.invalidateQueries({ queryKey: ["user", uid] });
qc.invalidateQueries({ queryKey: ["partner-groups", uid] });
qc.invalidateQueries({ queryKey: ["roles", uid] });
}
}}
/>
`;
}
if (Boolean(auth.userId && auth.token) && userQuery.isLoading) {
return html`
<div className="onboard">
<${GraphDebugBar} enabled=${graphDebugUi} onToggle=${toggleGraphDebugUi} />
<div className="card stack">
<div className="top-bar-title">Loading your profile…</div>
<${StatusText} text=${status} />
</div>
</div>
`;
}
if (needsStarterFlow) {
return html`
<div className="page starter-page" data-testid="starter-gate">
<${GraphDebugBar} enabled=${graphDebugUi} onToggle=${toggleGraphDebugUi} />
<div className="starter-hero" data-testid="starter-hero">
<div>
<div className="top-bar-title">Start broad</div>
<p className="sub">${starterState === "finalizing"
? "Finishing your starter picks. Full discovery unlocks as soon as those saves land."
: `Rate ${STARTER_TARGET} common starter cards first. After that, discovery opens up and partner influence can join in.`}</p>
${pendingPartnerId ? html`<p className="tiny">Connection invite saved for <code>${pendingPartnerId}</code>. It will be sent automatically once your profile is ready.</p>` : null}
</div>
<button className="ghost sm" data-testid="starter-logout" onClick=${logout}>Log out</button>
</div>
<${DiscoverView}
auth=${auth}
activeGroupId=""
groupSelectionReady=${true}
partnerGroups=${[]}
onActiveGroupChange=${() => {}}
photosOnCards=${showImages}
onPhotoPreference=${(value) => prefMutation.mutate(value)}
onRate=${handleRate}
onNavigate=${handleNavigate}
onOpenScenarios=${handleOpenScenarios}
savedCount=${savedCount}
currentPlays=${currentPlays}
discoverSheetDirections=${discoverPositiveDirections}
onDiscoverSheetDirectionsChange=${handleDiscoverDirectionsChange}
starterMode=${true}
starterFinalizing=${starterState === "finalizing"}
starterTarget=${STARTER_TARGET}
showSearch=${false}
headerTitle="Starter deck"
headerSubtitle="Rate a dozen broad starters before full discovery unlocks."
/>
<${StatusText} text=${status} />
</div>
`;
}
const incomingRequests = (user?.incoming_partner_requests || []).length;
const tabs = [
{ key: VIEWS.DISCOVER, label: "Discover", icon: "\uD83D\uDD25" },
{ key: VIEWS.SCENARIOS, label: "Scenarios", icon: "\uD83C\uDFAC" },
{ key: VIEWS.PLAYS, label: "My Plays", icon: "\uD83C\uDFB5", badge: savedCount || null },
{ key: VIEWS.TOGETHER, label: "Together", icon: "\uD83E\uDD1D", attention: incomingRequests > 0, badge: incomingRequests || null },
{ key: VIEWS.SETTINGS, label: "Settings", icon: "\u2699\uFE0F" },
];
return html`
<div className="page">
<${GraphDebugBar} enabled=${graphDebugUi} onToggle=${toggleGraphDebugUi} />
<nav className="desktop-nav" data-testid="desktop-nav">
${tabs.map((tab) => html`
<button key=${tab.key} data-testid=${`nav-tab-${tab.key}`} className=${`tab-btn ${view === tab.key ? "active" : ""}`} onClick=${() => setView(tab.key)} style=${{ position: "relative" }}>
<span className="tab-icon">${tab.icon}</span>
<span>${tab.label}</span>
${tab.badge ? html`<span className="tab-badge">${tab.badge}</span>` : null}
${tab.attention && !tab.badge ? html`<span className="tab-attention" data-testid=${`tab-attention-${tab.key}`} aria-label="needs your attention"></span>` : null}
</button>
`)}
<div style=${{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
<span className="tiny">${user?.id || ""}</span>
<button className="ghost sm" data-testid="app-logout" onClick=${logout}>Log out</button>
</div>
</nav>
${view === VIEWS.DISCOVER
? html`
<${DiscoverView}
auth=${auth}
activeGroupId=${activeGroupId}
groupSelectionReady=${groupSelectionReady}
partnerGroups=${partnerGroups}
partnerContextOptOut=${partnerContextOptOut}
onActiveGroupChange=${(nextId) => {
if (nextId === "") {
// Explicit "Just me" — opt out so the auto-pick effect won't override.
setPartnerContextOptOut(true);
setActiveGroupId("");
} else {
setPartnerContextOptOut(false);
setActiveGroupId(nextId);
}
}}
photosOnCards=${showImages}
onPhotoPreference=${(value) => prefMutation.mutate(value)}
onRate=${handleRate}
onNavigate=${handleNavigate}
onOpenScenarios=${handleOpenScenarios}
savedCount=${savedCount}
currentPlays=${currentPlays}
discoverSheetDirections=${discoverPositiveDirections}
onDiscoverSheetDirectionsChange=${handleDiscoverDirectionsChange}
/>
`
: null}
${view === VIEWS.SCENARIOS
? html`
<${ScenariosView}
auth=${auth}
currentPlays=${currentPlays}
scenarioPreferences=${scenarioPreferences}
onScenarioRate=${handleScenarioRate}
onNavigateToPlays=${(kinkId) => handleNavigate(VIEWS.PLAYS, kinkId)}
focusParentKinkId=${scenarioFocus.parentKinkId}
focusScenarioKinkId=${scenarioFocus.scenarioKinkId}
onScenarioFocusConsumed=${() => setScenarioFocus({ parentKinkId: "", scenarioKinkId: "" })}
/>
`
: null}
${view === VIEWS.PLAYS
? html`
<${MyPlaysView}
auth=${auth}
currentPlays=${currentPlays}
scenarioPreferences=${scenarioPreferences}
showImages=${showImages}
onRate=${handleRate}
onDirectionToggle=${handleDirectionToggle}
onDirectionColumnChange=${handleDirectionColumnChange}
onRateScenario=${handleScenarioRate}
onNavigate=${handleNavigate}
onOpenScenarios=${handleOpenScenarios}
onDeletePlay=${handleDeletePlay}
focusKinkId=${playsFocusKinkId}
onFocusKinkConsumed=${() => setPlaysFocusKinkId("")}
/>
`
: null}
${view === VIEWS.TOGETHER
? html`
<${TogetherView}
auth=${auth}
user=${user}
currentPlays=${currentPlays}
scenarioPreferences=${scenarioPreferences}
onRate=${handleRate}
onDirectionToggle=${handleDirectionToggle}
onDeletePlay=${handleDeletePlay}
onRateScenario=${handleScenarioRate}
onNavigate=${handleNavigate}
onOpenScenarios=${handleOpenScenarios}
setStatus=${setStatus}
graphDebugUi=${graphDebugUi}
linkMutation=${linkMutation}
partnerRequestMutations=${partnerRequestMutations}
createGroupMutation=${createGroupMutation}
shareToggleMutation=${shareToggleMutation}
/>
`
: null}
${view === VIEWS.SETTINGS
? html`
<${SettingsView}
auth=${auth}
user=${user}
status=${status}
selectedRoles=${selectedRoles}
roleSearchInput=${roleSearchInput}
onRoleSearch=${handleRoleSearch}
roleSearchItems=${visibleRoleSearchItems}
saveRoleMutation=${saveRoleMutation}
onPhotoPreference=${(value) => prefMutation.mutate(value)}
logout=${logout}
onDeleted=${logout}
setStatus=${setStatus}
/>
`
: null}
<nav className="tab-bar" data-testid="mobile-tab-bar">
${tabs.map((tab) => html`
<button key=${tab.key} data-testid=${`mobile-tab-${tab.key}`} className=${`tab-btn ${view === tab.key ? "active" : ""}`} onClick=${() => setView(tab.key)} style=${{ position: "relative" }}>
<span className="tab-icon">${tab.icon}</span>
<span>${tab.label}</span>
${tab.badge ? html`<span className="tab-badge">${tab.badge}</span>` : null}
${tab.attention && !tab.badge ? html`<span className="tab-attention" data-testid=${`tab-attention-${tab.key}`} aria-label="needs your attention"></span>` : null}
</button>
`)}
</nav>
</div>
`;
}
function NewProfileCredentials({ createResult }) {
const [revealed, setRevealed] = useState(false);
const [justCopied, setJustCopied] = useState(false);
const token = createResult.private_token || "";
const masked = `••••••••${token.slice(-4)}`;
useEffect(() => {
if (!justCopied) return;
const t = setTimeout(() => setJustCopied(false), 1500);
return () => clearTimeout(t);
}, [justCopied]);
const copy = () => {
try { navigator.clipboard?.writeText(token); } catch {}
setJustCopied(true);
};
return html`
<div className="creds">
<div><strong>Profile ID</strong></div>
<code data-testid="onboarding-created-user-id">${createResult.id}</code>
<div style=${{ marginTop: 8 }}><strong>Private pass</strong></div>
<div className="onboarding-token-row" style=${{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
${revealed
? html`<code data-testid="onboarding-created-private-token">${token}</code>`
: html`<code data-testid="onboarding-created-private-token-mask">${masked}</code>`}
<button
type="button"
className="ghost sm"
data-testid="onboarding-reveal-token"
onClick=${() => setRevealed((v) => !v)}
>${revealed ? "Hide" : "Reveal"}</button>
<button
type="button"
className="ghost sm"
data-testid="onboarding-copy-token"
onClick=${copy}
>${justCopied ? "Copied ✓" : "Copy"}</button>
</div>
<div className="tiny muted">Save the private pass somewhere safe. You will need it to reopen this profile.</div>
</div>
`;
}
function Onboarding({
step,
setStep,
loginForm,
setLoginForm,
createResult,
status,
loginMutation,
createMutation,
saveRoleMutation,
roleSearchInput,
onRoleSearch,
roleSearchItems,
selectedRoles,
pendingPartnerId,
onFinish,
}) {
const [showLoginToken, setShowLoginToken] = useState(false);
if (createResult) {
return html`
<div className="onboard" data-testid="onboarding-setup">
<h1>Set up your profile</h1>
<p className="sub">Keep these credentials. You need them to reopen the profile later.</p>
<div className="card stack">
<${NewProfileCredentials} createResult=${createResult} />
${pendingPartnerId ? html`<div className="tiny">Invite saved for <code>${pendingPartnerId}</code>. The connection request will go out after this setup.</div>` : null}
<div>
<strong>Pick your roles</strong>
<div className="tiny">Optional. You can edit these later in Settings.</div>
</div>
<input data-testid="onboarding-role-search" placeholder="Search roles..." value=${roleSearchInput} onInput=${(e) => onRoleSearch(e.target.value)} />
${selectedRoles.length
? html`
<div className="chip-flow">
${selectedRoles.map((item) => html`
<${RoleChip}
key=${item.id}
kink=${item}
selected=${true}
onToggle=${(id, selected) => saveRoleMutation.mutate({ kinkId: id, selected })}
/>
`)}
</div>
`
: html`<div className="tiny">No roles selected yet.</div>`}
${roleSearchItems.length
? html`
<div className="chip-flow">
${roleSearchItems.map((item) => html`
<${RoleChip}
key=${item.kink.id}
kink=${item.kink}
selected=${false}
onToggle=${(id, selected) => saveRoleMutation.mutate({ kinkId: id, selected })}
/>
`)}
</div>
`
: null}
<button data-testid="onboarding-start-starter" onClick=${onFinish}>Start starter deck</button>
</div>
<${StatusText} text=${status} />
</div>
`;
}
if (step === "login") {
return html`
<div className="onboard" data-testid="onboarding-login">
<h1>Welcome back</h1>
<p className="sub">Open your profile to continue discovery.</p>
<div className="card stack">
${pendingPartnerId ? html`<div className="tiny">You were invited by <code>${pendingPartnerId}</code>.</div>` : null}
<input
data-testid="onboarding-login-user-id"
placeholder="Profile ID"
autocomplete="username"
value=${loginForm.user_id}
onInput=${(e) => setLoginForm((value) => ({ ...value, user_id: e.target.value }))}
/>
<div className="password-field">
<input
data-testid="onboarding-login-private-token"
type=${showLoginToken ? "text" : "password"}
placeholder="Private pass"
autocomplete="current-password"
value=${loginForm.private_token}
onInput=${(e) => setLoginForm((value) => ({ ...value, private_token: e.target.value }))}
/>
<button
type="button"
className="password-field__toggle"
data-testid="onboarding-login-toggle-show-token"
aria-label=${showLoginToken ? "Hide private pass" : "Show private pass"}
aria-pressed=${showLoginToken}
onClick=${() => setShowLoginToken((v) => !v)}
>${showLoginToken ? "🙈" : "👁"}</button>
</div>
<button
data-testid="onboarding-open-profile"
disabled=${loginMutation.isPending}
onClick=${() => {
if (loginMutation.isPending) return;
loginMutation.mutate(
{ user_id: loginForm.user_id, private_token: loginForm.private_token },
{ onSuccess: () => setStep(null) },
);
}}
>${loginMutation.isPending ? "Opening profile…" : "Open profile"}</button>
<button className="ghost" onClick=${() => setStep("welcome")}>Back</button>
</div>
<${StatusText} text=${status} />
</div>
`;
}
return html`
<div className="onboard" data-testid="onboarding-welcome">
<h1>Play List</h1>
<p className="sub">Discover what you like. See what you share. Keep private things private by default.</p>
<div className="card stack">
${pendingPartnerId ? html`<div className="tiny">You were invited by <code>${pendingPartnerId}</code>. Create or open a profile and we’ll stage the connection for you.</div>` : null}
<button
data-testid="onboarding-create-profile"
disabled=${createMutation.isPending}
onClick=${() => {
if (!createMutation.isPending) createMutation.mutate();
}}
>
${createMutation.isPending ? "Creating profile…" : "Create profile"}
</button>
<button className="ghost" data-testid="onboarding-have-profile" onClick=${() => setStep("login")}>I have a profile</button>
</div>
<${StatusText} text=${status} />
</div>
`;
}
function App() {
return html`<${QueryClientProvider} client=${queryClient}><${AppInner} /></${QueryClientProvider}>`;
}
createRoot(document.getElementById("root")).render(html`<${App} />`);
initTelemetry();