Spaces:
Sleeping
Sleeping
Sync from kink_cli (Docker Space)
Browse files- README.md +7 -4
- api.py +107 -7
- backend/user_snapshot.py +182 -0
- backend/user_snapshot_hub.py +91 -0
- deploy/hf/README.md +15 -0
- frontend/app.js +41 -6
- frontend/discover-state.js +1 -0
- frontend/discover-state.test.mjs +9 -0
- frontend/dist/assets/index-BRxg-Ey6.js +0 -0
- frontend/dist/assets/index-HvMqkWIl.css +1 -0
- frontend/dist/index.html +2 -2
- frontend/mutations.js +40 -0
- frontend/my-plays-similar.js +17 -0
- frontend/my-plays.js +74 -189
- frontend/play-inspector.js +190 -0
- frontend/styles.css +39 -0
- frontend/together.js +174 -14
- scripts/playwright_product_flow.py +318 -1
- tests/test_discover_recommendations_invariant.py +41 -1
- tests/test_user_snapshot.py +127 -0
README.md
CHANGED
|
@@ -147,13 +147,16 @@ python scripts/audit_starter_deck.py --store data/store_slim.db --limit 24
|
|
| 147 |
|
| 148 |
## Live deployment
|
| 149 |
|
| 150 |
-
- **Hub:** [huggingface.co/spaces/
|
| 151 |
-
- **App URL:** [
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
Re-verify anytime:
|
| 154 |
|
| 155 |
```bash
|
| 156 |
-
HF_VERIFY_BASE_URL=https://
|
| 157 |
```
|
| 158 |
|
| 159 |
After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles + `app.js` so browsers load the new bundle, then redeploy (command below).
|
|
@@ -166,7 +169,7 @@ After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles
|
|
| 166 |
```bash
|
| 167 |
# Either export credentials, or rely on parser secrets.toml + [huggingface].username → <username>/kink-discovery
|
| 168 |
export HF_TOKEN=hf_...
|
| 169 |
-
export HF_SPACE_REPO=yourname/your-space-name # e.g.
|
| 170 |
python scripts/publish_hf_space.py --verify
|
| 171 |
```
|
| 172 |
|
|
|
|
| 147 |
|
| 148 |
## Live deployment
|
| 149 |
|
| 150 |
+
- **Hub:** [huggingface.co/spaces/Perplexed7675/kink-discovery](https://huggingface.co/spaces/Perplexed7675/kink-discovery)
|
| 151 |
+
- **App URL:** [perplexed7675-kink-discovery.hf.space](https://perplexed7675-kink-discovery.hf.space) (same-origin API + static UI)
|
| 152 |
+
- **Account persistence:** pick **one** option so user profiles survive restarts:
|
| 153 |
+
- **Paid:** enable HF Persistent Storage on the Space and set `KINK_STORE_PATH=/data/store_slim.db` + `KINK_FAIL_ON_EPHEMERAL_STORE=1`. Zero engineering, perfect durability.
|
| 154 |
+
- **Free (Hub-snapshot):** create a private HF Hub dataset (e.g. `Perplexed7675/kink-userstate`), then set Space variables `KINK_USER_SNAPSHOT_REPO=Perplexed7675/kink-userstate` and `HF_TOKEN=<write-token>`. The container then pulls the latest user-state snapshot on cold boot and pushes back every 60s when the user tables change (catalog stays on `/tmp`). Tunables: `KINK_USER_SNAPSHOT_FILENAME` (default `user_state.db`), `KINK_USER_SNAPSHOT_INTERVAL_S` (default 60). Trade-off: up to ~60s of writes can be lost on a hard crash; the Space must stay single-replica (cpu-basic is).
|
| 155 |
|
| 156 |
Re-verify anytime:
|
| 157 |
|
| 158 |
```bash
|
| 159 |
+
HF_VERIFY_BASE_URL=https://perplexed7675-kink-discovery.hf.space python scripts/verify_hf_stack.py
|
| 160 |
```
|
| 161 |
|
| 162 |
After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles + `app.js` so browsers load the new bundle, then redeploy (command below).
|
|
|
|
| 169 |
```bash
|
| 170 |
# Either export credentials, or rely on parser secrets.toml + [huggingface].username → <username>/kink-discovery
|
| 171 |
export HF_TOKEN=hf_...
|
| 172 |
+
export HF_SPACE_REPO=yourname/your-space-name # e.g. Perplexed7675/kink-discovery
|
| 173 |
python scripts/publish_hf_space.py --verify
|
| 174 |
```
|
| 175 |
|
api.py
CHANGED
|
@@ -44,12 +44,17 @@ def _frontend_static_cache_control(target: Path) -> str:
|
|
| 44 |
|
| 45 |
|
| 46 |
def _require_frontend_build() -> None:
|
| 47 |
-
if FRONTEND_PATH.is_file():
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def _hf_space_published_image() -> bool:
|
|
@@ -144,6 +149,7 @@ def _get_backend() -> Backend:
|
|
| 144 |
ensure_media_cache(MEDIA_ROOT)
|
| 145 |
path = ensure_store_db(_default_store)
|
| 146 |
_warn_or_fail_ephemeral_store(path)
|
|
|
|
| 147 |
b = Backend(path)
|
| 148 |
# Catalog must be ready before the first recommendations request. Build it once here:
|
| 149 |
# starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
|
|
@@ -170,12 +176,106 @@ class _BackendProxy:
|
|
| 170 |
backend = _BackendProxy()
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
@asynccontextmanager
|
| 174 |
async def lifespan(_app: FastAPI):
|
| 175 |
"""Begin Hub download + DB open in a worker thread so the server binds to PORT immediately (HF Spaces timeout)."""
|
| 176 |
loop = asyncio.get_running_loop()
|
| 177 |
loop.run_in_executor(None, _get_backend)
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
app = FastAPI(title="Kink Discovery API", version="0.1.0", lifespan=lifespan)
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _require_frontend_build() -> None:
|
| 47 |
+
if not FRONTEND_PATH.is_file():
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=503,
|
| 50 |
+
detail="Frontend build missing. Run `npm ci && npm run build` before serving the app.",
|
| 51 |
+
)
|
| 52 |
+
assets_dir = FRONTEND_DIST_DIR / "assets"
|
| 53 |
+
if not assets_dir.is_dir() or not any(assets_dir.iterdir()):
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=503,
|
| 56 |
+
detail="Frontend build incomplete (no bundled assets). Run `npm ci && npm run build` to refresh.",
|
| 57 |
+
)
|
| 58 |
|
| 59 |
|
| 60 |
def _hf_space_published_image() -> bool:
|
|
|
|
| 149 |
ensure_media_cache(MEDIA_ROOT)
|
| 150 |
path = ensure_store_db(_default_store)
|
| 151 |
_warn_or_fail_ephemeral_store(path)
|
| 152 |
+
_restore_user_snapshot_on_boot(path)
|
| 153 |
b = Backend(path)
|
| 154 |
# Catalog must be ready before the first recommendations request. Build it once here:
|
| 155 |
# starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
|
|
|
|
| 176 |
backend = _BackendProxy()
|
| 177 |
|
| 178 |
|
| 179 |
+
def _restore_user_snapshot_on_boot(store_path: Path) -> None:
|
| 180 |
+
"""Pull the latest user-state snapshot from the configured Hub dataset when local user tables are empty.
|
| 181 |
+
|
| 182 |
+
No-op when KINK_USER_SNAPSHOT_REPO is unset. Failures are logged and swallowed so a Hub outage
|
| 183 |
+
cannot prevent the Space from booting (catalog still serves; periodic push will retry).
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
from backend import user_snapshot, user_snapshot_hub
|
| 187 |
+
except ImportError:
|
| 188 |
+
return
|
| 189 |
+
if not user_snapshot_hub.snapshot_enabled():
|
| 190 |
+
return
|
| 191 |
+
try:
|
| 192 |
+
if not user_snapshot.user_state_is_empty(store_path):
|
| 193 |
+
return
|
| 194 |
+
tmp = store_path.parent / ".user_state_snapshot.db"
|
| 195 |
+
if not user_snapshot_hub.pull_user_snapshot(tmp):
|
| 196 |
+
return
|
| 197 |
+
counts = user_snapshot.restore_user_state(store_path, tmp)
|
| 198 |
+
try:
|
| 199 |
+
tmp.unlink()
|
| 200 |
+
except OSError:
|
| 201 |
+
pass
|
| 202 |
+
applied = sum(counts.values())
|
| 203 |
+
print(
|
| 204 |
+
f"[kink_cli] restored {applied} user-state rows from snapshot "
|
| 205 |
+
f"({user_snapshot_hub.snapshot_repo()}/{user_snapshot_hub.snapshot_filename()})"
|
| 206 |
+
)
|
| 207 |
+
except Exception as exc: # noqa: BLE001 — degrade gracefully on any restore failure
|
| 208 |
+
print(f"[kink_cli] user-state snapshot restore failed: {exc}")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def _user_snapshot_flusher(store_path: Path, interval_s: float) -> None:
|
| 212 |
+
"""Periodic push: when fingerprint changes, dump user tables and upload to Hub. Tolerant of transient failures."""
|
| 213 |
+
from backend import user_snapshot, user_snapshot_hub
|
| 214 |
+
|
| 215 |
+
last_fingerprint = user_snapshot.compute_user_state_fingerprint(store_path)
|
| 216 |
+
while True:
|
| 217 |
+
try:
|
| 218 |
+
await asyncio.sleep(interval_s)
|
| 219 |
+
fp = await asyncio.to_thread(user_snapshot.compute_user_state_fingerprint, store_path)
|
| 220 |
+
if fp == last_fingerprint:
|
| 221 |
+
continue
|
| 222 |
+
tmp = store_path.parent / ".user_state_snapshot_out.db"
|
| 223 |
+
await asyncio.to_thread(user_snapshot.dump_user_state, store_path, tmp)
|
| 224 |
+
sent = await asyncio.to_thread(user_snapshot_hub.push_user_snapshot, tmp)
|
| 225 |
+
try:
|
| 226 |
+
tmp.unlink()
|
| 227 |
+
except OSError:
|
| 228 |
+
pass
|
| 229 |
+
if sent:
|
| 230 |
+
last_fingerprint = fp
|
| 231 |
+
except asyncio.CancelledError:
|
| 232 |
+
raise
|
| 233 |
+
except Exception as exc: # noqa: BLE001 — never let the flusher die
|
| 234 |
+
print(f"[kink_cli] user-state snapshot flush failed: {exc}")
|
| 235 |
+
|
| 236 |
+
|
| 237 |
@asynccontextmanager
|
| 238 |
async def lifespan(_app: FastAPI):
|
| 239 |
"""Begin Hub download + DB open in a worker thread so the server binds to PORT immediately (HF Spaces timeout)."""
|
| 240 |
loop = asyncio.get_running_loop()
|
| 241 |
loop.run_in_executor(None, _get_backend)
|
| 242 |
+
|
| 243 |
+
flusher_task: asyncio.Task | None = None
|
| 244 |
+
try:
|
| 245 |
+
from backend import user_snapshot_hub
|
| 246 |
+
except ImportError:
|
| 247 |
+
user_snapshot_hub = None # type: ignore[assignment]
|
| 248 |
+
if user_snapshot_hub is not None and user_snapshot_hub.snapshot_enabled():
|
| 249 |
+
interval_s = float(os.environ.get("KINK_USER_SNAPSHOT_INTERVAL_S", "60") or "60")
|
| 250 |
+
|
| 251 |
+
async def _start_when_ready() -> None:
|
| 252 |
+
while _backend_impl is None:
|
| 253 |
+
await asyncio.sleep(1.0)
|
| 254 |
+
await _user_snapshot_flusher(_backend_impl.path, interval_s)
|
| 255 |
+
|
| 256 |
+
flusher_task = asyncio.create_task(_start_when_ready())
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
yield
|
| 260 |
+
finally:
|
| 261 |
+
if flusher_task is not None:
|
| 262 |
+
flusher_task.cancel()
|
| 263 |
+
try:
|
| 264 |
+
await flusher_task
|
| 265 |
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
| 266 |
+
pass
|
| 267 |
+
if _backend_impl is not None:
|
| 268 |
+
try:
|
| 269 |
+
from backend import user_snapshot, user_snapshot_hub as _hub
|
| 270 |
+
tmp = _backend_impl.path.parent / ".user_state_snapshot_shutdown.db"
|
| 271 |
+
user_snapshot.dump_user_state(_backend_impl.path, tmp)
|
| 272 |
+
_hub.push_user_snapshot(tmp, commit_message="user-state snapshot (shutdown)")
|
| 273 |
+
try:
|
| 274 |
+
tmp.unlink()
|
| 275 |
+
except OSError:
|
| 276 |
+
pass
|
| 277 |
+
except Exception as exc: # noqa: BLE001
|
| 278 |
+
print(f"[kink_cli] final user-state snapshot push failed: {exc}")
|
| 279 |
|
| 280 |
|
| 281 |
app = FastAPI(title="Kink Discovery API", version="0.1.0", lifespan=lifespan)
|
backend/user_snapshot.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Snapshot of user-state tables only — the slim, durable subset of the SQLite store.
|
| 2 |
+
|
| 3 |
+
Used to keep accounts alive across HF Space restarts without paid persistent storage:
|
| 4 |
+
the live container reads/writes the catalog DB at ``KINK_STORE_PATH`` (ephemeral on
|
| 5 |
+
``/tmp``), and a periodic snapshotter pushes only the user-state tables to a private
|
| 6 |
+
HF Hub dataset (or any pull/push pair) so a fresh container can restore them on boot.
|
| 7 |
+
|
| 8 |
+
Authoritative user-table list mirrors ``scripts/dev_reset_local.py`` so a wipe and a
|
| 9 |
+
snapshot/restore stay in sync. Catalog tables (``kink``, ``play_fts*``, ``fetlife*``,
|
| 10 |
+
``similarityedge``, ``kinkscenarioparent``) are NOT included — they bootstrap from the
|
| 11 |
+
catalog seed/Hub dataset/B2 separately.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sqlite3
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
USER_TABLES: tuple[str, ...] = (
|
| 19 |
+
"user",
|
| 20 |
+
"userpreference",
|
| 21 |
+
"playpreference",
|
| 22 |
+
"rolepreference",
|
| 23 |
+
"scenariopreference",
|
| 24 |
+
"promptdismissal",
|
| 25 |
+
"partnerlink",
|
| 26 |
+
"partnerlinkrequest",
|
| 27 |
+
"partnergroup",
|
| 28 |
+
"partnergroupmember",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
| 33 |
+
row = conn.execute(
|
| 34 |
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
| 35 |
+
(name,),
|
| 36 |
+
).fetchone()
|
| 37 |
+
return row is not None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _present_tables(conn: sqlite3.Connection) -> list[str]:
|
| 41 |
+
return [name for name in USER_TABLES if _table_exists(conn, name)]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _table_create_sql(conn: sqlite3.Connection, name: str) -> str:
|
| 45 |
+
row = conn.execute(
|
| 46 |
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
| 47 |
+
(name,),
|
| 48 |
+
).fetchone()
|
| 49 |
+
if not row or not row[0]:
|
| 50 |
+
raise RuntimeError(f"missing CREATE TABLE for {name!r} in source store")
|
| 51 |
+
return row[0]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def dump_user_state(store_path: Path, out_path: Path) -> dict[str, int]:
|
| 55 |
+
"""Write a slim SQLite at ``out_path`` containing only USER_TABLES rows from ``store_path``.
|
| 56 |
+
|
| 57 |
+
Returns a mapping of table -> row count. Does not modify ``store_path``.
|
| 58 |
+
"""
|
| 59 |
+
store_path = Path(store_path)
|
| 60 |
+
out_path = Path(out_path)
|
| 61 |
+
if not store_path.is_file():
|
| 62 |
+
raise FileNotFoundError(f"store does not exist: {store_path}")
|
| 63 |
+
if out_path.exists():
|
| 64 |
+
out_path.unlink()
|
| 65 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
|
| 67 |
+
src = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
|
| 68 |
+
try:
|
| 69 |
+
present = _present_tables(src)
|
| 70 |
+
creates = {name: _table_create_sql(src, name) for name in present}
|
| 71 |
+
|
| 72 |
+
dst = sqlite3.connect(out_path)
|
| 73 |
+
try:
|
| 74 |
+
counts: dict[str, int] = {}
|
| 75 |
+
with dst:
|
| 76 |
+
dst.execute("PRAGMA journal_mode=OFF")
|
| 77 |
+
dst.execute("PRAGMA synchronous=OFF")
|
| 78 |
+
for name in present:
|
| 79 |
+
dst.execute(creates[name])
|
| 80 |
+
for name in present:
|
| 81 |
+
rows = list(src.execute(f'SELECT * FROM "{name}"'))
|
| 82 |
+
if not rows:
|
| 83 |
+
counts[name] = 0
|
| 84 |
+
continue
|
| 85 |
+
placeholders = ",".join(["?"] * len(rows[0]))
|
| 86 |
+
dst.executemany(
|
| 87 |
+
f'INSERT INTO "{name}" VALUES ({placeholders})',
|
| 88 |
+
rows,
|
| 89 |
+
)
|
| 90 |
+
counts[name] = len(rows)
|
| 91 |
+
return counts
|
| 92 |
+
finally:
|
| 93 |
+
dst.close()
|
| 94 |
+
finally:
|
| 95 |
+
src.close()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def restore_user_state(store_path: Path, in_path: Path) -> dict[str, int]:
|
| 99 |
+
"""Upsert all USER_TABLES rows from ``in_path`` into ``store_path``.
|
| 100 |
+
|
| 101 |
+
Existing rows with conflicting primary keys are replaced (the snapshot wins).
|
| 102 |
+
Catalog tables in ``store_path`` are untouched. Returns table -> rows-applied.
|
| 103 |
+
"""
|
| 104 |
+
store_path = Path(store_path)
|
| 105 |
+
in_path = Path(in_path)
|
| 106 |
+
if not store_path.is_file():
|
| 107 |
+
raise FileNotFoundError(f"store does not exist: {store_path}")
|
| 108 |
+
if not in_path.is_file():
|
| 109 |
+
raise FileNotFoundError(f"snapshot does not exist: {in_path}")
|
| 110 |
+
|
| 111 |
+
counts: dict[str, int] = {}
|
| 112 |
+
dst = sqlite3.connect(store_path)
|
| 113 |
+
try:
|
| 114 |
+
dst.execute("PRAGMA foreign_keys=OFF")
|
| 115 |
+
dst.execute(f"ATTACH DATABASE '{in_path.as_posix()}' AS snap")
|
| 116 |
+
try:
|
| 117 |
+
snap_tables = {
|
| 118 |
+
row[0]
|
| 119 |
+
for row in dst.execute("SELECT name FROM snap.sqlite_master WHERE type='table'")
|
| 120 |
+
}
|
| 121 |
+
with dst:
|
| 122 |
+
for name in USER_TABLES:
|
| 123 |
+
if name not in snap_tables:
|
| 124 |
+
continue
|
| 125 |
+
if not _table_exists(dst, name):
|
| 126 |
+
continue
|
| 127 |
+
cur = dst.execute(
|
| 128 |
+
f'INSERT OR REPLACE INTO main."{name}" SELECT * FROM snap."{name}"'
|
| 129 |
+
)
|
| 130 |
+
counts[name] = cur.rowcount or 0
|
| 131 |
+
finally:
|
| 132 |
+
dst.execute("DETACH DATABASE snap")
|
| 133 |
+
dst.execute("PRAGMA foreign_keys=ON")
|
| 134 |
+
finally:
|
| 135 |
+
dst.close()
|
| 136 |
+
return counts
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def compute_user_state_fingerprint(store_path: Path) -> str:
|
| 140 |
+
"""Cheap fingerprint that changes whenever user-state rows change.
|
| 141 |
+
|
| 142 |
+
Combines per-table row count with max(updated_at) when the column exists. A bare
|
| 143 |
+
count would miss updates to existing rows; reading every column would be wasteful.
|
| 144 |
+
"""
|
| 145 |
+
store_path = Path(store_path)
|
| 146 |
+
if not store_path.is_file():
|
| 147 |
+
return "missing"
|
| 148 |
+
conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
|
| 149 |
+
try:
|
| 150 |
+
parts: list[str] = []
|
| 151 |
+
for name in USER_TABLES:
|
| 152 |
+
if not _table_exists(conn, name):
|
| 153 |
+
parts.append(f"{name}:-")
|
| 154 |
+
continue
|
| 155 |
+
count = conn.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()[0]
|
| 156 |
+
cols = {row[1] for row in conn.execute(f'PRAGMA table_info("{name}")')}
|
| 157 |
+
ts = ""
|
| 158 |
+
if "updated_at" in cols:
|
| 159 |
+
ts_row = conn.execute(f'SELECT MAX(updated_at) FROM "{name}"').fetchone()
|
| 160 |
+
ts = "" if ts_row is None or ts_row[0] is None else str(ts_row[0])
|
| 161 |
+
parts.append(f"{name}:{count}:{ts}")
|
| 162 |
+
return "|".join(parts)
|
| 163 |
+
finally:
|
| 164 |
+
conn.close()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def user_state_is_empty(store_path: Path) -> bool:
|
| 168 |
+
"""True when no rows exist in any USER_TABLES — used to gate boot-time pull."""
|
| 169 |
+
store_path = Path(store_path)
|
| 170 |
+
if not store_path.is_file():
|
| 171 |
+
return True
|
| 172 |
+
conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
|
| 173 |
+
try:
|
| 174 |
+
for name in USER_TABLES:
|
| 175 |
+
if not _table_exists(conn, name):
|
| 176 |
+
continue
|
| 177 |
+
count = conn.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()[0]
|
| 178 |
+
if count > 0:
|
| 179 |
+
return False
|
| 180 |
+
return True
|
| 181 |
+
finally:
|
| 182 |
+
conn.close()
|
backend/user_snapshot_hub.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Hub dataset I/O for the user-state snapshot.
|
| 2 |
+
|
| 3 |
+
Auth + repo come from environment so the snapshotter is a no-op when not configured:
|
| 4 |
+
KINK_USER_SNAPSHOT_REPO ``owner/dataset`` of a private Hub dataset (required to enable)
|
| 5 |
+
KINK_USER_SNAPSHOT_FILENAME default ``user_state.db``
|
| 6 |
+
HF_TOKEN write token; reuses the same secret as the catalog/Space deploy
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
DEFAULT_SNAPSHOT_FILENAME = "user_state.db"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def snapshot_repo() -> str:
|
| 20 |
+
return (os.environ.get("KINK_USER_SNAPSHOT_REPO") or "").strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def snapshot_filename() -> str:
|
| 24 |
+
return (os.environ.get("KINK_USER_SNAPSHOT_FILENAME") or DEFAULT_SNAPSHOT_FILENAME).strip() or DEFAULT_SNAPSHOT_FILENAME
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def snapshot_enabled() -> bool:
|
| 28 |
+
return bool(snapshot_repo())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _hf_token() -> str | None:
|
| 32 |
+
return (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip() or None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def pull_user_snapshot(target_path: Path) -> bool:
|
| 36 |
+
"""Download the latest snapshot from the configured Hub dataset to ``target_path``.
|
| 37 |
+
|
| 38 |
+
Returns True on success, False when snapshot is disabled or the file is not yet present
|
| 39 |
+
on the Hub. All other Hub errors propagate.
|
| 40 |
+
"""
|
| 41 |
+
if not snapshot_enabled():
|
| 42 |
+
return False
|
| 43 |
+
target_path = Path(target_path)
|
| 44 |
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
try:
|
| 46 |
+
from huggingface_hub import hf_hub_download
|
| 47 |
+
from huggingface_hub.errors import EntryNotFoundError, RepositoryNotFoundError
|
| 48 |
+
except ImportError:
|
| 49 |
+
logger.warning("huggingface_hub not installed; skipping snapshot pull")
|
| 50 |
+
return False
|
| 51 |
+
try:
|
| 52 |
+
downloaded = hf_hub_download(
|
| 53 |
+
repo_id=snapshot_repo(),
|
| 54 |
+
repo_type="dataset",
|
| 55 |
+
filename=snapshot_filename(),
|
| 56 |
+
token=_hf_token(),
|
| 57 |
+
)
|
| 58 |
+
except (EntryNotFoundError, RepositoryNotFoundError):
|
| 59 |
+
logger.info("snapshot %s/%s not present on Hub yet (cold start)", snapshot_repo(), snapshot_filename())
|
| 60 |
+
return False
|
| 61 |
+
Path(downloaded).replace(target_path)
|
| 62 |
+
return True
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def push_user_snapshot(source_path: Path, *, commit_message: str | None = None) -> bool:
|
| 66 |
+
"""Upload ``source_path`` as the latest snapshot. Returns True when sent."""
|
| 67 |
+
if not snapshot_enabled():
|
| 68 |
+
return False
|
| 69 |
+
source_path = Path(source_path)
|
| 70 |
+
if not source_path.is_file():
|
| 71 |
+
logger.warning("snapshot source %s missing; skipping push", source_path)
|
| 72 |
+
return False
|
| 73 |
+
try:
|
| 74 |
+
from huggingface_hub import HfApi
|
| 75 |
+
except ImportError:
|
| 76 |
+
logger.warning("huggingface_hub not installed; skipping snapshot push")
|
| 77 |
+
return False
|
| 78 |
+
token = _hf_token()
|
| 79 |
+
if not token:
|
| 80 |
+
logger.warning("HF_TOKEN missing; cannot push snapshot")
|
| 81 |
+
return False
|
| 82 |
+
api = HfApi(token=token)
|
| 83 |
+
api.create_repo(repo_id=snapshot_repo(), repo_type="dataset", private=True, exist_ok=True)
|
| 84 |
+
api.upload_file(
|
| 85 |
+
path_or_fileobj=str(source_path),
|
| 86 |
+
path_in_repo=snapshot_filename(),
|
| 87 |
+
repo_id=snapshot_repo(),
|
| 88 |
+
repo_type="dataset",
|
| 89 |
+
commit_message=commit_message or "user-state snapshot",
|
| 90 |
+
)
|
| 91 |
+
return True
|
deploy/hf/README.md
CHANGED
|
@@ -10,6 +10,21 @@
|
|
| 10 |
|
| 11 |
SQLite stores users in the **same file** as the catalog. If that file is on **persistent storage**, new users and plays are written into that file and remain after image updates or container restarts. If the DB only existed on ephemeral disk, it would reset when the Space restarts.
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
## First boot (empty volume)
|
| 14 |
|
| 15 |
Pick **one** way to supply the full `store_slim.db` (multi‑GB):
|
|
|
|
| 10 |
|
| 11 |
SQLite stores users in the **same file** as the catalog. If that file is on **persistent storage**, new users and plays are written into that file and remain after image updates or container restarts. If the DB only existed on ephemeral disk, it would reset when the Space restarts.
|
| 12 |
|
| 13 |
+
### Free alternative: snapshot user-state to a private Hub dataset
|
| 14 |
+
|
| 15 |
+
If you do not want to pay for HF Persistent Storage, set these Space variables instead:
|
| 16 |
+
|
| 17 |
+
- `KINK_USER_SNAPSHOT_REPO=<owner>/<dataset>` — a private dataset repo for the snapshot.
|
| 18 |
+
- `HF_TOKEN=<write-token>` — same secret used by the catalog/Space deploy.
|
| 19 |
+
- Optional: `KINK_USER_SNAPSHOT_FILENAME` (default `user_state.db`), `KINK_USER_SNAPSHOT_INTERVAL_S` (default 60).
|
| 20 |
+
|
| 21 |
+
Behaviour:
|
| 22 |
+
|
| 23 |
+
- On cold boot, after the catalog opens, `_restore_user_snapshot_on_boot` pulls the latest snapshot from the dataset and upserts it into the live SQLite — but only when the local user tables are empty. Failures are logged and ignored so a Hub outage cannot block boot.
|
| 24 |
+
- A background task in `lifespan` recomputes the user-state fingerprint every `KINK_USER_SNAPSHOT_INTERVAL_S` seconds; when it changes, it dumps `USER_TABLES` to a slim SQLite and uploads via `huggingface_hub.upload_file`. A final push runs on lifespan shutdown.
|
| 25 |
+
- Tables shipped: `user`, `userpreference`, `playpreference`, `rolepreference`, `scenariopreference`, `promptdismissal`, `partnerlink`, `partnerlinkrequest`, `partnergroup`, `partnergroupmember`. Catalog tables (`kink`, `play_fts*`, `similarityedge`, `fetlife*`, `kinkscenarioparent`) stay on the ephemeral image.
|
| 26 |
+
- Trade-off: up to ~`interval` seconds of writes can be lost on a hard crash. Multi-replica Spaces would race on push — keep cpu-basic (single replica) or add an external mutex before scaling.
|
| 27 |
+
|
| 28 |
## First boot (empty volume)
|
| 29 |
|
| 30 |
Pick **one** way to supply the full `store_slim.db` (multi‑GB):
|
frontend/app.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
| 20 |
useLoginMutation,
|
| 21 |
useCreateMutation,
|
| 22 |
useSavePlayMutation,
|
|
|
|
| 23 |
useSaveRoleMutation,
|
| 24 |
usePrefMutation,
|
| 25 |
useScenarioPreferenceMutation,
|
|
@@ -298,6 +299,11 @@ function AppInner() {
|
|
| 298 |
onSaveStart: () => setPendingPlaySaveCount((count) => count + 1),
|
| 299 |
onSaveSettled: () => setPendingPlaySaveCount((count) => Math.max(0, count - 1)),
|
| 300 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
const saveRoleMutation = useSaveRoleMutation({ auth, setStatus });
|
| 302 |
const prefMutation = usePrefMutation({ auth, setStatus });
|
| 303 |
const scenarioPreferenceMutation = useScenarioPreferenceMutation({ auth, activeGroupId, setStatus });
|
|
@@ -595,6 +601,7 @@ function AppInner() {
|
|
| 595 |
onDirectionColumnChange=${handleDirectionColumnChange}
|
| 596 |
onNavigate=${handleNavigate}
|
| 597 |
onOpenScenarios=${handleOpenScenarios}
|
|
|
|
| 598 |
focusKinkId=${playsFocusKinkId}
|
| 599 |
onFocusKinkConsumed=${() => setPlaysFocusKinkId("")}
|
| 600 |
/>
|
|
@@ -608,6 +615,8 @@ function AppInner() {
|
|
| 608 |
user=${user}
|
| 609 |
currentPlays=${currentPlays}
|
| 610 |
onRate=${handleRate}
|
|
|
|
|
|
|
| 611 |
onNavigate=${handleNavigate}
|
| 612 |
onOpenScenarios=${handleOpenScenarios}
|
| 613 |
setStatus=${setStatus}
|
|
@@ -652,6 +661,37 @@ function AppInner() {
|
|
| 652 |
`;
|
| 653 |
}
|
| 654 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
function Onboarding({
|
| 656 |
step,
|
| 657 |
setStep,
|
|
@@ -675,12 +715,7 @@ function Onboarding({
|
|
| 675 |
<h1>Set up your profile</h1>
|
| 676 |
<p className="sub">Keep these credentials. You need them to reopen the profile later.</p>
|
| 677 |
<div className="card stack">
|
| 678 |
-
<
|
| 679 |
-
<div><strong>Profile ID</strong></div>
|
| 680 |
-
<code data-testid="onboarding-created-user-id">${createResult.id}</code>
|
| 681 |
-
<div style=${{ marginTop: 8 }}><strong>Private pass</strong></div>
|
| 682 |
-
<code data-testid="onboarding-created-private-token">${createResult.private_token}</code>
|
| 683 |
-
</div>
|
| 684 |
|
| 685 |
${pendingPartnerId ? html`<div className="tiny">Invite saved for <code>${pendingPartnerId}</code>. The connection request will go out after this setup.</div>` : null}
|
| 686 |
|
|
|
|
| 20 |
useLoginMutation,
|
| 21 |
useCreateMutation,
|
| 22 |
useSavePlayMutation,
|
| 23 |
+
useDeletePlayMutation,
|
| 24 |
useSaveRoleMutation,
|
| 25 |
usePrefMutation,
|
| 26 |
useScenarioPreferenceMutation,
|
|
|
|
| 299 |
onSaveStart: () => setPendingPlaySaveCount((count) => count + 1),
|
| 300 |
onSaveSettled: () => setPendingPlaySaveCount((count) => Math.max(0, count - 1)),
|
| 301 |
});
|
| 302 |
+
const deletePlayMutation = useDeletePlayMutation({ auth, setStatus });
|
| 303 |
+
const handleDeletePlay = useCallback((kinkId) => {
|
| 304 |
+
if (!kinkId) return;
|
| 305 |
+
deletePlayMutation.mutate(kinkId);
|
| 306 |
+
}, [deletePlayMutation]);
|
| 307 |
const saveRoleMutation = useSaveRoleMutation({ auth, setStatus });
|
| 308 |
const prefMutation = usePrefMutation({ auth, setStatus });
|
| 309 |
const scenarioPreferenceMutation = useScenarioPreferenceMutation({ auth, activeGroupId, setStatus });
|
|
|
|
| 601 |
onDirectionColumnChange=${handleDirectionColumnChange}
|
| 602 |
onNavigate=${handleNavigate}
|
| 603 |
onOpenScenarios=${handleOpenScenarios}
|
| 604 |
+
onDeletePlay=${handleDeletePlay}
|
| 605 |
focusKinkId=${playsFocusKinkId}
|
| 606 |
onFocusKinkConsumed=${() => setPlaysFocusKinkId("")}
|
| 607 |
/>
|
|
|
|
| 615 |
user=${user}
|
| 616 |
currentPlays=${currentPlays}
|
| 617 |
onRate=${handleRate}
|
| 618 |
+
onDirectionToggle=${handleDirectionToggle}
|
| 619 |
+
onDeletePlay=${handleDeletePlay}
|
| 620 |
onNavigate=${handleNavigate}
|
| 621 |
onOpenScenarios=${handleOpenScenarios}
|
| 622 |
setStatus=${setStatus}
|
|
|
|
| 661 |
`;
|
| 662 |
}
|
| 663 |
|
| 664 |
+
function NewProfileCredentials({ createResult }) {
|
| 665 |
+
const [revealed, setRevealed] = useState(false);
|
| 666 |
+
const token = createResult.private_token || "";
|
| 667 |
+
const masked = `••••••••${token.slice(-4)}`;
|
| 668 |
+
return html`
|
| 669 |
+
<div className="creds">
|
| 670 |
+
<div><strong>Profile ID</strong></div>
|
| 671 |
+
<code data-testid="onboarding-created-user-id">${createResult.id}</code>
|
| 672 |
+
<div style=${{ marginTop: 8 }}><strong>Private pass</strong></div>
|
| 673 |
+
<div className="onboarding-token-row" style=${{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
| 674 |
+
${revealed
|
| 675 |
+
? html`<code data-testid="onboarding-created-private-token">${token}</code>`
|
| 676 |
+
: html`<code data-testid="onboarding-created-private-token-mask">${masked}</code>`}
|
| 677 |
+
<button
|
| 678 |
+
type="button"
|
| 679 |
+
className="ghost sm"
|
| 680 |
+
data-testid="onboarding-reveal-token"
|
| 681 |
+
onClick=${() => setRevealed((v) => !v)}
|
| 682 |
+
>${revealed ? "Hide" : "Reveal"}</button>
|
| 683 |
+
<button
|
| 684 |
+
type="button"
|
| 685 |
+
className="ghost sm"
|
| 686 |
+
data-testid="onboarding-copy-token"
|
| 687 |
+
onClick=${() => { try { navigator.clipboard?.writeText(token); } catch {} }}
|
| 688 |
+
>Copy</button>
|
| 689 |
+
</div>
|
| 690 |
+
<div className="tiny muted">Save the private pass somewhere safe. You will need it to reopen this profile.</div>
|
| 691 |
+
</div>
|
| 692 |
+
`;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
function Onboarding({
|
| 696 |
step,
|
| 697 |
setStep,
|
|
|
|
| 715 |
<h1>Set up your profile</h1>
|
| 716 |
<p className="sub">Keep these credentials. You need them to reopen the profile later.</p>
|
| 717 |
<div className="card stack">
|
| 718 |
+
<${NewProfileCredentials} createResult=${createResult} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
|
| 720 |
${pendingPartnerId ? html`<div className="tiny">Invite saved for <code>${pendingPartnerId}</code>. The connection request will go out after this setup.</div>` : null}
|
| 721 |
|
frontend/discover-state.js
CHANGED
|
@@ -5,6 +5,7 @@ export function visibleDiscoveryItems(rawItems, currentPlays) {
|
|
| 5 |
for (const row of rawItems || []) {
|
| 6 |
const id = row?.kink?.id;
|
| 7 |
if (id == null || id === "") continue;
|
|
|
|
| 8 |
const sid = String(id);
|
| 9 |
if (played[sid] || seen.has(sid)) continue;
|
| 10 |
seen.add(sid);
|
|
|
|
| 5 |
for (const row of rawItems || []) {
|
| 6 |
const id = row?.kink?.id;
|
| 7 |
if (id == null || id === "") continue;
|
| 8 |
+
if (row.kink?.is_scenario) continue;
|
| 9 |
const sid = String(id);
|
| 10 |
if (played[sid] || seen.has(sid)) continue;
|
| 11 |
seen.add(sid);
|
frontend/discover-state.test.mjs
CHANGED
|
@@ -27,6 +27,15 @@ test("visibleDiscoveryItems drops already-rated raw recommendation rows", () =>
|
|
| 27 |
assert.deepEqual(visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id), ["b"]);
|
| 28 |
});
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
test("visibleDiscoveryItems skips malformed rows, blank ids, and duplicate ids", () => {
|
| 31 |
const rawItems = [
|
| 32 |
null,
|
|
|
|
| 27 |
assert.deepEqual(visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id), ["b"]);
|
| 28 |
});
|
| 29 |
|
| 30 |
+
test("visibleDiscoveryItems drops scenario kinks even when not yet rated", () => {
|
| 31 |
+
const rawItems = [
|
| 32 |
+
{ kink: { id: "a", name: "regular" } },
|
| 33 |
+
{ kink: { id: "b", name: "scenario", is_scenario: true } },
|
| 34 |
+
{ kink: { id: "c", name: "another" } },
|
| 35 |
+
];
|
| 36 |
+
assert.deepEqual(visibleDiscoveryItems(rawItems, {}).map((row) => row.kink.id), ["a", "c"]);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
test("visibleDiscoveryItems skips malformed rows, blank ids, and duplicate ids", () => {
|
| 40 |
const rawItems = [
|
| 41 |
null,
|
frontend/dist/assets/index-BRxg-Ey6.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/dist/assets/index-HvMqkWIl.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--bg: #f5efe7;--bg-2: #efe5d7;--surface: rgba(255, 251, 246, .92);--surface-strong: #fffaf3;--ink: #1d1815;--muted: #6d6157;--line: #dbcdbd;--accent: #b33636;--accent-2: #264653;--accent-soft: #f0ddd0;--ok: #236b45;--radius: 18px;--radius-sm: 12px;--radius-pill: 999px;--tab-height: 56px}*{box-sizing:border-box}body{margin:0;color:var(--ink);font-family:IBM Plex Sans,Inter,system-ui,sans-serif;background:radial-gradient(circle at top left,rgba(179,54,54,.14),transparent 24%),radial-gradient(circle at bottom right,rgba(38,70,83,.12),transparent 22%),linear-gradient(180deg,var(--bg-2),var(--bg));min-height:100vh;padding-bottom:calc(var(--tab-height) + 12px)}button,input,textarea{font:inherit}button{border:0;border-radius:var(--radius-pill);background:var(--accent);color:#fff;padding:10px 16px;cursor:pointer;transition:transform .12s ease,opacity .12s ease;font-size:.88rem}button:hover{transform:translateY(-1px)}button:active{transform:scale(.97)}button.secondary{background:var(--accent-2)}button.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}button.sm{padding:6px 12px;font-size:.82rem}input,textarea{width:100%;border:1px solid var(--line);border-radius:14px;padding:12px 14px;background:var(--surface-strong);color:var(--ink)}.page{max-width:680px;margin:0 auto;padding:16px}.app-global-bar{display:flex;justify-content:flex-end;align-items:center;margin:0 0 10px;min-height:36px}.app-graph-debug-toggle--on{border-color:var(--accent-2);background:#2646531a;color:var(--accent-2)}.tab-bar{position:fixed;bottom:0;left:0;right:0;display:flex;justify-content:center;gap:0;height:var(--tab-height);background:var(--surface-strong);border-top:1px solid var(--line);z-index:90}.tab-btn{flex:1;max-width:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;background:transparent;color:var(--muted);border:none;border-radius:0;padding:4px 0;font-size:.72rem;font-weight:600;letter-spacing:.02em;transition:color .1s ease}.tab-btn:hover{transform:none}.tab-btn.active{color:var(--accent)}.tab-icon{font-size:1.3rem;line-height:1}.tab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:var(--radius-pill);background:var(--accent);color:#fff;font-size:.66rem;font-weight:700;padding:0 5px;position:absolute;top:4px;right:calc(50% - 20px)}.top-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.top-bar-title{font-size:1.4rem;font-weight:800;letter-spacing:-.03em;line-height:1}.top-bar-right{display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--muted)}.top-bar-right strong{color:var(--ink)}.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:20px;box-shadow:0 8px 24px #0000000a}.card-lg{padding:28px 24px;text-align:center}.card-title{margin:0 0 8px;font-size:1.8rem;font-weight:800;letter-spacing:-.03em;line-height:1.1}.card-definition{color:var(--muted);font-size:1rem;line-height:1.5;max-width:480px;margin:0 auto 16px}.kink-pill,.card-cluster{display:inline-block;padding:5px 11px;border-radius:var(--radius-pill);background:#26465314;color:var(--accent-2);font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-bottom:12px}.card-image-area{width:100%;aspect-ratio:16 / 9;border-radius:var(--radius-sm);background:linear-gradient(135deg,#c9b8a7,#a89584);overflow:hidden;margin-bottom:16px;position:relative;cursor:pointer}.card-image-area img{width:100%;height:100%;object-fit:cover;filter:blur(20px);transition:filter .3s ease}.card-image-area.revealed img{filter:none}.card-counter{font-size:.84rem;color:var(--muted);font-weight:600}.card-image-area--preview{cursor:pointer}.card-image-area--preview .card-image-blur{width:100%;height:100%;object-fit:cover;filter:blur(20px);transform:scale(1.04);display:block}.card-image-cta{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:.95rem;font-weight:700;text-shadow:0 1px 8px rgba(0,0,0,.75);pointer-events:none}.card-image-scroll{max-height:340px;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;border-radius:var(--radius-sm);margin-bottom:16px;border:1px solid var(--line);-webkit-overflow-scrolling:touch;overscroll-behavior:contain;scrollbar-width:thin}.card-image-slide{min-height:220px;scroll-snap-align:start;scroll-snap-stop:always}.card-image-slide img{width:100%;height:100%;min-height:220px;object-fit:cover;display:block}.discover-card-wrap{margin:0 auto 10px;max-width:420px;border-radius:22px;box-shadow:0 12px 40px #0000001f}.discover-photo-hint{text-align:center;line-height:1.45;max-width:420px;margin:0 auto 8px;padding:0 8px}.discover-photo-hint strong{color:var(--ink);font-weight:800}.discover-reason-row{max-width:420px;margin:0 auto 8px;padding:0 8px}.discover-reason-pill{display:inline-flex;align-items:center;padding:6px 12px;border-radius:var(--radius-pill);background:#1d18150f;color:var(--ink);font-size:.78rem;font-weight:600;line-height:1.3}.discover-reason-pill--partner_influenced{background:#b336361f;color:var(--accent)}.discover-reason-pill--starter{background:#2646531a;color:var(--accent-2)}.discover-group-row{max-width:420px;margin:0 auto 14px;padding:0 4px}.discover-group-label{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px}.partner-group-picker{margin-bottom:8px}.discover-photo-toggle{display:flex;align-items:center;gap:10px;margin:0 auto 12px;max-width:420px;font-size:.86rem;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none}.discover-photo-toggle input{width:1.05rem;height:1.05rem;accent-color:var(--accent, #c62828)}.discover-directions{max-width:420px;margin:4px auto 6px;padding:0 10px}.discover-dir-label{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px}.discover-direction-sheet{max-width:420px;margin:0 auto 14px;padding:14px;border:1px solid var(--line);border-radius:var(--radius);background:#ffffffe0;display:grid;gap:10px}.discover-direction-actions{display:flex;gap:8px;flex-wrap:wrap}.discover-scenario-hint{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.discover-scenario-hint strong{display:block;margin-bottom:2px}.tinder-card{background:var(--surface-strong);border:1px solid var(--line);border-radius:22px;overflow:hidden;text-align:left}.tinder-card__media{position:relative;background:linear-gradient(145deg,#3d3430,#1a1513)}.tinder-card__media-inner{position:relative;height:min(58vh,520px);height:min(58dvh,520px);min-height:280px;overflow:hidden}.tinder-card__carousel{position:relative;width:100%;height:100%}.tinder-card__slide--solo{height:100%}.tinder-card__slide--solo img{width:100%;height:100%;min-height:min(58vh,520px);min-height:min(58dvh,520px);object-fit:cover;display:block}.tinder-edge{position:absolute;top:0;bottom:calc(104px + env(safe-area-inset-bottom,0px));width:30%;max-width:150px;z-index:4;cursor:pointer;background:transparent;border:none;padding:0;-webkit-tap-highlight-color:transparent}.tinder-edge--left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.14),transparent)}.tinder-edge--right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.14),transparent)}.tinder-edge:active{opacity:.85}.tinder-dots{position:absolute;bottom:14px;left:50%;transform:translate(-50%);display:flex;gap:7px;z-index:5;pointer-events:none}.tinder-dots span{width:7px;height:7px;border-radius:50%;background:#ffffff61;transition:transform .12s ease,background .12s ease}.tinder-dots span.on{background:#fff;transform:scale(1.12)}.tinder-card__media-scroll{height:100%;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;scrollbar-width:thin}.tinder-card__slide{min-height:100%;scroll-snap-align:start;scroll-snap-stop:always;box-sizing:border-box}.tinder-card__slide img{width:100%;height:100%;min-height:min(58vh,520px);object-fit:cover;display:block}.tinder-card__img-blur{filter:blur(22px);transform:scale(1.06)}.tinder-card__media--placeholder{display:flex;align-items:center;justify-content:center;aspect-ratio:3 / 4;max-height:58vh;min-height:280px}.tinder-card__placeholder-letters{font-size:3.5rem;font-weight:800;color:#ffffff38;letter-spacing:-.06em}.tinder-card__photo-badge{position:absolute;top:12px;right:12px;left:auto;bottom:auto;transform:none;z-index:4;padding:5px 12px;border-radius:var(--radius-pill);background:#00000080;color:#fff;font-size:.74rem;font-weight:700;letter-spacing:.04em;pointer-events:none}.tinder-card__gradient{position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(180deg,transparent 38%,rgba(0,0,0,.55) 100%);pointer-events:none;z-index:2}.tinder-card__reveal-row{position:absolute;left:0;right:0;bottom:0;height:calc(104px + env(safe-area-inset-bottom,0px));display:flex;align-items:center;justify-content:center;z-index:6;pointer-events:none;box-sizing:border-box;padding-bottom:env(safe-area-inset-bottom,0px)}.tinder-card__reveal{position:relative;flex:0 0 auto;width:max-content;max-width:min(280px,calc(100% - 32px));margin:0;padding:11px 20px;border-radius:var(--radius-pill);border:1px solid rgba(255,255,255,.5);background:#00000080;color:#fff;font-size:.84rem;font-weight:700;cursor:pointer;box-shadow:0 6px 24px #00000059;touch-action:manipulation;-webkit-user-select:none;user-select:none;pointer-events:auto;outline:none}@media (hover: hover) and (pointer: fine){.tinder-card__reveal:hover{background:#0000009e}}.tinder-card__reveal:focus-visible{box-shadow:0 6px 24px #00000059,0 0 0 2px #ffffff73}.tinder-card__body{padding:16px 18px 20px}button.tinder-card__body.tinder-card__body--tappable{display:block;width:100%;border:none;margin:0;padding:16px 18px 20px;font:inherit;color:inherit;text-align:left;background:var(--surface-strong);-moz-appearance:none;appearance:none;-webkit-appearance:none;border-radius:0;box-sizing:border-box}.tinder-card__body--tappable{cursor:pointer;border-radius:0 0 18px 18px;transition:background .14s ease;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.tinder-card__body--tappable:focus-visible{outline:none;box-shadow:inset 0 0 0 2px #6450c859}.tinder-card__body--tappable:active{background:#0000000b}.tinder-card__body--expanded{max-height:min(52vh,420px);overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.tinder-card__body .kink-pill{margin-bottom:8px}.tinder-card__body .card-title{font-size:1.55rem;text-align:left;margin-bottom:6px}.tinder-card__body:not(.tinder-card__body--expanded) .card-definition{text-align:left;font-size:.92rem;line-height:1.45;display:block;overflow:visible;margin-bottom:0;color:var(--ink)}.tinder-card__body--expanded .card-definition--discover-full{text-align:left;font-size:.92rem;line-height:1.5;display:block;margin-bottom:0;overflow:visible;color:var(--ink)}.tinder-card__notes{margin-top:10px;font-size:.84rem;line-height:1.45;white-space:pre-wrap;word-break:break-word;color:#5c5c5c}.tinder-card__info-cue{margin-top:10px;font-size:.72rem;font-weight:600;letter-spacing:.02em;color:#888;text-align:center}.tinder-card .safety-badge{margin-top:8px}.action-bar{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;padding:16px 8px;max-width:520px;margin:0 auto}.action-btn{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 20px;border-radius:16px;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.76rem;font-weight:600;min-width:64px;transition:transform .1s ease,background .1s ease,border-color .1s ease}.action-btn:hover{transform:translateY(-2px)}.action-btn:active{transform:scale(.94)}.action-btn .action-icon{font-size:1.4rem;line-height:1}.action-btn.love{border-color:#e57373;background:#fff5f5}.action-btn.like{border-color:#81c784;background:#f5fff5}.action-btn.maybe{border-color:#b39ddb;background:#f3e5f5}.action-btn.skip{border-color:var(--line)}.action-btn.nogo{border-color:#bbb;background:#fafafa}.chip-flow{display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start}.chip-flow--with-dnd{flex-direction:column;align-items:stretch}.plays-board-card--inline{width:100%}.play-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffd1;font-size:.88rem;font-weight:600;cursor:pointer;transition:border-color .1s ease,box-shadow .1s ease;max-width:100%}.chip-top-row{display:inline-flex;align-items:center;gap:6px;min-width:0;flex:1}.chip-near-title{flex-shrink:0;opacity:.55;font-size:.85em;font-weight:600;cursor:help}.play-chip:hover{border-color:var(--accent)}.play-chip .chip-emoji{flex-shrink:0}.play-chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.play-chip.expanded{width:100%;max-width:100%;min-width:0;box-sizing:border-box;flex-direction:column;align-items:stretch;gap:10px;padding:12px;background:#fff;border-color:var(--accent);box-shadow:0 4px 16px #0000000f}.play-chip.expanded .chip-top-row{align-items:flex-start}.play-chip.expanded .chip-name{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;text-overflow:ellipsis;white-space:normal;word-break:break-word}.plays-inspector{scroll-margin-top:12px}.plays-inspector--tentative{border:1px dashed var(--accent, #888);background:#ffffff05}.plays-inspector-head-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.tentative-badge{display:inline-block;margin-top:4px;padding:2px 8px;font-size:.72rem;border-radius:999px;border:1px dashed var(--accent, #888);color:var(--muted)}.play-chip--expandable{cursor:pointer}.play-chip--expanded{background:#ffffff0a}.play-chip-inspector{width:100%;margin-top:8px}.play-chip-inspector--floating{margin-top:12px}.plays-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.plays-inspector-copy{min-width:0}.plays-inspector-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.plays-inspector-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0}.plays-inspector-title{margin:0;font-size:1.2rem;line-height:1.2;overflow-wrap:anywhere;word-break:break-word}.plays-inspector-emoji{flex-shrink:0;font-size:1.2rem;line-height:1}.plays-inspector-definition{margin:0}.plays-inspector-actions,.plays-inspector-actions-row,.plays-inspector-section{display:grid;gap:10px}.inline-detail{display:grid;gap:10px;min-width:0;max-width:100%}.inline-detail .detail-def{font-size:.9rem;line-height:1.45;color:var(--muted);font-weight:400;overflow-wrap:anywhere;word-break:break-word}.scenario-panel{display:grid;gap:8px;padding:10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#00000005}.scenario-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.scenario-panel-mother{display:grid;gap:2px;min-width:0}.scenario-panel-mother-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.scenario-panel-mother-name{font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word}.scenario-panel-hint{margin:0 0 4px;line-height:1.4}.scenario-list{display:grid;gap:8px}.scenario-list--page{gap:10px}.scenario-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0}.scenario-row-text{display:grid;gap:2px;min-width:0}.scenario-name{font-size:.84rem;font-weight:650;line-height:1.4;overflow-wrap:anywhere;word-break:break-word;white-space:normal}.scenario-match-chip .chip-name{white-space:normal;overflow-wrap:anywhere}.scenario-row--page{padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffd1}.scenario-row--highlight{border-color:var(--accent);box-shadow:0 0 0 2px #b336361f}.scenario-row-actions{display:flex;align-items:center;justify-content:flex-end}.scenarios-parent-head,.scenarios-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.scenarios-parent-search{max-width:240px}.scenarios-parent-list{display:grid;gap:8px}.scenarios-parent-item{width:100%;display:grid;gap:3px;justify-items:start;text-align:left;border-radius:var(--radius-sm);border:1px solid var(--line);background:#ffffffd1;color:var(--ink);padding:12px 14px}.scenarios-parent-item.active{border-color:var(--accent);background:#fff}.scenarios-parent-name{font-weight:700;overflow-wrap:anywhere;word-break:break-word}.scenarios-parent-meta{font-size:.78rem;color:var(--muted)}.scenarios-detail-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.scenarios-detail-title{margin:4px 0;line-height:1.15}@media (max-width: 560px){.discover-scenario-hint{align-items:stretch;flex-direction:column}.scenario-row{grid-template-columns:1fr}.scenario-row-actions{justify-content:flex-start}}.dir-pills{display:flex;gap:6px;flex-wrap:wrap}.dir-pill{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--line);border-radius:var(--radius-pill);background:#fff;color:var(--ink);font-size:.8rem;font-weight:500;line-height:1.2;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.dir-pill.active{background:var(--accent-2);color:#fff;border-color:transparent}.dir-pill:disabled{opacity:.55;cursor:not-allowed}.discover-dir-strip{margin:.65rem 0 .35rem;padding:0 2px}.discover-dir-strip__label{margin-bottom:6px;text-align:center;line-height:1.35}.discover-dir-strip__pills{justify-content:center}.reaction-bar{display:flex;gap:6px;flex-wrap:wrap}.react-btn{display:inline-flex;align-items:center;justify-content:center;padding:5px 8px;border-radius:var(--radius-pill);border:1px solid var(--line);background:#fff;font-size:.9rem;min-width:32px;min-height:32px;transition:transform 80ms ease,background 80ms ease}.react-btn:active{transform:scale(.92)}.react-btn.active{background:var(--accent-soft);border-color:transparent}.section-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-title{margin:0;font-size:.88rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.section-count{font-size:.76rem;color:var(--muted);font-weight:600}.plays-direction-board{margin-bottom:20px}.plays-board-hint{margin:0 0 12px;line-height:1.45}.plays-board-columns{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;align-items:stretch}.plays-board-columns>.plays-board-column{min-width:0}@media (max-width: 780px){.plays-board-columns{grid-template-columns:1fr}.plays-board-card{flex-wrap:wrap;align-items:flex-start}.plays-board-card .plays-dnd-handle{margin-top:4px}.plays-board-card-body{flex:1 1 calc(100% - 48px);min-width:0}.plays-touch-moves{display:flex;order:3}}.plays-board-column{display:flex;flex-direction:column;min-height:140px;border:2px dashed var(--line);border-radius:var(--radius);background:#ffffff8c;padding:8px;transition:border-color .12s ease,background .12s ease,box-shadow .12s ease}.plays-board-column--over{border-color:var(--accent);background:#ffecec73;box-shadow:0 0 0 1px #c6282826}.plays-board-column-head{display:flex;align-items:center;gap:6px;margin-bottom:8px;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.plays-board-column-icon{font-size:1rem}.plays-board-column-title{overflow-wrap:anywhere}.plays-board-column-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}.plays-board-empty{flex:1;display:flex;align-items:center;justify-content:center;padding:16px 8px;text-align:center;color:var(--muted);opacity:.9}.plays-board-card{display:flex;gap:6px;align-items:flex-start;min-width:0;max-width:100%;width:100%;box-sizing:border-box}.plays-dnd-handle{flex-shrink:0;cursor:grab;-webkit-user-select:none;user-select:none;padding:4px 6px;margin-top:2px;border-radius:6px;color:var(--muted);font-size:1rem;line-height:1;touch-action:none}.plays-dnd-handle:hover{background:#0000000a;color:var(--ink)}.plays-dnd-handle:active{cursor:grabbing}.plays-touch-moves{display:none;flex:1 1 100%;flex-wrap:wrap;gap:6px;align-items:stretch;margin-top:2px}.plays-touch-move-btn{flex:1 1 auto;min-width:0;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:8px 6px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffeb;font-size:.72rem;font-weight:600;line-height:1.15;text-align:center;color:var(--ink);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.plays-touch-move-btn:active{background:#c6282814}.plays-touch-move-icon{font-size:.95rem;line-height:1}.plays-board-card-body{flex:1;min-width:0;overflow-x:hidden}.plays-board-card-body .play-chip,.plays-board-card-body .inline-detail .reaction-bar,.plays-board-card-body .inline-detail .dir-pills,.plays-board-card-body .inline-detail .chips{max-width:100%}.inline-detail .chips .chip{max-width:100%;min-width:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;text-overflow:ellipsis;line-height:1.35;text-align:left;white-space:normal;word-break:break-word}.plays-board-card-body .inline-detail button.ghost.sm{max-width:100%;justify-self:start}.plays-board-card-body .play-assets,.plays-board-card-body .play-assets-carousel{max-width:100%;box-sizing:border-box}.collapse-toggle{all:unset;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:.84rem;font-weight:600;color:var(--muted);padding:6px 0}.collapse-toggle .arrow{transition:transform .2s ease}.collapse-toggle.open .arrow{transform:rotate(90deg)}.partner-code{display:flex;align-items:center;gap:10px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius);background:#ffffffb8}.partner-code code{font-size:1.1rem;font-weight:700;flex:1;overflow-wrap:anywhere}.sub-tabs{display:flex;gap:0;border-bottom:2px solid var(--line);margin-bottom:12px}.sub-tab{padding:8px 16px;background:transparent;border:none;border-bottom:2px solid transparent;border-radius:0;color:var(--muted);font-weight:600;font-size:.84rem;margin-bottom:-2px;transition:color .1s,border-color .1s}.sub-tab:hover{transform:none;color:var(--ink)}.sub-tab.active{color:var(--accent);border-bottom-color:var(--accent)}.match-indicator{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:var(--radius-pill);font-size:.74rem;font-weight:600}.match-indicator.perfect{background:#e8f5e9;color:#2e7d32}.match-indicator.partial{background:#fff3e0;color:#e65100}.together-scenario-card{flex-direction:column;align-items:stretch;gap:10px}.together-scenario-copy{display:grid;gap:4px;min-width:0}.together-mother-label,.together-scenario-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted);font-size:.68rem}.together-mother-name{font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word}button.together-mother-link{all:unset;cursor:pointer;font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word;color:var(--accent);text-decoration:underline;text-underline-offset:2px}button.together-mother-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.together-scenario-title{font-size:.88rem;font-weight:650;line-height:1.45;overflow-wrap:anywhere;word-break:break-word;white-space:normal}.together-scenario-meta{display:flex;flex-direction:column;gap:8px;min-width:0}.together-scenario-you{display:grid;gap:4px;padding-top:4px;border-top:1px solid var(--line)}.onboard{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;text-align:center}.onboard .card{width:100%;max-width:440px;text-align:left}.onboard h1{font-size:2.4rem;letter-spacing:-.04em;line-height:1;margin:0 0 8px}.onboard .sub{color:var(--muted);margin:0 0 24px;font-size:.96rem}.starter-page{padding-top:24px}.starter-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.starter-hero .sub{margin:8px 0 0;max-width:520px}.creds{border:1px solid var(--line);border-radius:var(--radius);padding:14px;background:#ffffffb8}.creds code{display:block;margin-top:6px;padding:8px 10px;border-radius:var(--radius-sm);background:#f5eee4;overflow-wrap:anywhere;font-weight:600}.settings-code{display:inline-block;margin-top:6px;padding:8px 10px;border-radius:var(--radius-sm);background:#f5eee4;overflow-wrap:anywhere;font-weight:600}.settings-toggle{margin:0;max-width:none}.settings-share-row{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffb8}.settings-share-title{font-size:.9rem;font-weight:700;margin-bottom:4px}.search-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:80;background:#1d18156b;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);display:flex;flex-direction:column;align-items:center;padding:16px}.search-overlay .search-inner{width:100%;max-width:560px;display:grid;gap:12px}.search-overlay input{font-size:1.1rem;padding:14px 16px}.play-assets{width:100%}.play-assets-carousel{position:relative;border-radius:var(--radius-sm);border:1px solid var(--line);overflow:hidden;background:var(--surface)}.play-assets-carousel .play-assets-slide{min-height:200px;max-height:min(48vh,340px)}.play-assets-carousel .play-assets-slide img{width:100%;height:100%;min-height:200px;max-height:min(48vh,340px);object-fit:cover;display:block}.play-edge{position:absolute;top:0;bottom:0;width:28%;max-width:120px;z-index:2;cursor:pointer;border:none;padding:0;background:transparent;-webkit-tap-highlight-color:transparent}.play-edge--left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.08),transparent)}.play-edge--right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.08),transparent)}.play-assets-dots{position:absolute;bottom:10px;left:50%;transform:translate(-50%);display:flex;gap:6px;z-index:3;pointer-events:none}.play-assets-dots span{width:6px;height:6px;border-radius:50%;background:#ffffff73}.play-assets-dots span.on{background:#fff}.play-assets-badge{position:absolute;top:8px;right:8px;z-index:3;padding:4px 10px;border-radius:var(--radius-pill);background:#0000007a;color:#fff;font-size:.72rem;font-weight:700;pointer-events:none}.image-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.image-tile{position:relative;aspect-ratio:1;background:#c9b8a7;border-radius:var(--radius-sm);overflow:hidden}.image-tile img{width:100%;height:100%;object-fit:cover}.image-tile .reveal-btn{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:0;background:linear-gradient(180deg,#0000001f,#000000b3);display:flex;align-items:center;justify-content:center;font-size:.82rem}.safety-badge{padding:4px 8px;border-radius:8px;font-size:.78rem;font-weight:600;display:inline-block}.safety-badge.extreme{background:#ffebee;color:#c62828;border:1px solid #ef9a9a}.safety-badge.advanced{background:#fff3e0;color:#e65100;border:1px solid #ffcc80}.stack{display:grid;gap:12px}.stack-sm{display:grid;gap:8px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.muted{color:var(--muted)}.tiny{font-size:.82rem;color:var(--muted)}.empty{color:var(--muted);border:1px dashed rgba(219,205,189,.7);border-radius:14px;padding:10px 12px;background:#ffffff59;font-size:.88rem}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{border:1px solid var(--line);border-radius:var(--radius-pill);padding:5px 10px;font-size:.82rem;background:#fff;color:var(--ink);cursor:pointer;font-weight:500}.chip.active{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);font-weight:600}.status{min-height:1.2em;color:var(--muted);font-size:.86rem}.together-scope-hint{margin:0 0 10px;color:var(--muted)}.together-section-label{margin:8px 0 2px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.related-match-chip{align-items:flex-start}.related-arrow{color:var(--muted);font-weight:700}.related-match-meta{flex:1 1 240px;color:var(--muted)}.their-board-picker{margin-bottom:12px}.together-react-row{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;max-width:100%}.together-react-pair{display:inline-flex;gap:3px;align-items:center}.together-react-id{font-family:ui-monospace,monospace;font-size:.75em;opacity:.85}@media (min-width: 768px){.page{max-width:720px;padding:24px}.action-btn{padding:14px 28px;min-width:80px;font-size:.82rem}.action-btn .action-icon{font-size:1.6rem}.card-lg{padding:36px 32px}.card-title{font-size:2.2rem}}.desktop-nav{display:none}@media (min-width: 1024px){.page{max-width:880px}.tab-bar{display:none}body{padding-bottom:0}.desktop-nav{display:flex;gap:0;margin-bottom:20px;border-bottom:2px solid var(--line)}.desktop-nav .tab-btn{flex-direction:row;gap:6px;padding:10px 20px;font-size:.88rem;max-width:none;border-bottom:2px solid transparent;margin-bottom:-2px}.desktop-nav .tab-btn.active{border-bottom-color:var(--accent);color:var(--accent)}}
|
frontend/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Play List</title>
|
| 7 |
<link rel="icon" href="/favicon.ico" type="image/jpeg" />
|
| 8 |
-
<script type="module" crossorigin src="/frontend/assets/index-
|
| 9 |
-
<link rel="stylesheet" crossorigin href="/frontend/assets/index-
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="root"></div>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Play List</title>
|
| 7 |
<link rel="icon" href="/favicon.ico" type="image/jpeg" />
|
| 8 |
+
<script type="module" crossorigin src="/frontend/assets/index-BRxg-Ey6.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="/frontend/assets/index-HvMqkWIl.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="root"></div>
|
frontend/mutations.js
CHANGED
|
@@ -127,6 +127,46 @@ export function useSavePlayMutation({
|
|
| 127 |
});
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
export function useSaveRoleMutation({ auth, setStatus }) {
|
| 131 |
const qc = useQueryClient();
|
| 132 |
return useMutation({
|
|
|
|
| 127 |
});
|
| 128 |
}
|
| 129 |
|
| 130 |
+
export function useDeletePlayMutation({ auth, setStatus }) {
|
| 131 |
+
const qc = useQueryClient();
|
| 132 |
+
return useMutation({
|
| 133 |
+
mutationFn: (kinkId) =>
|
| 134 |
+
api(`/users/${auth.userId}/plays/${encodeURIComponent(kinkId)}`, {
|
| 135 |
+
method: "DELETE",
|
| 136 |
+
headers: authHeaders(auth.token),
|
| 137 |
+
}),
|
| 138 |
+
onMutate: async (kinkId) => {
|
| 139 |
+
const playKey = String(kinkId);
|
| 140 |
+
const previousUser = qc.getQueryData(["user", auth.userId]);
|
| 141 |
+
const previousBoard = qc.getQueryData(["board", auth.userId]);
|
| 142 |
+
qc.setQueryData(["user", auth.userId], (old) => {
|
| 143 |
+
if (!old?.plays) return old;
|
| 144 |
+
const { [playKey]: _drop, ...rest } = old.plays;
|
| 145 |
+
return { ...old, plays: rest };
|
| 146 |
+
});
|
| 147 |
+
qc.setQueryData(["board", auth.userId], (old) => {
|
| 148 |
+
if (!old) return old;
|
| 149 |
+
const next = { ...old };
|
| 150 |
+
for (const col of ["to_me", "by_me", "together", "hidden", "no_go"]) {
|
| 151 |
+
if (Array.isArray(next[col])) next[col] = next[col].filter((item) => String(item?.id) !== playKey);
|
| 152 |
+
}
|
| 153 |
+
return next;
|
| 154 |
+
});
|
| 155 |
+
return { previousUser, previousBoard };
|
| 156 |
+
},
|
| 157 |
+
onSuccess: () => {
|
| 158 |
+
qc.invalidateQueries({ queryKey: ["board", auth.userId] });
|
| 159 |
+
qc.invalidateQueries({ queryKey: ["user", auth.userId] });
|
| 160 |
+
setStatus?.("Removed.");
|
| 161 |
+
},
|
| 162 |
+
onError: (error, _kinkId, context) => {
|
| 163 |
+
if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser);
|
| 164 |
+
if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard);
|
| 165 |
+
setStatus?.(error.message);
|
| 166 |
+
},
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
export function useSaveRoleMutation({ auth, setStatus }) {
|
| 171 |
const qc = useQueryClient();
|
| 172 |
return useMutation({
|
frontend/my-plays-similar.js
CHANGED
|
@@ -15,3 +15,20 @@ export function planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirec
|
|
| 15 |
directions: tentativeSimilarDirections(parentDirections),
|
| 16 |
};
|
| 17 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
directions: tentativeSimilarDirections(parentDirections),
|
| 16 |
};
|
| 17 |
}
|
| 18 |
+
|
| 19 |
+
export function openSimilarKinkInline({
|
| 20 |
+
similarKinkId,
|
| 21 |
+
parentDirections,
|
| 22 |
+
currentPlays,
|
| 23 |
+
onRate,
|
| 24 |
+
onInspect,
|
| 25 |
+
onMarkTentative,
|
| 26 |
+
}) {
|
| 27 |
+
const plan = planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirections);
|
| 28 |
+
if (plan.shouldTentativeAdd) {
|
| 29 |
+
onRate(similarKinkId, "curious", plan.directions);
|
| 30 |
+
onMarkTentative?.(similarKinkId);
|
| 31 |
+
}
|
| 32 |
+
onInspect(similarKinkId);
|
| 33 |
+
return plan;
|
| 34 |
+
}
|
frontend/my-plays.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
| 2 |
import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js";
|
| 3 |
-
import { PlayChip,
|
| 4 |
import { useBoardQuery, useSearchQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
|
| 5 |
import { uiReactionFor } from "./api.js";
|
| 6 |
-
import {
|
|
|
|
| 7 |
import {
|
| 8 |
DIRECTION_COLUMN_ORDER,
|
| 9 |
directionColumnsForPlay,
|
|
@@ -33,12 +34,16 @@ function PlaysTouchColumnMoves({ kinkId, onDirectionColumnChange }) {
|
|
| 33 |
`;
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
function positivePlaysUnique(board) {
|
| 37 |
const seen = new Set();
|
| 38 |
const out = [];
|
| 39 |
for (const col of DIR_ORDER) {
|
| 40 |
for (const item of board[col] || []) {
|
| 41 |
-
if (seen.has(item.id)) continue;
|
| 42 |
seen.add(item.id);
|
| 43 |
out.push(item);
|
| 44 |
}
|
|
@@ -50,21 +55,15 @@ function boardItemsById(board) {
|
|
| 50 |
const map = new Map();
|
| 51 |
for (const col of [...DIR_ORDER, "hidden", "no_go"]) {
|
| 52 |
for (const item of board?.[col] || []) {
|
| 53 |
-
if (!item?.id || map.has(item.id)) continue;
|
| 54 |
map.set(item.id, item);
|
| 55 |
}
|
| 56 |
}
|
| 57 |
return map;
|
| 58 |
}
|
| 59 |
|
| 60 |
-
function
|
| 61 |
-
return (
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
function openSimilarKink({ similarKinkId, parentDirections, currentPlays, onRate, onInspect }) {
|
| 65 |
-
const plan = planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirections);
|
| 66 |
-
if (plan.shouldTentativeAdd) onRate(similarKinkId, "curious", plan.directions);
|
| 67 |
-
onInspect(similarKinkId);
|
| 68 |
}
|
| 69 |
|
| 70 |
function DirectionsColumnBoard({
|
|
@@ -259,170 +258,6 @@ function CollapsibleSection(props) {
|
|
| 259 |
`;
|
| 260 |
}
|
| 261 |
|
| 262 |
-
function ImageGrid({ assets }) {
|
| 263 |
-
const [revealed, setRevealed] = useState(false);
|
| 264 |
-
const [idx, setIdx] = useState(0);
|
| 265 |
-
const [failedAssetUrls, setFailedAssetUrls] = useState(() => new Set());
|
| 266 |
-
const visibleAssets = assets.filter((asset) => !failedAssetUrls.has(asset.asset_url));
|
| 267 |
-
const n = visibleAssets.length;
|
| 268 |
-
const cur = visibleAssets[idx];
|
| 269 |
-
|
| 270 |
-
useEffect(() => {
|
| 271 |
-
setIdx(0);
|
| 272 |
-
setFailedAssetUrls(new Set());
|
| 273 |
-
}, [assets?.length, assets?.[0]?.asset_url]);
|
| 274 |
-
|
| 275 |
-
const go = useCallback((delta, e) => {
|
| 276 |
-
e?.stopPropagation?.();
|
| 277 |
-
if (n < 2) return;
|
| 278 |
-
setIdx((i) => (i + delta + n) % n);
|
| 279 |
-
}, [n]);
|
| 280 |
-
|
| 281 |
-
const markAssetFailed = useCallback((assetUrl) => {
|
| 282 |
-
if (!assetUrl) return;
|
| 283 |
-
setFailedAssetUrls((prev) => {
|
| 284 |
-
if (prev.has(assetUrl)) return prev;
|
| 285 |
-
const next = new Set(prev);
|
| 286 |
-
next.add(assetUrl);
|
| 287 |
-
return next;
|
| 288 |
-
});
|
| 289 |
-
}, []);
|
| 290 |
-
|
| 291 |
-
useEffect(() => {
|
| 292 |
-
setIdx((i) => (n ? Math.min(Math.max(0, i), n - 1) : 0));
|
| 293 |
-
}, [n]);
|
| 294 |
-
|
| 295 |
-
if (!n) return null;
|
| 296 |
-
|
| 297 |
-
return html`
|
| 298 |
-
<div className="play-assets">
|
| 299 |
-
${!revealed
|
| 300 |
-
? html`
|
| 301 |
-
<button
|
| 302 |
-
type="button"
|
| 303 |
-
className="ghost sm"
|
| 304 |
-
onClick=${(e) => { e.stopPropagation(); setRevealed(true); }}
|
| 305 |
-
>Show ${n} photo${n > 1 ? "s" : ""}</button>
|
| 306 |
-
`
|
| 307 |
-
: html`
|
| 308 |
-
<div className="play-assets-carousel">
|
| 309 |
-
${n > 1
|
| 310 |
-
? html`
|
| 311 |
-
<button type="button" className="play-edge play-edge--left" aria-label="Previous photo" onClick=${(e) => go(-1, e)} />
|
| 312 |
-
<button type="button" className="play-edge play-edge--right" aria-label="Next photo" onClick=${(e) => go(1, e)} />
|
| 313 |
-
`
|
| 314 |
-
: null}
|
| 315 |
-
<div className="play-assets-slide">
|
| 316 |
-
<img
|
| 317 |
-
loading="eager"
|
| 318 |
-
decoding="async"
|
| 319 |
-
src=${cur.asset_url}
|
| 320 |
-
alt=""
|
| 321 |
-
draggable=${false}
|
| 322 |
-
onError=${() => markAssetFailed(cur.asset_url)}
|
| 323 |
-
/>
|
| 324 |
-
</div>
|
| 325 |
-
${n > 1
|
| 326 |
-
? html`
|
| 327 |
-
<div className="play-assets-dots" aria-hidden="true">
|
| 328 |
-
${visibleAssets.map((_, i) => html`<span key=${`pd-${i}`} className=${i === idx ? "on" : ""} />`)}
|
| 329 |
-
</div>
|
| 330 |
-
<div className="play-assets-badge">${idx + 1} / ${n}</div>
|
| 331 |
-
`
|
| 332 |
-
: null}
|
| 333 |
-
</div>
|
| 334 |
-
`}
|
| 335 |
-
</div>
|
| 336 |
-
`;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
function PlayInspectorCallout({
|
| 340 |
-
item,
|
| 341 |
-
play,
|
| 342 |
-
detailData,
|
| 343 |
-
detailLoading,
|
| 344 |
-
similarData,
|
| 345 |
-
onRate,
|
| 346 |
-
onDirectionToggle,
|
| 347 |
-
onOpenSimilar,
|
| 348 |
-
onOpenScenarios,
|
| 349 |
-
onClose,
|
| 350 |
-
showImages,
|
| 351 |
-
inspectorRef,
|
| 352 |
-
}) {
|
| 353 |
-
if (!item?.id) return null;
|
| 354 |
-
const reaction = uiReactionFor(play?.interest_state);
|
| 355 |
-
const reactionMeta = UI_REACTIONS.find((entry) => entry.ui === reaction);
|
| 356 |
-
const directions = play?.directions || [];
|
| 357 |
-
const detail = detailData?.id === item.id ? detailData : null;
|
| 358 |
-
const defText = (detail?.definition || detail?.detail_summary || item.summary || "").trim();
|
| 359 |
-
const similarItems = visibleSimilarItems(similarData);
|
| 360 |
-
const scenarioCount = Number(detail?.scenario_child_count || item.scenario_child_count || 0);
|
| 361 |
-
|
| 362 |
-
return html`
|
| 363 |
-
<section className="plays-inspector card stack" data-testid="plays-inspector" ref=${inspectorRef}>
|
| 364 |
-
<div className="plays-inspector-head">
|
| 365 |
-
<div className="plays-inspector-copy">
|
| 366 |
-
<div className="tiny plays-inspector-label">Selected play</div>
|
| 367 |
-
<div className="plays-inspector-title-row">
|
| 368 |
-
<span className="plays-inspector-emoji">${reactionMeta?.icon || ""}</span>
|
| 369 |
-
<h3 className="plays-inspector-title">${item.name}</h3>
|
| 370 |
-
</div>
|
| 371 |
-
</div>
|
| 372 |
-
<button type="button" className="ghost sm" data-testid="plays-inspector-close" onClick=${onClose}>Close</button>
|
| 373 |
-
</div>
|
| 374 |
-
${defText
|
| 375 |
-
? html`<div className="detail-def plays-inspector-definition">${defText}</div>`
|
| 376 |
-
: detailLoading
|
| 377 |
-
? html`<div className="tiny muted">Loading details…</div>`
|
| 378 |
-
: null}
|
| 379 |
-
<div className="plays-inspector-actions">
|
| 380 |
-
<${ReactionBar} current=${reaction} onRate=${(rating) => onRate(item.id, rating)} />
|
| 381 |
-
<${DirectionPills}
|
| 382 |
-
directions=${directions}
|
| 383 |
-
locked=${!reaction}
|
| 384 |
-
onToggle=${(direction) => onDirectionToggle(item.id, direction)}
|
| 385 |
-
/>
|
| 386 |
-
</div>
|
| 387 |
-
<div className="plays-inspector-actions-row">
|
| 388 |
-
<button
|
| 389 |
-
type="button"
|
| 390 |
-
className="ghost sm"
|
| 391 |
-
data-testid="plays-open-scenarios"
|
| 392 |
-
disabled=${scenarioCount < 1}
|
| 393 |
-
onClick=${() => onOpenScenarios?.(item.id)}
|
| 394 |
-
>${scenarioCount > 0 ? `Explore scenarios (${scenarioCount})` : "No scenarios yet"}</button>
|
| 395 |
-
<button className="ghost sm" onClick=${() => onRate(item.id, "not_interested")}>Remove from list</button>
|
| 396 |
-
</div>
|
| 397 |
-
${similarItems.length
|
| 398 |
-
? html`
|
| 399 |
-
<div className="plays-inspector-section">
|
| 400 |
-
<div className="tiny plays-inspector-label">Similar</div>
|
| 401 |
-
<div className="chips">
|
| 402 |
-
${similarItems.map((entry) => html`
|
| 403 |
-
<button
|
| 404 |
-
key=${entry.kink.id}
|
| 405 |
-
type="button"
|
| 406 |
-
className="chip"
|
| 407 |
-
title=${entry.kink.name}
|
| 408 |
-
onClick=${(e) => {
|
| 409 |
-
e.stopPropagation();
|
| 410 |
-
onOpenSimilar(entry.kink.id, directions);
|
| 411 |
-
}}
|
| 412 |
-
>
|
| 413 |
-
${entry.kink.name}
|
| 414 |
-
</button>
|
| 415 |
-
`)}
|
| 416 |
-
</div>
|
| 417 |
-
</div>
|
| 418 |
-
`
|
| 419 |
-
: null}
|
| 420 |
-
${detail?.is_extreme ? html`<div className="safety-badge extreme">Significant risk</div>` : null}
|
| 421 |
-
${showImages && detail?.assets?.length ? html`<${ImageGrid} assets=${detail.assets} />` : null}
|
| 422 |
-
</section>
|
| 423 |
-
`;
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
export function MyPlaysView({
|
| 427 |
auth,
|
| 428 |
currentPlays,
|
|
@@ -432,10 +267,12 @@ export function MyPlaysView({
|
|
| 432 |
onDirectionColumnChange,
|
| 433 |
onNavigate,
|
| 434 |
onOpenScenarios,
|
|
|
|
| 435 |
focusKinkId,
|
| 436 |
onFocusKinkConsumed,
|
| 437 |
}) {
|
| 438 |
const [inspectedId, setInspectedId] = useState("");
|
|
|
|
| 439 |
const [dragOver, setDragOver] = useState(null);
|
| 440 |
const [searchOpen, setSearchOpen] = useState(false);
|
| 441 |
const [searchInput, setSearchInput] = useState("");
|
|
@@ -445,7 +282,15 @@ export function MyPlaysView({
|
|
| 445 |
|
| 446 |
const boardQuery = useBoardQuery(auth);
|
| 447 |
const board = boardQuery.data || {};
|
| 448 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
const inspectedItem = boardById.get(inspectedId) || null;
|
| 450 |
const detailQuery = useDetailQuery(inspectedId);
|
| 451 |
const similarQuery = useSimilarQuery(inspectedId);
|
|
@@ -453,10 +298,10 @@ export function MyPlaysView({
|
|
| 453 |
const totalCount = Object.keys(currentPlays || {}).length;
|
| 454 |
|
| 455 |
const nearDupHints = useMemo(() => {
|
| 456 |
-
const pos = positivePlaysUnique(
|
| 457 |
-
const combined = [...pos, ...(
|
| 458 |
return nearDuplicateHintsById(combined);
|
| 459 |
-
}, [
|
| 460 |
|
| 461 |
useEffect(() => {
|
| 462 |
if (!inspectedId) return;
|
|
@@ -483,17 +328,55 @@ export function MyPlaysView({
|
|
| 483 |
searchDebounceRef.current = setTimeout(() => setSearchDebounced(value.trim()), 300);
|
| 484 |
}, []);
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
const handleOpenSimilar = useCallback((similarKinkId, directions) => {
|
| 487 |
-
|
| 488 |
similarKinkId,
|
| 489 |
parentDirections: directions,
|
| 490 |
currentPlays,
|
| 491 |
onRate,
|
| 492 |
onInspect: setInspectedId,
|
|
|
|
| 493 |
});
|
| 494 |
-
}, [currentPlays, onRate]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
|
| 496 |
const empty = totalCount === 0;
|
|
|
|
| 497 |
|
| 498 |
return html`
|
| 499 |
<div data-testid="plays-view">
|
|
@@ -514,13 +397,15 @@ export function MyPlaysView({
|
|
| 514 |
detailData=${detailQuery.data}
|
| 515 |
detailLoading=${detailQuery.isFetching}
|
| 516 |
similarData=${similarQuery.data}
|
| 517 |
-
onRate=${
|
| 518 |
-
onDirectionToggle=${
|
| 519 |
onOpenSimilar=${handleOpenSimilar}
|
| 520 |
onOpenScenarios=${onOpenScenarios}
|
| 521 |
onClose=${() => setInspectedId("")}
|
| 522 |
showImages=${showImages}
|
| 523 |
inspectorRef=${inspectorRef}
|
|
|
|
|
|
|
| 524 |
/>
|
| 525 |
`
|
| 526 |
: null}
|
|
@@ -533,7 +418,7 @@ export function MyPlaysView({
|
|
| 533 |
? html`<div className="empty">No plays yet. Go to Discover to find things you like.</div>`
|
| 534 |
: html`
|
| 535 |
<${DirectionsColumnBoard}
|
| 536 |
-
board=${
|
| 537 |
currentPlays=${currentPlays}
|
| 538 |
inspectedId=${inspectedId}
|
| 539 |
onInspect=${setInspectedId}
|
|
@@ -548,8 +433,8 @@ export function MyPlaysView({
|
|
| 548 |
|
| 549 |
<${CollapsibleSection}
|
| 550 |
title="Hidden"
|
| 551 |
-
emoji="
|
| 552 |
-
items=${
|
| 553 |
inspectedId=${inspectedId}
|
| 554 |
onInspect=${setInspectedId}
|
| 555 |
nearDupHints=${nearDupHints}
|
|
@@ -560,8 +445,8 @@ export function MyPlaysView({
|
|
| 560 |
/>
|
| 561 |
<${CollapsibleSection}
|
| 562 |
title="No-go"
|
| 563 |
-
emoji="
|
| 564 |
-
items=${
|
| 565 |
inspectedId=${inspectedId}
|
| 566 |
onInspect=${setInspectedId}
|
| 567 |
nearDupHints=${nearDupHints}
|
|
|
|
| 1 |
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
| 2 |
import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js";
|
| 3 |
+
import { PlayChip, SearchOverlay } from "./components.js";
|
| 4 |
import { useBoardQuery, useSearchQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
|
| 5 |
import { uiReactionFor } from "./api.js";
|
| 6 |
+
import { openSimilarKinkInline } from "./my-plays-similar.js";
|
| 7 |
+
import { PlayInspectorCallout } from "./play-inspector.js";
|
| 8 |
import {
|
| 9 |
DIRECTION_COLUMN_ORDER,
|
| 10 |
directionColumnsForPlay,
|
|
|
|
| 34 |
`;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
function isScenarioItem(item) {
|
| 38 |
+
return Boolean(item?.is_scenario);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
function positivePlaysUnique(board) {
|
| 42 |
const seen = new Set();
|
| 43 |
const out = [];
|
| 44 |
for (const col of DIR_ORDER) {
|
| 45 |
for (const item of board[col] || []) {
|
| 46 |
+
if (seen.has(item.id) || isScenarioItem(item)) continue;
|
| 47 |
seen.add(item.id);
|
| 48 |
out.push(item);
|
| 49 |
}
|
|
|
|
| 55 |
const map = new Map();
|
| 56 |
for (const col of [...DIR_ORDER, "hidden", "no_go"]) {
|
| 57 |
for (const item of board?.[col] || []) {
|
| 58 |
+
if (!item?.id || map.has(item.id) || isScenarioItem(item)) continue;
|
| 59 |
map.set(item.id, item);
|
| 60 |
}
|
| 61 |
}
|
| 62 |
return map;
|
| 63 |
}
|
| 64 |
|
| 65 |
+
function filterScenariosFromBucket(items) {
|
| 66 |
+
return (items || []).filter((item) => !isScenarioItem(item));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
function DirectionsColumnBoard({
|
|
|
|
| 258 |
`;
|
| 259 |
}
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
export function MyPlaysView({
|
| 262 |
auth,
|
| 263 |
currentPlays,
|
|
|
|
| 267 |
onDirectionColumnChange,
|
| 268 |
onNavigate,
|
| 269 |
onOpenScenarios,
|
| 270 |
+
onDeletePlay,
|
| 271 |
focusKinkId,
|
| 272 |
onFocusKinkConsumed,
|
| 273 |
}) {
|
| 274 |
const [inspectedId, setInspectedId] = useState("");
|
| 275 |
+
const [tentativeIds, setTentativeIds] = useState(() => new Set());
|
| 276 |
const [dragOver, setDragOver] = useState(null);
|
| 277 |
const [searchOpen, setSearchOpen] = useState(false);
|
| 278 |
const [searchInput, setSearchInput] = useState("");
|
|
|
|
| 282 |
|
| 283 |
const boardQuery = useBoardQuery(auth);
|
| 284 |
const board = boardQuery.data || {};
|
| 285 |
+
const filteredBoard = useMemo(() => ({
|
| 286 |
+
...board,
|
| 287 |
+
to_me: filterScenariosFromBucket(board.to_me),
|
| 288 |
+
by_me: filterScenariosFromBucket(board.by_me),
|
| 289 |
+
together: filterScenariosFromBucket(board.together),
|
| 290 |
+
hidden: filterScenariosFromBucket(board.hidden),
|
| 291 |
+
no_go: filterScenariosFromBucket(board.no_go),
|
| 292 |
+
}), [board]);
|
| 293 |
+
const boardById = useMemo(() => boardItemsById(filteredBoard), [filteredBoard]);
|
| 294 |
const inspectedItem = boardById.get(inspectedId) || null;
|
| 295 |
const detailQuery = useDetailQuery(inspectedId);
|
| 296 |
const similarQuery = useSimilarQuery(inspectedId);
|
|
|
|
| 298 |
const totalCount = Object.keys(currentPlays || {}).length;
|
| 299 |
|
| 300 |
const nearDupHints = useMemo(() => {
|
| 301 |
+
const pos = positivePlaysUnique(filteredBoard);
|
| 302 |
+
const combined = [...pos, ...(filteredBoard.hidden || []), ...(filteredBoard.no_go || [])];
|
| 303 |
return nearDuplicateHintsById(combined);
|
| 304 |
+
}, [filteredBoard]);
|
| 305 |
|
| 306 |
useEffect(() => {
|
| 307 |
if (!inspectedId) return;
|
|
|
|
| 328 |
searchDebounceRef.current = setTimeout(() => setSearchDebounced(value.trim()), 300);
|
| 329 |
}, []);
|
| 330 |
|
| 331 |
+
const markTentative = useCallback((id) => {
|
| 332 |
+
setTentativeIds((prev) => {
|
| 333 |
+
if (prev.has(id)) return prev;
|
| 334 |
+
const next = new Set(prev);
|
| 335 |
+
next.add(id);
|
| 336 |
+
return next;
|
| 337 |
+
});
|
| 338 |
+
}, []);
|
| 339 |
+
|
| 340 |
+
const clearTentative = useCallback((id) => {
|
| 341 |
+
setTentativeIds((prev) => {
|
| 342 |
+
if (!prev.has(id)) return prev;
|
| 343 |
+
const next = new Set(prev);
|
| 344 |
+
next.delete(id);
|
| 345 |
+
return next;
|
| 346 |
+
});
|
| 347 |
+
}, []);
|
| 348 |
+
|
| 349 |
+
const handleRateInInspector = useCallback((kinkId, rating, dirs) => {
|
| 350 |
+
clearTentative(kinkId);
|
| 351 |
+
onRate(kinkId, rating, dirs);
|
| 352 |
+
}, [clearTentative, onRate]);
|
| 353 |
+
|
| 354 |
+
const handleDirectionToggleInInspector = useCallback((kinkId, direction) => {
|
| 355 |
+
clearTentative(kinkId);
|
| 356 |
+
onDirectionToggle(kinkId, direction);
|
| 357 |
+
}, [clearTentative, onDirectionToggle]);
|
| 358 |
+
|
| 359 |
const handleOpenSimilar = useCallback((similarKinkId, directions) => {
|
| 360 |
+
openSimilarKinkInline({
|
| 361 |
similarKinkId,
|
| 362 |
parentDirections: directions,
|
| 363 |
currentPlays,
|
| 364 |
onRate,
|
| 365 |
onInspect: setInspectedId,
|
| 366 |
+
onMarkTentative: markTentative,
|
| 367 |
});
|
| 368 |
+
}, [currentPlays, onRate, markTentative]);
|
| 369 |
+
|
| 370 |
+
const handleUndoTentative = useCallback(() => {
|
| 371 |
+
if (!inspectedId) return;
|
| 372 |
+
const target = inspectedId;
|
| 373 |
+
clearTentative(target);
|
| 374 |
+
setInspectedId("");
|
| 375 |
+
onDeletePlay?.(target);
|
| 376 |
+
}, [inspectedId, clearTentative, onDeletePlay]);
|
| 377 |
|
| 378 |
const empty = totalCount === 0;
|
| 379 |
+
const isInspectedTentative = inspectedItem ? tentativeIds.has(inspectedItem.id) : false;
|
| 380 |
|
| 381 |
return html`
|
| 382 |
<div data-testid="plays-view">
|
|
|
|
| 397 |
detailData=${detailQuery.data}
|
| 398 |
detailLoading=${detailQuery.isFetching}
|
| 399 |
similarData=${similarQuery.data}
|
| 400 |
+
onRate=${handleRateInInspector}
|
| 401 |
+
onDirectionToggle=${handleDirectionToggleInInspector}
|
| 402 |
onOpenSimilar=${handleOpenSimilar}
|
| 403 |
onOpenScenarios=${onOpenScenarios}
|
| 404 |
onClose=${() => setInspectedId("")}
|
| 405 |
showImages=${showImages}
|
| 406 |
inspectorRef=${inspectorRef}
|
| 407 |
+
tentative=${isInspectedTentative}
|
| 408 |
+
onUndoTentative=${onDeletePlay ? handleUndoTentative : undefined}
|
| 409 |
/>
|
| 410 |
`
|
| 411 |
: null}
|
|
|
|
| 418 |
? html`<div className="empty">No plays yet. Go to Discover to find things you like.</div>`
|
| 419 |
: html`
|
| 420 |
<${DirectionsColumnBoard}
|
| 421 |
+
board=${filteredBoard}
|
| 422 |
currentPlays=${currentPlays}
|
| 423 |
inspectedId=${inspectedId}
|
| 424 |
onInspect=${setInspectedId}
|
|
|
|
| 433 |
|
| 434 |
<${CollapsibleSection}
|
| 435 |
title="Hidden"
|
| 436 |
+
emoji="🙈"
|
| 437 |
+
items=${filteredBoard.hidden || []}
|
| 438 |
inspectedId=${inspectedId}
|
| 439 |
onInspect=${setInspectedId}
|
| 440 |
nearDupHints=${nearDupHints}
|
|
|
|
| 445 |
/>
|
| 446 |
<${CollapsibleSection}
|
| 447 |
title="No-go"
|
| 448 |
+
emoji="🚫"
|
| 449 |
+
items=${filteredBoard.no_go || []}
|
| 450 |
inspectedId=${inspectedId}
|
| 451 |
onInspect=${setInspectedId}
|
| 452 |
nearDupHints=${nearDupHints}
|
frontend/play-inspector.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from "react";
|
| 2 |
+
import { html, UI_REACTIONS } from "./constants.js";
|
| 3 |
+
import { ReactionBar, DirectionPills } from "./components.js";
|
| 4 |
+
import { uiReactionFor } from "./api.js";
|
| 5 |
+
|
| 6 |
+
export function visibleSimilarItems(similarData) {
|
| 7 |
+
return (similarData?.items || []).filter((item) => item?.kink && !item.kink.is_scenario).slice(0, 6);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function ImageGrid({ assets }) {
|
| 11 |
+
const [revealed, setRevealed] = useState(false);
|
| 12 |
+
const [idx, setIdx] = useState(0);
|
| 13 |
+
const [failedAssetUrls, setFailedAssetUrls] = useState(() => new Set());
|
| 14 |
+
const visibleAssets = assets.filter((asset) => !failedAssetUrls.has(asset.asset_url));
|
| 15 |
+
const n = visibleAssets.length;
|
| 16 |
+
const cur = visibleAssets[idx];
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
setIdx(0);
|
| 20 |
+
setFailedAssetUrls(new Set());
|
| 21 |
+
}, [assets?.length, assets?.[0]?.asset_url]);
|
| 22 |
+
|
| 23 |
+
const go = useCallback((delta, e) => {
|
| 24 |
+
e?.stopPropagation?.();
|
| 25 |
+
if (n < 2) return;
|
| 26 |
+
setIdx((i) => (i + delta + n) % n);
|
| 27 |
+
}, [n]);
|
| 28 |
+
|
| 29 |
+
const markAssetFailed = useCallback((assetUrl) => {
|
| 30 |
+
if (!assetUrl) return;
|
| 31 |
+
setFailedAssetUrls((prev) => {
|
| 32 |
+
if (prev.has(assetUrl)) return prev;
|
| 33 |
+
const next = new Set(prev);
|
| 34 |
+
next.add(assetUrl);
|
| 35 |
+
return next;
|
| 36 |
+
});
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
setIdx((i) => (n ? Math.min(Math.max(0, i), n - 1) : 0));
|
| 41 |
+
}, [n]);
|
| 42 |
+
|
| 43 |
+
if (!n) return null;
|
| 44 |
+
|
| 45 |
+
return html`
|
| 46 |
+
<div className="play-assets">
|
| 47 |
+
${!revealed
|
| 48 |
+
? html`
|
| 49 |
+
<button
|
| 50 |
+
type="button"
|
| 51 |
+
className="ghost sm"
|
| 52 |
+
onClick=${(e) => { e.stopPropagation(); setRevealed(true); }}
|
| 53 |
+
>Show ${n} photo${n > 1 ? "s" : ""}</button>
|
| 54 |
+
`
|
| 55 |
+
: html`
|
| 56 |
+
<div className="play-assets-carousel">
|
| 57 |
+
${n > 1
|
| 58 |
+
? html`
|
| 59 |
+
<button type="button" className="play-edge play-edge--left" aria-label="Previous photo" onClick=${(e) => go(-1, e)} />
|
| 60 |
+
<button type="button" className="play-edge play-edge--right" aria-label="Next photo" onClick=${(e) => go(1, e)} />
|
| 61 |
+
`
|
| 62 |
+
: null}
|
| 63 |
+
<div className="play-assets-slide">
|
| 64 |
+
<img
|
| 65 |
+
loading="eager"
|
| 66 |
+
decoding="async"
|
| 67 |
+
src=${cur.asset_url}
|
| 68 |
+
alt=""
|
| 69 |
+
draggable=${false}
|
| 70 |
+
onError=${() => markAssetFailed(cur.asset_url)}
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
${n > 1
|
| 74 |
+
? html`
|
| 75 |
+
<div className="play-assets-dots" aria-hidden="true">
|
| 76 |
+
${visibleAssets.map((_, i) => html`<span key=${`pd-${i}`} className=${i === idx ? "on" : ""} />`)}
|
| 77 |
+
</div>
|
| 78 |
+
<div className="play-assets-badge">${idx + 1} / ${n}</div>
|
| 79 |
+
`
|
| 80 |
+
: null}
|
| 81 |
+
</div>
|
| 82 |
+
`}
|
| 83 |
+
</div>
|
| 84 |
+
`;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export function PlayInspectorCallout({
|
| 88 |
+
item,
|
| 89 |
+
play,
|
| 90 |
+
detailData,
|
| 91 |
+
detailLoading,
|
| 92 |
+
similarData,
|
| 93 |
+
onRate,
|
| 94 |
+
onDirectionToggle,
|
| 95 |
+
onOpenSimilar,
|
| 96 |
+
onOpenScenarios,
|
| 97 |
+
onClose,
|
| 98 |
+
showImages,
|
| 99 |
+
inspectorRef,
|
| 100 |
+
tentative = false,
|
| 101 |
+
onUndoTentative,
|
| 102 |
+
}) {
|
| 103 |
+
if (!item?.id) return null;
|
| 104 |
+
const reaction = uiReactionFor(play?.interest_state);
|
| 105 |
+
const reactionMeta = UI_REACTIONS.find((entry) => entry.ui === reaction);
|
| 106 |
+
const directions = play?.directions || [];
|
| 107 |
+
const detail = detailData?.id === item.id ? detailData : null;
|
| 108 |
+
const defText = (detail?.definition || detail?.detail_summary || item.summary || "").trim();
|
| 109 |
+
const similarItems = visibleSimilarItems(similarData);
|
| 110 |
+
const scenarioCount = Number(detail?.scenario_child_count || item.scenario_child_count || 0);
|
| 111 |
+
const sectionClass = `plays-inspector card stack${tentative ? " plays-inspector--tentative" : ""}`;
|
| 112 |
+
|
| 113 |
+
return html`
|
| 114 |
+
<section className=${sectionClass} data-testid="plays-inspector" ref=${inspectorRef}>
|
| 115 |
+
<div className="plays-inspector-head">
|
| 116 |
+
<div className="plays-inspector-copy">
|
| 117 |
+
<div className="tiny plays-inspector-label">Selected play</div>
|
| 118 |
+
<div className="plays-inspector-title-row">
|
| 119 |
+
<span className="plays-inspector-emoji">${reactionMeta?.icon || ""}</span>
|
| 120 |
+
<h3 className="plays-inspector-title">${item.name}</h3>
|
| 121 |
+
</div>
|
| 122 |
+
${tentative ? html`
|
| 123 |
+
<span className="tentative-badge" data-testid="plays-inspector-tentative">
|
| 124 |
+
Tentative — added as Curious
|
| 125 |
+
</span>
|
| 126 |
+
` : null}
|
| 127 |
+
</div>
|
| 128 |
+
<div className="plays-inspector-head-actions">
|
| 129 |
+
${tentative && onUndoTentative ? html`
|
| 130 |
+
<button
|
| 131 |
+
type="button"
|
| 132 |
+
className="ghost sm"
|
| 133 |
+
data-testid="plays-inspector-undo"
|
| 134 |
+
onClick=${onUndoTentative}
|
| 135 |
+
>Undo</button>
|
| 136 |
+
` : null}
|
| 137 |
+
<button type="button" className="ghost sm" data-testid="plays-inspector-close" onClick=${onClose}>Close</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
${defText
|
| 141 |
+
? html`<div className="detail-def plays-inspector-definition">${defText}</div>`
|
| 142 |
+
: detailLoading
|
| 143 |
+
? html`<div className="tiny muted">Loading details…</div>`
|
| 144 |
+
: null}
|
| 145 |
+
<div className="plays-inspector-actions">
|
| 146 |
+
<${ReactionBar} current=${reaction} onRate=${(rating) => onRate(item.id, rating)} />
|
| 147 |
+
<${DirectionPills}
|
| 148 |
+
directions=${directions}
|
| 149 |
+
locked=${!reaction}
|
| 150 |
+
onToggle=${(direction) => onDirectionToggle(item.id, direction)}
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
<div className="plays-inspector-actions-row">
|
| 154 |
+
<button
|
| 155 |
+
type="button"
|
| 156 |
+
className="ghost sm"
|
| 157 |
+
data-testid="plays-open-scenarios"
|
| 158 |
+
disabled=${scenarioCount < 1}
|
| 159 |
+
onClick=${() => onOpenScenarios?.(item.id)}
|
| 160 |
+
>${scenarioCount > 0 ? `Explore scenarios (${scenarioCount})` : "No scenarios yet"}</button>
|
| 161 |
+
<button className="ghost sm" onClick=${() => onRate(item.id, "not_interested")}>Remove from list</button>
|
| 162 |
+
</div>
|
| 163 |
+
${similarItems.length
|
| 164 |
+
? html`
|
| 165 |
+
<div className="plays-inspector-section">
|
| 166 |
+
<div className="tiny plays-inspector-label">Similar</div>
|
| 167 |
+
<div className="chips">
|
| 168 |
+
${similarItems.map((entry) => html`
|
| 169 |
+
<button
|
| 170 |
+
key=${entry.kink.id}
|
| 171 |
+
type="button"
|
| 172 |
+
className="chip"
|
| 173 |
+
title=${entry.kink.name}
|
| 174 |
+
onClick=${(e) => {
|
| 175 |
+
e.stopPropagation();
|
| 176 |
+
onOpenSimilar(entry.kink.id, directions);
|
| 177 |
+
}}
|
| 178 |
+
>
|
| 179 |
+
${entry.kink.name}
|
| 180 |
+
</button>
|
| 181 |
+
`)}
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
`
|
| 185 |
+
: null}
|
| 186 |
+
${detail?.is_extreme ? html`<div className="safety-badge extreme">Significant risk</div>` : null}
|
| 187 |
+
${showImages && detail?.assets?.length ? html`<${ImageGrid} assets=${detail.assets} />` : null}
|
| 188 |
+
</section>
|
| 189 |
+
`;
|
| 190 |
+
}
|
frontend/styles.css
CHANGED
|
@@ -836,6 +836,45 @@ button.tinder-card__body.tinder-card__body--tappable {
|
|
| 836 |
scroll-margin-top: 12px;
|
| 837 |
}
|
| 838 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
.plays-inspector-head {
|
| 840 |
display: flex;
|
| 841 |
align-items: flex-start;
|
|
|
|
| 836 |
scroll-margin-top: 12px;
|
| 837 |
}
|
| 838 |
|
| 839 |
+
.plays-inspector--tentative {
|
| 840 |
+
border: 1px dashed var(--accent, #888);
|
| 841 |
+
background: rgba(255, 255, 255, 0.02);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.plays-inspector-head-actions {
|
| 845 |
+
display: flex;
|
| 846 |
+
gap: 8px;
|
| 847 |
+
flex-wrap: wrap;
|
| 848 |
+
align-items: center;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
.tentative-badge {
|
| 852 |
+
display: inline-block;
|
| 853 |
+
margin-top: 4px;
|
| 854 |
+
padding: 2px 8px;
|
| 855 |
+
font-size: 0.72rem;
|
| 856 |
+
border-radius: 999px;
|
| 857 |
+
border: 1px dashed var(--accent, #888);
|
| 858 |
+
color: var(--muted);
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.play-chip--expandable {
|
| 862 |
+
cursor: pointer;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.play-chip--expanded {
|
| 866 |
+
background: rgba(255, 255, 255, 0.04);
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.play-chip-inspector {
|
| 870 |
+
width: 100%;
|
| 871 |
+
margin-top: 8px;
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
.play-chip-inspector--floating {
|
| 875 |
+
margin-top: 12px;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
.plays-inspector-head {
|
| 879 |
display: flex;
|
| 880 |
align-items: flex-start;
|
frontend/together.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
| 1 |
-
import React, { useState, useEffect, useMemo } from "react";
|
| 2 |
import { html, UI_REACTIONS, VIEWS, partnerGroupChipLabel, partnerGroupPartnerIdsTitle } from "./constants.js";
|
| 3 |
import { ReactionBar } from "./components.js";
|
| 4 |
-
import { useGroupsQuery, useOverlapQuery, usePartnerBoardQuery } from "./queries.js";
|
| 5 |
import { uiReactionFor } from "./api.js";
|
| 6 |
import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export function TogetherView({
|
| 9 |
auth,
|
| 10 |
user,
|
| 11 |
currentPlays,
|
| 12 |
onRate,
|
|
|
|
|
|
|
| 13 |
onNavigate,
|
| 14 |
onOpenScenarios,
|
| 15 |
setStatus,
|
|
@@ -254,6 +258,10 @@ export function TogetherView({
|
|
| 254 |
currentPlays=${currentPlays}
|
| 255 |
auth=${auth}
|
| 256 |
participantCount=${participantCount}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
onNavigate=${onNavigate}
|
| 258 |
onOpenScenarios=${onOpenScenarios}
|
| 259 |
/>`
|
|
@@ -295,6 +303,10 @@ function SharedTab({
|
|
| 295 |
currentPlays,
|
| 296 |
auth,
|
| 297 |
participantCount = 2,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
onNavigate,
|
| 299 |
onOpenScenarios,
|
| 300 |
}) {
|
|
@@ -304,6 +316,67 @@ function SharedTab({
|
|
| 304 |
? "No play is shared across everyone in this connection yet."
|
| 305 |
: "No shared plays yet. Both of you need a few overlapping likes first.";
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
if (!matches.length && !scenarioMatches.length) return html`<div className="empty">${emptyHint}</div>`;
|
| 308 |
|
| 309 |
return html`
|
|
@@ -319,20 +392,23 @@ function SharedTab({
|
|
| 319 |
const partnerUi = uiReactionFor(state);
|
| 320 |
return (myReaction === "love" || myReaction === "like") && (partnerUi === "love" || partnerUi === "like");
|
| 321 |
});
|
| 322 |
-
const
|
|
|
|
|
|
|
| 323 |
return html`
|
| 324 |
<div
|
| 325 |
key=${kink.id}
|
| 326 |
-
className="play-chip"
|
| 327 |
data-testid=${`together-shared-direct-${kink.id}`}
|
| 328 |
-
style=${{ width: "100%" }}
|
| 329 |
role="button"
|
| 330 |
tabIndex="0"
|
| 331 |
-
|
|
|
|
| 332 |
onKeyDown=${(e) => {
|
| 333 |
if (e.key !== "Enter" && e.key !== " ") return;
|
| 334 |
e.preventDefault();
|
| 335 |
-
|
| 336 |
}}
|
| 337 |
>
|
| 338 |
<span className="chip-name" style=${{ flex: 1 }}>${kink.name}</span>
|
|
@@ -345,12 +421,66 @@ function SharedTab({
|
|
| 345 |
</span>
|
| 346 |
`)}
|
| 347 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</div>
|
| 349 |
`;
|
| 350 |
})}
|
| 351 |
`
|
| 352 |
: null}
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
${scenarioMatches.length
|
| 355 |
? html`
|
| 356 |
<div className="tiny together-section-label" data-testid="together-shared-scenarios-section">
|
|
@@ -417,7 +547,11 @@ function WorthExploringTab({
|
|
| 417 |
const similar = overlap?.similar_matches || [];
|
| 418 |
|
| 419 |
if (!related.length && !scenarioRelated.length && !similar.length) {
|
| 420 |
-
return html`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
|
| 423 |
return html`
|
|
@@ -565,25 +699,51 @@ function TheirListTab({
|
|
| 565 |
: null;
|
| 566 |
|
| 567 |
if (!boardPartnerId) {
|
| 568 |
-
return html`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
}
|
| 570 |
-
if (isPending) return html`<div>${picker}<div className="empty">Loading…</div></div>`;
|
| 571 |
if (isError) {
|
| 572 |
const forbidden = error?.status === 403;
|
| 573 |
-
return html`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
}
|
| 575 |
-
if (!data) return html`<div>${picker}<div className="empty">Could not load this list.</div></div>`;
|
| 576 |
|
| 577 |
const allPlays = [...(data.to_me || []), ...(data.by_me || []), ...(data.together || [])];
|
| 578 |
const seen = new Set();
|
| 579 |
const deduped = allPlays.filter((play) => {
|
| 580 |
-
if (seen.has(play.id)) return false;
|
| 581 |
seen.add(play.id);
|
| 582 |
return true;
|
| 583 |
});
|
| 584 |
|
| 585 |
if (!deduped.length) {
|
| 586 |
-
return html`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
}
|
| 588 |
|
| 589 |
return html`
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
| 2 |
import { html, UI_REACTIONS, VIEWS, partnerGroupChipLabel, partnerGroupPartnerIdsTitle } from "./constants.js";
|
| 3 |
import { ReactionBar } from "./components.js";
|
| 4 |
+
import { useGroupsQuery, useOverlapQuery, usePartnerBoardQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
|
| 5 |
import { uiReactionFor } from "./api.js";
|
| 6 |
import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
|
| 7 |
+
import { PlayInspectorCallout } from "./play-inspector.js";
|
| 8 |
+
import { openSimilarKinkInline } from "./my-plays-similar.js";
|
| 9 |
|
| 10 |
export function TogetherView({
|
| 11 |
auth,
|
| 12 |
user,
|
| 13 |
currentPlays,
|
| 14 |
onRate,
|
| 15 |
+
onDirectionToggle,
|
| 16 |
+
onDeletePlay,
|
| 17 |
onNavigate,
|
| 18 |
onOpenScenarios,
|
| 19 |
setStatus,
|
|
|
|
| 258 |
currentPlays=${currentPlays}
|
| 259 |
auth=${auth}
|
| 260 |
participantCount=${participantCount}
|
| 261 |
+
showImages=${false}
|
| 262 |
+
onRate=${onRate}
|
| 263 |
+
onDirectionToggle=${onDirectionToggle}
|
| 264 |
+
onDeletePlay=${onDeletePlay}
|
| 265 |
onNavigate=${onNavigate}
|
| 266 |
onOpenScenarios=${onOpenScenarios}
|
| 267 |
/>`
|
|
|
|
| 303 |
currentPlays,
|
| 304 |
auth,
|
| 305 |
participantCount = 2,
|
| 306 |
+
showImages = false,
|
| 307 |
+
onRate,
|
| 308 |
+
onDirectionToggle,
|
| 309 |
+
onDeletePlay,
|
| 310 |
onNavigate,
|
| 311 |
onOpenScenarios,
|
| 312 |
}) {
|
|
|
|
| 316 |
? "No play is shared across everyone in this connection yet."
|
| 317 |
: "No shared plays yet. Both of you need a few overlapping likes first.";
|
| 318 |
|
| 319 |
+
const [expandedKinkId, setExpandedKinkId] = useState("");
|
| 320 |
+
const [tentativeIds, setTentativeIds] = useState(() => new Set());
|
| 321 |
+
const detailQuery = useDetailQuery(expandedKinkId);
|
| 322 |
+
const similarQuery = useSimilarQuery(expandedKinkId);
|
| 323 |
+
|
| 324 |
+
const matchById = useMemo(() => {
|
| 325 |
+
const map = new Map();
|
| 326 |
+
for (const m of matches) {
|
| 327 |
+
if (m?.id) map.set(m.id, m);
|
| 328 |
+
}
|
| 329 |
+
return map;
|
| 330 |
+
}, [matches]);
|
| 331 |
+
const expandedItem = expandedKinkId ? matchById.get(expandedKinkId) || { id: expandedKinkId, name: detailQuery.data?.name || "" } : null;
|
| 332 |
+
|
| 333 |
+
const markTentative = useCallback((id) => {
|
| 334 |
+
setTentativeIds((prev) => {
|
| 335 |
+
if (prev.has(id)) return prev;
|
| 336 |
+
const next = new Set(prev);
|
| 337 |
+
next.add(id);
|
| 338 |
+
return next;
|
| 339 |
+
});
|
| 340 |
+
}, []);
|
| 341 |
+
|
| 342 |
+
const clearTentative = useCallback((id) => {
|
| 343 |
+
setTentativeIds((prev) => {
|
| 344 |
+
if (!prev.has(id)) return prev;
|
| 345 |
+
const next = new Set(prev);
|
| 346 |
+
next.delete(id);
|
| 347 |
+
return next;
|
| 348 |
+
});
|
| 349 |
+
}, []);
|
| 350 |
+
|
| 351 |
+
const handleRateInline = useCallback((kinkId, rating, dirs) => {
|
| 352 |
+
clearTentative(kinkId);
|
| 353 |
+
onRate?.(kinkId, rating, dirs);
|
| 354 |
+
}, [clearTentative, onRate]);
|
| 355 |
+
|
| 356 |
+
const handleDirectionToggleInline = useCallback((kinkId, direction) => {
|
| 357 |
+
clearTentative(kinkId);
|
| 358 |
+
onDirectionToggle?.(kinkId, direction);
|
| 359 |
+
}, [clearTentative, onDirectionToggle]);
|
| 360 |
+
|
| 361 |
+
const handleOpenSimilarInline = useCallback((similarKinkId, dirs) => {
|
| 362 |
+
openSimilarKinkInline({
|
| 363 |
+
similarKinkId,
|
| 364 |
+
parentDirections: dirs,
|
| 365 |
+
currentPlays,
|
| 366 |
+
onRate,
|
| 367 |
+
onInspect: setExpandedKinkId,
|
| 368 |
+
onMarkTentative: markTentative,
|
| 369 |
+
});
|
| 370 |
+
}, [currentPlays, onRate, markTentative]);
|
| 371 |
+
|
| 372 |
+
const handleUndoInline = useCallback(() => {
|
| 373 |
+
if (!expandedKinkId) return;
|
| 374 |
+
const target = expandedKinkId;
|
| 375 |
+
clearTentative(target);
|
| 376 |
+
setExpandedKinkId("");
|
| 377 |
+
onDeletePlay?.(target);
|
| 378 |
+
}, [expandedKinkId, clearTentative, onDeletePlay]);
|
| 379 |
+
|
| 380 |
if (!matches.length && !scenarioMatches.length) return html`<div className="empty">${emptyHint}</div>`;
|
| 381 |
|
| 382 |
return html`
|
|
|
|
| 392 |
const partnerUi = uiReactionFor(state);
|
| 393 |
return (myReaction === "love" || myReaction === "like") && (partnerUi === "love" || partnerUi === "like");
|
| 394 |
});
|
| 395 |
+
const expanded = expandedKinkId === kink.id;
|
| 396 |
+
const toggle = () => setExpandedKinkId((prev) => (prev === kink.id ? "" : kink.id));
|
| 397 |
+
const isTentative = tentativeIds.has(kink.id);
|
| 398 |
return html`
|
| 399 |
<div
|
| 400 |
key=${kink.id}
|
| 401 |
+
className=${`play-chip play-chip--expandable${expanded ? " play-chip--expanded" : ""}`}
|
| 402 |
data-testid=${`together-shared-direct-${kink.id}`}
|
| 403 |
+
style=${{ width: "100%", flexWrap: "wrap" }}
|
| 404 |
role="button"
|
| 405 |
tabIndex="0"
|
| 406 |
+
aria-expanded=${expanded}
|
| 407 |
+
onClick=${toggle}
|
| 408 |
onKeyDown=${(e) => {
|
| 409 |
if (e.key !== "Enter" && e.key !== " ") return;
|
| 410 |
e.preventDefault();
|
| 411 |
+
toggle();
|
| 412 |
}}
|
| 413 |
>
|
| 414 |
<span className="chip-name" style=${{ flex: 1 }}>${kink.name}</span>
|
|
|
|
| 421 |
</span>
|
| 422 |
`)}
|
| 423 |
</span>
|
| 424 |
+
${expanded
|
| 425 |
+
? html`
|
| 426 |
+
<div
|
| 427 |
+
className="play-chip-inspector"
|
| 428 |
+
data-testid=${`together-shared-direct-inspector-${kink.id}`}
|
| 429 |
+
style=${{ width: "100%" }}
|
| 430 |
+
onClick=${(e) => e.stopPropagation()}
|
| 431 |
+
onKeyDown=${(e) => e.stopPropagation()}
|
| 432 |
+
>
|
| 433 |
+
<${PlayInspectorCallout}
|
| 434 |
+
item=${expandedItem || kink}
|
| 435 |
+
play=${currentPlays[kink.id] || {}}
|
| 436 |
+
detailData=${detailQuery.data}
|
| 437 |
+
detailLoading=${detailQuery.isFetching}
|
| 438 |
+
similarData=${similarQuery.data}
|
| 439 |
+
onRate=${handleRateInline}
|
| 440 |
+
onDirectionToggle=${handleDirectionToggleInline}
|
| 441 |
+
onOpenSimilar=${handleOpenSimilarInline}
|
| 442 |
+
onOpenScenarios=${onOpenScenarios}
|
| 443 |
+
onClose=${() => setExpandedKinkId("")}
|
| 444 |
+
showImages=${showImages}
|
| 445 |
+
tentative=${isTentative}
|
| 446 |
+
onUndoTentative=${onDeletePlay ? handleUndoInline : undefined}
|
| 447 |
+
/>
|
| 448 |
+
</div>
|
| 449 |
+
`
|
| 450 |
+
: null}
|
| 451 |
</div>
|
| 452 |
`;
|
| 453 |
})}
|
| 454 |
`
|
| 455 |
: null}
|
| 456 |
|
| 457 |
+
${expandedKinkId && !matchById.has(expandedKinkId)
|
| 458 |
+
? html`
|
| 459 |
+
<div
|
| 460 |
+
className="play-chip-inspector play-chip-inspector--floating"
|
| 461 |
+
data-testid=${`together-shared-direct-inspector-${expandedKinkId}`}
|
| 462 |
+
onClick=${(e) => e.stopPropagation()}
|
| 463 |
+
onKeyDown=${(e) => e.stopPropagation()}
|
| 464 |
+
>
|
| 465 |
+
<${PlayInspectorCallout}
|
| 466 |
+
item=${expandedItem || { id: expandedKinkId, name: detailQuery.data?.name || "" }}
|
| 467 |
+
play=${currentPlays[expandedKinkId] || {}}
|
| 468 |
+
detailData=${detailQuery.data}
|
| 469 |
+
detailLoading=${detailQuery.isFetching}
|
| 470 |
+
similarData=${similarQuery.data}
|
| 471 |
+
onRate=${handleRateInline}
|
| 472 |
+
onDirectionToggle=${handleDirectionToggleInline}
|
| 473 |
+
onOpenSimilar=${handleOpenSimilarInline}
|
| 474 |
+
onOpenScenarios=${onOpenScenarios}
|
| 475 |
+
onClose=${() => setExpandedKinkId("")}
|
| 476 |
+
showImages=${showImages}
|
| 477 |
+
tentative=${tentativeIds.has(expandedKinkId)}
|
| 478 |
+
onUndoTentative=${onDeletePlay ? handleUndoInline : undefined}
|
| 479 |
+
/>
|
| 480 |
+
</div>
|
| 481 |
+
`
|
| 482 |
+
: null}
|
| 483 |
+
|
| 484 |
${scenarioMatches.length
|
| 485 |
? html`
|
| 486 |
<div className="tiny together-section-label" data-testid="together-shared-scenarios-section">
|
|
|
|
| 547 |
const similar = overlap?.similar_matches || [];
|
| 548 |
|
| 549 |
if (!related.length && !scenarioRelated.length && !similar.length) {
|
| 550 |
+
return html`
|
| 551 |
+
<div className="empty" data-testid="together-explore-empty">
|
| 552 |
+
No nearby ideas yet. <strong>Rate more in Discover</strong>, or ask your partner to share theirs in Together.
|
| 553 |
+
</div>
|
| 554 |
+
`;
|
| 555 |
}
|
| 556 |
|
| 557 |
return html`
|
|
|
|
| 699 |
: null;
|
| 700 |
|
| 701 |
if (!boardPartnerId) {
|
| 702 |
+
return html`
|
| 703 |
+
<div data-testid="together-their-list-panel">
|
| 704 |
+
${picker}
|
| 705 |
+
<div className="empty">
|
| 706 |
+
No partner has turned on full-list sharing for this connection yet.
|
| 707 |
+
<div className="tiny muted">See the <strong>Share link</strong> above to invite or remind them.</div>
|
| 708 |
+
</div>
|
| 709 |
+
</div>
|
| 710 |
+
`;
|
| 711 |
+
}
|
| 712 |
+
if (isPending) {
|
| 713 |
+
return html`<div data-testid="together-their-list-panel">${picker}<div className="empty">Loading…</div></div>`;
|
| 714 |
}
|
|
|
|
| 715 |
if (isError) {
|
| 716 |
const forbidden = error?.status === 403;
|
| 717 |
+
return html`
|
| 718 |
+
<div data-testid="together-their-list-panel">
|
| 719 |
+
${picker}
|
| 720 |
+
<div className="empty">
|
| 721 |
+
${forbidden
|
| 722 |
+
? html`This partner has not turned on sharing yet. Ask <code>${boardPartnerId.slice(0, 12)}…</code> to enable it from their Together → Privacy.`
|
| 723 |
+
: (error?.message || "Could not load this list.")}
|
| 724 |
+
</div>
|
| 725 |
+
</div>
|
| 726 |
+
`;
|
| 727 |
+
}
|
| 728 |
+
if (!data) {
|
| 729 |
+
return html`<div data-testid="together-their-list-panel">${picker}<div className="empty">Could not load this list.</div></div>`;
|
| 730 |
}
|
|
|
|
| 731 |
|
| 732 |
const allPlays = [...(data.to_me || []), ...(data.by_me || []), ...(data.together || [])];
|
| 733 |
const seen = new Set();
|
| 734 |
const deduped = allPlays.filter((play) => {
|
| 735 |
+
if (seen.has(play.id) || play?.is_scenario) return false;
|
| 736 |
seen.add(play.id);
|
| 737 |
return true;
|
| 738 |
});
|
| 739 |
|
| 740 |
if (!deduped.length) {
|
| 741 |
+
return html`
|
| 742 |
+
<div data-testid="together-their-list-panel">
|
| 743 |
+
${picker}
|
| 744 |
+
<div className="empty">This shared list is empty. Ask them to rate a few in Discover.</div>
|
| 745 |
+
</div>
|
| 746 |
+
`;
|
| 747 |
}
|
| 748 |
|
| 749 |
return html`
|
scripts/playwright_product_flow.py
CHANGED
|
@@ -240,8 +240,13 @@ def create_profile_in_browser(page: Page, *, path: str = "") -> Credentials:
|
|
| 240 |
expect(create_btn).to_be_visible(timeout=min(30000, t_onboard))
|
| 241 |
create_btn.click()
|
| 242 |
expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=t_onboard)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
creds = Credentials(
|
| 244 |
-
user_id=
|
| 245 |
private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
|
| 246 |
)
|
| 247 |
page.get_by_test_id("onboarding-start-starter").click()
|
|
@@ -763,11 +768,323 @@ def scenario_couple_audit(browser: Browser) -> dict[str, object]:
|
|
| 763 |
right_context.close()
|
| 764 |
|
| 765 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
|
| 767 |
"onboarding": lambda browser: _run_in_fresh_context(browser, scenario_onboarding_create_button),
|
| 768 |
"starter": lambda browser: _run_in_fresh_context(browser, scenario_starter_gate),
|
| 769 |
"settings": lambda browser: _run_in_fresh_context(browser, scenario_settings_controls),
|
| 770 |
"couple": scenario_couple_audit,
|
|
|
|
| 771 |
}
|
| 772 |
|
| 773 |
|
|
|
|
| 240 |
expect(create_btn).to_be_visible(timeout=min(30000, t_onboard))
|
| 241 |
create_btn.click()
|
| 242 |
expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=t_onboard)
|
| 243 |
+
user_id = page.get_by_test_id("onboarding-created-user-id").inner_text().strip()
|
| 244 |
+
reveal_btn = page.get_by_test_id("onboarding-reveal-token")
|
| 245 |
+
if reveal_btn.count():
|
| 246 |
+
reveal_btn.click()
|
| 247 |
+
expect(page.get_by_test_id("onboarding-created-private-token")).to_be_visible(timeout=15000)
|
| 248 |
creds = Credentials(
|
| 249 |
+
user_id=user_id,
|
| 250 |
private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
|
| 251 |
)
|
| 252 |
page.get_by_test_id("onboarding-start-starter").click()
|
|
|
|
| 768 |
right_context.close()
|
| 769 |
|
| 770 |
|
| 771 |
+
def scenario_couple_adversarial(browser: Browser) -> dict[str, object]:
|
| 772 |
+
"""Couple flow with many kinks, conflicts, and the Same-Plays / My-Plays similar-tentative-add path.
|
| 773 |
+
|
| 774 |
+
Adds adversarial coverage on top of ``scenario_couple_audit``:
|
| 775 |
+
* both partners seeded past 80 likes (recommender stress + bigger overlap pool)
|
| 776 |
+
* curated love/love overlap of ~9 known-name kinks → asserted in Together > Shared
|
| 777 |
+
* one love(left)/hard_no(right) conflict → must be ABSENT from Same Plays
|
| 778 |
+
* clicking a Same Plays chip lands on the My Plays inspector for that kink
|
| 779 |
+
* clicking a 'Similar' chip in My Plays tentatively adds the similar kink
|
| 780 |
+
as ``curious`` with the parent's directions, then opens it in the inspector
|
| 781 |
+
* ``visibleSimilarItems`` invariant: no scenario kinks in the Similar chip set
|
| 782 |
+
"""
|
| 783 |
+
overlap_targets = [
|
| 784 |
+
("kissing", "Kissing"),
|
| 785 |
+
("massage", "Massage"),
|
| 786 |
+
("oral sex", "Oral Sex"),
|
| 787 |
+
("anal", "Anal"),
|
| 788 |
+
("spanking", "Spanking"),
|
| 789 |
+
("blindfolds", "Blindfolds"),
|
| 790 |
+
("role play", "Role Play"),
|
| 791 |
+
("hair pulling", "Hair Pulling"),
|
| 792 |
+
("cuddling", "Cuddling"),
|
| 793 |
+
]
|
| 794 |
+
conflict_candidates = [("biting", "Biting"), ("hickeys", "Hickeys"), ("tickling", "Tickling")]
|
| 795 |
+
right_only_options = [("foot worship", "Foot Worship"), ("lingerie", "Lingerie"), ("rope bondage", "Rope Bondage")]
|
| 796 |
+
|
| 797 |
+
left = create_user()
|
| 798 |
+
right = create_user()
|
| 799 |
+
seed_user_with_recommendations(left, 80)
|
| 800 |
+
seed_user_with_recommendations(right, 80)
|
| 801 |
+
|
| 802 |
+
overlap_pairs: list[tuple[str, str]] = []
|
| 803 |
+
for query, expected in overlap_targets:
|
| 804 |
+
try:
|
| 805 |
+
kink_id = find_kink_id(query, expected)
|
| 806 |
+
except AssertionError:
|
| 807 |
+
continue
|
| 808 |
+
save_play(left, kink_id, "love", ["together"])
|
| 809 |
+
save_play(right, kink_id, "love", ["together"])
|
| 810 |
+
overlap_pairs.append((kink_id, expected))
|
| 811 |
+
if len(overlap_pairs) < 5:
|
| 812 |
+
raise AssertionError(f"Could not seed enough overlap kinks: only {len(overlap_pairs)} matched")
|
| 813 |
+
|
| 814 |
+
overlap_id_set = {kid for kid, _ in overlap_pairs}
|
| 815 |
+
conflict_pair: tuple[str, str] | None = None
|
| 816 |
+
for query, expected in conflict_candidates:
|
| 817 |
+
try:
|
| 818 |
+
kink_id = find_kink_id(query, expected)
|
| 819 |
+
except AssertionError:
|
| 820 |
+
continue
|
| 821 |
+
if kink_id in overlap_id_set:
|
| 822 |
+
continue
|
| 823 |
+
save_play(left, kink_id, "love", ["together"])
|
| 824 |
+
save_play(right, kink_id, "hard_no", [])
|
| 825 |
+
conflict_pair = (kink_id, expected)
|
| 826 |
+
break
|
| 827 |
+
if conflict_pair is None:
|
| 828 |
+
raise AssertionError("Could not seed a love/hard_no conflict pair (no candidate matched the catalog)")
|
| 829 |
+
|
| 830 |
+
right_only_pair: tuple[str, str] | None = None
|
| 831 |
+
for query, expected in right_only_options:
|
| 832 |
+
try:
|
| 833 |
+
kink_id = find_kink_id(query, expected)
|
| 834 |
+
except AssertionError:
|
| 835 |
+
continue
|
| 836 |
+
if kink_id in overlap_id_set or kink_id == conflict_pair[0]:
|
| 837 |
+
continue
|
| 838 |
+
ensure_play_absent(left, kink_id)
|
| 839 |
+
save_play(right, kink_id, "love", ["together"])
|
| 840 |
+
right_only_pair = (kink_id, expected)
|
| 841 |
+
break
|
| 842 |
+
if right_only_pair is None:
|
| 843 |
+
raise AssertionError("Could not seed a right-only kink for Their List coverage")
|
| 844 |
+
|
| 845 |
+
parent_id, parent_name, scenario_a, scenario_b = find_parent_with_scenario_pair()
|
| 846 |
+
save_play(left, parent_id, "like", ["together"])
|
| 847 |
+
save_play(right, parent_id, "like", ["together"])
|
| 848 |
+
save_scenario(left, parent_id, scenario_a, "like", ["together"])
|
| 849 |
+
save_scenario(right, parent_id, scenario_a, "like", ["together"])
|
| 850 |
+
save_scenario(right, parent_id, scenario_b, "like", ["together"])
|
| 851 |
+
|
| 852 |
+
send_partner_request(right, left.user_id)
|
| 853 |
+
wait_for(
|
| 854 |
+
lambda: right.user_id in get_user(left).get("incoming_partner_requests", []),
|
| 855 |
+
label="left incoming partner request",
|
| 856 |
+
)
|
| 857 |
+
accept_partner_request(left, right.user_id)
|
| 858 |
+
wait_for(
|
| 859 |
+
lambda: right.user_id in get_user(left).get("partners", []),
|
| 860 |
+
label="linked partners",
|
| 861 |
+
)
|
| 862 |
+
group_id = group_id_for_pair(left, right.user_id)
|
| 863 |
+
toggle_share(right, group_id, True)
|
| 864 |
+
wait_for(
|
| 865 |
+
lambda: right.user_id in next((g for g in list_groups(left) if g["id"] == group_id), {}).get("sharing_member_ids", []),
|
| 866 |
+
label="right sharing toggle",
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
overlap_path = f"/users/{left.user_id}/partner-groups/{urllib.parse.quote(group_id)}/overlap"
|
| 870 |
+
wait_for(
|
| 871 |
+
lambda: {item.get("id") for item in get_json(overlap_path, headers=auth_headers(left)).get("direct_matches", [])} >= overlap_id_set,
|
| 872 |
+
timeout_s=45.0,
|
| 873 |
+
label="all curated overlap kinks visible in Same Plays",
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
overlap_payload = get_json(overlap_path, headers=auth_headers(left))
|
| 877 |
+
direct_ids = {item.get("id") for item in overlap_payload.get("direct_matches", [])}
|
| 878 |
+
missing_overlap = sorted(overlap_id_set - direct_ids)
|
| 879 |
+
if missing_overlap:
|
| 880 |
+
raise AssertionError(f"Same Plays missing curated kinks: {missing_overlap}")
|
| 881 |
+
if conflict_pair[0] in direct_ids:
|
| 882 |
+
raise AssertionError(f"Conflict (love/hard_no) kink {conflict_pair[1]!r} leaked into Same Plays")
|
| 883 |
+
scenario_match_ids = {
|
| 884 |
+
item.get("scenario_kink", {}).get("id") for item in overlap_payload.get("scenario_matches", [])
|
| 885 |
+
}
|
| 886 |
+
if scenario_a not in scenario_match_ids:
|
| 887 |
+
raise AssertionError("Shared scenario A missing from scenario_matches")
|
| 888 |
+
|
| 889 |
+
checkpoints: list[dict[str, object]] = [
|
| 890 |
+
{
|
| 891 |
+
"stage": "seed_and_overlap",
|
| 892 |
+
"expected": "both users seeded past 80 likes; curated overlap appears in Same Plays; love/hard_no conflict excluded",
|
| 893 |
+
"actual": {
|
| 894 |
+
"left_play_count": len(get_user(left).get("plays", {})),
|
| 895 |
+
"right_play_count": len(get_user(right).get("plays", {})),
|
| 896 |
+
"overlap_count_api": len(direct_ids),
|
| 897 |
+
"curated_overlap": [name for _, name in overlap_pairs],
|
| 898 |
+
"conflict_excluded": conflict_pair[1],
|
| 899 |
+
"shared_scenario": scenario_a,
|
| 900 |
+
},
|
| 901 |
+
"summary": "API overlap matches curated love/love and excludes love/hard_no conflict",
|
| 902 |
+
}
|
| 903 |
+
]
|
| 904 |
+
|
| 905 |
+
left_context = browser.new_context(
|
| 906 |
+
viewport={"width": 1280, "height": 720},
|
| 907 |
+
extra_http_headers={"Cache-Control": "no-cache"},
|
| 908 |
+
)
|
| 909 |
+
page = left_context.new_page()
|
| 910 |
+
try:
|
| 911 |
+
login_existing_user(page, left)
|
| 912 |
+
expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=15000)
|
| 913 |
+
open_together(page)
|
| 914 |
+
expect(page.get_by_test_id(f"together-group-chip-{group_id}")).to_be_visible(timeout=15000)
|
| 915 |
+
page.get_by_test_id(f"together-group-chip-{group_id}").click()
|
| 916 |
+
expect(page.get_by_test_id("together-shared-panel")).to_be_visible(timeout=15000)
|
| 917 |
+
expect(page.get_by_test_id("together-shared-direct-section")).to_be_visible(timeout=15000)
|
| 918 |
+
|
| 919 |
+
for kink_id, name in overlap_pairs:
|
| 920 |
+
expect(page.get_by_test_id(f"together-shared-direct-{kink_id}")).to_be_visible(timeout=20000)
|
| 921 |
+
if page.get_by_test_id(f"together-shared-direct-{conflict_pair[0]}").count():
|
| 922 |
+
raise AssertionError(f"Conflict chip rendered in Same Plays for {conflict_pair[1]!r}")
|
| 923 |
+
expect(page.get_by_test_id(f"together-shared-scenario-{scenario_a}")).to_be_visible(timeout=15000)
|
| 924 |
+
checkpoints.append(
|
| 925 |
+
{
|
| 926 |
+
"stage": "shared_panel_render",
|
| 927 |
+
"expected": "every curated overlap kink renders as a Same Plays chip; conflict chip absent; shared scenario chip visible",
|
| 928 |
+
"actual": {
|
| 929 |
+
"overlap_rendered": [name for _, name in overlap_pairs],
|
| 930 |
+
"conflict_absent": conflict_pair[1],
|
| 931 |
+
"scenario_visible": scenario_a,
|
| 932 |
+
},
|
| 933 |
+
"summary": "Same Plays UI matches API overlap; love/hard_no conflict not shown",
|
| 934 |
+
}
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
focus_kink_id, focus_kink_name = overlap_pairs[0]
|
| 938 |
+
page.get_by_test_id(f"together-shared-direct-{focus_kink_id}").click()
|
| 939 |
+
expect(page.get_by_test_id("together-view")).to_be_visible(timeout=15000)
|
| 940 |
+
inline_inspector = page.get_by_test_id(f"together-shared-direct-inspector-{focus_kink_id}")
|
| 941 |
+
expect(inline_inspector).to_be_visible(timeout=15000)
|
| 942 |
+
expect(inline_inspector.locator(".plays-inspector-title")).to_have_text(focus_kink_name, timeout=15000)
|
| 943 |
+
if page.get_by_test_id("plays-view").count():
|
| 944 |
+
raise AssertionError("Same Plays chip should expand inline, not route to plays-view")
|
| 945 |
+
checkpoints.append(
|
| 946 |
+
{
|
| 947 |
+
"stage": "same_plays_click_expands_inline",
|
| 948 |
+
"expected": "clicking a Same Plays chip expands the inspector inline inside Together → Shared (no view switch)",
|
| 949 |
+
"actual": {"focused_kink_id": focus_kink_id, "focused_kink_name": focus_kink_name},
|
| 950 |
+
"summary": "Same Plays chip expanded inline with the inspector body; stayed on Together",
|
| 951 |
+
}
|
| 952 |
+
)
|
| 953 |
+
|
| 954 |
+
similar_chips = inline_inspector.locator(".chips button.chip")
|
| 955 |
+
try:
|
| 956 |
+
similar_chips.first.wait_for(state="visible", timeout=20000)
|
| 957 |
+
except Exception as exc:
|
| 958 |
+
raise AssertionError(f"No Similar chips appeared inline for {focus_kink_name!r}: {exc}") from exc
|
| 959 |
+
chip_count = similar_chips.count()
|
| 960 |
+
chip_labels = [similar_chips.nth(i).inner_text().strip() for i in range(chip_count)]
|
| 961 |
+
first_similar_label = chip_labels[0]
|
| 962 |
+
|
| 963 |
+
plays_before = set(get_user(left).get("plays", {}).keys())
|
| 964 |
+
with page.expect_response(
|
| 965 |
+
lambda res: res.request.method == "POST" and "/plays" in res.url,
|
| 966 |
+
timeout=15000,
|
| 967 |
+
) as similar_resp:
|
| 968 |
+
similar_chips.first.click()
|
| 969 |
+
body = similar_resp.value
|
| 970 |
+
if not body.ok:
|
| 971 |
+
raise AssertionError(f"Tentative add POST /plays failed: status={body.status}")
|
| 972 |
+
|
| 973 |
+
wait_for(
|
| 974 |
+
lambda: bool(set(get_user(left).get("plays", {}).keys()) - plays_before),
|
| 975 |
+
timeout_s=15.0,
|
| 976 |
+
label="tentative add visible in user plays",
|
| 977 |
+
)
|
| 978 |
+
plays_after = get_user(left).get("plays", {})
|
| 979 |
+
new_ids = sorted(set(plays_after.keys()) - plays_before)
|
| 980 |
+
if len(new_ids) != 1:
|
| 981 |
+
raise AssertionError(f"Expected exactly one new play after similar click, got {new_ids}")
|
| 982 |
+
new_kink_id = new_ids[0]
|
| 983 |
+
new_play = plays_after[new_kink_id]
|
| 984 |
+
if new_play.get("interest_state") != "curious":
|
| 985 |
+
raise AssertionError(f"Tentative add wrong state: {new_play.get('interest_state')!r}")
|
| 986 |
+
if "together" not in (new_play.get("directions") or []):
|
| 987 |
+
raise AssertionError(f"Tentative add did not inherit parent direction 'together': {new_play.get('directions')}")
|
| 988 |
+
|
| 989 |
+
new_kink_detail = get_json(f"/kinks/{urllib.parse.quote(new_kink_id)}")
|
| 990 |
+
if new_kink_detail.get("is_scenario"):
|
| 991 |
+
raise AssertionError("Similar chip surfaced a scenario kink (visibleSimilarItems should filter is_scenario)")
|
| 992 |
+
|
| 993 |
+
# The chip is now expanded on the new kink; expect title to update inline.
|
| 994 |
+
new_inline_inspector = page.get_by_test_id(f"together-shared-direct-inspector-{new_kink_id}")
|
| 995 |
+
expect(new_inline_inspector).to_be_visible(timeout=15000)
|
| 996 |
+
expect(new_inline_inspector.locator(".plays-inspector-title")).to_have_text(
|
| 997 |
+
new_kink_detail.get("name", first_similar_label), timeout=15000
|
| 998 |
+
)
|
| 999 |
+
expect(new_inline_inspector.get_by_test_id("plays-inspector-tentative")).to_be_visible(timeout=10000)
|
| 1000 |
+
|
| 1001 |
+
scenario_chips = [label for label in chip_labels if label.lower().startswith("scenario:")]
|
| 1002 |
+
if scenario_chips:
|
| 1003 |
+
raise AssertionError(f"Similar chip set contained scenario-prefixed entries: {scenario_chips}")
|
| 1004 |
+
|
| 1005 |
+
checkpoints.append(
|
| 1006 |
+
{
|
| 1007 |
+
"stage": "shared_inline_similar_tentative_add",
|
| 1008 |
+
"expected": "clicking a Similar chip on the inline Same-Plays inspector POSTs /plays as 'curious' with parent directions, re-inspects the new kink inline, and shows a Tentative badge",
|
| 1009 |
+
"actual": {
|
| 1010 |
+
"parent_kink_id": focus_kink_id,
|
| 1011 |
+
"parent_kink_name": focus_kink_name,
|
| 1012 |
+
"similar_chip_count": chip_count,
|
| 1013 |
+
"tentative_kink_id": new_kink_id,
|
| 1014 |
+
"tentative_kink_name": new_kink_detail.get("name"),
|
| 1015 |
+
"tentative_state": new_play.get("interest_state"),
|
| 1016 |
+
"tentative_directions": new_play.get("directions"),
|
| 1017 |
+
"similar_is_scenario": bool(new_kink_detail.get("is_scenario")),
|
| 1018 |
+
"chip_labels_sample": chip_labels[:6],
|
| 1019 |
+
},
|
| 1020 |
+
"summary": "Similar-chip click tentatively saved as curious/together inline and showed the Tentative badge",
|
| 1021 |
+
}
|
| 1022 |
+
)
|
| 1023 |
+
|
| 1024 |
+
# Undo the tentative add via the new Undo button; the kink should leave the user's plays.
|
| 1025 |
+
undo_btn = new_inline_inspector.get_by_test_id("plays-inspector-undo")
|
| 1026 |
+
expect(undo_btn).to_be_visible(timeout=5000)
|
| 1027 |
+
with page.expect_response(
|
| 1028 |
+
lambda res: res.request.method == "DELETE" and f"/plays/{urllib.parse.quote(new_kink_id)}" in res.url,
|
| 1029 |
+
timeout=15000,
|
| 1030 |
+
) as delete_resp:
|
| 1031 |
+
undo_btn.click()
|
| 1032 |
+
if not delete_resp.value.ok:
|
| 1033 |
+
raise AssertionError(f"Undo DELETE /plays failed: status={delete_resp.value.status}")
|
| 1034 |
+
wait_for(
|
| 1035 |
+
lambda: new_kink_id not in get_user(left).get("plays", {}),
|
| 1036 |
+
timeout_s=15.0,
|
| 1037 |
+
label="undo removes tentative play",
|
| 1038 |
+
)
|
| 1039 |
+
checkpoints.append(
|
| 1040 |
+
{
|
| 1041 |
+
"stage": "shared_inline_undo_tentative",
|
| 1042 |
+
"expected": "Undo on a tentative add removes the play row via DELETE /plays",
|
| 1043 |
+
"actual": {"undone_kink_id": new_kink_id, "undone_kink_name": new_kink_detail.get("name")},
|
| 1044 |
+
"summary": "Undo button removed the tentative play; user state matches pre-click",
|
| 1045 |
+
}
|
| 1046 |
+
)
|
| 1047 |
+
|
| 1048 |
+
open_together(page)
|
| 1049 |
+
page.get_by_test_id("together-tab-explore").click()
|
| 1050 |
+
expect(page.get_by_test_id("together-explore-panel")).to_be_visible(timeout=15000)
|
| 1051 |
+
page.get_by_test_id("together-tab-theirs").click()
|
| 1052 |
+
expect(page.get_by_test_id("together-their-list-panel")).to_be_visible(timeout=15000)
|
| 1053 |
+
expect(page.get_by_text(right_only_pair[1])).to_be_visible(timeout=15000)
|
| 1054 |
+
checkpoints.append(
|
| 1055 |
+
{
|
| 1056 |
+
"stage": "explore_and_theirs_render",
|
| 1057 |
+
"expected": "Worth Exploring + Their List populate; right-only kink visible after sharing",
|
| 1058 |
+
"actual": {"right_only_visible": right_only_pair[1]},
|
| 1059 |
+
"summary": "Explore and Their List subtabs both render and surface the partner-only item",
|
| 1060 |
+
}
|
| 1061 |
+
)
|
| 1062 |
+
|
| 1063 |
+
return {
|
| 1064 |
+
"summary": "adversarial couple flow: many likes, curated overlap, conflict suppression, Same-Plays inline expand, Similar tentative-add + Undo, and Worth Exploring / Their List all behaved as expected",
|
| 1065 |
+
"left_user_id": left.user_id,
|
| 1066 |
+
"right_user_id": right.user_id,
|
| 1067 |
+
"group_id": group_id,
|
| 1068 |
+
"left_play_count_final": len(plays_after),
|
| 1069 |
+
"right_play_count_final": len(get_user(right).get("plays", {})),
|
| 1070 |
+
"overlap_kinks": [name for _, name in overlap_pairs],
|
| 1071 |
+
"conflict_kink": conflict_pair[1],
|
| 1072 |
+
"right_only_kink": right_only_pair[1],
|
| 1073 |
+
"shared_scenario_id": scenario_a,
|
| 1074 |
+
"tentative_kink": new_kink_detail.get("name"),
|
| 1075 |
+
"tentative_kink_id": new_kink_id,
|
| 1076 |
+
"checkpoints": checkpoints,
|
| 1077 |
+
}
|
| 1078 |
+
finally:
|
| 1079 |
+
left_context.close()
|
| 1080 |
+
|
| 1081 |
+
|
| 1082 |
SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
|
| 1083 |
"onboarding": lambda browser: _run_in_fresh_context(browser, scenario_onboarding_create_button),
|
| 1084 |
"starter": lambda browser: _run_in_fresh_context(browser, scenario_starter_gate),
|
| 1085 |
"settings": lambda browser: _run_in_fresh_context(browser, scenario_settings_controls),
|
| 1086 |
"couple": scenario_couple_audit,
|
| 1087 |
+
"couple_adversarial": scenario_couple_adversarial,
|
| 1088 |
}
|
| 1089 |
|
| 1090 |
|
tests/test_discover_recommendations_invariant.py
CHANGED
|
@@ -10,7 +10,7 @@ import pytest
|
|
| 10 |
from fastapi.testclient import TestClient
|
| 11 |
from sqlmodel import Session
|
| 12 |
|
| 13 |
-
from models import Kink, KinkContentType, SimilarityEdge
|
| 14 |
|
| 15 |
|
| 16 |
def _seed_large_play_catalog(session: Session, *, n_kinks: int = 80) -> None:
|
|
@@ -71,6 +71,46 @@ def test_recommend_non_empty_after_47_distinct_ratings(big_catalog_backend):
|
|
| 71 |
assert out_ids.isdisjoint(rated), "recommendations must not repeat already-rated kinks"
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def test_http_recommendations_non_empty_after_47_ratings(
|
| 75 |
monkeypatch: pytest.MonkeyPatch,
|
| 76 |
big_catalog_backend,
|
|
|
|
| 10 |
from fastapi.testclient import TestClient
|
| 11 |
from sqlmodel import Session
|
| 12 |
|
| 13 |
+
from models import Kink, KinkContentType, KinkScenarioParent, SimilarityEdge
|
| 14 |
|
| 15 |
|
| 16 |
def _seed_large_play_catalog(session: Session, *, n_kinks: int = 80) -> None:
|
|
|
|
| 71 |
assert out_ids.isdisjoint(rated), "recommendations must not repeat already-rated kinks"
|
| 72 |
|
| 73 |
|
| 74 |
+
def _seed_scenario_under_parent(session: Session, *, parent_id: str, scenario_id: str) -> None:
|
| 75 |
+
session.add(Kink(id=parent_id, name="Parent Play", cluster="fetlife_fetish", short_definition="parent", notes="Kinksters: 1000"))
|
| 76 |
+
session.add(KinkContentType(kink_id=parent_id, content_kind="play", evidence="test"))
|
| 77 |
+
session.add(Kink(id=scenario_id, name="Parent Play scenario", cluster="fetlife_fetish", short_definition="child", notes="Kinksters: 100"))
|
| 78 |
+
session.add(KinkContentType(kink_id=scenario_id, content_kind="scenario", evidence="test"))
|
| 79 |
+
session.add(KinkScenarioParent(scenario_kink_id=scenario_id, parent_kink_id=parent_id, score=0.9, method="test"))
|
| 80 |
+
session.commit()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_recommendations_and_search_exclude_scenarios(big_catalog_backend, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 84 |
+
"""Flat recommendations and the public search must never surface scenario kinks."""
|
| 85 |
+
b = big_catalog_backend
|
| 86 |
+
parent_id, scenario_id = "scenario_parent_1", "scenario_child_1"
|
| 87 |
+
with Session(b.engine) as session:
|
| 88 |
+
_seed_scenario_under_parent(session, parent_id=parent_id, scenario_id=scenario_id)
|
| 89 |
+
b._invalidate_catalog_cache(force=True)
|
| 90 |
+
b._catalog()
|
| 91 |
+
|
| 92 |
+
monkeypatch.setenv("KINK_STORE_PATH", str(b.path))
|
| 93 |
+
monkeypatch.setenv("KINK_HF_REQUIRE_FULL_CATALOG", "0")
|
| 94 |
+
sys.modules.pop("api", None)
|
| 95 |
+
api_mod = importlib.import_module("api")
|
| 96 |
+
api_mod._backend_impl = None
|
| 97 |
+
client = TestClient(api_mod.app)
|
| 98 |
+
|
| 99 |
+
u = client.post("/users", json={}).json()
|
| 100 |
+
uid, tok = u["id"], u["private_token"]
|
| 101 |
+
headers = {"x-private-token": tok}
|
| 102 |
+
|
| 103 |
+
rec = client.get(f"/users/{uid}/recommendations?limit=48", headers=headers)
|
| 104 |
+
assert rec.status_code == 200, rec.text
|
| 105 |
+
rec_ids = {item["kink"]["id"] for item in rec.json().get("items") or []}
|
| 106 |
+
assert scenario_id not in rec_ids, f"scenario {scenario_id!r} leaked into /recommendations"
|
| 107 |
+
|
| 108 |
+
search = client.get("/search?q=scenario&limit=20")
|
| 109 |
+
assert search.status_code == 200, search.text
|
| 110 |
+
search_ids = {item["kink"]["id"] for item in search.json().get("items") or []}
|
| 111 |
+
assert scenario_id not in search_ids, f"scenario {scenario_id!r} leaked into /search"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def test_http_recommendations_non_empty_after_47_ratings(
|
| 115 |
monkeypatch: pytest.MonkeyPatch,
|
| 116 |
big_catalog_backend,
|
tests/test_user_snapshot.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User-state snapshot dump/restore/fingerprint round-trip tests.
|
| 2 |
+
|
| 3 |
+
The Hub I/O is exercised separately (and is a no-op when KINK_USER_SNAPSHOT_REPO is unset).
|
| 4 |
+
These tests stay offline: they prove the SQLite primitives (USER_TABLES preserved exactly,
|
| 5 |
+
catalog tables untouched) so the deployment-time integration only needs Hub auth to land.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sqlite3
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from sqlmodel import Session
|
| 15 |
+
|
| 16 |
+
from backend.core import Backend
|
| 17 |
+
from backend.user_snapshot import (
|
| 18 |
+
USER_TABLES,
|
| 19 |
+
compute_user_state_fingerprint,
|
| 20 |
+
dump_user_state,
|
| 21 |
+
restore_user_state,
|
| 22 |
+
user_state_is_empty,
|
| 23 |
+
)
|
| 24 |
+
from models import Kink, KinkContentType
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.fixture
|
| 28 |
+
def store_path(tmp_path: Path) -> Path:
|
| 29 |
+
os.environ["KINK_SKIP_HEAVY_WARM"] = "1"
|
| 30 |
+
db = tmp_path / "snap.db"
|
| 31 |
+
b = Backend(db)
|
| 32 |
+
with Session(b.engine) as session:
|
| 33 |
+
for i in range(3):
|
| 34 |
+
kid = f"snap_kink_{i}"
|
| 35 |
+
session.add(Kink(id=kid, name=f"Snap {i}", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
|
| 36 |
+
session.add(KinkContentType(kink_id=kid, content_kind="play", evidence="test"))
|
| 37 |
+
session.commit()
|
| 38 |
+
return db
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _table_count(db: Path, table: str) -> int:
|
| 42 |
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
| 43 |
+
try:
|
| 44 |
+
row = conn.execute(
|
| 45 |
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
| 46 |
+
(table,),
|
| 47 |
+
).fetchone()
|
| 48 |
+
if not row:
|
| 49 |
+
return -1
|
| 50 |
+
return conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
| 51 |
+
finally:
|
| 52 |
+
conn.close()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_user_state_is_empty_on_fresh_store(store_path: Path) -> None:
|
| 56 |
+
assert user_state_is_empty(store_path) is True
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_dump_restore_roundtrip_preserves_users_and_plays(store_path: Path, tmp_path: Path) -> None:
|
| 60 |
+
"""Dump after writes restores the same rows on a fresh store; catalog rows untouched."""
|
| 61 |
+
b = Backend(store_path)
|
| 62 |
+
user = b.create_user()
|
| 63 |
+
b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
|
| 64 |
+
b.save_play_preference(user.id, "snap_kink_1", "love", ["to_me"])
|
| 65 |
+
|
| 66 |
+
out = tmp_path / "snap_out.db"
|
| 67 |
+
counts = dump_user_state(store_path, out)
|
| 68 |
+
assert counts.get("user", 0) == 1
|
| 69 |
+
assert counts.get("playpreference", 0) == 2
|
| 70 |
+
|
| 71 |
+
fresh = tmp_path / "fresh.db"
|
| 72 |
+
Backend(fresh)
|
| 73 |
+
with Session(Backend(fresh).engine) as session:
|
| 74 |
+
session.add(Kink(id="snap_kink_0", name="Snap 0", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
|
| 75 |
+
session.add(Kink(id="snap_kink_1", name="Snap 1", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
|
| 76 |
+
session.add(KinkContentType(kink_id="snap_kink_0", content_kind="play", evidence="test"))
|
| 77 |
+
session.add(KinkContentType(kink_id="snap_kink_1", content_kind="play", evidence="test"))
|
| 78 |
+
session.commit()
|
| 79 |
+
|
| 80 |
+
assert user_state_is_empty(fresh) is True
|
| 81 |
+
catalog_kinks_before = _table_count(fresh, "kink")
|
| 82 |
+
|
| 83 |
+
restored = restore_user_state(fresh, out)
|
| 84 |
+
assert restored.get("user", 0) == 1
|
| 85 |
+
assert restored.get("playpreference", 0) == 2
|
| 86 |
+
assert _table_count(fresh, "user") == 1
|
| 87 |
+
assert _table_count(fresh, "playpreference") == 2
|
| 88 |
+
assert _table_count(fresh, "kink") == catalog_kinks_before
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_fingerprint_changes_when_user_row_added(store_path: Path) -> None:
|
| 92 |
+
b = Backend(store_path)
|
| 93 |
+
fp_before = compute_user_state_fingerprint(store_path)
|
| 94 |
+
user = b.create_user()
|
| 95 |
+
b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
|
| 96 |
+
fp_after = compute_user_state_fingerprint(store_path)
|
| 97 |
+
assert fp_before != fp_after
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_dump_excludes_catalog_tables(store_path: Path, tmp_path: Path) -> None:
|
| 101 |
+
"""USER_TABLES must contain no catalog table names — guards against accidentally shipping the catalog."""
|
| 102 |
+
catalog_tables = {"kink", "kinkalias", "kinkasset", "similarityedge", "kinkscenarioparent", "fetlifeuserfetish"}
|
| 103 |
+
assert catalog_tables.isdisjoint(set(USER_TABLES))
|
| 104 |
+
|
| 105 |
+
out = tmp_path / "snap_only.db"
|
| 106 |
+
dump_user_state(store_path, out)
|
| 107 |
+
snap_tables = set()
|
| 108 |
+
conn = sqlite3.connect(out)
|
| 109 |
+
try:
|
| 110 |
+
snap_tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
| 111 |
+
finally:
|
| 112 |
+
conn.close()
|
| 113 |
+
assert snap_tables.isdisjoint(catalog_tables), f"snapshot leaked catalog tables: {snap_tables & catalog_tables}"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_restore_is_upsert_not_destructive(store_path: Path, tmp_path: Path) -> None:
|
| 117 |
+
"""A second restore on top of existing rows replaces matching PKs without erroring on duplicates."""
|
| 118 |
+
b = Backend(store_path)
|
| 119 |
+
user = b.create_user()
|
| 120 |
+
b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
|
| 121 |
+
out = tmp_path / "snap_out.db"
|
| 122 |
+
dump_user_state(store_path, out)
|
| 123 |
+
# Restore on top of itself (no-op for content; checks INSERT OR REPLACE accepts duplicate PKs).
|
| 124 |
+
counts = restore_user_state(store_path, out)
|
| 125 |
+
assert counts.get("user", 0) >= 1
|
| 126 |
+
assert _table_count(store_path, "user") == 1
|
| 127 |
+
assert _table_count(store_path, "playpreference") == 1
|