this repo has no description
0
fork

Configure Feed

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

Preact refactor + wisp

uwx 4c603231 60ca9def

+878 -488
+1
.gitignore
··· 1 + node_modules
+24
package.json
··· 1 + { 2 + "name": "kinklist.gitlab.io", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "index.js", 6 + "scripts": { 7 + "test": "echo \"Error: no test specified\" && exit 1" 8 + }, 9 + "keywords": [], 10 + "author": "", 11 + "license": "ISC", 12 + "packageManager": "pnpm@10.28.0", 13 + "devDependencies": { 14 + "@types/masonry-layout": "^4.2.8", 15 + "typescript": "6.0.0-dev.20260213" 16 + }, 17 + "dependencies": { 18 + "@tippyjs/react": "^4.2.6", 19 + "lz-string": "^1.5.0", 20 + "preact": "^10.28.3", 21 + "react-masonry-css": "^1.0.16", 22 + "tippy.js": "^6.3.7" 23 + } 24 + }
+127
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + '@tippyjs/react': 12 + specifier: ^4.2.6 13 + version: 4.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 14 + lz-string: 15 + specifier: ^1.5.0 16 + version: 1.5.0 17 + preact: 18 + specifier: ^10.28.3 19 + version: 10.28.3 20 + react-masonry-css: 21 + specifier: ^1.0.16 22 + version: 1.0.16(react@19.2.4) 23 + tippy.js: 24 + specifier: ^6.3.7 25 + version: 6.3.7 26 + devDependencies: 27 + '@types/masonry-layout': 28 + specifier: ^4.2.8 29 + version: 4.2.8 30 + typescript: 31 + specifier: 6.0.0-dev.20260213 32 + version: 6.0.0-dev.20260213 33 + 34 + packages: 35 + 36 + '@popperjs/core@2.11.8': 37 + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} 38 + 39 + '@tippyjs/react@4.2.6': 40 + resolution: {integrity: sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw==} 41 + peerDependencies: 42 + react: '>=16.8' 43 + react-dom: '>=16.8' 44 + 45 + '@types/jquery@3.5.33': 46 + resolution: {integrity: sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==} 47 + 48 + '@types/masonry-layout@4.2.8': 49 + resolution: {integrity: sha512-Et2to22C31FG1UFaHRBL6BznMOhrur3Ckr9gvR7fRVmPgxqiwCEKZtV8GpFscHyNAKhZ0QlkwXJRPnJvxZUKQw==} 50 + 51 + '@types/sizzle@2.3.10': 52 + resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} 53 + 54 + lz-string@1.5.0: 55 + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 56 + hasBin: true 57 + 58 + preact@10.28.3: 59 + resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} 60 + 61 + react-dom@19.2.4: 62 + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} 63 + peerDependencies: 64 + react: ^19.2.4 65 + 66 + react-masonry-css@1.0.16: 67 + resolution: {integrity: sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==} 68 + peerDependencies: 69 + react: '>=16.0.0' 70 + 71 + react@19.2.4: 72 + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} 73 + engines: {node: '>=0.10.0'} 74 + 75 + scheduler@0.27.0: 76 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} 77 + 78 + tippy.js@6.3.7: 79 + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} 80 + 81 + typescript@6.0.0-dev.20260213: 82 + resolution: {integrity: sha512-zGIJwsX3OEsKIoEvXJzHRpt58fk370/T1N0GsSECAbcTlrsfUe2QQFbdKyKT3HyG2hFFyZg+Q0zYn6VLeahafg==} 83 + engines: {node: '>=14.17'} 84 + hasBin: true 85 + 86 + snapshots: 87 + 88 + '@popperjs/core@2.11.8': {} 89 + 90 + '@tippyjs/react@4.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': 91 + dependencies: 92 + react: 19.2.4 93 + react-dom: 19.2.4(react@19.2.4) 94 + tippy.js: 6.3.7 95 + 96 + '@types/jquery@3.5.33': 97 + dependencies: 98 + '@types/sizzle': 2.3.10 99 + 100 + '@types/masonry-layout@4.2.8': 101 + dependencies: 102 + '@types/jquery': 3.5.33 103 + 104 + '@types/sizzle@2.3.10': {} 105 + 106 + lz-string@1.5.0: {} 107 + 108 + preact@10.28.3: {} 109 + 110 + react-dom@19.2.4(react@19.2.4): 111 + dependencies: 112 + react: 19.2.4 113 + scheduler: 0.27.0 114 + 115 + react-masonry-css@1.0.16(react@19.2.4): 116 + dependencies: 117 + react: 19.2.4 118 + 119 + react@19.2.4: {} 120 + 121 + scheduler@0.27.0: {} 122 + 123 + tippy.js@6.3.7: 124 + dependencies: 125 + '@popperjs/core': 2.11.8 126 + 127 + typescript@6.0.0-dev.20260213: {}
+1
public/.wispignore
··· 1 + *.ts
-208
public/dom-tools.d.ts
··· 1 - export declare type ElementList<T extends Element> = NodeListOf<T> | HTMLCollectionOf<T> | T[]; 2 - export declare type ElementListWithElement<T extends Element> = { 3 - 0: T; 4 - } & ElementList<T>; 5 - declare type ElementsInBrackets<T> = { 6 - [P in keyof T & string as `<${P}>`]: T[P]; 7 - }; 8 - /** 9 - * Creates a DOMNode from a CSS selector, fast-tracking where possible, with the selector root being a parameter 10 - * @param selector the selector 11 - * @param root the selector root 12 - * @param alwaysQuerySelector set to true to simply do querySelectorAll 13 - * @param rootIsDocument set to true when root === window.document, allows fast-track #id and [name], and create element via <tag> 14 - */ 15 - export declare function $element<T extends ElementsInBrackets<HTMLElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T, root: Element | Document, alwaysQuerySelector?: false, rootIsDocument?: boolean): BaseContainer<TK>; 16 - export declare function $element<T extends ElementsInBrackets<SVGElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T, root: Element | Document, alwaysQuerySelector?: false, rootIsDocument?: boolean): BaseContainer<TK>; 17 - export declare function $element<T extends HTMLElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK, root: Element | Document, alwaysQuerySelector?: false, rootIsDocument?: boolean): BaseContainer<TK>; 18 - export declare function $element<T extends SVGElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK, root: Element | Document, alwaysQuerySelector?: false, rootIsDocument?: boolean): BaseContainer<TK>; 19 - export declare function $element<E extends Element = Element>(selector: string, root: Element | Document, alwaysQuerySelector?: boolean, rootIsDocument?: boolean): BaseContainer<E>; 20 - /** 21 - * Delays a function's execution to when the DOM loads, but before all images and media are loaded 22 - * @param func the function to execute 23 - */ 24 - export declare function $onLoad(func: () => void): void; 25 - /** 26 - * Creates a DOMNode wrapping around an existing element-like object 27 - * @param obj the object to wrap around 28 - */ 29 - export declare function $wrap<T extends Element>(obj: T): ElementContainer<T>; 30 - export declare function $wrap<T extends Element>(obj: ElementList<T>): ElementListContainer<T>; 31 - export declare function $wrap<T extends BaseContainer<E>, E extends Element>(obj: T): T; 32 - export declare function $wrap(obj: Document): EventTargetContainer<Document, HTMLElement>; 33 - export declare function $wrap(obj: Window): EventTargetContainer<Window, HTMLElement>; 34 - export declare function $wrap<T extends Element>(obj: T | ElementList<T> | BaseContainer<T> | Window | Document): BaseContainer<T>; 35 - /** 36 - * The main DomTools function. it's basically like jQuery! 37 - * @param arg the argument - a selector, a function to execute on DOM load, or an object to wrap into a DOMNode 38 - */ 39 - export declare function $d<T extends ElementsInBrackets<HTMLElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T): BaseContainer<TK>; 40 - export declare function $d<T extends ElementsInBrackets<SVGElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T): BaseContainer<TK>; 41 - export declare function $d<T extends HTMLElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK): BaseContainer<TK>; 42 - export declare function $d<T extends SVGElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK): BaseContainer<TK>; 43 - export declare function $d<E extends Element = Element>(selector: string): BaseContainer<E>; 44 - export declare function $d(arg: (() => void)): void; 45 - export declare function $d<T extends Element>(obj: T): ElementContainer<T>; 46 - export declare function $d<T extends Element>(obj: ElementList<T>): ElementListContainer<T>; 47 - export declare function $d<T extends BaseContainer<E>, E extends Element>(obj: T): T; 48 - export declare function $d(obj: Document): EventTargetContainer<Document, HTMLElement>; 49 - export declare function $d(obj: Window): EventTargetContainer<Window, HTMLElement>; 50 - export declare function $d<T extends Element>(obj: T | ElementList<T> | BaseContainer<T> | Window | Document): BaseContainer<T>; 51 - export declare function $d<E extends Element = Element>(arg: E | ElementList<E> | Window | Document | BaseContainer<E> | string | (() => void)): void | BaseContainer<E>; 52 - export declare namespace $d { 53 - /** 54 - * Calls DomTools but throws if the returned value is empty. This is a direct opposited to jQuery which never throws. 55 - * @param arg the argument - a selector, a function to execute on DOM load, or an object to wrap into a DOMNode 56 - */ 57 - function strict<T extends ElementsInBrackets<HTMLElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T): BaseContainer<TK>; 58 - function strict<T extends ElementsInBrackets<SVGElementTagNameMap>, K extends keyof T, TK extends T[K] & Element>(selector: T): BaseContainer<TK>; 59 - function strict<T extends HTMLElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK): BaseContainer<TK>; 60 - function strict<T extends SVGElementTagNameMap, K extends keyof T, TK extends T[K] & Element>(selector: TK): BaseContainer<TK>; 61 - function strict<E extends Element = Element>(selector: string): BaseContainer<E>; 62 - function strict(arg: (() => void)): void; 63 - function strict<T extends Element>(obj: T): ElementContainer<T>; 64 - function strict<T extends Element>(obj: ElementList<T>): ElementListContainer<T>; 65 - function strict<T extends BaseContainer<E>, E extends Element>(obj: T): T; 66 - function strict(obj: Document): EventTargetContainer<Document, HTMLElement>; 67 - function strict(obj: Window): EventTargetContainer<Window, HTMLElement>; 68 - function strict<T extends Element>(obj: T | ElementList<T> | BaseContainer<T> | Window | Document): BaseContainer<T>; 69 - /** set to true to fallback to querySelector without passing alwaysQuerySelector=true */ 70 - let allowQuerySelector: boolean; 71 - /** set to true to throw instead of nooping when an error occurs */ 72 - let useStrict: boolean; 73 - /** set to true for verbose logging for debugging purposes */ 74 - let verbose: boolean; 75 - } 76 - export declare abstract class BaseContainer<TElement extends Element> { 77 - _originalDisplay?: string; 78 - /** 79 - * Returns an iterable containing the elements represented by this container. If there is only one element, returns 80 - * an array containing a single entry. If there are no elements, returns an empty array. 81 - */ 82 - abstract get elements(): ElementList<TElement>; 83 - /** 84 - * Returns the first element represented by this container. If there is only one element, returns that element. If 85 - * there are no elements, returns null. 86 - */ 87 - get element(): TElement | null; 88 - /** 89 - * Get a given element at an index. 90 - */ 91 - get(index: number): TElement | null; 92 - /** 93 - * Throws an error if this node contains no elements, and returns the current object otherwise. Methods that operate 94 - * on this.elements will never throw if the list is empty, so this is an option. 95 - */ 96 - throwIfEmpty(): this & { 97 - element: TElement; 98 - elements: ElementListWithElement<TElement>; 99 - }; 100 - /** 101 - * Utility for TypeScript - it is not yet possible (issue #34636) to have both an assertion and a return statement, 102 - * so throwIfEmpty and narrowNotEmpty let you choose which you want. 103 - */ 104 - narrowNotEmpty(): asserts this is this & { 105 - element: TElement; 106 - elements: ElementListWithElement<TElement>; 107 - }; 108 - get data(): Record<string, string | undefined>; 109 - withData(operation: (dataset: Record<string, string | undefined>) => void): this; 110 - html(string: string): this; 111 - html(): string; 112 - empty(): this; 113 - append(arg: string | Node | BaseContainer<Element> | Array<string | Node | BaseContainer<Element>>): this; 114 - appendToAll(arg: string | Node | BaseContainer<Element> | Array<string | Node | BaseContainer<Element>>): this; 115 - appendText(text: string): this; 116 - attr(name: string): string; 117 - attr(name: string, value: string): this; 118 - addClass(class1: string, ...otherClasses: string[]): this; 119 - removeClass(dropClass: string): this; 120 - children(): ElementListContainer<Element>; 121 - parent(): BaseContainer<Element>; 122 - find<T extends ElementsInBrackets<HTMLElementTagNameMap>, K extends keyof T>(selector: T, alwaysQuerySelector?: boolean): T[K]; 123 - find<T extends ElementsInBrackets<SVGElementTagNameMap>, K extends keyof T>(selector: T, alwaysQuerySelector?: boolean): T[K]; 124 - find<T extends HTMLElementTagNameMap, K extends keyof T>(selector: K, alwaysQuerySelector?: boolean): T[K] | null; 125 - find<T extends SVGElementTagNameMap, K extends keyof T>(selector: K, alwaysQuerySelector?: boolean): T[K] | null; 126 - find<E extends Element = Element>(selector: string, alwaysQuerySelector?: boolean): BaseContainer<E>; 127 - remove(): this; 128 - on<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): this; 129 - on(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): this; 130 - once<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, useCapture?: boolean): this; 131 - once(type: string, listener: EventListenerOrEventListenerObject, useCapture?: boolean): this; 132 - off<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, options?: boolean | EventListenerOptions): this; 133 - off(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): this; 134 - each(callback: (el: Element, index: number, elements: ElementList<TElement>) => void): this; 135 - map<T>(callback: (el: Element, index: number, elements: ElementList<TElement>) => T): T[]; 136 - $each(callback: (el: BaseContainer<TElement>, index: number, elements: ElementList<TElement>) => void): this; 137 - $map(callback: (el: BaseContainer<TElement>, index: number, elements: ElementList<TElement>) => BaseContainer<Element> | Element): BaseContainer<Element>; 138 - hide(): this; 139 - show(): this; 140 - css(property: keyof CSSStyleDeclaration & string): string; 141 - css(property: keyof CSSStyleDeclaration & string, value: string): this; 142 - clearChildren(): this; 143 - text(): string; 144 - text(string: string): this; 145 - val(): string; 146 - val(value: string): this; 147 - checked(): boolean; 148 - checked(value: boolean): this; 149 - private _eventFunction; 150 - private _dualEventFunction; 151 - click(): this; 152 - click(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 153 - blur(): this; 154 - blur(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 155 - focus(): this; 156 - focus(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 157 - keypress(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 158 - submit(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 159 - load(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 160 - dblclick(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 161 - keydown(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 162 - change(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 163 - resize(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 164 - mouseenter(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 165 - keyup(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 166 - scroll(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 167 - mouseleave(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 168 - unload(callback: EventListenerOrEventListenerObject, useCapture?: boolean | AddEventListenerOptions): this; 169 - } 170 - declare class EmptyContainer<TElement extends Element> extends BaseContainer<TElement> { 171 - get elements(): ElementList<any>; 172 - get element(): null; 173 - } 174 - declare class ElementContainer<TElement extends Element> extends BaseContainer<TElement> { 175 - _elements: TElement[]; 176 - constructor(element: TElement); 177 - get element(): TElement; 178 - get elements(): TElement[]; 179 - children(): ElementListContainer<Element>; 180 - parent(): ElementContainer<Element> | EmptyContainer<Element>; 181 - find<T extends ElementsInBrackets<HTMLElementTagNameMap>, K extends keyof T>(selector: T, alwaysQuerySelector?: boolean): T[K]; 182 - find<T extends ElementsInBrackets<SVGElementTagNameMap>, K extends keyof T>(selector: T, alwaysQuerySelector?: boolean): T[K]; 183 - find<T extends HTMLElementTagNameMap, K extends keyof T>(selector: K, alwaysQuerySelector?: boolean): T[K] | null; 184 - find<T extends SVGElementTagNameMap, K extends keyof T>(selector: K, alwaysQuerySelector?: boolean): T[K] | null; 185 - find<E extends Element = Element>(selector: string, alwaysQuerySelector?: boolean): BaseContainer<E>; 186 - each(callback: (el: Element, index: number, elements: TElement[]) => void): this; 187 - map<T>(callback: (el: Element, index: number, elements: TElement[]) => T): T[]; 188 - $each(callback: (el: BaseContainer<TElement>, index: number, elements: TElement[]) => void): this; 189 - $map(callback: (el: this, index: number, elements: TElement[]) => ElementContainer<Element> | Element): ElementContainer<Element>; 190 - } 191 - export declare class ElementListContainer<TElement extends Element> extends BaseContainer<TElement> { 192 - _elements: ElementList<TElement>; 193 - constructor(elements: ElementList<TElement>); 194 - get elements(): ElementList<TElement>; 195 - } 196 - export declare class EventTargetContainer<TEventTarget extends EventTarget, TElement extends Element> extends ElementContainer<TElement> { 197 - _eventTarget: TEventTarget; 198 - constructor(eventTarget: TEventTarget, element: TElement); 199 - on<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): this; 200 - on(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): this; 201 - once<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, useCapture?: boolean): this; 202 - once(type: string, listener: EventListenerOrEventListenerObject, useCapture?: boolean): this; 203 - off<K extends keyof ElementEventMap>(type: K, listener: (this: Element, ev: ElementEventMap[K]) => any, options?: boolean | EventListenerOptions): this; 204 - off(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): this; 205 - } 206 - export default $d; 207 - export declare const $window: EventTargetContainer<Window, HTMLElement>; 208 - export declare const $document: EventTargetContainer<Document, HTMLElement>;
+6 -6
public/exporter.js
··· 11 11 * @param {CanvasPattern} pattern 12 12 * @param {{ data: string; x: number; y: number; }} drawCall 13 13 */ 14 - // @ts-ignore 15 14 simpleTitle(context, pattern, drawCall) { 16 15 context.fillStyle = '#000000'; 17 16 context.font = 'bold 18px Arial'; ··· 22 21 * @param {CanvasPattern} pattern 23 22 * @param {{ data: { category: any; fields: any[]; }; x: number; y: number; }} drawCall 24 23 */ 25 - // @ts-ignore 26 24 titleSubtitle(context, pattern, drawCall) { 27 25 context.fillStyle = '#000000'; 28 26 context.font = 'bold 18px Arial'; ··· 69 67 }; 70 68 71 69 const patternImg = new Image(); 72 - patternImg.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNScgaGVpZ2h0PScxNSc+CiAgPHJlY3Qgd2lkdGg9JzE1JyBoZWlnaHQ9JzE1JyBmaWxsPSd3aGl0ZScvPgogIDxwYXRoIGQ9J00tMSwxIGwyLC0yCiAgICAgICAgICAgTTAsNSBsMTUsLTE1CiAgICAgICAgICAgTTAsMTAgbDE1LC0xNQogICAgICAgICAgIE0wLDE1IGwxNSwtMTUKICAgICAgICAgICBNMCwyMCBsMTUsLTE1CiAgICAgICAgICAgTTAsMjUgbDE1LC0xNQogICAgICAgICAgIE0xNCwxNiBsMiwtMicgc3Ryb2tlPSdibGFjaycgc3Ryb2tlLXdpZHRoPScxJy8+Cjwvc3ZnPgo='; 70 + patternImg.src = 71 + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc' + 72 + 'xNScgaGVpZ2h0PScxNSc+CiAgPHJlY3Qgd2lkdGg9JzE1JyBoZWlnaHQ9JzE1JyBmaWxsPSd3aGl0ZScvPgogIDxwYXRoIGQ9J00tMSwxI' + 73 + 'GwyLC0yCiAgICAgICAgICAgTTAsNSBsMTUsLTE1CiAgICAgICAgICAgTTAsMTAgbDE1LC0xNQogICAgICAgICAgIE0wLDE1IGwxNSwtMTUK' + 74 + 'ICAgICAgICAgICBNMCwyMCBsMTUsLTE1CiAgICAgICAgICAgTTAsMjUgbDE1LC0xNQogICAgICAgICAgIE0xNCwxNiBsMiwtMicgc3Ryb2t' + 75 + 'lPSdibGFjaycgc3Ryb2tlLXdpZHRoPScxJy8+Cjwvc3ZnPgo='; 73 76 74 77 /** 75 78 * @param {number} width ··· 244 247 245 248 // Try 1: https://stackoverflow.com/a/57546936 246 249 247 - // @ts-ignore 248 250 if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write !== undefined) { 249 251 canvas.toBlob(blob => { 250 252 // @ts-ignore ··· 256 258 return; 257 259 } 258 260 259 - // @ts-ignore 260 261 const item = new ClipboardItem({ 'image/png': blob }); 261 - // @ts-ignore 262 262 navigator.clipboard.write([item]); 263 263 264 264 alert('Copied to clipboard!');
+7
public/helpers.d.ts
··· 1 + declare const tippy: typeof import('tippy.js')['default']; 2 + // declare const Masonry: typeof import('masonry-layout'); 3 + declare const preact: typeof import('preact'); 4 + declare const preactHooks: typeof import('preact/hooks'); 5 + declare const preactCompat: typeof import('preact/compat'); 6 + // declare const Tippy: typeof import('@tippyjs/react'); 7 + declare const Masonry: typeof import('react-masonry-css')['default'];
+27 -34
public/index.html
··· 18 18 <meta property="og:description" content="Generate a list of your sexual preferences and kinks, then export and share your list!"> 19 19 20 20 <!--<link rel="stylesheet" href="https://unpkg.com/bulma-prefers-dark@0.1.0-beta.0/css/bulma-prefers-dark.css">--> 21 + <link rel="stylesheet" href="https://unpkg.com/tippy.js@6/dist/tippy.css"> 21 22 <script> 22 23 // https://stackoverflow.com/a/56550819 23 24 // determines if the user has a set theme ··· 133 134 display: none !important; 134 135 } 135 136 136 - /*.masonry { 137 - display: flex; 138 - flex-direction: column; 139 - flex-wrap: wrap; 140 - height: 100vw; 141 - max-height: 800px; 142 - }*/ 143 - .masonry > .masonry-inner { 144 - float: left; 145 - width: 100%; 146 - /*transition: .8s opacity;*/ 147 - } 148 - 149 - @media screen and (min-width: 1024px) { 150 - .masonry > .masonry-inner { 151 - width: 50%; 152 - } 153 - } 154 - 155 - @media screen and (min-width: 1530px) { 156 - .masonry > .masonry-inner { 157 - width: 33.3%; 158 - } 159 - } 160 - 161 137 /* Margin in between choices */ 162 138 .choices .columns.is-gapless .column:not(:last-child) { 163 139 margin-right: 3px !important; ··· 245 221 html.dark-theme .choice { 246 222 border-color: white; 247 223 } 224 + .masonry { 225 + display: -webkit-box; /* Not needed if autoprefixing */ 226 + display: -ms-flexbox; /* Not needed if autoprefixing */ 227 + display: flex; 228 + width: auto; 229 + } 230 + .masonry-column { 231 + background-clip: padding-box; 232 + } 248 233 </style> 249 234 250 235 <kinks> ··· 522 507 <div class="box content"> 523 508 <h1>Changelog</h2> 524 509 510 + <h2>Version 14 - February 13th 2026</h2> 511 + <ul> 512 + <li>Use Preact for rendering</li> 513 + </ul> 514 + 525 515 <h2>Version 13 - November 14th 2022</h2> 526 516 <ul> 527 517 <li>Remove Futanari options in favor of an easier to understand selection of sexual characteristics irrespective of gender</li> ··· 608 598 <button class="modal-close is-large" aria-label="close"></button> 609 599 </div> 610 600 611 - <script type="module"> 612 - import * as DOMTools from 'https://rawcdn.githack.com/uwx/dom-tools/0ffaa4212dee3fbd1e15a211aa3f470bed65f29b/out/browser/dom-tools.js'; 613 - 614 - for (const [k, v] of Object.entries(DOMTools)) { 615 - window[k] = v; 616 - } 617 - </script> 618 601 <script src="https://unpkg.com/@popperjs/core@2"></script> 619 602 <script src="https://unpkg.com/tippy.js@6"></script> 620 - <!--<script src="https://gistcdn.githack.com/uwx/da1b8582cc5300b6b3d42540d738df9e/raw/7b29235ca1dd327c138317031327b60a9f1782a1/dom-tools.js"></script>--> 621 - <script src="https://unpkg.com/masonry-layout@4.2.2/dist/masonry.pkgd.min.js"></script> 603 + <script src="https://unpkg.com/preact@10.28.3/dist/preact.min.js"></script> 604 + <script src="https://unpkg.com/preact@10.28.3/hooks/dist/hooks.umd.js"></script> 605 + <script src="https://unpkg.com/preact@10.28.3/compat/dist/compat.umd.js"></script> 606 + <script> 607 + // Alias React UMD globals to Preact globals 608 + window.React = window.preactCompat; 609 + window.react = window.preactCompat; 610 + window.ReactDOM = window.preactCompat; 611 + </script> 612 + <script src="https://unpkg.com/react-masonry-css@1.0.16/dist/react-masonry-css.umd.js"></script> 622 613 623 614 <!-- defer is used here so that DOMTools is loaded in time as it's async --> 624 615 <script src="pageinfo.js" async></script> 616 + <script src="qfs.js" defer></script> 617 + <script src="preact-tippy.js" defer></script> 625 618 <script src="kinklist.js" defer></script> 626 619 <script src="exporter.js" defer></script> 627 620 <script>
+3 -1
public/jsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 + "strict": true, 4 + "strictNullChecks": false, 3 5 "module": "commonjs", 4 6 "target": "es6", 5 - "lib": ["es2019", "dom", "dom.iterable"] 7 + "lib": ["ESNext", "dom", "dom.iterable"] 6 8 }, 7 9 "exclude": ["node_modules", "**/node_modules/*"], 8 10 "include": ["*"]
+264 -239
public/kinklist.js
··· 4 4 5 5 // @ts-check 6 6 7 - /// <reference path="dom-tools.d.ts" /> 8 - 9 - /** 10 - * @typedef {object} DOMNode 11 - */ 12 - 13 - // @ts-ignore 14 - const $ = window.$d; 7 + /// <reference path="helpers.d.ts" /> 15 8 16 - const root = $('#root'); 17 - const legend = $('#legend'); 9 + const { h, render, Fragment } = preact; 10 + const { useState, useEffect, useReducer } = preactHooks; 18 11 19 - const kinkCode = [...document.querySelectorAll('kinks')] 20 - .flatMap(e => e.textContent.split('\n')) 21 - .map(e => e.trim()) 22 - .filter(e => e); 12 + const root = document.querySelector('#root'); 13 + const legend = document.querySelector('#legend'); 23 14 24 15 /** 25 16 * @typedef {object} Kink ··· 35 26 * @property {string[]} participants 36 27 */ 37 28 38 - /** @type {KinkCategory[]} */ 39 - const kinkCategories = []; 29 + /** 30 + * @param {string} kinkStr 31 + */ 32 + function parseKinks(kinkStr) { 33 + const kinkCode = kinkStr.split('\n') 34 + .map(e => e.trim()) 35 + .filter(e => e); 36 + 37 + /** @type {KinkCategory[]} */ 38 + const kinkCategories = []; 39 + 40 + /** @type {Kink[]} */ 41 + const kinksById = []; 40 42 41 - /** @type {Kink[]} */ 42 - const kinksById = []; 43 + /** 44 + * @type {Partial<KinkCategory>} 45 + */ 46 + let curKinkCategory; 47 + let curKinkId = 0; 48 + for (const line of kinkCode) { 49 + if (line.startsWith('#')) { 50 + const [categoryName, categoryDesc] = sliceOnce(removeSymbols(line, '#'), '?'); 51 + 52 + curKinkCategory = { 53 + name: categoryName, 54 + description: categoryDesc, 55 + kinks: [], 56 + participants: ['Unknown'] 57 + }; 58 + // @ts-ignore 59 + kinkCategories.push(curKinkCategory); 60 + } else if (line.startsWith('(') && line.endsWith(')')) { 61 + if (curKinkCategory === undefined) { 62 + throw new Error('Encountered a participant definition before a kink type declaration'); 63 + } 64 + curKinkCategory.participants = removeSymbols(line, '(', ')').split(',').map(e => e.trim()); 65 + } else if (line.startsWith('*')) { 66 + if (curKinkCategory === undefined) { 67 + throw new Error('Encountered a kink definition before a kink type declaration'); 68 + } 43 69 44 - /** @type {[string, string, string][]} */ 70 + const [kinkName, kinkDescription] = sliceOnce(removeSymbols(line, '*'), '?'); 71 + const kink = { name: kinkName, description: kinkDescription }; 72 + curKinkCategory.kinks.push(kink); 73 + kinksById[curKinkId++] = kink; 74 + } 75 + } 76 + 77 + return { kinkCategories, kinksById }; 78 + } 79 + 80 + const { kinkCategories, kinksById } = parseKinks([...document.querySelectorAll('kinks')].map(e => e.textContent).join('\n')); 81 + 82 + /** @type {[id: string, name: string, color: string][]} */ 45 83 const choiceOptions = [ 46 84 ['not-entered', 'Not Entered', '#FFFFFF'], 47 85 ['favorite', 'Favorite', '#6DB5FE'], ··· 51 89 ['no', 'No', '#920000'], 52 90 ['try', 'Want To Try', 'pattern'], 53 91 ]; 54 - const choiceOptionIndices = Object.fromEntries(choiceOptions.map(([id, _name], i) => [id, i])); 92 + const choiceOptionIndices = Object.fromEntries(choiceOptions.map(([id], i) => [id, i])); 55 93 56 94 /** 57 95 * Maps kink name -> participant -> choice (id string) ··· 63 101 /** 64 102 * Maps kink -> participant -> choice option -> button element 65 103 * Entries may be undefined! 66 - * @type {Map<Kink, Map<string, Map<string, DOMNode>>>} 104 + * @type {Map<Kink, Map<string, Map<string, (action: 'select' | 'deselect') => void>>>} 67 105 */ 68 106 const kinkButtons = new Map(); 69 107 ··· 81 119 return undefined; 82 120 } 83 121 84 - for (const [type, typeDescription] of choiceOptions) { 85 - legend.append( 86 - $('<div>') 87 - .addClass('level-item') 88 - .addClass('is-justify-content-flex-start') 89 - .append( 90 - $('<button>') 91 - .addClass('choice', type) 92 - .attr('title', typeDescription) 93 - .attr('disabled', '') 94 - ) 95 - .append( 96 - $('<span>') 97 - .appendText(typeDescription) 98 - ) 99 - ); 122 + /** 123 + * @typedef {object} LegendChoiceProps 124 + * @property {string} type 125 + * @property {string} typeDescription 126 + */ 127 + 128 + /** 129 + * @param {LegendChoiceProps} props 130 + * @returns 131 + */ 132 + function LegendChoice({type, typeDescription}) { 133 + return h('div', { 134 + class: 'level-item is-justify-content-flex-start' 135 + }, [ 136 + h('button', { 137 + class: 'choice ' + type, 138 + title: typeDescription, 139 + disabled: true 140 + }), 141 + h('span', {}, typeDescription) 142 + ]); 100 143 } 144 + 145 + function Legend() { 146 + return h(Fragment, null, choiceOptions.map(e => h(LegendChoice, { type: e[0], typeDescription: e[1] }))); 147 + } 148 + 149 + render(h(Legend, null), legend); 101 150 102 151 /** 103 152 * @param {string} str ··· 134 183 return str.trim(); 135 184 } 136 185 137 - // Parse kinks 138 - { 139 - /** 140 - * @type {Partial<KinkCategory>} 141 - */ 142 - let curKinkCategory; 143 - let curKinkId = 0; 144 - for (const line of kinkCode) { 145 - if (line.startsWith('#')) { 146 - const [categoryName, categoryDesc] = sliceOnce(removeSymbols(line, '#'), '?'); 147 - 148 - curKinkCategory = { 149 - name: categoryName, 150 - description: categoryDesc, 151 - kinks: [], 152 - participants: ['Unknown'] 153 - }; 154 - // @ts-ignore 155 - kinkCategories.push(curKinkCategory); 156 - } else if (line.startsWith('(') && line.endsWith(')')) { 157 - if (curKinkCategory === undefined) { 158 - throw new Error('Encountered a participant definition before a kink type declaration'); 159 - } 160 - curKinkCategory.participants = removeSymbols(line, '(', ')').split(',').map(e => e.trim()); 161 - } else if (line.startsWith('*')) { 162 - if (curKinkCategory === undefined) { 163 - throw new Error('Encountered a kink definition before a kink type declaration'); 164 - } 165 - 166 - const [kinkName, kinkDescription] = sliceOnce(removeSymbols(line, '*'), '?'); 167 - const kink = {name: kinkName, description: kinkDescription}; 168 - curKinkCategory.kinks.push(kink); 169 - kinksById[curKinkId++] = kink; 170 - } 171 - } 172 - } 173 - 174 186 const base64Alphabet = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/']; // base64 alphabet 175 187 176 188 /** ··· 181 193 182 194 /** 183 195 * https://stackoverflow.com/a/62362724 184 - * @param {number[]} arr 196 + * @param {number[] | Uint8Array} arr 185 197 * @returns {string} 186 198 */ 187 199 function bytesArrToBase64(arr) { 200 + if (Uint8Array.prototype.toBase64) { 201 + return Uint8Array.from(arr).toBase64(); 202 + } 203 + 188 204 const l = arr.length; 189 205 let result = ''; 190 206 191 - for (let i=0; i<=(l-1)/3; i++) { 192 - const c1 = i*3+1>=l; // case when "=" is on end 193 - const c2 = i*3+2>=l; // case when "=" is on end 194 - const chunk = toBinary(arr[3*i]) + toBinary(c1? 0:arr[3*i+1]) + toBinary(c2? 0:arr[3*i+2]); 195 - const r = chunk.match(/.{1,6}/g).map((x,j)=> j==3&&c2 ? '=' :(j==2&&c1 ? '=':base64Alphabet[+('0b'+x)])); 207 + for (let i = 0; i <= (l - 1) / 3; i++) { 208 + const c1 = i * 3 + 1 >= l; // case when "=" is on end 209 + const c2 = i * 3 + 2 >= l; // case when "=" is on end 210 + const chunk = toBinary(arr[3 * i]) + toBinary(c1 ? 0 : arr[3 * i + 1]) + toBinary(c2 ? 0 : arr[3 * i + 2]); 211 + const r = chunk.match(/.{1,6}/g).map((x, j) => j == 3 && c2 ? '=' : (j == 2 && c1 ? '=' : base64Alphabet[+('0b' + x)])); 196 212 result += r.join(''); 197 213 } 198 214 ··· 201 217 /** 202 218 * https://stackoverflow.com/a/62364519 203 219 * @param {string} str 204 - * @returns {number[]} 220 + * @returns {Uint8Array} 205 221 */ 206 222 function base64ToBytesArr(str) { 223 + if (Uint8Array.fromBase64) { 224 + const bytes = Uint8Array.fromBase64(str); 225 + return bytes; 226 + } 227 + 207 228 const result = []; 208 229 209 - for (let i=0; i<str.length/4; i++) { 210 - const chunk = [...str.slice(4*i,4*i+4)]; 211 - const bin = chunk.map(x=> base64Alphabet.indexOf(x).toString(2).padStart(6, '0')).join(''); 212 - const bytes = bin.match(/.{1,8}/g).map(x=> +('0b'+x)); 213 - result.push(...bytes.slice(0,3 - (str[4*i+2]=='='?1:0) - (str[4*i+3]=='='?1:0))); 230 + for (let i = 0; i < str.length / 4; i++) { 231 + const chunk = [...str.slice(4 * i, 4 * i + 4)]; 232 + const bin = chunk.map(x => base64Alphabet.indexOf(x).toString(2).padStart(6, '0')).join(''); 233 + const bytes = bin.match(/.{1,8}/g).map(x => +('0b' + x)); 234 + result.push(...bytes.slice(0, 3 - (str[4 * i + 2] == '=' ? 1 : 0) - (str[4 * i + 3] == '=' ? 1 : 0))); 214 235 } 215 - return result; 236 + return new Uint8Array(result); 216 237 } 217 238 218 239 /** ··· 243 264 * @returns {string} 244 265 */ 245 266 function serializeChoices() { 267 + /** 268 + * @type {number[]} 269 + */ 246 270 const bytes = []; 247 271 248 272 let i = -1; 273 + 274 + /** 275 + * @param {number} num 276 + */ 249 277 function pushNumber(num) { 250 278 if (i !== -1) { 251 279 i |= num << 4; ··· 267 295 pushNumber(PADDING_MARKER); 268 296 } 269 297 270 - return bytesArrToBase64(bytes); 298 + // return bytesArrToBase64(bytes); 299 + 300 + return '!' + bytesArrToBase64(qfs.compress(new Uint8Array(bytes))); 271 301 } 272 302 273 303 /** 274 304 * @param {string} base64 275 305 */ 276 306 function deserializeChoices(base64) { 277 - const bytes = base64ToBytesArr(base64); 307 + const bytes = base64.startsWith('!') 308 + ? qfs.decompress(base64ToBytesArr(base64.slice(1))) 309 + : base64ToBytesArr(base64); 278 310 279 311 let isUpperHalf = false; 280 312 let index = 0; ··· 300 332 } 301 333 302 334 const choiceId = choiceOptions[choice][0]; 335 + // console.log('Setting kink selection for', kink.name, participant, 'to', choiceId); 303 336 setKinkSelection(kink, participant, choiceId); 304 337 305 338 for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 306 339 if (thatChoiceId === choiceId) { 307 - thatButton.addClass('selected'); 340 + // console.log('Setting select', thatChoiceId) 341 + thatButton('select'); 308 342 } else { 309 - thatButton.removeClass('selected'); 343 + // console.log('Setting deselect', thatChoiceId) 344 + thatButton('deselect'); 310 345 } 311 346 } 312 347 } ··· 316 351 } 317 352 318 353 /** 319 - * @param {string} choiceId 320 - * @param {string} choiceDescription 321 - * @returns {{container: DOMNode, button: DOMNode}} 354 + * @typedef {object} KinkChoiceButtonProps 355 + * @property {Kink} kink 356 + * @property {string} participant 357 + * @property {string} choiceId 358 + * @property {string} choiceDescription 322 359 */ 323 - function createKinkChoiceButton(choiceId, choiceDescription) { 360 + 361 + /** 362 + * @param {KinkChoiceButtonProps} props 363 + * @returns 364 + */ 365 + function KinkChoiceButton({kink, participant, choiceId, choiceDescription}) { 324 366 // <div class="column"><button class="choice notEntered" title="Not Entered"></button></div> 325 367 326 - const button = $('<button>') 327 - .addClass('choice', choiceId) 328 - .attr('title', choiceDescription); 368 + /** 369 + * @param {boolean} state 370 + * @param {'select' | 'deselect'} action 371 + * @returns {boolean} 372 + */ 373 + function reducer(state, action) { 374 + if (action === 'select') { 375 + return true; 376 + } else if (action === 'deselect') { 377 + return false; 378 + } 379 + } 329 380 330 - const column = $('<div>') 331 - .addClass('column') 332 - .append(button); 333 - 334 - return {container: column, button}; 335 - } 336 - 337 - 338 - /** 339 - * Adds a new button DOMNode to kinkButtons for a kink. 340 - * @param {Kink} kink 341 - * @param {string} participant 342 - * @param {string} choiceId 343 - * @param {DOMNode} button 344 - */ 345 - function addKinkButtonState(kink, participant, choiceId, button) { 346 - // kink -> participant -> choice option -> button element 381 + const [selected, update] = useReducer(reducer, false); 347 382 348 383 let participants = kinkButtons.get(kink); 349 384 if (participants === undefined) { ··· 355 390 participants.set(participant, choices = new Map()); 356 391 } 357 392 358 - choices.set(choiceId, button); 393 + choices.set(choiceId, update); 394 + 395 + // kink -> participant -> choice option -> button element 396 + 397 + return h('div', { class: 'column' }, [ 398 + h('button', { 399 + class: `choice ${choiceId}${selected ? ' selected' : ''}`, 400 + title: choiceDescription, 401 + onClick: () => { 402 + setKinkSelection(kink, participant, choiceId); 403 + 404 + // Unselect all other buttons for this kink+participant combo, and select the applicable one. 405 + for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 406 + if (thatChoiceId === choiceId) { 407 + thatButton('select'); 408 + } else { 409 + thatButton('deselect'); 410 + } 411 + } 412 + 413 + window.location.hash = serializeChoices(); 414 + } 415 + }) 416 + ]); 359 417 } 360 418 361 419 /** 362 420 * Gets the mappings from choice->button element for a kink+participant combo. 363 421 * @param {Kink} kink 364 422 * @param {string} participant 365 - * @returns {Map<string, DOMNode>} 423 + * @returns {Map<string, (action: "select" | "deselect") => void>} 366 424 */ 367 425 function getKinkButtonStates(kink, participant) { 368 426 return kinkButtons.get(kink).get(participant); 369 427 } 370 428 371 429 /** 372 - * @param {KinkCategory} kinkCategory 373 - * @param {Kink} kink 430 + * @param {{kinkCategory: KinkCategory, kink: Kink}} props 374 431 */ 375 - function createKink(kinkCategory, kink) { 432 + function TheKink({kinkCategory, kink}) { 376 433 /* 377 434 <tr class="kinkRow kink-skinny"> 378 435 <td> ··· 392 449 </tr> 393 450 */ 394 451 395 - const choices = []; 396 - 397 - for (const participant of kinkCategory.participants) { 398 - const choiceButtons = []; 399 - 400 - for (const [choiceId, choiceDescription] of choiceOptions) { 401 - const {container, button} = createKinkChoiceButton(choiceId, choiceDescription); 402 - choiceButtons.push(container); 403 - 404 - addKinkButtonState(kink, participant, choiceId, button); 405 - 406 - button.on('click', () => { 407 - setKinkSelection(kink, participant, choiceId); 408 - 409 - // Unselect all other buttons for this kink+participant combo, and select the applicable one. 410 - for (const [thatChoiceId, thatButton] of getKinkButtonStates(kink, participant).entries()) { 411 - if (thatChoiceId === choiceId) { 412 - thatButton.addClass('selected'); 413 - } else { 414 - thatButton.removeClass('selected'); 415 - } 416 - } 452 + return h('tr', { class: 'kinks-row' }, [ 453 + kinkCategory.participants.map(participant => h('td', { 'data-choice-type': participant }, [ 454 + h('div', { class: 'choices choice-general' }, [ 455 + h('div', { class: 'columns is-mobile is-gapless' }, [ 456 + choiceOptions.map(e => h(KinkChoiceButton, { 457 + key: kink + '_' + participant + '_' + e[0], 458 + kink, 459 + participant, 460 + choiceId: e[0], 461 + choiceDescription: e[1] 462 + })) 463 + ]) 464 + ]) 465 + ])), 466 + h('td', null, [ 467 + kink.description != null 468 + ? h(Tippy, { content: kink.description }, [ 469 + h('span', { 470 + class: 'has-description', 471 + title: kink.description 472 + }, kink.name) 473 + ]) 474 + : h('span', null, kink.name), 475 + ]) 476 + ]); 477 + } 417 478 418 - window.location.hash = serializeChoices(); 419 - }); 420 - } 421 - 422 - choices.push($('<td>') 423 - .attr('data-choice-type', participant) 424 - .append( 425 - $('<div>') 426 - .addClass('choices choice-general') 427 - .append( 428 - $('<div>') 429 - .addClass('columns is-mobile is-gapless') 430 - .append(choiceButtons) 431 - ) 432 - ) 433 - ); 434 - } 435 - 436 - const kinkLabel = $('<span>') 437 - .appendText(kink.name); 438 - 439 - if (kink.description !== null) { 440 - kinkLabel 441 - .addClass('has-description') 442 - .attr('aria-description', kink.description) 443 - .attr('data-tippy-content', kink.description); 444 - } 445 - 446 - const kinkElement = $('<tr>') 447 - .addClass('kinks-row') 448 - .append(choices) 449 - .append( 450 - $('<td>') 451 - .append(kinkLabel) 452 - ); 453 - 454 - return {kinkElement, choices}; 455 - } 479 + /** 480 + * @typedef {object} TheKinkCategoryProps 481 + * @property {KinkCategory} kinkCategory 482 + */ 456 483 457 484 /** 458 - * @param {KinkCategory} kinkCategory 485 + * @param {TheKinkCategoryProps} kinkCategory 459 486 */ 460 - function createKinkCategory(kinkCategory) { 487 + function TheKinkCategory({kinkCategory}) { 461 488 /* 462 489 <div class="column is-narrow"> 463 490 <!--<p class="notification is-primary"> ··· 487 514 </tr> 488 515 */ 489 516 490 - const headers = kinkCategory.participants.map(e => $('<th>').addClass('kinks-header').appendText(e)); 491 - const kinks = kinkCategory.kinks.map(e => createKink(kinkCategory, e)); 517 + return h( 518 + 'div', 519 + { class: 'masonry-inner', 'data-num-participants': String(kinkCategory.participants.length) }, 520 + [ 521 + h( 522 + 'h1', 523 + { class: 'subtitle kinks-subtitle' }, 524 + [ 525 + kinkCategory.name, 526 + ...( 527 + kinkCategory.description != null 528 + ? [ 529 + ' ', 530 + h(Tippy, { 531 + content: kinkCategory.description, 532 + }, [ 533 + h('span', { 534 + class: 'has-subtitle-description', 535 + ariaDescription: kinkCategory.description 536 + }, '(?)') 537 + ]) 538 + ] 539 + : [] 540 + ) 541 + ] 542 + ), 543 + h('table', { class: 'table kinks-table is-striped is-narrow is-hoverable' }, [ 544 + h('thead', null, [ 545 + ...kinkCategory.participants.map(e => h('th', { class: 'kinks-header' }, e)) 546 + ]), 547 + h('tbody', null, [ 548 + ...kinkCategory.kinks.map(kink => h(TheKink, { kinkCategory, kink }, null)) 549 + ]) 550 + ]) 551 + ] 552 + ); 553 + } 492 554 493 - const subtitle = $('<h1>') 494 - .addClass('subtitle kinks-subtitle') 495 - .appendText(kinkCategory.name); 496 - 497 - if (kinkCategory.description !== null) { 498 - subtitle 499 - .appendText(' ') 500 - .append( 501 - $('<span>') 502 - .appendText('(?)') 503 - .addClass('has-subtitle-description') 504 - .attr('aria-description', kinkCategory.description) 505 - .attr('data-tippy-content', kinkCategory.description) 506 - ); 507 - } 508 - 509 - const kinkCategoryContainer = $('<div>') 510 - .addClass('masonry-inner') 511 - .attr('data-num-participants', kinkCategory.participants.length) 512 - .append( 513 - subtitle 514 - ) 515 - .append( 516 - $('<table>') 517 - .addClass('table kinks-table is-striped is-narrow is-hoverable') 518 - .append( 519 - $('<thead>') 520 - .append(headers) 521 - ) 522 - .append( 523 - $('<tbody>') 524 - .append(kinks.map(e => e.kinkElement)) 525 - ) 526 - ); 527 - 528 - return kinkCategoryContainer; 555 + function Root() { 556 + return h(Masonry, { breakpointCols: { 557 + // breakpoints are the opposite to what you'd expect 558 + 1023: 1, 559 + 1530: 2, 560 + default: 3, 561 + }, className: 'masonry', columnClassName: 'masonry-column' }, [ 562 + kinkCategories.map(kinkCategory => h(TheKinkCategory, { kinkCategory }, null)) 563 + ]); 529 564 } 530 565 531 - for (const kinkCategory of kinkCategories) { 532 - root.append(createKinkCategory(kinkCategory)); 533 - } 566 + render(h(Root, null), root); 534 567 535 - if (window.location.hash) { 536 - try { 537 - deserializeChoices(window.location.hash.slice(1)); 538 - } catch (err) { 539 - console.error('Failed to load saved kinks:', err); 568 + setTimeout(() => { 569 + if (window.location.hash) { 570 + try { 571 + deserializeChoices(window.location.hash.slice(1)); 572 + } catch (err) { 573 + console.error('Failed to load saved kinks:', err); 574 + } 540 575 } 541 - } 542 - 543 - // @ts-ignore 544 - const masonry = new Masonry('.masonry', { 545 - itemSelector: '.masonry-inner', 546 - columnWidth: '.masonry-inner:first-child', 547 - percentPosition: true 548 - }); 549 - 550 - // @ts-ignore 551 - tippy('[data-tippy-content]'); 576 + }, 0);
+28
public/preact-tippy.js
··· 1 + // @ts-check 2 + 3 + /** 4 + * @typedef {object} TippyProps 5 + * @property {import("tippy.js").Props} [config] 6 + * @property {string} content 7 + * @property {import("preact").ComponentChildren} [children] 8 + * @property {string} [class] 9 + */ 10 + 11 + /** 12 + * @param {TippyProps} props 13 + */ 14 + function Tippy({ config, content, children, class: className }) { 15 + const ref = preactHooks.useRef(null); 16 + 17 + preactHooks.useEffect(() => { 18 + const element = ref.current; 19 + if (element === null) return; 20 + 21 + const instance = tippy(element, {content, ...config}); 22 + return () => { 23 + instance[0].destroy(); 24 + }; 25 + }, []); 26 + 27 + return h('span', { ref, className }, children); 28 + }
+390
public/qfs.js
··· 1 + // @ts-check 2 + 3 + /*! 4 + MIT License 5 + 6 + Copyright (c) 2021 Sebastiaan Marynissen 7 + 8 + Permission is hereby granted, free of charge, to any person obtaining a copy 9 + of this software and associated documentation files (the "Software"), to deal 10 + in the Software without restriction, including without limitation the rights 11 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 + copies of the Software, and to permit persons to whom the Software is 13 + furnished to do so, subject to the following conditions: 14 + 15 + The above copyright notice and this permission notice shall be included in all 16 + copies or substantial portions of the Software. 17 + 18 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 + SOFTWARE. 25 + */ 26 + 27 + const qfs = (() => { 28 + 29 + // # index.js 30 + // A JavaScript implementation of the QFS compression and decompression 31 + // algorithms. Based on wouanagaine's C library found here: https://github.com/ 32 + // wouanagaine/SC4Mapper-2013/blob/master/Modules/qfs.c 33 + 34 + // # decompress(input) 35 + // JavaScript implementation of the QFS decompression algorithm. 36 + // IMPORTANT! In some cases, the first 4 bytes indicate the size of the input 37 + // buffer. We **don't** detect this automatically, you need to discard those 4 38 + // bytes yourself! 39 + 40 + /** 41 + * @param {Uint8Array} input 42 + * @returns {Uint8Array} 43 + */ 44 + function decompress(input) { 45 + 46 + // Check magic number. 47 + let [a, b] = input; 48 + if (!((a === 0x10 || a === 0x11) && b === 0xfb)) { 49 + throw new Error( 50 + 'Input is not a valid QFS compressed buffer! Did you forget to truncate the size bytes?' 51 + ); 52 + } 53 + 54 + // Create an malloc function based on the input buffer class we received. 55 + const malloc = createMalloc(input); 56 + 57 + // First two bytes are 0x10fb (QFS id), then follows the *uncompressed* 58 + // size, which allows us to prepare a buffer for it. 59 + const size = 0x10000*input[2] + 0x100*input[3] + input[4]; 60 + const out = malloc(size); 61 + 62 + // Start decoding now. Note that trailing bytes are handled separately, 63 + // indicated by a control character >= 0xfc. 64 + let inpos = input[0] & 0x01 ? 8 : 5; 65 + let outpos = 0; 66 + while (inpos < input.length && input[inpos] < 0xfc) { 67 + let code = input[inpos]; 68 + let a = input[inpos+1]; 69 + let b = input[inpos+2]; 70 + if (!(code & 0x80)) { 71 + let length = code & 3; 72 + memcpy(out, outpos, input, inpos+2, length); 73 + inpos += length+2; 74 + outpos += length; 75 + 76 + // Repeat data that is already in the output. This is the essence 77 + // of the compression algorithm. 78 + length = ((code & 0x1c) >> 2) + 3; 79 + let offset = ((code >> 5) << 8) + a + 1; 80 + memcpy(out, outpos, out, outpos-offset, length); 81 + outpos += length; 82 + 83 + } else if (!(code & 0x40)) { 84 + let length = (a >> 6) & 3; 85 + memcpy(out, outpos, input, inpos+3, length); 86 + inpos += length+3; 87 + outpos += length; 88 + 89 + // Repeat data already in the outpot. 90 + length = (code & 0x3f) + 4; 91 + let offset = (a & 0x3f)*256 + b + 1; 92 + memcpy(out, outpos, out, outpos-offset, length); 93 + outpos += length; 94 + 95 + } else if (!(code & 0x20)) { 96 + let c = input[inpos+3]; 97 + let length = code & 3; 98 + memcpy(out, outpos, input, inpos+4, length); 99 + inpos += length+4; 100 + outpos += length; 101 + 102 + // Repeat data that is already in the output. 103 + length = ((code>>2) & 3)*256 + c + 5; 104 + let offset = ((code & 0x10)<<12)+256*a + b + 1; 105 + memcpy(out, outpos, out, outpos-offset, length); 106 + outpos += length; 107 + 108 + } else { 109 + 110 + // The last case means there's no compression really, we just copy 111 + // as is. 112 + let length = (code & 0x1f)*4 + 4; 113 + memcpy(out, outpos, input, inpos+1, length); 114 + inpos += length+1; 115 + outpos += length; 116 + 117 + } 118 + 119 + } 120 + 121 + // Trailing bytes. This is indicated by the control character being 122 + // greater than 0xfc. 123 + if (inpos < input.length && outpos < out.length) { 124 + let length = input[inpos] & 3; 125 + memcpy(out, outpos, input, inpos+1, length); 126 + outpos += length; 127 + } 128 + 129 + // Check if everything is correct. 130 + if (outpos !== out.length) { 131 + throw new Error('Error when decompressing!'); 132 + } 133 + 134 + // We're done! 135 + return out; 136 + 137 + } 138 + 139 + // # createMalloc(buffer) 140 + // Returns an `malloc()` function which reuses the constructor of the given 141 + // buffer. That way, if we receive an Uint8Array, we output one as well and 142 + // vice versa: if we receive a Node.js buffer - even in the browser - we return 143 + // one. 144 + /** 145 + * @param {Uint8Array} buffer 146 + * @returns {(size: number) => Uint8Array} 147 + */ 148 + function createMalloc(buffer) { 149 + const Ctor = buffer.constructor; 150 + // @ts-expect-error 151 + const Constructor = Ctor[Symbol.species] || Ctor; 152 + return size => new Constructor(size); 153 + } 154 + 155 + // # memcpy(out, outpos, input, inpos, length) 156 + // LZ-compatible memcopy function. We don't use buffer.copy here because we 157 + // might be copying from ourselves as well! 158 + /** 159 + * @param {Uint8Array} out 160 + * @param {number} outpos 161 + * @param {Uint8Array} input 162 + * @param {number} inpos 163 + * @param {number} length 164 + */ 165 + function memcpy(out, outpos, input, inpos, length) { 166 + let i = length; 167 + while (i--) { 168 + out[outpos++] = input[inpos++]; 169 + } 170 + } 171 + 172 + // # SmartBuffer 173 + // Tiny implementation of a smart buffer that only supports writing raw 174 + // *bytes*. 175 + const DEFAULT_SIZE = 4096; 176 + const MAX_SIZE = 32*1024*1024; 177 + class SmartBuffer { 178 + /** 179 + * @param {{ (size: number): Uint8Array; }} malloc 180 + */ 181 + constructor(malloc) { 182 + this.length = 0; 183 + this.buffer = malloc(DEFAULT_SIZE); 184 + this.malloc = malloc; 185 + } 186 + /** 187 + * @param {number} byte 188 + */ 189 + push(byte) { 190 + let { buffer } = this; 191 + if (buffer.length < this.length+1) { 192 + let newLength = Math.min(MAX_SIZE, 2*buffer.length); 193 + let newBuffer = this.malloc(newLength); 194 + newBuffer.set(buffer); 195 + this.buffer = newBuffer; 196 + } 197 + this.buffer[this.length++] = byte; 198 + } 199 + toBuffer() { 200 + return this.buffer.subarray(0, this.length); 201 + } 202 + } 203 + 204 + // Performance calibration constants for compression. 205 + const QFS_MAXITER = 50; 206 + 207 + // # compress(input, opts) 208 + // A JavaScript implementation of QFS compression. We use a smart buffer here 209 + // so that we don't have to manage the output size manually. 210 + /** 211 + * @param {Uint8Array} input 212 + * @param {{ windowBits?: number, includeSize?: boolean }} [opts] 213 + * @returns {Uint8Array} 214 + */ 215 + function compress(input, opts = {}) { 216 + 217 + // Important! If the input buffer is larger than 16MB, we can't compress 218 + // because that would cause a bit overflow and the size to be stored as 0! 219 + const inlen = input.length; 220 + if (inlen > 0xffffff) { 221 + throw new Error(`Input size cannot be larger than ${0xffffff} bytes!`); 222 + } 223 + 224 + // Constants for tuning performance. 225 + const { windowBits = 17, includeSize = false } = opts; 226 + const WINDOW_LEN = 2**windowBits; 227 + const WINDOW_MASK = WINDOW_LEN-1; 228 + 229 + // Prepare our buffer to which we'll write the output. 230 + const malloc = createMalloc(input); 231 + const out = new SmartBuffer(malloc); 232 + const push = out.push.bind(out); 233 + 234 + // Initialize our occurence tables. The C++ code is rather difficult to 235 + // understand here as there is a lot of pointer magic involved.Anyway, 236 + // `rev_similar` is an array where we store the offsets that we calculated 237 + // every input position. 238 + let rev_similar = new Int32Array(WINDOW_LEN).fill(-1); 239 + 240 + // The `rev_last` code is a lot more difficult to understand though. In 241 + // C++ it's a data structure that can hold 256 x 256 integer pointers. 242 + // This is actually a table for tracking the *offset* at which the last 243 + // [a, b] byte 244 + // sequence was found! We implement this table simply as a flat array. of 245 + // 256*256 size, which means our indices have to be calculated as 256*a + 246 + // b. 247 + let rev_last = new Int32Array(256*256).fill(-1); 248 + 249 + // The "fill" method simply writes uncompressed data to the output stream. 250 + // We always do this right before writing away a "best length" match. 251 + let inpos = 0; 252 + let lastwrot = 0; 253 + const fill = () => { 254 + while (inpos - lastwrot >= 4) { 255 + let length = Math.floor((inpos - lastwrot)/4) - 1; 256 + if (length > 0x1b) length = 0x1b; 257 + push(0xe0 + length); 258 + length = 4*length + 4; 259 + while (length--) push(input[lastwrot++]); 260 + } 261 + }; 262 + 263 + // If we have to include the size of the compressed buffer as well, we'll 264 + // reserve 4 bytes to write this away once we know the size. 265 + if (includeSize) { 266 + for (let i = 0; i < 4; i++) push(0); 267 + } 268 + 269 + // Write the header to the output. 270 + push(0x10); 271 + push(0xfb); 272 + push(inlen >> 16); 273 + push((inlen >> 8) & 0xff); 274 + push(inlen & 0xff); 275 + 276 + // Main encoding loop. 277 + const max = inlen-1; 278 + for (; inpos < max; inpos++) { 279 + 280 + // Update the occurence tables. The C++ code uses some pointer magic 281 + // for this, but we will do it in a more modern way. We simply update 282 + // the last time this combination was found. 283 + let index = 256*input[inpos] + input[inpos+1]; 284 + let offs = rev_similar[inpos & WINDOW_MASK] = rev_last[index]; 285 + rev_last[index] = inpos; 286 + 287 + // If this part has already been compressed, skip ahead. 288 + if (inpos < lastwrot) continue; 289 + 290 + // Look for a redundancy now. 291 + let bestlen = 0; 292 + let bestoffs = 0; 293 + let i = 0; 294 + while (offs >= 0 && inpos-offs < WINDOW_LEN && i++ < QFS_MAXITER) { 295 + let length = 2; 296 + let incmp = inpos + 2; 297 + let inref = offs + 2; 298 + while ( 299 + incmp < inlen && 300 + inref < inlen && 301 + input[incmp++] === input[inref++] && 302 + length < 1028 303 + ) { 304 + length++; 305 + } 306 + if (length > bestlen) { 307 + bestlen = length; 308 + bestoffs = inpos-offs; 309 + } 310 + offs = rev_similar[offs & WINDOW_MASK]; 311 + } 312 + 313 + // Check if redundancy is good enough. 314 + if (bestlen > inlen-inpos) { 315 + bestlen = inpos-inlen; 316 + } else if ( 317 + bestlen <= 2 || 318 + (bestlen === 3 && bestoffs > 1024) || 319 + (bestlen === 4 && bestoffs > 16384) 320 + ) { 321 + continue; 322 + } 323 + 324 + // If we did not find a suitable redundancy length by now, continue. 325 + // We do this to avoid additional nesting. 326 + if (!bestlen) continue; 327 + 328 + // Cool, we found a good redundancy. Now write away. 329 + fill(); 330 + let length = inpos-lastwrot; 331 + if (bestlen <= 10 && bestoffs <= 1024) { 332 + 333 + // 2-byte control character. 334 + let d = bestoffs-1; 335 + push(((d>>8)<<5) + ((bestlen-3)<<2) + length); 336 + push(d & 0xff); 337 + while (length--) push(input[lastwrot++]); 338 + lastwrot += bestlen; 339 + 340 + } else if (bestlen <= 67 && bestoffs <= 16384) { 341 + 342 + // 3-byte control character. 343 + let d = bestoffs-1; 344 + push(0x80 + (bestlen-4)); 345 + push((length<<6) + (d>>8)); 346 + push(d & 0xff); 347 + while (length--) push(input[lastwrot++]); 348 + lastwrot += bestlen; 349 + 350 + } else if (bestlen <= 1028 && bestoffs < WINDOW_LEN) { 351 + 352 + // 4-byte control character. 353 + let d = bestoffs-1; 354 + push(0xC0 + ((d>>16)<<4) + (((bestlen-5)>>8)<<2) + length); 355 + push((d>>8) & 0xff); 356 + push(d & 0xff); 357 + push((bestlen-5) & 0xff); 358 + while (length--) push(input[lastwrot++]); 359 + lastwrot += bestlen; 360 + 361 + } 362 + 363 + } 364 + 365 + // Grab the length of what still needs to be processed and write it away 366 + // as a control character. Then, write the raw contents. 367 + inpos = inlen; 368 + fill(); 369 + let length = inpos - lastwrot; 370 + push(0xfc + length); 371 + while (length--) push(input[lastwrot++]); 372 + 373 + // If we have to include the size, of the *compressed* buffer, do that as 374 + // well. 375 + let buffer = out.toBuffer(); 376 + if (includeSize) { 377 + let size = out.length - 4; 378 + buffer[0] = size & 0xff; 379 + buffer[1] = (size >> 8) & 0xff; 380 + buffer[2] = (size >> 16) & 0xff; 381 + buffer[3] = (size >> 24) & 0xff; 382 + } 383 + 384 + // We're done! 385 + return buffer; 386 + 387 + } 388 + 389 + return { decompress, compress }; 390 + })();