Story 4.2 completed
This commit is contained in:
+24
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user