Story 4.2 completed

This commit is contained in:
2026-05-24 00:37:21 +02:00
parent de1b33c453
commit 56eeb7cc83
21 changed files with 3836 additions and 56 deletions
+24 -5
View File
@@ -12,12 +12,14 @@ export class RoleRenderer {
* @param {object} controller - ScryingPoolController instance
* @param {object} avTileAdapter - AVTileAdapter instance
* @param {object} adapter - FoundryAdapter instance
* @param {object} [portraitFallbackHandler] - PortraitFallbackHandler instance (Story 4.2)
*/
constructor(stateStore, controller, avTileAdapter, adapter) {
constructor(stateStore, controller, avTileAdapter, adapter, portraitFallbackHandler = null) {
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
this._portraitFallbackHandler = portraitFallbackHandler;
/** @type {ScryingPoolStrip|null} */
this._strip = null;
}
@@ -39,6 +41,16 @@ export class RoleRenderer {
this._strip.render(true);
}
});
// Story 4.2: Refresh AV tile portrait when custom portrait changes
if (this._portraitFallbackHandler) {
this._portraitFallbackHandler.onPortraitChange((userId) => {
const state = this._stateStore.getState(userId);
if (state === 'never-connected' || state === 'cam-lost') {
this._applyAVTileState(userId, state);
}
});
}
}
/**
@@ -59,10 +71,17 @@ export class RoleRenderer {
lockEl.title = 'Camera hidden by GM';
this._avTileAdapter.mount(userId, lockEl);
} else if (CAMERA_ABSENT) {
const fallbackEl = document.createElement('div');
fallbackEl.className = 'sp-portrait-fallback';
fallbackEl.dataset.spRole = 'portrait-fallback';
this._avTileAdapter.mount(userId, fallbackEl);
// Story 4.2: Use PortraitFallbackHandler if available, otherwise fall back to generic div
if (this._portraitFallbackHandler) {
const portraitElement = this._portraitFallbackHandler.getFallbackImageElement(userId);
this._avTileAdapter.mount(userId, portraitElement);
} else {
// Backward compatibility: generic fallback div
const fallbackEl = document.createElement('div');
fallbackEl.className = 'sp-portrait-fallback';
fallbackEl.dataset.spRole = 'portrait-fallback';
this._avTileAdapter.mount(userId, fallbackEl);
}
} else {
this._avTileAdapter.unmount(userId);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { PlayerPrivacyPanel } from '../player/PlayerPrivacyPanel.js';
*/
let _adapter = null;
let _playerPrivacyManager = null;
let _portraitFallbackHandler = null;
/**
* Flag to track if dependencies have been initialized.
@@ -23,8 +24,9 @@ let _isInitialized = false;
* Called once during module initialization.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
*/
export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager) {
export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager, portraitFallbackHandler = null) {
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('initGMPlayerPrivacySelector: adapter is required');
}
@@ -34,6 +36,7 @@ export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager) {
_adapter = adapter;
_playerPrivacyManager = playerPrivacyManager;
_portraitFallbackHandler = portraitFallbackHandler;
_isInitialized = true;
// Register the settings menu
@@ -247,7 +250,8 @@ export class GMPlayerPrivacySelectorMenu {
const panel = new PlayerPrivacyPanel(
this._adapter,
this._playerPrivacyManager,
userId
userId,
_portraitFallbackHandler
);
this._panels.push(panel);
panel.render(true);
+209 -1
View File
@@ -1,4 +1,5 @@
// @ts-nocheck
import { PortraitFallbackHandler } from '../../core/PortraitFallbackHandler.js';
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
@@ -65,11 +66,13 @@ export class PlayerPrivacyPanel extends _AppBase {
* Injected FoundryAdapter surface.
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* Injected PlayerPrivacyManager for privacy settings operations.
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
* Injected PortraitFallbackHandler for portrait operations (Story 4.2).
* @param {string} targetUserId - The user ID whose settings are being viewed/edited.
* @param {object} [options]
* @throws {TypeError} If adapter or playerPrivacyManager is invalid.
*/
constructor(adapter, playerPrivacyManager, targetUserId, options = {}) {
constructor(adapter, playerPrivacyManager, targetUserId, portraitFallbackHandler = null, options = {}) {
// Validate dependencies
if (!adapter || typeof adapter !== 'object') {
throw new TypeError(
@@ -91,6 +94,7 @@ export class PlayerPrivacyPanel extends _AppBase {
this._adapter = adapter;
this._playerPrivacyManager = playerPrivacyManager;
this._portraitFallbackHandler = portraitFallbackHandler;
this._targetUserId = targetUserId;
// Cache for DOM elements
@@ -98,6 +102,12 @@ export class PlayerPrivacyPanel extends _AppBase {
this._reactionCamToggle = null;
/** @type {HTMLElement|null} */
this._hpReactiveCamToggle = null;
/** @type {HTMLElement|null} */
this._fileInput = null;
/** @type {HTMLElement|null} */
this._portraitPreview = null;
/** @type {boolean} */
this._uploading = false;
// Current settings state
/** @type {import('../../contracts/privacy-settings.js').PrivacySettings|null} */
@@ -120,6 +130,17 @@ export class PlayerPrivacyPanel extends _AppBase {
const isOwnUser = this._targetUserId === currentUserId;
const isReadOnly = !isOwnUser;
// Story 4.2: Get portrait fallback data
let portraitPreviewURL = null;
let hasCustomPortrait = false;
if (this._portraitFallbackHandler) {
const custom = this._playerPrivacyManager.getPortraitFallback(this._targetUserId);
hasCustomPortrait = custom !== null && custom !== undefined && custom !== '';
portraitPreviewURL = hasCustomPortrait
? custom
: this._portraitFallbackHandler.getFallbackImageURL(this._targetUserId);
}
return {
// Panel metadata
title: i18n.localize('SCRYING_POOL.PrivacyPanel.title'),
@@ -148,6 +169,15 @@ export class PlayerPrivacyPanel extends _AppBase {
toggleOnLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'),
toggleOffLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'),
// Story 4.2: Portrait fallback section
hasPortraitSection: !!this._portraitFallbackHandler,
portraitLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.portraitFallbackLabel'),
portraitDescription: i18n.localize('SCRYING_POOL.PrivacyPanel.portraitFallbackDescription'),
chooseImageLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.chooseImageLabel'),
removeImageLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageLabel'),
hasCustomPortrait,
portraitPreviewURL,
// State
isReadOnly,
isOwnUser,
@@ -163,6 +193,9 @@ export class PlayerPrivacyPanel extends _AppBase {
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]');
this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]');
// Story 4.2: Set up portrait section event handlers
this._setupPortraitHandlers(element);
// Set up toggle change handlers
this._setupToggleHandlers(element);
}
@@ -179,6 +212,179 @@ export class PlayerPrivacyPanel extends _AppBase {
}
}
/**
* Sets up event handlers for portrait fallback section.
* @param {HTMLElement} element - The dialog element.
*/
_setupPortraitHandlers(element) {
if (!this._portraitFallbackHandler || this._isReadOnlyMode()) {
return;
}
// Cache portrait elements
this._fileInput = element.querySelector('.player-privacy-panel__portrait-input');
this._portraitPreview = element.querySelector('.player-privacy-panel__portrait-preview img');
// Find and set up the choose image button
const chooseButton = element.querySelector('.player-privacy-panel__portrait-choose');
if (chooseButton) {
chooseButton.addEventListener('click', () => this._onChooseImageClick());
}
// Find and set up the remove button (only shown when custom portrait exists)
const removeButton = element.querySelector('.player-privacy-panel__portrait-remove');
if (removeButton) {
removeButton.addEventListener('click', () => this._onRemovePortraitClick());
}
// Set up file input change handler
if (this._fileInput) {
this._fileInput.addEventListener('change', (event) => this._onFileSelected(event));
}
}
/**
* Handles click on Choose Image button.
* @private
*/
_onChooseImageClick() {
if (this._fileInput) {
this._fileInput.click();
}
}
/**
* Handles file selection from the file picker.
* @param {Event} event - The change event from the file input.
* @private
*/
async _onFileSelected(event) {
if (this._uploading) return;
const file = event.target?.files?.[0];
if (!file) return;
this._uploading = true;
// Validate the file
const validation = PortraitFallbackHandler.validatePortraitFile(file);
if (!validation.valid) {
this._adapter.notifications.error(validation.error);
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
this._uploading = false;
return;
}
try {
// Convert file to DataURL
const dataURL = await PortraitFallbackHandler.fileToDataURL(file);
// Validate the DataURL
const result = this._playerPrivacyManager.setPortraitFallback(
this._targetUserId,
dataURL
);
// Wait for the async operation
await result;
// Update the preview and notification
if (this._portraitPreview) {
this._portraitPreview.src = dataURL;
}
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
// Update our cached state
if (this._currentSettings) {
this._currentSettings.customPortraitFallback = dataURL;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitSaved')
);
// Re-render to update the UI (show remove button, etc.)
// Guard against stale render after panel has been closed
if (this.rendered) {
this.render(true);
}
} catch (err) {
console.error('[ScryingPool] PlayerPrivacyPanel: failed to set portrait:', err);
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
// Show error notification
if (err instanceof TypeError) {
this._adapter.notifications.error(err.message);
} else {
this._adapter.notifications.error(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitErrorInvalid')
);
}
} finally {
this._uploading = false;
}
}
/**
* Handles click on Remove custom image button.
* @private
*/
async _onRemovePortraitClick() {
if (this._isReadOnlyMode()) return;
try {
// Show confirmation dialog
// Use FoundryVTT Dialog.confirm if available, fallback to browser confirm
let confirmed;
if (typeof Dialog !== 'undefined' && Dialog.confirm) {
confirmed = await Dialog.confirm({
title: this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageLabel'),
content: this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageConfirm'),
});
} else {
confirmed = window.confirm(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageConfirm')
);
}
if (!confirmed) return;
// Remove the custom portrait
await this._playerPrivacyManager.removePortraitFallback(this._targetUserId);
// Update our cached state
if (this._currentSettings) {
this._currentSettings.customPortraitFallback = null;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitRemoved')
);
// Re-render to update the UI (guard against stale render)
if (this.rendered) {
this.render(true);
}
} catch (err) {
console.error('[ScryingPool] PlayerPrivacyPanel: failed to remove portrait:', err);
this._adapter.notifications.error(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitErrorInvalid')
);
}
}
/**
* Handles toggle change events.
* @param {Event} event - The change event from the checkbox.
@@ -253,6 +459,8 @@ export class PlayerPrivacyPanel extends _AppBase {
// Clear cached elements
this._reactionCamToggle = null;
this._hpReactiveCamToggle = null;
this._fileInput = null;
this._portraitPreview = null;
this._currentSettings = null;
}
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { PlayerPrivacyPanel } from './PlayerPrivacyPanel.js';
*/
let _adapter = null;
let _playerPrivacyManager = null;
let _portraitFallbackHandler = null;
/**
* Flag to track if dependencies have been initialized.
@@ -24,8 +25,9 @@ let _isInitialized = false;
* Called once during module initialization.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
*/
export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager) {
export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager, portraitFallbackHandler = null) {
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('initPlayerPrivacyPanelMenu: adapter is required');
}
@@ -35,6 +37,7 @@ export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager) {
_adapter = adapter;
_playerPrivacyManager = playerPrivacyManager;
_portraitFallbackHandler = portraitFallbackHandler;
_isInitialized = true;
}
@@ -75,6 +78,7 @@ export class PlayerPrivacyPanelMenu {
_adapter,
_playerPrivacyManager,
currentUser.id,
_portraitFallbackHandler,
options
);
}