this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Quiz mode

uwx 0afd35f9 629d8920

+723 -325
+537 -312
public/main.tsx
··· 1 - import { signal, computed } from '@preact/signals'; 2 - import { h, Fragment, render } from 'preact'; 3 - import { useEffect, useReducer, useState } from 'preact/hooks'; 4 - import Portal from 'preact-portal'; 5 - import { Masonry } from 'masonic'; 6 - import Tippy from '@tippyjs/react'; 7 - import { choiceOptions, choiceOptionIndices, Choice } from './base'; 8 - import { exportImage } from './exporter'; 9 - import { compress, decompress } from 'qfs-compression'; 10 - import { createPortal } from 'preact/compat'; 11 - import { kinkText as kinkTextContent } from './kinks'; 12 - import { oauthClient, user } from './atproto/signed-in-user'; 1 + import { signal, computed, Signal } from "@preact/signals"; 2 + import { render } from "preact"; 3 + import { useEffect, useMemo, useReducer, useState } from "preact/hooks"; 4 + import { Masonry } from "masonic"; 5 + import Tippy from "@tippyjs/react"; 6 + import { choiceOptions, choiceOptionIndices, Choice } from "./base"; 7 + import { exportImage } from "./exporter"; 8 + import { compress, decompress } from "qfs-compression"; 9 + import { createPortal } from "preact/compat"; 10 + import { kinkText as kinkTextContent } from "./kinks"; 11 + import { oauthClient, user } from "./atproto/signed-in-user"; 13 12 14 - const root = document.querySelector('#root'); 13 + const root = document.querySelector("#root"); 15 14 16 15 export interface Kink { 17 16 name: string; ··· 29 28 * @param {string} kinkStr 30 29 */ 31 30 function parseKinks(kinkStr: string) { 32 - const kinkCode = kinkStr.split('\n') 33 - .map(e => e.trim()) 34 - .filter(e => e); 31 + const kinkCode = kinkStr 32 + .split("\n") 33 + .map((e) => e.trim()) 34 + .filter((e) => e); 35 35 36 36 /** @type {KinkCategory[]} */ 37 37 const kinkCategories: KinkCategory[] = []; ··· 42 42 let curKinkCategory: Partial<KinkCategory> | undefined; 43 43 let curKinkId = 0; 44 44 for (const line of kinkCode) { 45 - if (line.startsWith('#')) { 46 - const [categoryName, categoryDesc] = sliceOnce(removeSymbols(line, '#'), ':::'); 45 + if (line.startsWith("#")) { 46 + const [categoryName, categoryDesc] = sliceOnce(removeSymbols(line, "#"), ":::"); 47 47 48 48 curKinkCategory = { 49 49 name: categoryName, 50 50 description: categoryDesc, 51 51 kinks: [], 52 - participants: ['Unknown'] 52 + participants: ["Unknown"], 53 53 }; 54 54 // @ts-ignore 55 55 kinkCategories.push(curKinkCategory); 56 - } else if (line.startsWith('(') && line.endsWith(')')) { 56 + } else if (line.startsWith("(") && line.endsWith(")")) { 57 57 if (curKinkCategory === undefined) { 58 - throw new Error('Encountered a participant definition before a kink type declaration'); 58 + throw new Error("Encountered a participant definition before a kink type declaration"); 59 59 } 60 - curKinkCategory.participants = removeSymbols(line, '(', ')').split(',').map(e => e.trim()); 61 - } else if (line.startsWith('*')) { 60 + curKinkCategory.participants = removeSymbols(line, "(", ")") 61 + .split(",") 62 + .map((e) => e.trim()); 63 + } else if (line.startsWith("*")) { 62 64 if (curKinkCategory?.kinks === undefined) { 63 - throw new Error('Encountered a kink definition before a kink type declaration'); 65 + throw new Error("Encountered a kink definition before a kink type declaration"); 64 66 } 65 67 66 - const [kinkName, kinkDescription] = sliceOnce(removeSymbols(line, '*'), '?'); 68 + const [kinkName, kinkDescription] = sliceOnce(removeSymbols(line, "*"), ":::"); 67 69 const kink = { name: kinkName, description: kinkDescription }; 68 70 curKinkCategory.kinks.push(kink); 69 71 kinksById[curKinkId++] = kink; ··· 81 83 * Entries may be undefined! 82 84 * @type {Map<Kink, Map<string, Choice>>} 83 85 */ 84 - const kinkSelections: Map<Kink, Map<string, Choice>> = new Map(); 86 + const kinkSelections: Map<Kink, Map<string, Signal<Choice>>> = new Map(); 85 87 86 88 /** 87 89 * Maps kink -> participant -> choice option -> button element 88 90 * Entries may be undefined! 89 91 * @type {Map<Kink, Map<string, Map<string, (action: 'select' | 'deselect') => void>>>} 90 92 */ 91 - const kinkButtons: Map<Kink, Map<string, Map<string, (action: 'select' | 'deselect') => void>>> = new Map(); 93 + const kinkButtons: Map<Kink, Map<string, Map<string, (action: "select" | "deselect") => void>>> = new Map(); 92 94 93 95 /** 94 96 * @param {Kink} kink ··· 113 115 * @param {LegendChoiceProps} props 114 116 * @returns 115 117 */ 116 - function LegendChoice({type, typeDescription}: LegendChoiceProps) { 117 - return <div class="level-item is-justify-content-flex-start"> 118 - <button class={'choice ' + type} title={typeDescription} disabled /> 119 - <span>{typeDescription}</span> 120 - </div>; 118 + function LegendChoice({ type, typeDescription }: LegendChoiceProps) { 119 + return ( 120 + <div class="level-item is-justify-content-flex-start"> 121 + <button class={"choice " + type} title={typeDescription} disabled /> 122 + <span>{typeDescription}</span> 123 + </div> 124 + ); 121 125 } 122 126 123 127 function Legend() { 124 - return <>{choiceOptions.map(e => <LegendChoice type={e[0]} typeDescription={e[1]} />)}</>; 128 + return ( 129 + <> 130 + {choiceOptions.map((e) => ( 131 + <LegendChoice type={e[0]} typeDescription={e[1]} /> 132 + ))} 133 + </> 134 + ); 125 135 } 126 136 127 137 /** ··· 159 169 return str.trim(); 160 170 } 161 171 162 - const base64Alphabet = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/']; // base64 alphabet 172 + const base64Alphabet = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"]; // base64 alphabet 163 173 164 174 /** 165 175 * @param {number} n 166 176 * @returns {string} 167 177 */ 168 - const toBinary = (n: number): string => n.toString(2).padStart(8, '0'); // convert num to 8-bit binary string 178 + const toBinary = (n: number): string => n.toString(2).padStart(8, "0"); // convert num to 8-bit binary string 169 179 170 180 /** 171 181 * https://stackoverflow.com/a/62362724 ··· 179 189 } 180 190 181 191 const l = arr.length; 182 - let result = ''; 192 + let result = ""; 183 193 184 194 for (let i = 0; i <= (l - 1) / 3; i++) { 185 195 const c1 = i * 3 + 1 >= l; // case when "=" is on end 186 196 const c2 = i * 3 + 2 >= l; // case when "=" is on end 187 197 const chunk = toBinary(arr[3 * i]) + toBinary(c1 ? 0 : arr[3 * i + 1]) + toBinary(c2 ? 0 : arr[3 * i + 2]); 188 - const r = chunk.match(/.{1,6}/g)!.map((x, j) => j == 3 && c2 ? '=' : (j == 2 && c1 ? '=' : base64Alphabet[+('0b' + x)])); 189 - result += r.join(''); 198 + const r = chunk 199 + .match(/.{1,6}/g)! 200 + .map((x, j) => (j == 3 && c2 ? "=" : j == 2 && c1 ? "=" : base64Alphabet[+("0b" + x)])); 201 + result += r.join(""); 190 202 } 191 203 192 204 return result; ··· 206 218 207 219 for (let i = 0; i < str.length / 4; i++) { 208 220 const chunk = [...str.slice(4 * i, 4 * i + 4)]; 209 - const bin = chunk.map(x => base64Alphabet.indexOf(x).toString(2).padStart(6, '0')).join(''); 210 - const bytes = bin.match(/.{1,8}/g)!.map(x => +('0b' + x)); 211 - result.push(...bytes.slice(0, 3 - (str[4 * i + 2] == '=' ? 1 : 0) - (str[4 * i + 3] == '=' ? 1 : 0))); 221 + const bin = chunk.map((x) => base64Alphabet.indexOf(x).toString(2).padStart(6, "0")).join(""); 222 + const bytes = bin.match(/.{1,8}/g)!.map((x) => +("0b" + x)); 223 + result.push(...bytes.slice(0, 3 - (str[4 * i + 2] == "=" ? 1 : 0) - (str[4 * i + 3] == "=" ? 1 : 0))); 212 224 } 213 225 return new Uint8Array(result); 214 226 } ··· 216 228 /** 217 229 * @param {Kink} kink 218 230 * @param {string} participant 219 - * @returns {string} 231 + * @returns {Signal<Choice>} 220 232 */ 221 - function getSelectedKinkOrDefault(kink: Kink, participant: string): Choice { 222 - return kinkSelections.has(kink) && kinkSelections.get(kink)?.get(participant) || Choice.NotEntered; 233 + function getSelectedKinkOrDefault(kink: Kink, participant: string): Signal<Choice> { 234 + let theKink = kinkSelections.get(kink); 235 + if (!theKink) kinkSelections.set(kink, theKink = new Map()); 236 + let theSignal = theKink.get(participant); 237 + if (!theSignal) theKink.set(participant, theSignal = signal(Choice.NotEntered)); 238 + return theSignal; 223 239 } 224 240 225 241 /** ··· 227 243 * @param {string} participant 228 244 * @param {string} toChoiceId 229 245 */ 230 - function setKinkSelection(kink: Kink, participant: string, toChoiceId: Choice) { 231 - if (!kinkSelections.has(kink)) kinkSelections.set(kink, new Map()); 246 + function setKinkSelection(kink: Kink, participant: string, toChoiceId: Choice, updateHash = true) { 247 + getSelectedKinkOrDefault(kink, participant).value = toChoiceId; 248 + 249 + for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 250 + if (thatChoiceId === toChoiceId) { 251 + thatButton("select"); 252 + } else { 253 + thatButton("deselect"); 254 + } 255 + } 232 256 233 - kinkSelections.get(kink)!.set(participant, toChoiceId); 257 + if (updateHash) { 258 + window.location.hash = serializeChoices(); 259 + } 234 260 } 235 261 236 262 /** 237 263 * 4-bit padding reference 238 264 */ 239 - const PADDING_MARKER = 0xF; 265 + const PADDING_MARKER = 0xf; 240 266 function serializeChoices(): string { 241 267 const bytes: number[] = []; 242 268 ··· 255 281 const category = findKinkCategory(kink); 256 282 if (category) { 257 283 for (const participant of category.participants) { 258 - pushNumber(choiceOptionIndices[getSelectedKinkOrDefault(kink, participant)]); 284 + pushNumber(choiceOptionIndices[getSelectedKinkOrDefault(kink, participant).value]); 259 285 } 260 286 } 261 287 } ··· 266 292 267 293 // return bytesArrToBase64(bytes); 268 294 269 - return '!' + bytesArrToBase64(compress(new Uint8Array(bytes))); 295 + return "!" + bytesArrToBase64(compress(new Uint8Array(bytes))); 270 296 } 271 297 272 298 function deserializeChoices(base64: string) { 273 - const bytes = base64.startsWith('!') 274 - ? decompress(base64ToBytesArr(base64.slice(1))) 275 - : base64ToBytesArr(base64); 299 + const bytes = base64.startsWith("!") ? decompress(base64ToBytesArr(base64.slice(1))) : base64ToBytesArr(base64); 276 300 277 301 let isUpperHalf = false; 278 302 let index = 0; ··· 286 310 const byte = bytes[index]; 287 311 let choice; 288 312 if (isUpperHalf) { 289 - choice = byte >> 4 & 0xF; 313 + choice = (byte >> 4) & 0xf; 290 314 291 315 if (choice === PADDING_MARKER) { 292 - console.warn('Reached the end of data early.'); 316 + console.warn("Reached the end of data early."); 293 317 break; 294 318 } 295 319 296 320 isUpperHalf = false; 297 321 index++; 298 322 } else { 299 - choice = byte & 0xF; 323 + choice = byte & 0xf; 300 324 301 325 isUpperHalf = true; 302 326 } 303 327 304 328 const choiceId = choiceOptions[choice][0]; 305 329 // console.log('Setting kink selection for', kink.name, participant, 'to', choiceId); 306 - setKinkSelection(kink, participant, choiceId); 307 - 308 - for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 309 - if (thatChoiceId === choiceId) { 310 - // console.log('Setting select', thatChoiceId) 311 - thatButton('select'); 312 - } else { 313 - // console.log('Setting deselect', thatChoiceId) 314 - thatButton('deselect'); 315 - } 316 - } 330 + setKinkSelection(kink, participant, choiceId, false); 317 331 } 318 332 } 319 333 ··· 331 345 * @param {KinkChoiceButtonProps} props 332 346 * @returns 333 347 */ 334 - function KinkChoiceButton({kink, participant, choiceId, choiceDescription}: KinkChoiceButtonProps) { 348 + function KinkChoiceButton({ kink, participant, choiceId, choiceDescription }: KinkChoiceButtonProps) { 335 349 /** 336 350 * @param {boolean} state 337 351 * @param {'select' | 'deselect'} action 338 352 * @returns {boolean} 339 353 */ 340 - function reducer(state: boolean, action: 'select' | 'deselect'): boolean { 341 - if (action === 'select') { 354 + function reducer(state: boolean, action: "select" | "deselect"): boolean { 355 + if (action === "select") { 342 356 return true; 343 - } else if (action === 'deselect') { 357 + } else if (action === "deselect") { 344 358 return false; 345 359 } 346 360 return state; 347 361 } 348 362 349 - const [selected, update] = useReducer(reducer, false); 363 + const [selected, update] = useReducer(reducer, false); 350 364 351 365 let participants = kinkButtons.get(kink); 352 366 if (participants === undefined) { 353 - kinkButtons.set(kink, participants = new Map()); 367 + kinkButtons.set(kink, (participants = new Map())); 354 368 } 355 369 356 370 let choices = participants.get(participant); 357 371 if (choices === undefined) { 358 - participants.set(participant, choices = new Map()); 372 + participants.set(participant, (choices = new Map())); 359 373 } 360 374 361 375 choices.set(choiceId, update); 362 376 363 - return <div class="column"> 364 - <button 365 - class={`choice ${choiceId}${selected ? ' selected' : ''}`} 366 - title={choiceDescription} 367 - onClick={() => { 368 - setKinkSelection(kink, participant, choiceId); 369 - 370 - for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 371 - if (thatChoiceId === choiceId) { 372 - thatButton('select'); 373 - } else { 374 - thatButton('deselect'); 375 - } 376 - } 377 - 378 - window.location.hash = serializeChoices(); 379 - }} 380 - /> 381 - </div>; 377 + return ( 378 + <div class="column"> 379 + <button 380 + class={`choice ${choiceId}${selected ? " selected" : ""}`} 381 + title={choiceDescription} 382 + onClick={() => { 383 + setKinkSelection(kink, participant, choiceId); 384 + }} 385 + /> 386 + </div> 387 + ); 382 388 } 383 389 384 390 /** ··· 392 398 return kinkButtons.get(kink)!.get(participant)!; 393 399 } 394 400 395 - function TheKink({kinkCategory, kink}: { kinkCategory: KinkCategory; kink: Kink; }) { 396 - return <tr class="kinks-row"> 397 - {kinkCategory.participants.map(participant => <td data-choice-type={participant}> 398 - <div class="choices choice-general"> 399 - <div class="columns is-mobile is-gapless"> 400 - {choiceOptions.map(([id, name]) => <KinkChoiceButton 401 - key={kink + '_' + participant + '_' + id} 402 - kink={kink} 403 - participant={participant} 404 - choiceId={id} 405 - choiceDescription={name} 406 - />)} 407 - </div> 408 - </div> 409 - </td>)} 410 - <td> 411 - {kink.description != null 412 - ? <Tippy content={kink.description}> 413 - <span class="has-description" title={kink.description}> 414 - {kink.name} 415 - </span> 416 - </Tippy> 417 - : <span>{kink.name}</span> 418 - } 419 - </td> 420 - </tr>; 401 + function TheKink({ kinkCategory, kink }: { kinkCategory: KinkCategory; kink: Kink }) { 402 + return ( 403 + <tr class="kinks-row"> 404 + {kinkCategory.participants.map((participant) => ( 405 + <td data-choice-type={participant}> 406 + <div class="choices choice-general"> 407 + <div class="columns is-mobile is-gapless"> 408 + {choiceOptions.map(([id, name]) => ( 409 + <KinkChoiceButton 410 + key={kink + "_" + participant + "_" + id} 411 + kink={kink} 412 + participant={participant} 413 + choiceId={id} 414 + choiceDescription={name} 415 + /> 416 + ))} 417 + </div> 418 + </div> 419 + </td> 420 + ))} 421 + <td> 422 + {kink.description != null ? ( 423 + <Tippy content={kink.description}> 424 + <span class="has-description" title={kink.description}> 425 + {kink.name} 426 + </span> 427 + </Tippy> 428 + ) : ( 429 + <span>{kink.name}</span> 430 + )} 431 + </td> 432 + </tr> 433 + ); 421 434 } 422 435 423 436 interface TheKinkCategoryProps { 424 437 kinkCategory: KinkCategory; 425 438 } 426 439 427 - function TheKinkCategory({kinkCategory}: TheKinkCategoryProps) { 428 - return <div class="masonry-inner" data-num-participants={String(kinkCategory.participants.length)}> 429 - <h1 class="subtitle kinks-subtitle"> 430 - {kinkCategory.name} 431 - {kinkCategory.description != null && <> 432 - {' '} 433 - <Tippy content={kinkCategory.description}> 434 - <span class="has-subtitle-description" aria-description={kinkCategory.description}> 435 - (?) 436 - </span> 437 - </Tippy> 438 - </>} 439 - </h1> 440 - <table class="table kinks-table is-striped is-narrow is-hoverable"> 441 - <thead> 442 - {kinkCategory.participants.map(participant => <th class="kinks-header">{participant}</th>)} 443 - </thead> 444 - <tbody> 445 - {kinkCategory.kinks.map(kink => <TheKink kinkCategory={kinkCategory} kink={kink} />)} 446 - </tbody> 447 - </table> 448 - </div>; 440 + function TheKinkCategory({ kinkCategory }: TheKinkCategoryProps) { 441 + return ( 442 + <div class="masonry-inner" data-num-participants={String(kinkCategory.participants.length)}> 443 + <h1 class="subtitle kinks-subtitle"> 444 + {kinkCategory.name} 445 + {kinkCategory.description != null && ( 446 + <> 447 + {" "} 448 + <Tippy content={kinkCategory.description}> 449 + <span class="has-subtitle-description" aria-description={kinkCategory.description}> 450 + (?) 451 + </span> 452 + </Tippy> 453 + </> 454 + )} 455 + </h1> 456 + <table class="table kinks-table is-striped is-narrow is-hoverable"> 457 + <thead> 458 + {kinkCategory.participants.map((participant) => ( 459 + <th class="kinks-header">{participant}</th> 460 + ))} 461 + </thead> 462 + <tbody> 463 + {kinkCategory.kinks.map((kink) => ( 464 + <TheKink kinkCategory={kinkCategory} kink={kink} /> 465 + ))} 466 + </tbody> 467 + </table> 468 + </div> 469 + ); 449 470 } 450 471 451 472 interface MasonryItemProps { ··· 459 480 } 460 481 461 482 function KinksSection() { 462 - return <Masonry 463 - items={kinkData.value.kinkCategories} 464 - render={MasonryItem} 465 - columnWidth={460} 466 - maxColumnCount={8} 467 - overscanBy={10} 468 - />; 483 + return ( 484 + <Masonry 485 + items={kinkData.value.kinkCategories} 486 + render={MasonryItem} 487 + columnWidth={460} 488 + maxColumnCount={8} 489 + overscanBy={10} 490 + /> 491 + ); 469 492 } 470 493 471 - function Modal({ children, open, onClose, container }: { children: import('preact').ComponentChildren; open: boolean; onClose: () => void; container: HTMLElement; }) { 494 + function Modal({ 495 + children, 496 + open, 497 + onClose, 498 + container, 499 + }: { 500 + children: import("preact").ComponentChildren; 501 + open: boolean; 502 + onClose: () => void; 503 + container: HTMLElement; 504 + }) { 472 505 if (!container) return null; 473 506 474 507 return createPortal( 475 - <div class={`modal${open ? ' is-active' : ''}`}> 508 + <div class={`modal${open ? " is-active" : ""}`}> 476 509 <div class="modal-background" /> 477 510 <div class="modal-content">{children}</div> 478 511 <button class="modal-close is-large" aria-label="close" onClick={onClose} /> 479 512 </div>, 480 - container 513 + container, 481 514 ); 482 515 } 483 516 484 517 const changelog = [ 485 - { version: '14 - February 13th 2026', changes: [ 486 - 'Use Preact for rendering', 487 - 'Upgrade to Bulma 1.0.4', 488 - 'Port to TypeScript', 489 - ]}, 490 - { version: '13 - November 14th 2022', changes: [ 491 - 'Remove Futanari options in favor of an easier to understand selection of sexual characteristics irrespective of gender', 492 - 'Changed title of "Fantasy / Non-con" to "Fantasy / Con-non-con"', 493 - 'Changed "without breath control" to "w/o breath control" so it fits in the margin', 494 - 'Added "Clone / selfcest"', 495 - ]}, 496 - { version: '12 - September 17th 2021', changes: [ 497 - 'Improve mobile layout', 498 - ]}, 499 - { version: '11 - June 17th 2021', changes: [ 500 - 'Properly handle fail scenario in exporter when clipboard access permission is not given', 501 - 'Rename "Fantasy / Consensual non-con" back to "Fantasy / Non-con" because it didn\'t fit in the image (oops)', 502 - ]}, 503 - { version: '10 - June 15th 2021', changes: [ 504 - 'Rename "Fantasy / Non-con" category to "Fantasy / Consensual non-con"', 505 - 'Change choice button border color in dark theme to white, thanks to anonymous contributor for this fix', 506 - ]}, 507 - { version: '9 - June 15th 2021', changes: [ 508 - 'Add Macrophilia, Microphilia, Detachable body parts, Oviposition, Rope bondage, Suspension bondage, Cages, Gaping, Fear play and Water bondage (both types) to the list', 509 - 'Added a distinction between Futanari/Transfeminine and Futanari/Hermaphrodite', 510 - 'Removed Hate sex from the list', 511 - 'Added support for descriptions, and added descriptions to most of the kinks (and some categories)', 512 - 'Add light/dark theme detection and toggle', 513 - 'Add Self/Partner to Bodies category', 514 - 'Rename Interactions to Interactions & Groupings', 515 - 'Moved fantasy roleplay scenarios to a new Fantasy / Interactions category', 516 - 'Add website logo and Twitter/Discord embed code', 517 - 'Add this changelog', 518 - ]}, 519 - { version: '8 - April 27th 2021', changes: [ 520 - 'Align legend to the left on mobile devices, thanks to anonymous contributor for this fix', 521 - ]}, 522 - { version: '7 - November 9th 2020', changes: [ 523 - 'Add Scat to the list', 524 - ]}, 525 - { version: '6 - October 9th 2020', changes: [ 526 - 'Rewrite the entire list, using Bulma, in order to completely eliminate scrolling issues', 527 - ]}, 528 - { version: '5 - September 21th 2020', changes: [ 529 - 'Add Clown, Hate sex and Choking to list', 530 - 'Remove "Handholding" meme entry from list', 531 - ]}, 532 - { version: '4 - August 17th 2020', changes: [ 533 - 'Add Zettai ryoiki, Gas masks, Hypnoplay, Droneplay and Kitsunemimi to list', 534 - ]}, 535 - { version: '3 - August 9th 2020', changes: [ 536 - 'Add Beard, Hairy body, Shaven body, Bimbofication, Excessive cum and Gas to list', 537 - ]}, 538 - { version: '2 - July 11th 2020', changes: [ 539 - 'Many internal changes', 540 - 'Fix issues with hotkeys', 541 - 'Add "Autosave" checkbox, as an attempt to stop scrolling issues in Android Chrome webview', 542 - ]}, 543 - { version: '1 - July 10th 2020', changes: [ 544 - 'Initial GitLab release', 545 - ]}, 546 - ] 518 + { 519 + version: "14 - February 13th 2026", 520 + changes: ["Use Preact for rendering", "Upgrade to Bulma 1.0.4", "Port to TypeScript"], 521 + }, 522 + { 523 + version: "13 - November 14th 2022", 524 + changes: [ 525 + "Remove Futanari options in favor of an easier to understand selection of sexual characteristics irrespective of gender", 526 + 'Changed title of "Fantasy / Non-con" to "Fantasy / Con-non-con"', 527 + 'Changed "without breath control" to "w/o breath control" so it fits in the margin', 528 + 'Added "Clone / selfcest"', 529 + ], 530 + }, 531 + { version: "12 - September 17th 2021", changes: ["Improve mobile layout"] }, 532 + { 533 + version: "11 - June 17th 2021", 534 + changes: [ 535 + "Properly handle fail scenario in exporter when clipboard access permission is not given", 536 + 'Rename "Fantasy / Consensual non-con" back to "Fantasy / Non-con" because it didn\'t fit in the image (oops)', 537 + ], 538 + }, 539 + { 540 + version: "10 - June 15th 2021", 541 + changes: [ 542 + 'Rename "Fantasy / Non-con" category to "Fantasy / Consensual non-con"', 543 + "Change choice button border color in dark theme to white, thanks to anonymous contributor for this fix", 544 + ], 545 + }, 546 + { 547 + version: "9 - June 15th 2021", 548 + changes: [ 549 + "Add Macrophilia, Microphilia, Detachable body parts, Oviposition, Rope bondage, Suspension bondage, Cages, Gaping, Fear play and Water bondage (both types) to the list", 550 + "Added a distinction between Futanari/Transfeminine and Futanari/Hermaphrodite", 551 + "Removed Hate sex from the list", 552 + "Added support for descriptions, and added descriptions to most of the kinks (and some categories)", 553 + "Add light/dark theme detection and toggle", 554 + "Add Self/Partner to Bodies category", 555 + "Rename Interactions to Interactions & Groupings", 556 + "Moved fantasy roleplay scenarios to a new Fantasy / Interactions category", 557 + "Add website logo and Twitter/Discord embed code", 558 + "Add this changelog", 559 + ], 560 + }, 561 + { 562 + version: "8 - April 27th 2021", 563 + changes: ["Align legend to the left on mobile devices, thanks to anonymous contributor for this fix"], 564 + }, 565 + { version: "7 - November 9th 2020", changes: ["Add Scat to the list"] }, 566 + { 567 + version: "6 - October 9th 2020", 568 + changes: ["Rewrite the entire list, using Bulma, in order to completely eliminate scrolling issues"], 569 + }, 570 + { 571 + version: "5 - September 21th 2020", 572 + changes: ["Add Clown, Hate sex and Choking to list", 'Remove "Handholding" meme entry from list'], 573 + }, 574 + { 575 + version: "4 - August 17th 2020", 576 + changes: ["Add Zettai ryoiki, Gas masks, Hypnoplay, Droneplay and Kitsunemimi to list"], 577 + }, 578 + { 579 + version: "3 - August 9th 2020", 580 + changes: ["Add Beard, Hairy body, Shaven body, Bimbofication, Excessive cum and Gas to list"], 581 + }, 582 + { 583 + version: "2 - July 11th 2020", 584 + changes: [ 585 + "Many internal changes", 586 + "Fix issues with hotkeys", 587 + 'Add "Autosave" checkbox, as an attempt to stop scrolling issues in Android Chrome webview', 588 + ], 589 + }, 590 + { version: "1 - July 10th 2020", changes: ["Initial GitLab release"] }, 591 + ]; 592 + 593 + function getKinkAndParticipantByIndex(index: number): { category: KinkCategory, kink: Kink, participant: string } | undefined { 594 + for (const category of kinkData.value.kinkCategories) { 595 + for (const participant of category.participants) { 596 + for (const kink of category.kinks) { 597 + if (index === 0) { 598 + return { category, kink, participant }; 599 + } 600 + index--; 601 + } 602 + } 603 + } 604 + return undefined; 605 + } 606 + 607 + function maxKinkIndex() { 608 + let max = 0; 609 + for (const category of kinkData.value.kinkCategories) { 610 + for (const participant of category.participants) { 611 + for (const kink of category.kinks) { 612 + max++; 613 + } 614 + } 615 + } 616 + return max; 617 + } 618 + 619 + function QuizMode() { 620 + const [currentIndex, setCurrentIndex] = useState(0); 621 + const maxIndex = useMemo(() => maxKinkIndex(), []); 622 + 623 + function wrap(index: number) { 624 + return (index + maxIndex) % maxIndex; 625 + } 626 + 627 + const currentKink = useMemo(() => getKinkAndParticipantByIndex(currentIndex), [currentIndex]); 628 + 629 + const indexMinus1 = useMemo(() => wrap(currentIndex - 1), [currentIndex]); 630 + const indexMinus2 = useMemo(() => wrap(currentIndex - 2), [currentIndex]); 631 + const indexMinus3 = useMemo(() => wrap(currentIndex - 3), [currentIndex]); 632 + const indexPlus1 = useMemo(() => wrap(currentIndex + 1), [currentIndex]); 633 + const indexPlus2 = useMemo(() => wrap(currentIndex + 2), [currentIndex]); 634 + const indexPlus3 = useMemo(() => wrap(currentIndex + 3), [currentIndex]); 635 + 636 + const kinkMinus1 = useMemo(() => getKinkAndParticipantByIndex(indexMinus1), [indexMinus1]); 637 + const kinkMinus2 = useMemo(() => getKinkAndParticipantByIndex(indexMinus2), [indexMinus2]); 638 + const kinkMinus3 = useMemo(() => getKinkAndParticipantByIndex(indexMinus3), [indexMinus3]); 639 + const kinkPlus1 = useMemo(() => getKinkAndParticipantByIndex(indexPlus1), [indexPlus1]); 640 + const kinkPlus2 = useMemo(() => getKinkAndParticipantByIndex(indexPlus2), [indexPlus2]); 641 + const kinkPlus3 = useMemo(() => getKinkAndParticipantByIndex(indexPlus3), [indexPlus3]); 642 + 643 + function getChoice({category, kink, participant}: { category: KinkCategory, kink: Kink, participant: string }) { 644 + return getSelectedKinkOrDefault(kink, participant).value; 645 + } 646 + 647 + function advance(amount: number) { 648 + setCurrentIndex(wrap(currentIndex + amount)); 649 + } 650 + 651 + function selectAndAdvance(choice: Choice) { 652 + setKinkSelection(currentKink!.kink, currentKink!.participant, choice); 653 + advance(1); 654 + } 655 + 656 + useEffect(() => { 657 + function onKeyDown(e: KeyboardEvent) { 658 + if (e.altKey || e.shiftKey || e.ctrlKey) return; 659 + 660 + if (e.key === 'ArrowUp') { 661 + advance(-1); 662 + e.preventDefault(); 663 + e.stopPropagation(); 664 + } 665 + if (e.key === 'ArrowDown') { 666 + advance(1); 667 + e.preventDefault(); 668 + e.stopPropagation(); 669 + } 670 + 671 + const btn = Number(e.key); 672 + if (!isNaN(btn)) { 673 + selectAndAdvance(choiceOptions[btn - 1][0]); 674 + e.preventDefault(); 675 + e.stopPropagation(); 676 + } 677 + } 678 + 679 + window.addEventListener("keydown", onKeyDown); 680 + return () => window.removeEventListener("keydown", onKeyDown); 681 + }); 682 + 683 + return ( 684 + <div id="InputOverlay"> 685 + <div class="widthWrapper"> 686 + <div id="InputPrevious"> 687 + <div class="kink-simple" role="button" onClick={() => advance(-3)}> 688 + <span class={`inline-choice ${getChoice(kinkMinus3!)}`}></span>{" "} 689 + <span class="txt-category">{kinkMinus3?.category.name}</span> 690 + <span class="txt-field">{kinkMinus3?.participant}</span> 691 + <span class="txt-kink">{kinkMinus3?.kink.name}</span> 692 + </div> 693 + <div class="kink-simple" role="button" onClick={() => advance(-2)}> 694 + <span class={`inline-choice ${getChoice(kinkMinus2!)}`}></span>{" "} 695 + <span class="txt-category">{kinkMinus2?.category.name}</span> 696 + <span class="txt-field">{kinkMinus2?.participant}</span> 697 + <span class="txt-kink">{kinkMinus2?.kink.name}</span> 698 + </div> 699 + <div class="kink-simple" role="button" onClick={() => advance(-1)}> 700 + <span class={`inline-choice ${getChoice(kinkMinus1!)}`}></span>{" "} 701 + <span class="txt-category">{kinkMinus1?.category.name}</span> 702 + <span class="txt-field">{kinkMinus1?.participant}</span> 703 + <span class="txt-kink">{kinkMinus1?.kink.name}</span> 704 + </div> 705 + </div> 706 + <div id="InputCurrent"> 707 + <h2 id="InputCategory">{currentKink?.category.name}</h2> 708 + <h3 id="InputField"><span class="participant">{currentKink?.participant}</span> {currentKink?.kink.name}</h3> 709 + <div id="InputValues"> 710 + <div> 711 + {choiceOptions.map(([id, name, color], idx) => ( 712 + <div 713 + class={"big-choice" + (getChoice(currentKink!) == id ? " selected" : "")} 714 + key={id} 715 + role="button" 716 + onClick={() => selectAndAdvance(id)}> 717 + <span data-color={color} class={`inline-choice ${id}`}></span>{" "} 718 + <span class="legend-text">{name}</span> 719 + <span class="btn-num-text">{idx + 1}</span> 720 + </div> 721 + ))} 722 + </div> 723 + </div> 724 + </div> 725 + <div id="InputNext"> 726 + <div class="kink-simple" role="button" onClick={() => advance(1)}> 727 + <span class={`inline-choice ${getChoice(kinkPlus1!)}`}></span>{" "} 728 + <span class="txt-category">{kinkPlus1?.category.name}</span> 729 + <span class="txt-kink">{kinkPlus1?.kink.name}</span> 730 + </div> 731 + <div class="kink-simple" role="button" onClick={() => advance(2)}> 732 + <span class={`inline-choice ${getChoice(kinkPlus2!)}`}></span>{" "} 733 + <span class="txt-category">{kinkPlus2?.category.name}</span> 734 + <span class="txt-kink">{kinkPlus2?.kink.name}</span> 735 + </div> 736 + <div class="kink-simple" role="button" onClick={() => advance(3)}> 737 + <span class={`inline-choice ${getChoice(kinkPlus3!)}`}></span>{" "} 738 + <span class="txt-category">{kinkPlus3?.category.name}</span> 739 + <span class="txt-kink">{kinkPlus3?.kink.name}</span> 740 + </div> 741 + </div> 742 + </div> 743 + </div> 744 + ); 745 + } 547 746 548 747 function RealRoot() { 549 748 const [changelogOpen, setChangelogOpen] = useState(false); 550 749 const [exportImageOpen, setExportImageOpen] = useState(false); 551 750 const [exportModalContent, setExportModalContent] = useState<HTMLElement | null>(null); 751 + const [quizModeOpen, setQuizModeOpen] = useState(false); 552 752 553 753 function exportImageCallback() { 554 - const canvas = exportImage(kinkData.value.kinkCategories, kinkData.value.kinksById, getSelectedKinkOrDefault); 754 + const canvas = exportImage(kinkData.value.kinkCategories, kinkData.value.kinksById, (kink, participant) => getSelectedKinkOrDefault(kink, participant).value); 555 755 556 756 // Try 1: https://stackoverflow.com/a/57546936 557 757 558 - if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write !== undefined) { 559 - canvas.toBlob(blob => { 758 + if (typeof ClipboardItem !== "undefined" && navigator.clipboard.write !== undefined) { 759 + canvas.toBlob((blob) => { 560 760 if (blob === null) { 561 761 noCopyFallback(); 562 762 return; 563 763 } 564 764 565 765 // @ts-ignore 566 - navigator.permissions.query({name: 'clipboard-write'}) 567 - .then(status => { 568 - if (status.state !== 'granted') { 766 + navigator.permissions 767 + .query({ name: "clipboard-write" }) 768 + .then((status) => { 769 + if (status.state !== "granted") { 569 770 // FAIL SCENARIO 570 771 noCopyFallback(); 571 772 return; 572 773 } 573 774 574 - const item = new ClipboardItem({ 'image/png': blob }); 775 + const item = new ClipboardItem({ "image/png": blob }); 575 776 navigator.clipboard.write([item]); 576 777 577 - alert('Copied to clipboard!'); 778 + alert("Copied to clipboard!"); 578 779 }) 579 - .catch(err => { 780 + .catch((err) => { 580 781 // FAIL SCENARIO 581 782 console.error(err); 582 783 noCopyFallback(); ··· 588 789 noCopyFallback(); 589 790 590 791 function noCopyFallback() { 591 - const image = document.createElement('img'); 792 + const image = document.createElement("img"); 592 793 image.src = canvas.toDataURL(); 593 794 594 795 setExportModalContent(image); ··· 599 800 const [hasInitialSession, setHasInitialSession] = useState(false); 600 801 601 802 async function exportAtprotoCallback() { 602 - localStorage.setItem('oauth-redirect-kinks', serializeChoices()); 803 + localStorage.setItem("oauth-redirect-kinks", serializeChoices()); 603 804 604 805 if (!hasInitialSession) { 605 806 await oauthClient.waitForInitialSession(); 606 807 } 607 808 608 809 if (!user.value) { 609 - const handle = prompt('Enter your ATProto handle:'); 810 + const handle = prompt("Enter your ATProto handle:"); 610 811 611 812 if (!handle) { 612 813 return; ··· 621 822 const success = await uploadKinks(); 622 823 623 824 if (success) { 624 - alert('Successfully uploaded kinks!'); 825 + alert("Successfully uploaded kinks!"); 625 826 } 626 827 } 627 828 ··· 631 832 } 632 833 633 834 await user.value.client.createOrUpdateProfile({ 634 - kinks: kinkData.value.kinkCategories.flatMap( 635 - category => category.kinks.flatMap( 636 - kink => category.participants.map( 637 - participant => ({ 638 - section: category.name, 639 - name: kink.name, 640 - participant: participant, 641 - choice: getSelectedKinkOrDefault(kink, participant), 642 - }) 643 - ) 644 - ) 835 + kinks: kinkData.value.kinkCategories.flatMap((category) => 836 + category.kinks.flatMap((kink) => 837 + category.participants.map((participant) => ({ 838 + section: category.name, 839 + name: kink.name, 840 + participant: participant, 841 + choice: getSelectedKinkOrDefault(kink, participant), 842 + })), 843 + ), 645 844 ), 646 845 }); 647 846 ··· 650 849 651 850 useEffect(() => { 652 851 const hash = location.hash.slice(1); 653 - oauthClient.waitForInitialSession() 852 + oauthClient 853 + .waitForInitialSession() 654 854 .then(() => setHasInitialSession(true)) 655 855 .finally(async () => { 656 - if (document.location.pathname.endsWith('/oauth-redirect.html')) { 657 - console.log('On OAuth redirect page, finalizing authorization...'); 856 + if (document.location.pathname.endsWith("/oauth-redirect.html")) { 857 + console.log("On OAuth redirect page, finalizing authorization..."); 658 858 659 859 // Retrieve kinks from local storage as OAuth metadata is in the hash 660 - const storedKinks = localStorage.getItem('oauth-redirect-kinks'); 860 + const storedKinks = localStorage.getItem("oauth-redirect-kinks"); 661 861 662 862 if (storedKinks) { 663 - console.log('Deserializing kinks from local storage'); 863 + console.log("Deserializing kinks from local storage"); 664 864 deserializeChoices(storedKinks); 665 865 666 - console.log('Finalizing authorization'); 866 + console.log("Finalizing authorization"); 667 867 if (!user.value) { 668 868 await oauthClient.finalizeAuthorization(new URLSearchParams(hash)); 669 869 } 670 870 671 - console.log('Uploading kinks'); 871 + console.log("Uploading kinks"); 672 872 const success = await uploadKinks(); 673 873 674 874 // Return to index page without redirecting 675 875 history.replaceState( 676 876 null, 677 - '', 877 + "", 678 878 location.origin + 679 - location.pathname.replace('/oauth-redirect.html', '') + 680 - location.search + '#' + 681 - serializeChoices() 879 + location.pathname.replace("/oauth-redirect.html", "") + 880 + location.search + 881 + "#" + 882 + serializeChoices(), 682 883 ); 683 884 684 885 // Remove local storage 685 - localStorage.removeItem('oauth-redirect-kinks'); 886 + localStorage.removeItem("oauth-redirect-kinks"); 686 887 687 888 if (success) { 688 - alert('Successfully uploaded kinks!'); 889 + alert("Successfully uploaded kinks!"); 689 890 } else { 690 - alert('Failed to upload kinks!'); 891 + alert("Failed to upload kinks!"); 691 892 } 692 893 } 693 894 } 694 895 }); 695 896 696 - if (hash && !document.location.pathname.endsWith('/oauth-redirect.html')) { 897 + if (hash && !document.location.pathname.endsWith("/oauth-redirect.html")) { 697 898 try { 698 899 deserializeChoices(hash); 699 900 } catch (err) { 700 - console.error('Failed to load saved kinks:', err); 901 + console.error("Failed to load saved kinks:", err); 701 902 } 702 903 } 703 904 }, []); 704 905 705 - return <div id="root"> 706 - <nav class="navbar px-5 mt-5 mb-2" role="navigation" aria-label="main navigation"> 707 - <div class="navbar-brand"> 708 - <div class="navbar-item"> 709 - <h1 class="title">Kink list</h1> 710 - </div> 711 - <div class="navbar-item"> 712 - <button id="export-image" class="button is-primary" onClick={exportImageCallback}>Export to Clipboard</button> 713 - </div> 714 - <div class="navbar-item"> 715 - <button id="export-atproto" class="button is-primary" onClick={exportAtprotoCallback}>Export to ATProto</button> 716 - </div> 717 - <div class="navbar-item"> 718 - <button id="view-changelog" class="button is-primary" onClick={() => setChangelogOpen(true)}>Changelog</button> 719 - </div> 720 - <div class="navbar-item"> 721 - <label class="checkbox"> 722 - <input id="dark-theme" type="checkbox" onChange={event => { 723 - if (event.currentTarget.checked) { 724 - localStorage.setItem('theme', 'dark'); 725 - document.documentElement.classList.add('theme-dark'); 726 - } else { 727 - localStorage.setItem('theme', 'light'); 728 - document.documentElement.classList.remove('theme-dark'); 729 - } 730 - }} checked={document.documentElement.classList.contains('theme-dark')} /> 731 - {' '}Dark theme 732 - </label> 733 - </div> 734 - </div> 735 - </nav> 736 - 737 - <nav class="level px-5"> 738 - <div id="legend" class="kinks-legend level-left"> 739 - <Legend /> 740 - </div> 741 - </nav> 906 + return ( 907 + <div id="root"> 908 + <nav class="navbar px-5 mt-5 mb-2" role="navigation" aria-label="main navigation"> 909 + <div class="navbar-brand"> 910 + <div class="navbar-item"> 911 + <h1 class="title">Kink list</h1> 912 + </div> 913 + <div class="navbar-item"> 914 + <button id="export-image" class="button is-primary" onClick={exportImageCallback}> 915 + Export to Clipboard 916 + </button> 742 917 743 - <section class="section kinks-section"> 744 - <KinksSection /> 745 - </section> 918 + <Modal open={exportImageOpen} onClose={() => setExportImageOpen(false)} container={document.body}> 919 + <div class="box"> 920 + <h1 class="title">Exported! Copy the image to your clipboard or save it now.</h1> 921 + <div id="export-modal-content">{exportModalContent}</div> 922 + </div> 923 + </Modal> 924 + </div> 925 + <div class="navbar-item"> 926 + <button id="export-atproto" class="button is-primary" onClick={exportAtprotoCallback}> 927 + Export to ATProto 928 + </button> 929 + </div> 930 + <div class="navbar-item"> 931 + <button id="view-changelog" class="button is-secondary" onClick={() => setChangelogOpen(true)}> 932 + Changelog 933 + </button> 746 934 747 - <Modal open={exportImageOpen} onClose={() => setExportImageOpen(false)} container={document.body}> 748 - <div class="box"> 749 - <h1 class="title">Exported! Copy the image to your clipboard or save it now.</h1> 750 - <div id="export-modal-content">{exportModalContent}</div> 751 - </div> 752 - </Modal> 935 + <Modal open={changelogOpen} onClose={() => setChangelogOpen(false)} container={document.body}> 936 + <div class="box content"> 937 + <h1>Changelog</h1> 938 + {changelog.map((entry) => ( 939 + <div> 940 + <h2>{entry.version}</h2> 941 + <ul> 942 + {entry.changes.map((change) => ( 943 + <li>{change}</li> 944 + ))} 945 + </ul> 946 + </div> 947 + ))} 948 + </div> 949 + </Modal> 950 + </div> 951 + <div class="navbar-item"> 952 + <button id="quiz-mode" class="button is-secondary" onClick={() => setQuizModeOpen(true)}> 953 + Quiz Mode 954 + </button> 753 955 754 - <Modal open={changelogOpen} onClose={() => setChangelogOpen(false)} container={document.body}> 755 - <div class="box content"> 756 - <h1>Changelog</h1> 757 - {changelog.map(entry => ( 758 - <div> 759 - <h2>{entry.version}</h2> 760 - <ul> 761 - {entry.changes.map(change => <li>{change}</li>)} 762 - </ul> 956 + <Modal open={quizModeOpen} onClose={() => setQuizModeOpen(false)} container={document.body}> 957 + <QuizMode /> 958 + </Modal> 959 + </div> 960 + <div class="navbar-item"> 961 + <label class="checkbox"> 962 + <input 963 + id="dark-theme" 964 + type="checkbox" 965 + onChange={(event) => { 966 + if (event.currentTarget.checked) { 967 + localStorage.setItem("theme", "dark"); 968 + document.documentElement.classList.add("theme-dark"); 969 + } else { 970 + localStorage.setItem("theme", "light"); 971 + document.documentElement.classList.remove("theme-dark"); 972 + } 973 + }} 974 + checked={document.documentElement.classList.contains("theme-dark")} 975 + />{" "} 976 + Dark theme 977 + </label> 763 978 </div> 764 - ))} 765 - </div> 766 - </Modal> 767 - </div>; 979 + </div> 980 + </nav> 981 + 982 + <nav class="level px-5"> 983 + <div id="legend" class="kinks-legend level-left"> 984 + <Legend /> 985 + </div> 986 + </nav> 987 + 988 + <section class="section kinks-section"> 989 + <KinksSection /> 990 + </section> 991 + </div> 992 + ); 768 993 } 769 994 770 995 render(<RealRoot />, root!);
+186 -13
public/style.css
··· 110 110 margin-right: 3px !important; 111 111 } 112 112 113 - .choice { 113 + .inline-choice { 114 + width: 16px; 115 + height: 16px; 116 + display: inline-block; 117 + } 118 + 119 + .choice, .inline-choice { 114 120 padding: 0; 115 121 font-size: 0; 116 122 outline: none; ··· 122 128 cursor: pointer; 123 129 } 124 130 125 - .choice:not([disabled]):hover { 131 + .choice:not([disabled]):hover, .inline-choice:not([disabled]):hover { 126 132 opacity: 0.7; 127 133 } 128 134 129 - .choice[disabled] { 135 + .choice[disabled], .inline-choice[disabled] { 130 136 cursor: inherit; 131 137 } 132 138 133 - .choice.selected { 139 + .choice.selected, .inline-choice.selected { 134 140 opacity: 1; 135 141 border-width: 2px; 136 142 } 137 143 138 - .choice.not-entered { 144 + .choice.not-entered, .inline-choice.not-entered { 139 145 background-color: #FFFFFF; 140 146 } 141 147 142 - .choice.favorite { 148 + .choice.favorite, .inline-choice.favorite { 143 149 background-color: #6DB5FE; 144 150 } 145 151 146 - .choice.like { 152 + .choice.like, .inline-choice.like { 147 153 background-color: #23FD22; 148 154 } 149 155 150 - .choice.okay { 156 + .choice.okay, .inline-choice.okay { 151 157 background-color: #FDFD6B; 152 158 } 153 159 154 - .choice.maybe { 160 + .choice.maybe, .inline-choice.maybe { 155 161 background-color: #DB6C00; 156 162 } 157 163 158 - .choice.no { 164 + .choice.no, .inline-choice.no { 159 165 background-color: #920000; 160 166 } 161 167 162 - .choice.want-to-try { 168 + .choice.want-to-try, .inline-choice.want-to-try { 163 169 background-color: white; 164 170 background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNScgaGVpZ2h0PScxNSc+CiAgPHJlY3Qgd2lkdGg9JzE1JyBoZWlnaHQ9JzE1JyBmaWxsPSd3aGl0ZScvPgogIDxwYXRoIGQ9J00tMSwxIGwyLC0yCiAgICAgICAgICAgTTAsNSBsMTUsLTE1CiAgICAgICAgICAgTTAsMTAgbDE1LC0xNQogICAgICAgICAgIE0wLDE1IGwxNSwtMTUKICAgICAgICAgICBNMCwyMCBsMTUsLTE1CiAgICAgICAgICAgTTAsMjUgbDE1LC0xNQogICAgICAgICAgIE0xNCwxNiBsMiwtMicgc3Ryb2tlPSdibGFjaycgc3Ryb2tlLXdpZHRoPScxJy8+Cjwvc3ZnPgo=); 165 171 background-repeat: repeat; ··· 207 213 } 208 214 209 215 /* Use white-colored border for choice buttons when dark theme is enabled */ 210 - html.theme-dark .choice { 216 + html.theme-dark .choice, html.theme-dark .inline-choice { 211 217 border-color: white; 212 218 } 213 219 ··· 218 224 flex-direction: column; 219 225 justify-content: center; 220 226 } 221 - } 227 + } 228 + 229 + /* Quiz mode */ 230 + 231 + #InputOverlay { 232 + text-align: center; 233 + white-space: nowrap; 234 + } 235 + #InputOverlay:before { 236 + content: ''; 237 + display: inline-block; 238 + height: 100%; 239 + vertical-align: middle; 240 + margin-right: -0.25em; 241 + } 242 + #InputOverlay .widthWrapper { 243 + display: inline-block; 244 + vertical-align: middle; 245 + width: 400px; 246 + text-align: left; 247 + max-width: 100%; 248 + } 249 + #InputOverlay .widthWrapper #InputCurrent { 250 + border-radius: 5px; 251 + } 252 + #InputPrevious .kink-simple { 253 + border-top-left-radius: 5px; 254 + border-top-right-radius: 5px; 255 + } 256 + #InputNext .kink-simple { 257 + border-bottom-left-radius: 5px; 258 + border-bottom-right-radius: 5px; 259 + } 260 + #InputOverlay .widthWrapper #InputCurrent, #InputOverlay .widthWrapper .kink-simple { 261 + display: block; 262 + box-sizing: border-box; 263 + padding: 10px; 264 + background-color: #EEE; 265 + } 266 + #InputOverlay .widthWrapper .kink-simple { 267 + position: relative; 268 + height: 40px; 269 + line-height: 20px; 270 + cursor: pointer; 271 + } 272 + #InputOverlay .widthWrapper .kink-simple .inline-choice { 273 + margin-right: 5px; 274 + } 275 + #InputOverlay .widthWrapper .kink-simple .txt-category { 276 + position: absolute; 277 + right: 10px; 278 + top: 10px; 279 + font-size: 90%; 280 + font-weight: bold; 281 + opacity: 0.6; 282 + line-height: 1em; 283 + } 284 + #InputOverlay .widthWrapper .kink-simple .txt-field, #InputOverlay .widthWrapper .kink-simple .txt-kink { 285 + vertical-align: middle; 286 + } 287 + #InputOverlay .widthWrapper .kink-simple .txt-field:empty { 288 + display: none; 289 + } 290 + #InputOverlay .widthWrapper .kink-simple .txt-field:before, #InputField .participant:before { 291 + content: '('; 292 + } 293 + #InputOverlay .widthWrapper .kink-simple .txt-field:after, #InputField .participant:after { 294 + content: ') '; 295 + } 296 + 297 + #InputOverlay .widthWrapper #InputPrevious .kink-simple:first-child, 298 + #InputOverlay .widthWrapper #InputNext .kink-simple:nth-child(3) { 299 + background-color: #BBB; 300 + font-size: 10px; 301 + margin-left: 12px; 302 + margin-right: 12px; 303 + height: 33px; 304 + } 305 + #InputOverlay .widthWrapper #InputPrevious .kink-simple:nth-child(2), 306 + #InputOverlay .widthWrapper #InputNext .kink-simple:nth-child(2) { 307 + background-color: #CCC; 308 + font-size: 11px; 309 + margin-left: 6px; 310 + margin-right: 6px; 311 + height: 37px; 312 + } 313 + #InputOverlay .widthWrapper #InputPrevious .kink-simple:nth-child(3), 314 + #InputOverlay .widthWrapper #InputNext .kink-simple:first-child { 315 + background-color: #DDD; 316 + margin-left: 3px; 317 + margin-right: 3px; 318 + } 319 + #InputOverlay .widthWrapper #InputPrevious .kink-simple:first-child { 320 + padding-bottom: 4px; 321 + padding-top: 7px; 322 + } 323 + #InputOverlay .widthWrapper #InputNext .kink-simple:nth-child(3) { 324 + padding-top: 4px; 325 + } 326 + #InputOverlay .widthWrapper #InputPrevious .kink-simple:nth-child(2) { 327 + padding-bottom: 7px; 328 + padding-top: 9px; 329 + } 330 + #InputOverlay .widthWrapper #InputNext .kink-simple:nth-child(2) { 331 + padding-top: 7px; 332 + } 333 + 334 + #InputOverlay .widthWrapper #InputCurrent { 335 + position: relative; 336 + } 337 + #InputOverlay .widthWrapper #InputCurrent .closePopup { 338 + position: absolute; 339 + top: 0; 340 + right: 5px; 341 + border-style: none; 342 + background-color: transparent; 343 + font-size: 30px; 344 + cursor: pointer; 345 + outline-style: none!important; 346 + opacity: 0.65; 347 + } 348 + #InputOverlay .widthWrapper #InputCurrent .closePopup:hover { 349 + opacity: 1; 350 + } 351 + #InputOverlay .widthWrapper #InputCurrent h2 { 352 + opacity: 0.6; 353 + margin: 0; 354 + } 355 + #InputOverlay .widthWrapper #InputCurrent h3 { 356 + margin-top: 3px; 357 + margin-bottom: 0; 358 + font-size: 14px; 359 + } 360 + #InputOverlay .widthWrapper #InputCurrent #InputValues .big-choice { 361 + padding: 10px; 362 + background-color: rgba(255,255,255,0.75); 363 + border-radius: 4px; 364 + margin-top: 5px; 365 + cursor: pointer; 366 + } 367 + #InputOverlay .widthWrapper #InputCurrent #InputValues .big-choice.selected { 368 + font-weight: bold; 369 + } 370 + #InputOverlay .widthWrapper #InputCurrent #InputValues .big-choice.selected .inline-choice { 371 + opacity: 1; 372 + } 373 + #InputOverlay .widthWrapper #InputCurrent #InputValues .big-choice:hover { 374 + padding: 8px; 375 + border: solid #999 2px; 376 + background-color: rgba(255,255,255,1); 377 + } 378 + #InputOverlay .widthWrapper #InputCurrent #InputValues .big-choice .btn-num-text { 379 + float: right; 380 + display: inline-block; 381 + border: solid #CCC 1px; 382 + text-align: center; 383 + width: 16px; 384 + border-radius: 3px; 385 + } 386 + 387 + .legend-text { 388 + line-height: 25px; 389 + vertical-align: middle; 390 + } 391 + /* 392 + .big-choice .inline-choice { 393 + margin-top: calc((25px - 16px) / 4); 394 + } */