forked from akai/readest
feat: add named highlight colors with sync and picker ux fixes (#3741)
* fix: add highlight color label fields
* fix: add default highlight label sync fields
* fix: add highlight prefs sync helpers
* fix: add highlight color name inputs
* fix: persist and sync highlight color names
* fix: add highlight color label helpers
* fix: add long press highlight label preview
* fix: pull highlight color prefs during library sync
* test: cover highlight color label helpers
* fix: widen highlight color name inputs
* fix: show highlight names on hover and touch hold
* fix: prevent highlight name input overlap
* fix: improve highlight name input responsiveness
* fix: support drag scrolling for highlight colors
* fix: batch custom color and label updates
* fix: serialize highlight prefs saves
* fix: align color strip drag and restore color clicks
* fix: translate default highlight color labels
* refactor: remove highlight preference sync wiring
* fix: align highlight option i18n with existing pattern
* fix: remove redundant english highlight keys
* fix: support raw and normalized highlight label keys
* fix: use underscore translator in highlight options
* fix: translate custom highlight color labels in editor
* refactor: simplify highlight settings persistence
* fix: maintianer review
* refactor: simplify highlight prefs save and clean up editor
- Drop the skipUserColors/skipLabels options from handleHighlightPrefsChange
and always persist both arrays; the flags only masked a no-op caller.
- Type handleHighlightColorsChange with Record<HighlightColor, string>
instead of typeof so the signature reads clearly.
- Remove the always-true `|| true` guard around the custom colors section.
- Stop wrapping user-typed custom color labels with _(), matching the
built-in color inputs and keeping the input value equal to what the
user typed.
* refactor: couple highlight labels to their colors
Replace the parallel `highlightColorLabels: Record<string, string>` map
with label storage that lives next to each color. This removes the hex
key normalization layer and its whole class of orphan/case-drift bugs.
Data model:
userHighlightColors: string[] -> UserHighlightColor[]
({ hex, label? })
highlightColorLabels: Record<string, string> -> (removed)
defaultHighlightLabels:
Partial<Record<DefaultHighlightColor, string>>
A `migrateHighlightColorPrefs` helper runs during `loadSettings` and
handles both the shipped `string[]` layout and the draft-build
`highlightColorLabels` layout: hex-keyed labels attach to matching user
colors, name-keyed labels move into `defaultHighlightLabels`. Malformed
entries are dropped.
Editor:
- Label inputs commit on blur (Enter also commits), so typing a long
label no longer fires `setSettings`/`saveSettings` on every
keystroke. A small `LabelInput` component owns the draft state and
syncs if the prop changes externally.
- Three explicit callbacks (`onCustomHighlightColorsChange`,
`onUserHighlightColorsChange`, `onDefaultHighlightLabelsChange`)
replace the previous single callback with opaque skip flags.
Picker:
- Extracted a reusable `useDragScroll` hook (mouse only, 6px
threshold, 120ms click suppression) from the inline state machine
in `HighlightOptions`. The picker drops to ~280 lines.
- Long-press preview stays touch/pen only. It now reads labels via
`getHighlightColorLabel` (which returns `undefined` when no user
label is set), letting the component layer decide whether to fall
back to a translated default name.
i18n:
- Default color names ('red' | 'yellow' | 'green' | 'blue' | 'violet')
are registered once at module scope via `stubTranslation` and
translated at the picker layer through `useTranslation`. User-typed
labels are never run through `_()`, so what the user types is what
the editor shows.
Tests:
- `annotator-util.test.ts`: rewritten around the new helper contract
(user label -> undefined fallback) and case-insensitive hex matching.
- `settings-highlight-migration.test.ts`: new, covers legacy
`string[]`, already-migrated entries, malformed hex filtering, and
the two draft-label fold paths.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { PREDEFINED_TEXTURES } from '@/styles/textures';
|
||||
import { useAtmosphereStore } from '@/store/atmosphereStore';
|
||||
import { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import ThemeEditor from './color/ThemeEditor';
|
||||
import ThemeModeSelector from './color/ThemeModeSelector';
|
||||
@@ -54,9 +55,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
const [customHighlightColors, setCustomHighlightColors] = useState(
|
||||
settings.globalReadSettings.customHighlightColors,
|
||||
);
|
||||
const [userHighlightColors, setUserHighlightColors] = useState(
|
||||
settings.globalReadSettings.userHighlightColors || [],
|
||||
const [userHighlightColors, setUserHighlightColors] = useState<UserHighlightColor[]>(
|
||||
settings.globalReadSettings.userHighlightColors ?? [],
|
||||
);
|
||||
const [defaultHighlightLabels, setDefaultHighlightLabels] = useState<
|
||||
Partial<Record<DefaultHighlightColor, string>>
|
||||
>(settings.globalReadSettings.defaultHighlightLabels ?? {});
|
||||
|
||||
const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled);
|
||||
const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines);
|
||||
@@ -94,6 +98,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
setBackgroundSize('cover');
|
||||
setCustomHighlightColors(HIGHLIGHT_COLOR_HEX);
|
||||
setUserHighlightColors([]);
|
||||
setDefaultHighlightLabels({});
|
||||
deactivateAtmosphere();
|
||||
};
|
||||
|
||||
@@ -267,20 +272,29 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
saveCustomTextures(envConfig);
|
||||
};
|
||||
|
||||
const handleHighlightColorsChange = (colors: typeof customHighlightColors) => {
|
||||
const handleCustomHighlightColorsChange = (colors: Record<HighlightColor, string>) => {
|
||||
setCustomHighlightColors(colors);
|
||||
settings.globalReadSettings.customHighlightColors = colors;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleUserHighlightColorsChange = (colors: string[]) => {
|
||||
const handleUserHighlightColorsChange = (colors: UserHighlightColor[]) => {
|
||||
setUserHighlightColors(colors);
|
||||
settings.globalReadSettings.userHighlightColors = colors;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleDefaultHighlightLabelsChange = (
|
||||
labels: Partial<Record<DefaultHighlightColor, string>>,
|
||||
) => {
|
||||
setDefaultHighlightLabels(labels);
|
||||
settings.globalReadSettings.defaultHighlightLabels = labels;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
{showCustomThemeEditor ? (
|
||||
@@ -352,10 +366,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
<HighlightColorsEditor
|
||||
customHighlightColors={customHighlightColors}
|
||||
userHighlightColors={userHighlightColors}
|
||||
defaultHighlightLabels={defaultHighlightLabels}
|
||||
highlightOpacity={highlightOpacity}
|
||||
isEink={viewSettings.isEink}
|
||||
onChange={handleHighlightColorsChange}
|
||||
onUserColorsChange={handleUserHighlightColorsChange}
|
||||
onCustomHighlightColorsChange={handleCustomHighlightColorsChange}
|
||||
onUserHighlightColorsChange={handleUserHighlightColorsChange}
|
||||
onDefaultHighlightLabelsChange={handleDefaultHighlightLabelsChange}
|
||||
onOpacityChange={setHighlightOpacity}
|
||||
data-setting-id='settings.color.highlightColors'
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
import { HighlightColor } from '@/types/book';
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_COLORS,
|
||||
DefaultHighlightColor,
|
||||
HighlightColor,
|
||||
UserHighlightColor,
|
||||
} from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import NumberInput from '../NumberInput';
|
||||
import ColorInput from './ColorInput';
|
||||
@@ -9,25 +14,70 @@ const MAX_USER_HIGHLIGHT_COLORS = 10;
|
||||
|
||||
interface HighlightColorsEditorProps {
|
||||
customHighlightColors: Record<HighlightColor, string>;
|
||||
userHighlightColors: string[];
|
||||
userHighlightColors: UserHighlightColor[];
|
||||
defaultHighlightLabels: Partial<Record<DefaultHighlightColor, string>>;
|
||||
highlightOpacity: number;
|
||||
isEink: boolean;
|
||||
onChange: (colors: Record<HighlightColor, string>) => void;
|
||||
onUserColorsChange: (colors: string[]) => void;
|
||||
onCustomHighlightColorsChange: (colors: Record<HighlightColor, string>) => void;
|
||||
onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void;
|
||||
onDefaultHighlightLabelsChange: (labels: Partial<Record<DefaultHighlightColor, string>>) => void;
|
||||
onOpacityChange: (opacity: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text input that commits on blur instead of on every keystroke, so we don't
|
||||
* thrash the settings store while the user is typing a label.
|
||||
*/
|
||||
const LabelInput: React.FC<{
|
||||
label: string;
|
||||
onCommit: (next: string) => void;
|
||||
placeholder: string;
|
||||
className: string;
|
||||
}> = ({ label, onCommit, placeholder, className }) => {
|
||||
const [draft, setDraft] = useState(label);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(label);
|
||||
}, [label]);
|
||||
|
||||
const commit = () => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed !== label) onCommit(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
maxLength={20}
|
||||
className={className}
|
||||
title={draft}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeHex = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
||||
customHighlightColors,
|
||||
userHighlightColors,
|
||||
defaultHighlightLabels,
|
||||
highlightOpacity,
|
||||
isEink,
|
||||
onChange,
|
||||
onUserColorsChange,
|
||||
onCustomHighlightColorsChange,
|
||||
onUserHighlightColorsChange,
|
||||
onDefaultHighlightLabelsChange,
|
||||
onOpacityChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [newColor, setNewColor] = useState('#808080');
|
||||
const [newColorLabel, setNewColorLabel] = useState('');
|
||||
|
||||
const highlightPreviewStyle: React.CSSProperties = {
|
||||
opacity: highlightOpacity,
|
||||
@@ -35,125 +85,171 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
||||
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
|
||||
};
|
||||
|
||||
const handleColorChange = (color: HighlightColor, value: string) => {
|
||||
const updated = { ...customHighlightColors, [color]: value };
|
||||
onChange(updated);
|
||||
const handleDefaultHexChange = (color: DefaultHighlightColor, hex: string) => {
|
||||
onCustomHighlightColorsChange({ ...customHighlightColors, [color]: hex });
|
||||
};
|
||||
|
||||
const handleDefaultLabelChange = (color: DefaultHighlightColor, label: string) => {
|
||||
const next = { ...defaultHighlightLabels };
|
||||
if (label) {
|
||||
next[color] = label;
|
||||
} else {
|
||||
delete next[color];
|
||||
}
|
||||
onDefaultHighlightLabelsChange(next);
|
||||
};
|
||||
|
||||
const handleUserLabelChange = (hex: string, label: string) => {
|
||||
const key = normalizeHex(hex);
|
||||
onUserHighlightColorsChange(
|
||||
userHighlightColors.map((entry) =>
|
||||
entry.hex === key ? { ...entry, label: label || undefined } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddUserColor = () => {
|
||||
if (userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS) return;
|
||||
if (!userHighlightColors.includes(newColor)) {
|
||||
const updatedColors = [...userHighlightColors, newColor];
|
||||
onUserColorsChange(updatedColors);
|
||||
}
|
||||
const hex = normalizeHex(newColor);
|
||||
if (!hex.startsWith('#')) return;
|
||||
if (userHighlightColors.some((entry) => entry.hex === hex)) return;
|
||||
const label = newColorLabel.trim();
|
||||
onUserHighlightColorsChange([...userHighlightColors, label ? { hex, label } : { hex }]);
|
||||
setNewColorLabel('');
|
||||
};
|
||||
|
||||
const handleDeleteUserColor = (hex: string) => {
|
||||
const updatedColors = userHighlightColors.filter((c) => c !== hex);
|
||||
onUserColorsChange(updatedColors);
|
||||
const key = normalizeHex(hex);
|
||||
onUserHighlightColorsChange(userHighlightColors.filter((entry) => entry.hex !== key));
|
||||
};
|
||||
|
||||
const handleUserColorChange = (oldHex: string, newHex: string) => {
|
||||
const updatedColors = userHighlightColors.map((c) => (c === oldHex ? newHex : c));
|
||||
onUserColorsChange(updatedColors);
|
||||
const handleUserHexChange = (oldHex: string, newHex: string) => {
|
||||
const oldKey = normalizeHex(oldHex);
|
||||
const newKey = normalizeHex(newHex);
|
||||
if (oldKey === newKey) return;
|
||||
// Drop the rename if it collides with another existing color.
|
||||
if (userHighlightColors.some((entry) => entry.hex === newKey)) return;
|
||||
onUserHighlightColorsChange(
|
||||
userHighlightColors.map((entry) =>
|
||||
entry.hex === oldKey ? { ...entry, hex: newKey } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const isDuplicateNewColor = userHighlightColors.some(
|
||||
(entry) => entry.hex === normalizeHex(newColor),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
|
||||
<div className='grid grid-cols-3 gap-3 p-4 sm:grid-cols-5'>
|
||||
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
|
||||
(color, index, array) => {
|
||||
const position =
|
||||
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
||||
return (
|
||||
<div key={color} className='flex flex-col items-center gap-2'>
|
||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||
<div
|
||||
className='h-full w-full rounded-full'
|
||||
style={{
|
||||
backgroundColor: customHighlightColors[color],
|
||||
...highlightPreviewStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={customHighlightColors[color]!}
|
||||
compact={true}
|
||||
pickerPosition={position}
|
||||
onChange={(value: string) => handleColorChange(color, value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(userHighlightColors.length > 0 || true) && (
|
||||
<div className='border-base-200 border-t p-4'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<span className='font-normal'>
|
||||
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
|
||||
<div className='grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'>
|
||||
{DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => {
|
||||
const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
||||
return (
|
||||
<div key={color} className='flex min-w-0 flex-col items-center gap-2'>
|
||||
<LabelInput
|
||||
label={defaultHighlightLabels[color] ?? ''}
|
||||
onCommit={(next) => handleDefaultLabelChange(color, next)}
|
||||
placeholder={_('Name')}
|
||||
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
|
||||
/>
|
||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||
<div
|
||||
className='h-full w-full rounded-full'
|
||||
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
|
||||
style={{
|
||||
backgroundColor: customHighlightColors[color],
|
||||
...highlightPreviewStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={newColor}
|
||||
value={customHighlightColors[color]!}
|
||||
compact={true}
|
||||
pickerPosition='right'
|
||||
onChange={setNewColor}
|
||||
pickerPosition={position}
|
||||
onChange={(value: string) => handleDefaultHexChange(color, value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUserColor}
|
||||
disabled={
|
||||
userHighlightColors.includes(newColor) ||
|
||||
userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
|
||||
}
|
||||
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
|
||||
>
|
||||
<span className='text-xs'>{_('Add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{userHighlightColors.length > 0 && (
|
||||
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
||||
{userHighlightColors.map((hex, index) => (
|
||||
<div key={hex} className='group relative flex flex-col items-center gap-2'>
|
||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||
<div
|
||||
className='h-full w-full rounded-full'
|
||||
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={hex}
|
||||
compact={true}
|
||||
pickerPosition={index === 0 ? 'left' : 'center'}
|
||||
onChange={(value: string) => handleUserColorChange(hex, value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDeleteUserColor(hex)}
|
||||
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
|
||||
title={_('Delete')}
|
||||
>
|
||||
<MdClose size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className='border-base-200 border-t p-4'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<span className='font-normal'>
|
||||
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
|
||||
</span>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
|
||||
<div
|
||||
className='h-full w-full rounded-full'
|
||||
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ColorInput
|
||||
label=''
|
||||
value={newColor}
|
||||
compact={true}
|
||||
pickerPosition='right'
|
||||
onChange={setNewColor}
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
value={newColorLabel}
|
||||
onChange={(e) => setNewColorLabel(e.target.value)}
|
||||
placeholder={_('Name')}
|
||||
maxLength={20}
|
||||
className='input input-xs bg-base-100 border-base-200/75 h-6 w-24 text-center text-xs'
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUserColor}
|
||||
disabled={
|
||||
isDuplicateNewColor || userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
|
||||
}
|
||||
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
|
||||
>
|
||||
<span className='text-xs'>{_('Add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userHighlightColors.length > 0 && (
|
||||
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
||||
{userHighlightColors.map(({ hex, label }, index) => (
|
||||
<div key={hex} className='group relative flex min-w-0 flex-col items-center gap-2'>
|
||||
<LabelInput
|
||||
label={label ?? ''}
|
||||
onCommit={(next) => handleUserLabelChange(hex, next)}
|
||||
placeholder={_('Name')}
|
||||
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
|
||||
/>
|
||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||
<div
|
||||
className='h-full w-full rounded-full'
|
||||
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={hex}
|
||||
compact={true}
|
||||
pickerPosition={index === 0 ? 'left' : 'center'}
|
||||
onChange={(value: string) => handleUserHexChange(hex, value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDeleteUserColor(hex)}
|
||||
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
|
||||
title={_('Delete')}
|
||||
>
|
||||
<MdClose size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NumberInput
|
||||
label={_('Opacity')}
|
||||
|
||||
Reference in New Issue
Block a user