From 6b78928269afb1bb06381fe21f3e0ab0e68c75b1 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 12:54:30 -0400 Subject: [PATCH] feat: extract room code display into ES module component Made-with: Cursor --- js/room-code-display.js | 279 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 js/room-code-display.js diff --git a/js/room-code-display.js b/js/room-code-display.js new file mode 100644 index 0000000..85c4061 --- /dev/null +++ b/js/room-code-display.js @@ -0,0 +1,279 @@ +/** + * Room code lobby display: staggered opacity animation for header, footer, and two code lines. + * Implements the overlay component contract: activate, deactivate, update, getStatus. + */ + +/** + * @typedef {object} RoomCodeDisplayElements + * @property {HTMLElement} header + * @property {HTMLElement} footer + * @property {HTMLElement} codePart1 + * @property {HTMLElement} codePart2 + */ + +/** + * @typedef {object} RoomCodeDisplayInputs + * @property {HTMLInputElement} code1 + * @property {HTMLInputElement} code2 + * @property {HTMLInputElement} color1 + * @property {HTMLInputElement} color2 + * @property {HTMLInputElement} offset1 + * @property {HTMLInputElement} offset2 + * @property {HTMLInputElement} size + * @property {HTMLInputElement} cycle + * @property {HTMLInputElement} headerText + * @property {HTMLInputElement} headerColor + * @property {HTMLInputElement} headerSize + * @property {HTMLInputElement} headerOffset + * @property {HTMLInputElement} footerText + * @property {HTMLInputElement} footerColor + * @property {HTMLInputElement} footerSize + * @property {HTMLInputElement} footerOffset + * @property {HTMLInputElement} headerAppearDelay + * @property {HTMLInputElement} headerAppearDuration + * @property {HTMLInputElement} headerHideTime + * @property {HTMLInputElement} headerHideDuration + * @property {HTMLInputElement} line1AppearDelay + * @property {HTMLInputElement} line1AppearDuration + * @property {HTMLInputElement} line1HideTime + * @property {HTMLInputElement} line1HideDuration + * @property {HTMLInputElement} line2AppearDelay + * @property {HTMLInputElement} line2AppearDuration + * @property {HTMLInputElement} line2HideTime + * @property {HTMLInputElement} line2HideDuration + */ + +export class RoomCodeDisplay { + /** @type {RoomCodeDisplayElements | null} */ + #elements = null; + + /** @type {RoomCodeDisplayInputs | null} */ + #inputs = null; + + /** @type {ReturnType[]} */ + #animationTimers = []; + + /** @type {boolean} */ + #active = false; + + /** + * @param {RoomCodeDisplayElements} elements + * @param {RoomCodeDisplayInputs} inputs + */ + init(elements, inputs) { + this.#elements = elements; + this.#inputs = inputs; + } + + /** + * @param {{ roomCode?: string, [key: string]: unknown }} ctx + */ + activate(ctx) { + this.deactivate(); + + const inputs = this.#inputs; + if (!inputs) { + return; + } + + this.#active = true; + + if (ctx?.roomCode != null && String(ctx.roomCode).trim().length > 0) { + const raw = String(ctx.roomCode).trim().toUpperCase(); + const mid = Math.floor(raw.length / 2); + inputs.code1.value = raw.slice(0, mid); + inputs.code2.value = raw.slice(mid); + } + + this.#applySettings(); + this.#startAnimation(); + } + + deactivate() { + this.#active = false; + this.#clearAnimationTimers(); + + const { header, footer, codePart1, codePart2 } = this.#elements ?? {}; + if (!header || !footer || !codePart1 || !codePart2) { + return; + } + + const fadeTime = '0.3s'; + header.style.transition = `opacity ${fadeTime} ease-out`; + footer.style.transition = `opacity ${fadeTime} ease-out`; + codePart1.style.transition = `opacity ${fadeTime} ease-out`; + codePart2.style.transition = `opacity ${fadeTime} ease-out`; + + header.style.opacity = 0; + footer.style.opacity = 0; + codePart1.style.opacity = 0; + codePart2.style.opacity = 0; + } + + /** + * @param {{ roomCode?: string, [key: string]: unknown }} ctx + */ + update(ctx) { + if (!this.#inputs) { + return; + } + + const next = ctx?.roomCode != null ? String(ctx.roomCode).trim().toUpperCase() : ''; + const current = + this.#inputs.code1.value.toUpperCase() + this.#inputs.code2.value.toUpperCase(); + + if (next !== current) { + this.activate(ctx); + } + } + + getStatus() { + const inputs = this.#inputs; + const roomCode = inputs + ? (inputs.code1.value + inputs.code2.value).toUpperCase() + : ''; + + return { + active: this.#active, + roomCode, + timersRunning: this.#animationTimers.length > 0, + }; + } + + #clearAnimationTimers() { + this.#animationTimers.forEach((id) => clearTimeout(id)); + this.#animationTimers = []; + } + + #applySettings() { + const el = this.#elements; + const inputs = this.#inputs; + if (!el || !inputs) { + return; + } + + const { + header, + footer, + codePart1, + codePart2, + } = el; + + codePart1.textContent = inputs.code1.value.toUpperCase(); + codePart2.textContent = inputs.code2.value.toUpperCase(); + + codePart1.style.color = inputs.color1.value; + codePart2.style.color = inputs.color2.value; + + codePart1.style.transform = `translateY(${inputs.offset1.value}px)`; + codePart2.style.transform = `translateY(${inputs.offset2.value}px)`; + + codePart1.style.fontSize = `${inputs.size.value}px`; + codePart2.style.fontSize = `${inputs.size.value}px`; + + header.textContent = inputs.headerText.value; + header.style.color = inputs.headerColor.value; + header.style.fontSize = `${inputs.headerSize.value}px`; + header.style.transform = `translateY(${inputs.headerOffset.value}px)`; + + footer.textContent = inputs.footerText.value; + footer.style.color = inputs.footerColor.value; + footer.style.fontSize = `${inputs.footerSize.value}px`; + footer.style.transform = `translateY(${inputs.footerOffset.value}px)`; + } + + #startAnimation() { + const el = this.#elements; + const inputs = this.#inputs; + if (!el || !inputs) { + return; + } + + const { header, footer, codePart1, codePart2 } = el; + + this.#clearAnimationTimers(); + + header.style.opacity = 0; + footer.style.opacity = 0; + codePart1.style.opacity = 0; + codePart2.style.opacity = 0; + + const sec = (input) => parseInt(input.value, 10) * 1000; + + const cycleDuration = sec(inputs.cycle); + + const headerAppearDelayMs = sec(inputs.headerAppearDelay); + const headerAppearDurationMs = sec(inputs.headerAppearDuration); + + this.#animationTimers.push( + setTimeout(() => { + header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`; + header.style.opacity = 1; + + footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`; + footer.style.opacity = 1; + }, headerAppearDelayMs), + ); + + const line1AppearDelayMs = sec(inputs.line1AppearDelay); + const line1AppearDurationMs = sec(inputs.line1AppearDuration); + + this.#animationTimers.push( + setTimeout(() => { + codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`; + codePart1.style.opacity = 1; + }, line1AppearDelayMs), + ); + + const line1HideTimeMs = sec(inputs.line1HideTime); + const line1HideDurationMs = sec(inputs.line1HideDuration); + + this.#animationTimers.push( + setTimeout(() => { + codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`; + codePart1.style.opacity = 0; + }, line1HideTimeMs), + ); + + const line2AppearDelayMs = sec(inputs.line2AppearDelay); + const line2AppearDurationMs = sec(inputs.line2AppearDuration); + + this.#animationTimers.push( + setTimeout(() => { + codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`; + codePart2.style.opacity = 1; + }, line2AppearDelayMs), + ); + + const line2HideTimeMs = sec(inputs.line2HideTime); + const line2HideDurationMs = sec(inputs.line2HideDuration); + + this.#animationTimers.push( + setTimeout(() => { + codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`; + codePart2.style.opacity = 0; + }, line2HideTimeMs), + ); + + const headerHideTimeMs = sec(inputs.headerHideTime); + const headerHideDurationMs = sec(inputs.headerHideDuration); + + this.#animationTimers.push( + setTimeout(() => { + header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`; + header.style.opacity = 0; + + footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`; + footer.style.opacity = 0; + }, headerHideTimeMs), + ); + + this.#animationTimers.push( + setTimeout(() => { + if (this.#active) { + this.#startAnimation(); + } + }, cycleDuration), + ); + } +}