Spaces:
Sleeping
Sleeping
| 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(); | |