mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(catalog filters): add color highlighting of threads matching pattern
This commit is contained in:
@@ -113,41 +113,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filtersProtip {
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filtersProtip code {
|
||||
padding: 1px 5px 1px 5px;
|
||||
background-color: #EEE;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.filtersProtip li {
|
||||
list-style: none;
|
||||
margin: 5px 0px;
|
||||
}
|
||||
|
||||
.filtersProtip ul {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4 {
|
||||
font-size: 15px;
|
||||
margin: 10px 0px 5px 0px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4:first-child {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.filtersProtip h4::before {
|
||||
content: "»";
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 5px;
|
||||
text-align: left;
|
||||
@@ -247,17 +212,3 @@
|
||||
font-size: 12px !important;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.clickbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: 1px solid #aaa;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
margin: auto;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import FiltersProtip from './filters-protip';
|
||||
import HighlightColorPicker from './highlight-color-picker';
|
||||
import styles from './catalog-filters.module.css';
|
||||
|
||||
const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
@@ -14,6 +16,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
...item,
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color ?? '',
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -26,6 +29,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
...item,
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color ?? '',
|
||||
})),
|
||||
);
|
||||
}, [filterItems]);
|
||||
@@ -47,6 +51,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
hide: true,
|
||||
top: false,
|
||||
color: '',
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -138,7 +143,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.clickbox} />
|
||||
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
|
||||
@@ -173,97 +178,26 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FiltersProtip = () => {
|
||||
return (
|
||||
<div className={styles.filtersProtip}>
|
||||
<h4>Patterns</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Matching whole words:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel</code> — will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>AND operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel girlfriend</code> — will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>OR operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel|girlfriend</code> — will match <em>"feel"</em> OR <em>"girlfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Mixing both operators:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>girlfriend|boyfriend feel</code> — matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Exact match search:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>"that feel when"</code> — place double quotes around the pattern to search for an exact string.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Wildcards:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel*</code> — matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc…
|
||||
</li>
|
||||
<li>
|
||||
<code>idolm*ster</code> — this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc…
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<strong>It is also possible to filter by regular expression:</strong>
|
||||
<li>
|
||||
<code>/^(?=.*detachable)(?=.*hats).*$/i</code> — AND operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>/^(?!.*touhou).*$/i</code> — NOT operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>{'/^>/'}</code> — threads starting with a quote (<em>{'">"'}</em> character as an html entity).
|
||||
</li>
|
||||
<li>
|
||||
<code>/^$/</code> — threads with no text.
|
||||
</li>
|
||||
</ul>
|
||||
<h4>Controls</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>On</strong> — enables or disables the filter.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hide</strong> — hides matched threads.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Top</strong> — moves the filter to the top of the feed.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const openHelp = () => setShowHelp(true);
|
||||
const closeHelp = () => setShowHelp(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showHelp) {
|
||||
closeHelp();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscapeKey);
|
||||
return () => document.removeEventListener('keydown', onEscapeKey);
|
||||
}, [closeModal, showHelp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={showHelp ? closeHelp : closeModal} />
|
||||
@@ -287,16 +221,6 @@ const CatalogFilters = () => {
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscapeKey);
|
||||
return () => document.removeEventListener('keydown', onEscapeKey);
|
||||
}, [closeModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${styles.filtersButton} button`} onClick={() => setShowModal(true)}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.filtersProtip {
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filtersProtip code {
|
||||
padding: 1px 5px 1px 5px;
|
||||
background-color: #EEE;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.filtersProtip li {
|
||||
list-style: none;
|
||||
margin: 5px 0px;
|
||||
}
|
||||
|
||||
.filtersProtip ul {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4 {
|
||||
font-size: 15px;
|
||||
margin: 10px 0px 5px 0px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.filtersProtip h4:first-child {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.filtersProtip h4::before {
|
||||
content: "»";
|
||||
margin-right: 3px;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import styles from './filters-protip.module.css';
|
||||
|
||||
const FiltersProtip = () => {
|
||||
return (
|
||||
<div className={styles.filtersProtip}>
|
||||
<h4>Patterns</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Matching whole words:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel</code> — will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>AND operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel girlfriend</code> — will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>OR operator:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel|girlfriend</code> — will match <em>"feel"</em> OR <em>"girlfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Mixing both operators:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>girlfriend|boyfriend feel</code> — matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Exact match search:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>"that feel when"</code> — place double quotes around the pattern to search for an exact string.
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Wildcards:</strong>
|
||||
</li>
|
||||
<li>
|
||||
<code>feel*</code> — matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc…
|
||||
</li>
|
||||
<li>
|
||||
<code>idolm*ster</code> — this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc…
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<strong>It is also possible to filter by regular expression:</strong>
|
||||
<li>
|
||||
<code>/^(?=.*detachable)(?=.*hats).*$/i</code> — AND operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>/^(?!.*touhou).*$/i</code> — NOT operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>{'/^>/'}</code> — threads starting with a quote (<em>{'">"'}</em> character as an html entity).
|
||||
</li>
|
||||
<li>
|
||||
<code>/^$/</code> — threads with no text.
|
||||
</li>
|
||||
</ul>
|
||||
<h4>Controls</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>On</strong> — enables or disables the filter.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hide</strong> — hides matched threads.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Top</strong> — moves the filter to the top of the feed.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FiltersProtip;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './filters-protip';
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
.colorClickbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: var(--reply-modal-field-input-border, revert);
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colorPickerModal {
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
background-color: var(--settings-modal-background-color);
|
||||
}
|
||||
|
||||
.colorPickerModal table {
|
||||
border-spacing: 1px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.colorPickerModal table td {
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.colorOption {
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
border: 1px solid #aaa;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.middleTxt input[type="text"] {
|
||||
width: 45px;
|
||||
margin: 0 2px;
|
||||
margin-right: 5px;
|
||||
padding: 1px;
|
||||
outline: none;
|
||||
border: var(--reply-modal-field-input-border, revert);
|
||||
font-size: 11px;
|
||||
background-color: default;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.middleTxt input[type="text"]:focus {
|
||||
border: var(--reply-modal-field-input-border-focus, revert);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.middleTxt input[type="text"] {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.middleTxt .colorClickbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border: 1px solid #aaa;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button {
|
||||
color: var(--button-desktop-text-color);
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button:hover {
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button::before{
|
||||
content: '[';
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button::after{
|
||||
content: ']';
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import styles from './highlight-color-picker.module.css';
|
||||
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
||||
|
||||
interface ColorPickerProps {
|
||||
item: any;
|
||||
index: number;
|
||||
updateLocalFilterItem: (index: number, item: any) => void;
|
||||
localFilterItems: any[];
|
||||
}
|
||||
|
||||
const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterItems }: ColorPickerProps) => {
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState<boolean>(false);
|
||||
const [customColor, setCustomColor] = useState<string>('');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: 'left-start',
|
||||
open: isColorPickerOpen,
|
||||
onOpenChange: setIsColorPickerOpen,
|
||||
middleware: [offset(10), shift()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
||||
|
||||
const headingId = useId();
|
||||
|
||||
const colorOptions = ['#E0B0FF', '#F2F3F4', '#7DF9FF', '#FFFF00', '#FBCEB1', '#FFBF00', '#ADFF2F', '#0047AB', '#00A550', '#007FFF', '#AF0A0F', '#B5BD68'];
|
||||
|
||||
const colorRows = [colorOptions.slice(0, 4), colorOptions.slice(4, 8), colorOptions.slice(8, 12)];
|
||||
|
||||
const setItemColor = (color: string) => {
|
||||
updateLocalFilterItem(index, { ...localFilterItems[index], color });
|
||||
setIsColorPickerOpen(false);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value;
|
||||
setCustomColor(newColor);
|
||||
};
|
||||
|
||||
const applyCustomColor = () => {
|
||||
if (customColor) {
|
||||
setItemColor(customColor);
|
||||
}
|
||||
};
|
||||
|
||||
const clearColor = () => {
|
||||
setItemColor('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.colorClickbox} style={{ backgroundColor: item.color || '#fff' }} ref={refs.setReference} {...getReferenceProps()}>
|
||||
{!item.color && '∕'}
|
||||
</span>
|
||||
|
||||
{isColorPickerOpen && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsColorPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<FloatingFocusManager context={context} modal={false} initialFocus={-1}>
|
||||
<div
|
||||
className={styles.colorPickerModal}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
aria-labelledby={headingId}
|
||||
{...getFloatingProps()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<table className={styles.colorGrid}>
|
||||
<tbody>
|
||||
{colorRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((color, colorIndex) => (
|
||||
<td key={colorIndex}>
|
||||
<span className={styles.colorOption} style={{ backgroundColor: color }} onClick={() => setItemColor(color)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>Custom</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.middleTxt} colSpan={4}>
|
||||
<input type='text' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
|
||||
<span
|
||||
className={styles.colorPreview}
|
||||
style={{
|
||||
backgroundColor: customColor || '#fff',
|
||||
display: 'inline-block',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '1px solid #aaa',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={applyCustomColor}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<span className={styles.button} onClick={() => setIsColorPickerOpen(false)}>
|
||||
<span className={styles.buttonText}>Close</span>
|
||||
</span>
|
||||
<span className={styles.button} onClick={clearColor}>
|
||||
<span className={styles.buttonText}>Clear</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightColorPicker;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './highlight-color-picker';
|
||||
@@ -13,6 +13,8 @@
|
||||
padding: 10px 0 3px 0;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.postContent {
|
||||
@@ -69,14 +71,14 @@
|
||||
background-color: var(--media-thumbnail-background-color);
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
clip-path: inset(-5px); /* direct media could change its dimensions after being published, so we need to clip the overflow. We can't use overflow: hidden because it will hide the box-shadow */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post img, .post video, .post audio {
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.25);
|
||||
max-width: var(--maxWidth);
|
||||
max-height: var(--maxHeight);
|
||||
}
|
||||
|
||||
@@ -2,34 +2,36 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import { useFloating, offset, size, autoUpdate, Placement } from '@floating-ui/react';
|
||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import { getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useEditCommentPrivileges from '../../hooks/use-author-privileges';
|
||||
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useHide from '../../hooks/use-hide';
|
||||
import useWindowWidth from '../../hooks/use-window-width';
|
||||
import useReplies from '../../hooks/use-replies';
|
||||
import { ContentPreview } from '../../views/home/popular-threads-box';
|
||||
import PostMenuDesktop from '../post-desktop/post-menu-desktop';
|
||||
import styles from './catalog-row.module.css';
|
||||
import _ from 'lodash';
|
||||
import { ContentPreview } from '../../views/home/popular-threads-box';
|
||||
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import useReplies from '../../hooks/use-replies';
|
||||
|
||||
interface CatalogPostMediaProps {
|
||||
cid: string;
|
||||
commentMediaInfo: any;
|
||||
isOutOfFeed?: boolean;
|
||||
linkWidth?: number;
|
||||
linkHeight?: number;
|
||||
}
|
||||
|
||||
export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => {
|
||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||
@@ -89,8 +91,16 @@ export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, lin
|
||||
thumbnailComponent = <audio src={url} controls />;
|
||||
}
|
||||
|
||||
const matchedFilterColor = useCatalogFiltersStore((state) => state.matchedFilters.get(cid || ''));
|
||||
|
||||
return (
|
||||
<div className={hasError ? '' : styles.mediaWrapper} style={CSSProperties}>
|
||||
<div
|
||||
className={hasError ? '' : styles.mediaWrapper}
|
||||
style={{
|
||||
...CSSProperties,
|
||||
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
{!isLoaded && !hasError && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
||||
{hasError ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
||||
</div>
|
||||
@@ -250,7 +260,13 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
||||
{spoiler ? (
|
||||
<img src='assets/spoiler.png' alt='' />
|
||||
) : (
|
||||
<CatalogPostMedia commentMediaInfo={commentMediaInfo} isOutOfFeed={isDescription || isRules} linkWidth={linkWidth} linkHeight={linkHeight} />
|
||||
<CatalogPostMedia
|
||||
cid={cid}
|
||||
commentMediaInfo={commentMediaInfo}
|
||||
isOutOfFeed={isDescription || isRules}
|
||||
linkWidth={linkWidth}
|
||||
linkHeight={linkHeight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface FilterItem {
|
||||
subplebbitFilteredCids: Map<string, Set<string>>;
|
||||
hide: boolean;
|
||||
top: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CatalogFiltersStore {
|
||||
@@ -36,6 +37,9 @@ interface CatalogFiltersStore {
|
||||
setSearchFilter: (text: string) => void;
|
||||
clearSearchFilter: () => void;
|
||||
resetCountsForCurrentSubplebbit: () => void;
|
||||
matchedFilters: Map<string, string>;
|
||||
setMatchedFilter: (cid: string, color: string) => void;
|
||||
clearMatchedFilters: () => void;
|
||||
}
|
||||
|
||||
const useCatalogFiltersStore = create(
|
||||
@@ -52,6 +56,21 @@ const useCatalogFiltersStore = create(
|
||||
filteredCount: 0,
|
||||
filteredCids: new Set<string>(),
|
||||
currentSubplebbitAddress: null,
|
||||
matchedFilters: new Map<string, string>(),
|
||||
setMatchedFilter: (cid: string, color: string) => {
|
||||
set((state) => {
|
||||
const newMatchedFilters = new Map(state.matchedFilters);
|
||||
if (color) {
|
||||
newMatchedFilters.set(cid, color);
|
||||
} else {
|
||||
newMatchedFilters.delete(cid);
|
||||
}
|
||||
return { matchedFilters: newMatchedFilters };
|
||||
});
|
||||
},
|
||||
clearMatchedFilters: () => {
|
||||
set({ matchedFilters: new Map<string, string>() });
|
||||
},
|
||||
setCurrentSubplebbitAddress: (address: string | null) => {
|
||||
const prevAddress = get().currentSubplebbitAddress;
|
||||
|
||||
@@ -123,6 +142,7 @@ const useCatalogFiltersStore = create(
|
||||
subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(),
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color || '',
|
||||
}));
|
||||
set({ filterItems: nonEmptyItems });
|
||||
get().recalcFilteredCount();
|
||||
@@ -138,6 +158,7 @@ const useCatalogFiltersStore = create(
|
||||
subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(),
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color || '',
|
||||
}));
|
||||
|
||||
// Compare new filter items with existing ones to detect pattern changes
|
||||
@@ -154,6 +175,7 @@ const useCatalogFiltersStore = create(
|
||||
filteredCids: existingItem.filteredCids,
|
||||
subplebbitCounts: existingItem.subplebbitCounts,
|
||||
subplebbitFilteredCids: existingItem.subplebbitFilteredCids,
|
||||
color: newItem.color || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,9 +186,13 @@ const useCatalogFiltersStore = create(
|
||||
filteredCids: new Set<string>(),
|
||||
subplebbitCounts: new Map<string, number>(),
|
||||
subplebbitFilteredCids: new Map<string, Set<string>>(),
|
||||
color: newItem.color || '',
|
||||
};
|
||||
});
|
||||
|
||||
// Clear matched filters when saving new filters
|
||||
get().clearMatchedFilters();
|
||||
|
||||
set({
|
||||
filterItems: updatedItems,
|
||||
filteredCids: new Set<string>(),
|
||||
|
||||
@@ -23,7 +23,7 @@ import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
|
||||
const createContentFilter = (
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean }[],
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
|
||||
subplebbitAddress: string,
|
||||
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
|
||||
) => {
|
||||
@@ -55,6 +55,11 @@ const createContentFilter = (
|
||||
// Fallback to the store method if no callback provided
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, subplebbitAddress);
|
||||
}
|
||||
|
||||
// If the filter has a color, track it in the matchedFilters map
|
||||
if (item.color) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
}
|
||||
}
|
||||
|
||||
// If this filter is set to hide, filter out the comment
|
||||
@@ -89,7 +94,7 @@ const createImageFilter = (showTextOnlyThreads: boolean) => {
|
||||
|
||||
const createCombinedFilter = (
|
||||
showTextOnlyThreads: boolean,
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean }[],
|
||||
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
|
||||
searchText: string,
|
||||
subplebbitAddress: string,
|
||||
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
|
||||
@@ -125,7 +130,7 @@ const Catalog = () => {
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const { hideAdultBoards, hideGoreBoards } = useInterfaceSettingsStore();
|
||||
const { showTextOnlyThreads, filterItems, searchText } = useCatalogFiltersStore();
|
||||
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
@@ -446,6 +451,37 @@ const Catalog = () => {
|
||||
document.title = documentTitle + ` - ${t('catalog')} - plebchan`;
|
||||
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t]);
|
||||
|
||||
// Clear matched filters when component mounts or when subplebbit changes
|
||||
useEffect(() => {
|
||||
clearMatchedFilters();
|
||||
return () => {
|
||||
clearMatchedFilters();
|
||||
};
|
||||
}, [clearMatchedFilters, subplebbitAddress]);
|
||||
|
||||
// Apply filter colors to posts when feed changes
|
||||
useEffect(() => {
|
||||
if (combinedFeed.length > 0 && filterItems.length > 0) {
|
||||
// Clear existing matched filters
|
||||
clearMatchedFilters();
|
||||
|
||||
// Apply colors to posts that match filters
|
||||
combinedFeed.forEach((comment) => {
|
||||
if (!comment?.cid) return;
|
||||
|
||||
// Check each filter
|
||||
for (const item of filterItems) {
|
||||
if (item.enabled && item.text.trim() !== '' && item.color) {
|
||||
if (commentMatchesPattern(comment, item.text)) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
break; // Use the first matching filter's color
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [combinedFeed, filterItems, clearMatchedFilters]);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<hr />
|
||||
|
||||
Reference in New Issue
Block a user