feat: add ItemSettingsModal for managing photo pack settings and enhance UI interactions

This commit is contained in:
alexsparkes
2026-02-03 16:36:05 +00:00
parent 1a8e91b02b
commit 7dec0a844e
4 changed files with 516 additions and 18 deletions

View File

@@ -5,7 +5,6 @@ import { Row, Content, Action } from 'components/Layout/Settings/Item';
import { Button } from 'components/Elements';
import Items from 'features/marketplace/components/Items/Items';
import { getBackgroundOptionItems } from '../optionTypes';
import PhotoPackSettings from './PhotoPackSettings';
const SourceSection = ({
backgroundType,
@@ -69,11 +68,6 @@ const SourceSection = ({
viewType="grid"
showChips={false}
/>
{/* Settings for API packs */}
{installedPhotoPacks.map((pack) =>
pack.api_enabled ? <PhotoPackSettings key={pack.id} pack={pack} /> : null,
)}
</>
)}
</>

View File

@@ -1,6 +1,6 @@
import variables from 'config/variables';
import React, { memo, useState, useMemo } from 'react';
import { MdCheckCircle, MdOutlineUploadFile, MdClose } from 'react-icons/md';
import { MdCheckCircle, MdOutlineUploadFile, MdClose, MdSettings } from 'react-icons/md';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import { Tooltip } from 'components/Elements';
@@ -9,6 +9,7 @@ import Switch from 'components/Form/Settings/Switch/Switch';
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
import EventBus from 'utils/eventbus';
import { getProxiedImageUrl } from 'utils/marketplace';
import ItemSettingsModal from '../Modals/ItemSettingsModal';
function filterItems(item, filter, categoryFilter) {
const lowerCaseFilter = filter.toLowerCase();
@@ -68,6 +69,8 @@ function ItemCard({
}) {
const isSideloaded = item.sideload === true;
const packId = item.id || item.name;
const isPhotoPack = item.type === 'photos' || item.type === 'photo_packs';
const hasSettings = isPhotoPack && item.settings_schema && item.settings_schema.length > 0;
// Use React state to manage enabled status for immediate UI updates
const [isEnabled, setIsEnabled] = useState(() => {
@@ -75,6 +78,15 @@ function ItemCard({
return enabledPacks[packId] !== false; // Default to enabled if not set
});
const [showSettingsModal, setShowSettingsModal] = useState(false);
const handleCardClick = () => {
if (isSideloaded || showSettingsModal) {
return;
}
toggleFunction(item);
};
const handleTogglePack = (e) => {
e.stopPropagation();
const newState = !isEnabled;
@@ -100,7 +112,7 @@ function ItemCard({
return (
<div
className={`item ${isSideloaded ? 'item-sideloaded' : ''} ${!isEnabled && isAdded ? 'item-disabled' : ''}`}
onClick={isSideloaded ? undefined : () => toggleFunction(item)}
onClick={handleCardClick}
key={item.name}
>
{isAdded && onTogglePack && (
@@ -207,18 +219,38 @@ function ItemCard({
)}
{isAdded && onUninstall && (
<Button
type="settings"
onClick={(e) => {
e.stopPropagation();
onUninstall(item.type, item.name);
}}
icon={<MdClose />}
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
style={{ marginTop: '10px', width: '100%' }}
/>
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', width: '100%' }}>
<Button
type="settings"
onClick={(e) => {
e.stopPropagation();
setShowSettingsModal(true);
}}
icon={<MdSettings />}
label={variables.getMessage('modals.main.marketplace.product.buttons.settings')}
style={{ flex: 1 }}
/>
<Button
type="settings"
onClick={(e) => {
e.stopPropagation();
onUninstall(item.type, item.name);
}}
icon={<MdClose />}
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
style={{ flex: 1 }}
/>
</div>
)}
</div>
{isAdded && (
<ItemSettingsModal
pack={item}
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
isEnabled={isEnabled}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useState, useEffect, useCallback } from 'react';
import Modal from 'react-modal';
import variables from 'config/variables';
import EventBus from 'utils/eventbus';
import { Dropdown, Text, Switch, Slider, ChipSelect } from 'components/Form/Settings';
import { Button } from 'components/Elements';
import { refreshAPIPackCache } from 'features/background/api/photoPackAPI';
import { MdRefresh, MdWarning, MdClose, MdCheckCircle, MdCancel } from 'react-icons/md';
import { getProxiedImageUrl } from 'utils/marketplace';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import './ItemSettingsModal.scss';
const ItemSettingsModal = ({ pack, isOpen, onClose, isEnabled }) => {
const [settings, setSettings] = useState(() => {
const saved = localStorage.getItem(`photopack_settings_${pack.id}`);
return saved ? JSON.parse(saved) : {};
});
const [dynamicOptions, setDynamicOptions] = useState({});
const [isRefreshing, setIsRefreshing] = useState(false);
const [validationErrors, setValidationErrors] = useState([]);
const validateSettings = useCallback(() => {
const errors = [];
pack.settings_schema?.forEach((field) => {
if (field.required && !settings[field.key]) {
errors.push(`${field.label} is required`);
}
});
setValidationErrors(errors);
// Update api_packs_ready list
const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]');
const isReady = errors.length === 0;
const isInList = apiPacksReady.includes(pack.id);
if (isReady && !isInList) {
apiPacksReady.push(pack.id);
localStorage.setItem('api_packs_ready', JSON.stringify(apiPacksReady));
} else if (!isReady && isInList) {
const filtered = apiPacksReady.filter((id) => id !== pack.id);
localStorage.setItem('api_packs_ready', JSON.stringify(filtered));
}
}, [pack.id, pack.settings_schema, settings]);
const loadDynamicOptions = async (field) => {
if (field.options_source === 'api:categories') {
try {
const response = await fetch(`${variables.constants.API_URL}/images/categories`);
const categories = await response.json();
setDynamicOptions((prev) => ({
...prev,
[field.key]: categories,
}));
} catch (error) {
console.error('Failed to load categories:', error);
}
}
};
// Load dynamic options (e.g., categories from API)
useEffect(() => {
if (!pack.settings_schema || pack.settings_schema.length === 0) {
return;
}
pack.settings_schema.forEach((field) => {
if (field.dynamic && field.options_source) {
loadDynamicOptions(field);
}
});
}, [pack.id, pack.settings_schema]);
// Validate settings
useEffect(() => {
if (!pack.settings_schema || pack.settings_schema.length === 0) {
return;
}
validateSettings();
}, [settings, validateSettings, pack.settings_schema]);
const handleSettingChange = (key, value, secure = false) => {
const processedValue = secure ? btoa(value) : value;
const newSettings = { ...settings, [key]: processedValue };
setSettings(newSettings);
localStorage.setItem(`photopack_settings_${pack.id}`, JSON.stringify(newSettings));
};
const handleManualRefresh = async () => {
setIsRefreshing(true);
await refreshAPIPackCache(pack.id);
setIsRefreshing(false);
// Trigger background refresh
EventBus.emit('refresh', 'background');
};
const renderField = (field) => {
const value =
field.secure && settings[field.key]
? atob(settings[field.key])
: settings[field.key] || field.default;
switch (field.type) {
case 'dropdown': {
const dropdownItems = field.options.map((opt) => ({
value: opt.value,
text: opt.label,
}));
return (
<Dropdown
label={field.label}
name={`${pack.id}_${field.key}`}
value={value}
items={dropdownItems}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
}
case 'chipselect': {
const options = field.dynamic ? dynamicOptions[field.key] || [] : field.options;
return (
<ChipSelect
label={field.label}
options={options}
name={`${pack.id}_${field.key}`}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
}
case 'text':
return (
<Text
title={field.label}
placeholder={field.placeholder}
value={value}
name={`${pack.id}_${field.key}`}
type={field.secure ? 'password' : 'text'}
onChange={(e) => handleSettingChange(field.key, e.target.value, field.secure)}
subtitle={field.help_text}
/>
);
case 'switch':
return (
<Switch
name={`${pack.id}_${field.key}`}
text={field.label}
value={value}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
case 'slider':
return (
<Slider
title={field.label}
name={`${pack.id}_${field.key}`}
min={field.min || 0}
max={field.max || 100}
step={field.step || 1}
value={value}
onChange={(newValue) => handleSettingChange(field.key, newValue)}
/>
);
default:
return null;
}
};
const hasSettings = pack.settings_schema && pack.settings_schema.length > 0;
const isPhotoPack = pack.type === 'photos' || pack.type === 'photo_packs';
return (
<Modal
closeTimeoutMS={100}
onRequestClose={onClose}
isOpen={isOpen}
className="Modal photoPackSettingsModal"
overlayClassName="Overlay photoPackSettingsOverlay"
ariaHideApp={false}
>
<div className="photoPackSettings-header">
<div className="photoPackSettings-header-info">
{pack.icon_url ? (
<img
className="photoPackSettings-icon"
alt="icon"
draggable={false}
src={getProxiedImageUrl(pack.icon_url)}
onError={(e) => {
e.target.onerror = null;
e.target.src = placeholderIcon;
}}
/>
) : (
<div className="photoPackSettings-icon photoPackSettings-icon-text">
{(pack.display_name || pack.name)?.substring(0, 2).toUpperCase()}
</div>
)}
<div className="photoPackSettings-header-text">
<h2>{pack.display_name || pack.name}</h2>
<span className="photoPackSettings-subtitle">
{pack.author && `by ${pack.author}`}
{pack.version && <span className="photoPackSettings-version">v{pack.version}</span>}
<span className={`photoPackSettings-status ${isEnabled ? 'enabled' : 'disabled'}`}>
{isEnabled ? <MdCheckCircle /> : <MdCancel />}
{isEnabled ? 'Enabled' : 'Disabled'}
</span>
</span>
</div>
</div>
<button className="photoPackSettings-close" onClick={onClose} aria-label="Close">
<MdClose />
</button>
</div>
<div className="photoPackSettings-content">
{hasSettings && (
<>
{validationErrors.length > 0 && (
<div className="photoPackSettings-error">
<MdWarning />
<span>Configuration incomplete: {validationErrors.join(', ')}</span>
</div>
)}
<div className="photoPackSettings-fields">
{pack.settings_schema.map((field) => (
<div key={field.key} className="photoPackSettings-field">
{renderField(field)}
</div>
))}
</div>
{isPhotoPack && (
<div className="photoPackSettings-actions">
<Button
onClick={handleManualRefresh}
icon={<MdRefresh />}
label={variables.getMessage(
'modals.main.settings.sections.background.photo_pack_settings.refresh_photos',
)}
disabled={isRefreshing || validationErrors.length > 0}
/>
</div>
)}
</>
)}
{!hasSettings && (
<div className="photoPackSettings-info">
<div className="photoPackSettings-info-item">
<span className="label">Type</span>
<span className="value">
{variables.getMessage(
'modals.main.marketplace.' + getTypeTranslationKey(pack.type),
)}
</span>
</div>
{pack.description && (
<div className="photoPackSettings-info-item">
<span className="label">Description</span>
<span className="value">{pack.description}</span>
</div>
)}
{pack.sideload && (
<div className="photoPackSettings-info-item">
<span className="label">Source</span>
<span className="value">Sideloaded</span>
</div>
)}
</div>
)}
</div>
</Modal>
);
};
function getTypeTranslationKey(type) {
const typeMap = {
photos: 'photo_packs',
quotes: 'quote_packs',
settings: 'preset_settings',
};
return typeMap[type] || type;
}
export default ItemSettingsModal;

View File

@@ -0,0 +1,182 @@
.photoPackSettingsModal {
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
background: var(--modal-background, #1a1a1a);
border-radius: 12px;
padding: 0;
}
.photoPackSettingsOverlay {
background-color: rgba(0, 0, 0, 0.7);
}
.photoPackSettings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.photoPackSettings-header-info {
display: flex;
align-items: center;
gap: 16px;
}
.photoPackSettings-icon {
width: 56px;
height: 56px;
border-radius: 12px;
object-fit: cover;
}
.photoPackSettings-icon-text {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--linkColor, #ff5c25) 0%, #ff8a25 100%);
color: white;
font-weight: 600;
font-size: 20px;
}
.photoPackSettings-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.photoPackSettings-header-text h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-color, #fff);
}
.photoPackSettings-subtitle {
font-size: 14px;
color: var(--subtitle-color, rgba(255, 255, 255, 0.6));
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.photoPackSettings-version {
padding: 2px 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.photoPackSettings-status {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.enabled {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
&.disabled {
background: rgba(158, 158, 158, 0.2);
color: #9e9e9e;
}
svg {
font-size: 14px;
}
}
.photoPackSettings-close {
background: transparent;
border: none;
color: var(--text-color, #fff);
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 8px;
transition: background 0.2s;
}
.photoPackSettings-close:hover {
background: rgba(255, 255, 255, 0.1);
}
.photoPackSettings-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.photoPackSettings-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 8px;
color: #f44336;
font-size: 14px;
}
.photoPackSettings-error svg {
font-size: 20px;
flex-shrink: 0;
}
.photoPackSettings-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.photoPackSettings-field {
width: 100%;
}
.photoPackSettings-actions {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.photoPackSettings-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.photoPackSettings-info-item {
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--subtitle-color, rgba(255, 255, 255, 0.5));
font-weight: 600;
}
.value {
font-size: 14px;
color: var(--text-color, rgba(255, 255, 255, 0.9));
}
}