- Replace all emoji characters across WebUI with inline Lucide SVG icons for cross-platform consistency (icon paths in icon-paths.js) - Add accent color picker popover with 9 preset colors + custom picker, persisted to localStorage, updates all CSS custom properties - Remove subtab separator line for cleaner look - Color badge icons with accent color for visual pop - Remove processing badge from target cards - Fix hardcoded #4CAF50 in FPS labels and active badges to use CSS vars - Replace CSS content emoji (▶) with pure CSS triangle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
22 KiB
JavaScript
496 lines
22 KiB
JavaScript
/**
|
|
* Profiles — profile cards, editor, condition builder, process picker.
|
|
*/
|
|
|
|
import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js';
|
|
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { CardSection } from '../core/card-sections.js';
|
|
import { updateTabBadge } from './tabs.js';
|
|
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js';
|
|
|
|
class ProfileEditorModal extends Modal {
|
|
constructor() { super('profile-editor-modal'); }
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: document.getElementById('profile-editor-name').value,
|
|
enabled: document.getElementById('profile-editor-enabled').checked.toString(),
|
|
logic: document.getElementById('profile-editor-logic').value,
|
|
conditions: JSON.stringify(getProfileEditorConditions()),
|
|
targets: JSON.stringify(getProfileEditorTargetIds()),
|
|
};
|
|
}
|
|
}
|
|
|
|
const profileModal = new ProfileEditorModal();
|
|
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' });
|
|
|
|
// Re-render profiles when language changes (only if tab is active)
|
|
document.addEventListener('languageChanged', () => {
|
|
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles();
|
|
});
|
|
|
|
// React to real-time profile state changes from global events WS
|
|
document.addEventListener('server:profile_state_changed', () => {
|
|
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') {
|
|
loadProfiles();
|
|
}
|
|
});
|
|
|
|
export async function loadProfiles() {
|
|
if (_profilesLoading) return;
|
|
set_profilesLoading(true);
|
|
const container = document.getElementById('profiles-content');
|
|
if (!container) { set_profilesLoading(false); return; }
|
|
setTabRefreshing('profiles-content', true);
|
|
|
|
try {
|
|
const [profilesResp, targetsResp] = await Promise.all([
|
|
fetchWithAuth('/profiles'),
|
|
fetchWithAuth('/picture-targets'),
|
|
]);
|
|
if (!profilesResp.ok) throw new Error('Failed to load profiles');
|
|
const data = await profilesResp.json();
|
|
const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] };
|
|
const allTargets = targetsData.targets || [];
|
|
// Batch fetch all target states in a single request
|
|
const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states');
|
|
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
|
|
const runningTargetIds = new Set(
|
|
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
|
|
);
|
|
set_profilesCache(data.profiles);
|
|
const activeCount = data.profiles.filter(p => p.is_active).length;
|
|
updateTabBadge('profiles', activeCount);
|
|
renderProfiles(data.profiles, runningTargetIds);
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to load profiles:', error);
|
|
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
|
} finally {
|
|
set_profilesLoading(false);
|
|
setTabRefreshing('profiles-content', false);
|
|
}
|
|
}
|
|
|
|
export function expandAllProfileSections() {
|
|
CardSection.expandAll([csProfiles]);
|
|
}
|
|
|
|
export function collapseAllProfileSections() {
|
|
CardSection.collapseAll([csProfiles]);
|
|
}
|
|
|
|
function renderProfiles(profiles, runningTargetIds = new Set()) {
|
|
const container = document.getElementById('profiles-content');
|
|
|
|
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
|
|
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
|
container.innerHTML = toolbar + csProfiles.render(items);
|
|
csProfiles.bind();
|
|
|
|
// Localize data-i18n elements within the profiles container only
|
|
// (calling global updateAllText() would trigger loadProfiles() again → infinite loop)
|
|
container.querySelectorAll('[data-i18n]').forEach(el => {
|
|
el.textContent = t(el.getAttribute('data-i18n'));
|
|
});
|
|
}
|
|
|
|
function createProfileCard(profile, runningTargetIds = new Set()) {
|
|
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
|
|
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
|
|
|
|
let condPills = '';
|
|
if (profile.conditions.length === 0) {
|
|
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
|
|
} else {
|
|
const parts = profile.conditions.map(c => {
|
|
if (c.condition_type === 'always') {
|
|
return `<span class="stream-card-prop">${ICON_OK} ${t('profiles.condition.always')}</span>`;
|
|
}
|
|
if (c.condition_type === 'application') {
|
|
const apps = (c.apps || []).join(', ');
|
|
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
|
|
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
|
}
|
|
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
|
});
|
|
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
|
|
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
|
|
}
|
|
|
|
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
|
|
|
|
let lastActivityMeta = '';
|
|
if (profile.last_activated_at) {
|
|
const ts = new Date(profile.last_activated_at);
|
|
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
|
|
}
|
|
|
|
return `
|
|
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
|
|
<div class="card-top-actions">
|
|
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">✕</button>
|
|
</div>
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
${escapeHtml(profile.name)}
|
|
<span class="badge badge-profile-${statusClass}">${statusText}</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-subtitle">
|
|
<span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span>
|
|
<span class="card-meta">${ICON_TARGET} ${targetCountText}</span>
|
|
${lastActivityMeta}
|
|
</div>
|
|
<div class="stream-card-props">${condPills}</div>
|
|
<div class="card-actions">
|
|
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">${ICON_SETTINGS}</button>
|
|
${profile.target_ids.length > 0 ? (() => {
|
|
const anyRunning = profile.target_ids.some(id => runningTargetIds.has(id));
|
|
return `<button class="btn btn-icon ${anyRunning ? 'btn-warning' : 'btn-success'}"
|
|
onclick="toggleProfileTargets('${profile.id}')"
|
|
title="${anyRunning ? t('profiles.toggle_all.stop') : t('profiles.toggle_all.start')}">
|
|
${anyRunning ? ICON_STOP_PLAIN : ICON_START}
|
|
</button>`;
|
|
})() : ''}
|
|
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
|
|
${profile.enabled ? ICON_PAUSE : ICON_START}
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
export async function openProfileEditor(profileId) {
|
|
const modal = document.getElementById('profile-editor-modal');
|
|
const titleEl = document.getElementById('profile-editor-title');
|
|
const idInput = document.getElementById('profile-editor-id');
|
|
const nameInput = document.getElementById('profile-editor-name');
|
|
const enabledInput = document.getElementById('profile-editor-enabled');
|
|
const logicSelect = document.getElementById('profile-editor-logic');
|
|
const condList = document.getElementById('profile-conditions-list');
|
|
const errorEl = document.getElementById('profile-editor-error');
|
|
|
|
errorEl.style.display = 'none';
|
|
condList.innerHTML = '';
|
|
|
|
await loadProfileTargetChecklist([]);
|
|
|
|
if (profileId) {
|
|
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`;
|
|
try {
|
|
const resp = await fetchWithAuth(`/profiles/${profileId}`);
|
|
if (!resp.ok) throw new Error('Failed to load profile');
|
|
const profile = await resp.json();
|
|
|
|
idInput.value = profile.id;
|
|
nameInput.value = profile.name;
|
|
enabledInput.checked = profile.enabled;
|
|
logicSelect.value = profile.condition_logic;
|
|
|
|
for (const c of profile.conditions) {
|
|
addProfileConditionRow(c);
|
|
}
|
|
|
|
await loadProfileTargetChecklist(profile.target_ids);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
return;
|
|
}
|
|
} else {
|
|
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.add')}`;
|
|
idInput.value = '';
|
|
nameInput.value = '';
|
|
enabledInput.checked = true;
|
|
logicSelect.value = 'or';
|
|
}
|
|
|
|
profileModal.open();
|
|
modal.querySelectorAll('[data-i18n]').forEach(el => {
|
|
el.textContent = t(el.getAttribute('data-i18n'));
|
|
});
|
|
profileModal.snapshot();
|
|
}
|
|
|
|
export async function closeProfileEditorModal() {
|
|
await profileModal.close();
|
|
}
|
|
|
|
async function loadProfileTargetChecklist(selectedIds) {
|
|
const container = document.getElementById('profile-targets-list');
|
|
try {
|
|
const resp = await fetchWithAuth('/picture-targets');
|
|
if (!resp.ok) throw new Error('Failed to load targets');
|
|
const data = await resp.json();
|
|
const targets = data.targets || [];
|
|
|
|
if (targets.length === 0) {
|
|
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = targets.map(tgt => {
|
|
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
|
|
return `<label class="profile-target-item">
|
|
<input type="checkbox" value="${tgt.id}" ${checked}>
|
|
<span>${escapeHtml(tgt.name)}</span>
|
|
</label>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
|
|
}
|
|
}
|
|
|
|
export function addProfileCondition() {
|
|
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
|
|
}
|
|
|
|
function addProfileConditionRow(condition) {
|
|
const list = document.getElementById('profile-conditions-list');
|
|
const row = document.createElement('div');
|
|
row.className = 'profile-condition-row';
|
|
const condType = condition.condition_type || 'application';
|
|
|
|
row.innerHTML = `
|
|
<div class="condition-header">
|
|
<select class="condition-type-select">
|
|
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
|
|
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
|
|
</select>
|
|
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
|
</div>
|
|
<div class="condition-fields-container"></div>
|
|
`;
|
|
|
|
const typeSelect = row.querySelector('.condition-type-select');
|
|
const container = row.querySelector('.condition-fields-container');
|
|
|
|
function renderFields(type, data) {
|
|
if (type === 'always') {
|
|
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
|
|
return;
|
|
}
|
|
const appsValue = (data.apps || []).join('\n');
|
|
const matchType = data.match_type || 'running';
|
|
container.innerHTML = `
|
|
<div class="condition-fields">
|
|
<div class="condition-field">
|
|
<label>${t('profiles.condition.application.match_type')}</label>
|
|
<select class="condition-match-type">
|
|
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
|
|
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
|
|
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option>
|
|
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option>
|
|
</select>
|
|
</div>
|
|
<div class="condition-field">
|
|
<div class="condition-apps-header">
|
|
<label>${t('profiles.condition.application.apps')}</label>
|
|
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
|
|
</div>
|
|
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
|
<div class="process-picker" style="display:none">
|
|
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
|
|
<div class="process-picker-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const browseBtn = container.querySelector('.btn-browse-apps');
|
|
const picker = container.querySelector('.process-picker');
|
|
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
|
|
const searchInput = container.querySelector('.process-picker-search');
|
|
searchInput.addEventListener('input', () => filterProcessPicker(picker));
|
|
}
|
|
|
|
renderFields(condType, condition);
|
|
typeSelect.addEventListener('change', () => {
|
|
renderFields(typeSelect.value, { apps: [], match_type: 'running' });
|
|
});
|
|
|
|
list.appendChild(row);
|
|
}
|
|
|
|
async function toggleProcessPicker(picker, row) {
|
|
if (picker.style.display !== 'none') {
|
|
picker.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const listEl = picker.querySelector('.process-picker-list');
|
|
const searchEl = picker.querySelector('.process-picker-search');
|
|
searchEl.value = '';
|
|
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`;
|
|
picker.style.display = '';
|
|
|
|
try {
|
|
const resp = await fetchWithAuth('/system/processes');
|
|
if (!resp.ok) throw new Error('Failed to fetch processes');
|
|
const data = await resp.json();
|
|
|
|
const textarea = row.querySelector('.condition-apps');
|
|
const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean));
|
|
|
|
picker._processes = data.processes;
|
|
picker._existing = existing;
|
|
renderProcessPicker(picker, data.processes, existing);
|
|
searchEl.focus();
|
|
} catch (e) {
|
|
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderProcessPicker(picker, processes, existing) {
|
|
const listEl = picker.querySelector('.process-picker-list');
|
|
if (processes.length === 0) {
|
|
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`;
|
|
return;
|
|
}
|
|
listEl.innerHTML = processes.map(p => {
|
|
const added = existing.has(p.toLowerCase());
|
|
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ✓' : ''}</div>`;
|
|
}).join('');
|
|
|
|
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const proc = item.dataset.process;
|
|
const row = picker.closest('.profile-condition-row');
|
|
const textarea = row.querySelector('.condition-apps');
|
|
const current = textarea.value.trim();
|
|
textarea.value = current ? current + '\n' + proc : proc;
|
|
item.classList.add('added');
|
|
item.textContent = proc + ' ✓';
|
|
picker._existing.add(proc.toLowerCase());
|
|
});
|
|
});
|
|
}
|
|
|
|
function filterProcessPicker(picker) {
|
|
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
|
|
const filtered = (picker._processes || []).filter(p => p.includes(query));
|
|
renderProcessPicker(picker, filtered, picker._existing || new Set());
|
|
}
|
|
|
|
function getProfileEditorConditions() {
|
|
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
|
|
const conditions = [];
|
|
rows.forEach(row => {
|
|
const typeSelect = row.querySelector('.condition-type-select');
|
|
const condType = typeSelect ? typeSelect.value : 'application';
|
|
if (condType === 'always') {
|
|
conditions.push({ condition_type: 'always' });
|
|
} else {
|
|
const matchType = row.querySelector('.condition-match-type').value;
|
|
const appsText = row.querySelector('.condition-apps').value.trim();
|
|
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
|
|
conditions.push({ condition_type: 'application', apps, match_type: matchType });
|
|
}
|
|
});
|
|
return conditions;
|
|
}
|
|
|
|
function getProfileEditorTargetIds() {
|
|
const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
|
|
return Array.from(checkboxes).map(cb => cb.value);
|
|
}
|
|
|
|
export async function saveProfileEditor() {
|
|
const idInput = document.getElementById('profile-editor-id');
|
|
const nameInput = document.getElementById('profile-editor-name');
|
|
const enabledInput = document.getElementById('profile-editor-enabled');
|
|
const logicSelect = document.getElementById('profile-editor-logic');
|
|
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
profileModal.showError(t('profiles.error.name_required'));
|
|
return;
|
|
}
|
|
|
|
const body = {
|
|
name,
|
|
enabled: enabledInput.checked,
|
|
condition_logic: logicSelect.value,
|
|
conditions: getProfileEditorConditions(),
|
|
target_ids: getProfileEditorTargetIds(),
|
|
};
|
|
|
|
const profileId = idInput.value;
|
|
const isEdit = !!profileId;
|
|
|
|
try {
|
|
const url = isEdit ? `/profiles/${profileId}` : '/profiles';
|
|
const resp = await fetchWithAuth(url, {
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.detail || 'Failed to save profile');
|
|
}
|
|
|
|
profileModal.forceClose();
|
|
showToast(isEdit ? t('profiles.updated') : t('profiles.created'), 'success');
|
|
loadProfiles();
|
|
} catch (e) {
|
|
if (e.isAuth) return;
|
|
profileModal.showError(e.message);
|
|
}
|
|
}
|
|
|
|
export async function toggleProfileTargets(profileId) {
|
|
try {
|
|
const profileResp = await fetchWithAuth(`/profiles/${profileId}`);
|
|
if (!profileResp.ok) throw new Error('Failed to load profile');
|
|
const profile = await profileResp.json();
|
|
// Batch fetch all target states to determine which are running
|
|
const batchResp = await fetchWithAuth('/picture-targets/batch/states');
|
|
const allStates = batchResp.ok ? (await batchResp.json()).states : {};
|
|
const runningSet = new Set(
|
|
profile.target_ids.filter(id => allStates[id]?.processing)
|
|
);
|
|
const shouldStop = profile.target_ids.some(id => runningSet.has(id));
|
|
await Promise.all(profile.target_ids.map(id =>
|
|
fetchWithAuth(`/picture-targets/${id}/${shouldStop ? 'stop' : 'start'}`, { method: 'POST' }).catch(() => {})
|
|
));
|
|
loadProfiles();
|
|
} catch (e) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function toggleProfileEnabled(profileId, enable) {
|
|
try {
|
|
const action = enable ? 'enable' : 'disable';
|
|
const resp = await fetchWithAuth(`/profiles/${profileId}/${action}`, {
|
|
method: 'POST',
|
|
});
|
|
if (!resp.ok) throw new Error(`Failed to ${action} profile`);
|
|
loadProfiles();
|
|
} catch (e) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function deleteProfile(profileId, profileName) {
|
|
const msg = t('profiles.delete.confirm').replace('{name}', profileName);
|
|
const confirmed = await showConfirm(msg);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const resp = await fetchWithAuth(`/profiles/${profileId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!resp.ok) throw new Error('Failed to delete profile');
|
|
showToast(t('profiles.deleted'), 'success');
|
|
loadProfiles();
|
|
} catch (e) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|