mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-02 14:16:39 +01:00
Filters, finally.
This commit is contained in:
parent
f6c2097a89
commit
717633e422
6 changed files with 739 additions and 3 deletions
|
@ -27,6 +27,7 @@ import AccountStatuses from './pages/account-statuses';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
// import Catchup from './pages/catchup';
|
// import Catchup from './pages/catchup';
|
||||||
import Favourites from './pages/favourites';
|
import Favourites from './pages/favourites';
|
||||||
|
import Filters from './pages/filters';
|
||||||
import FollowedHashtags from './pages/followed-hashtags';
|
import FollowedHashtags from './pages/followed-hashtags';
|
||||||
import Following from './pages/following';
|
import Following from './pages/following';
|
||||||
import Hashtag from './pages/hashtag';
|
import Hashtag from './pages/hashtag';
|
||||||
|
@ -463,7 +464,8 @@ function SecondaryRoutes({ isLoggedIn }) {
|
||||||
<Route index element={<Lists />} />
|
<Route index element={<Lists />} />
|
||||||
<Route path=":id" element={<List />} />
|
<Route path=":id" element={<List />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/ft" element={<FollowedHashtags />} />
|
<Route path="/fh" element={<FollowedHashtags />} />
|
||||||
|
<Route path="/ft" element={<Filters />} />
|
||||||
<Route
|
<Route
|
||||||
path="/catchup"
|
path="/catchup"
|
||||||
element={
|
element={
|
||||||
|
|
|
@ -78,6 +78,7 @@ export const ICONS = {
|
||||||
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
||||||
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
||||||
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
||||||
|
filters: () => import('@iconify-icons/mingcute/filter-line'),
|
||||||
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
||||||
react: () => import('@iconify-icons/mingcute/react-line'),
|
react: () => import('@iconify-icons/mingcute/react-line'),
|
||||||
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
||||||
|
|
|
@ -223,11 +223,15 @@ function NavMenu(props) {
|
||||||
<MenuLink to="/f">
|
<MenuLink to="/f">
|
||||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuLink to="/ft">
|
<MenuLink to="/fh">
|
||||||
<Icon icon="hashtag" size="l" />{' '}
|
<Icon icon="hashtag" size="l" />{' '}
|
||||||
<span>Followed Hashtags</span>
|
<span>Followed Hashtags</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
<MenuLink to="/ft">
|
||||||
|
<Icon icon="filters" size="l" />
|
||||||
|
Filters
|
||||||
|
</MenuLink>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showGenericAccounts = {
|
states.showGenericAccounts = {
|
||||||
|
|
149
src/pages/filters.css
Normal file
149
src/pages/filters.css
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
#filters-page {
|
||||||
|
.filters-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#filters-add-edit-modal {
|
||||||
|
.filter-form-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
+ .filter-form-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-block: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-keywords {
|
||||||
|
margin: 0 -16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-cols {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.filter-form-col {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords {
|
||||||
|
--gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: var(--gap);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 25vh;
|
||||||
|
background-color: var(--bg-faded-blur-color);
|
||||||
|
counter-reset: index;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
li {
|
||||||
|
counter-increment: index;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&:not(:only-child):before {
|
||||||
|
content: counter(index);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keyword-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords-footer {
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
580
src/pages/filters.jsx
Normal file
580
src/pages/filters.jsx
Normal file
|
@ -0,0 +1,580 @@
|
||||||
|
import './filters.css';
|
||||||
|
|
||||||
|
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import Modal from '../components/modal';
|
||||||
|
import NavMenu from '../components/nav-menu';
|
||||||
|
import RelativeTime from '../components/relative-time';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import useInterval from '../utils/useInterval';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_LABELS = {
|
||||||
|
home: 'Home and lists',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
public: 'Public timelines',
|
||||||
|
thread: 'Conversations',
|
||||||
|
account: 'Profiles',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPIRY_DURATIONS = [
|
||||||
|
0, // forever
|
||||||
|
30 * 60, // 30 minutes
|
||||||
|
60 * 60, // 1 hour
|
||||||
|
6 * 60 * 60, // 6 hours
|
||||||
|
12 * 60 * 60, // 12 hours
|
||||||
|
60 * 60 * 24, // 24 hours
|
||||||
|
60 * 60 * 24 * 7, // 7 days
|
||||||
|
60 * 60 * 24 * 30, // 30 days
|
||||||
|
];
|
||||||
|
const EXPIRY_DURATIONS_LABELS = {
|
||||||
|
0: 'Never',
|
||||||
|
1800: '30 minutes',
|
||||||
|
3600: '1 hour',
|
||||||
|
21600: '6 hours',
|
||||||
|
43200: '12 hours',
|
||||||
|
86_400: '24 hours',
|
||||||
|
604_800: '7 days',
|
||||||
|
2_592_000: '30 days',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Filters() {
|
||||||
|
const { masto } = api();
|
||||||
|
useTitle(`Filters`, `/ft`);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
|
||||||
|
|
||||||
|
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
|
||||||
|
const [filters, setFilters] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const filters = await masto.v2.filters.list();
|
||||||
|
filters.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
if (filter.keywords?.length) {
|
||||||
|
filter.keywords.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(filters);
|
||||||
|
setFilters(filters);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [reloadCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="filters-page" class="deck-container" tabIndex="-1">
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header>
|
||||||
|
<div class="header-grid">
|
||||||
|
<div class="header-side">
|
||||||
|
<NavMenu />
|
||||||
|
<Link to="/" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1>Filters</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="plus" size="l" alt="New filter" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{filters.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ul class="filters-list">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const { id, title, expiresAt, keywords } = filter;
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{keywords?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{keywords.map((k) => (
|
||||||
|
<>
|
||||||
|
<span class="tag collapsed insignificant">
|
||||||
|
{k.wholeWord ? `“${k.keyword}”` : k.keyword}
|
||||||
|
</span>{' '}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small class="insignificant">
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal({
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="pencil" size="l" alt="Edit filter" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{filters.length > 1 && (
|
||||||
|
<footer class="ui-state">
|
||||||
|
<small class="insignificant">
|
||||||
|
{filters.length} filter
|
||||||
|
{filters.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader />
|
||||||
|
</p>
|
||||||
|
) : uiState === 'error' ? (
|
||||||
|
<p class="ui-state">Unable to load filters.</p>
|
||||||
|
) : (
|
||||||
|
<p class="ui-state">No filters yet.</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{!!showFiltersAddEditModal && (
|
||||||
|
<Modal
|
||||||
|
title="Add filter"
|
||||||
|
onClose={() => {
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiltersAddEdit
|
||||||
|
filter={showFiltersAddEditModal?.filter}
|
||||||
|
onClose={(result) => {
|
||||||
|
if (result.state === 'success') {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FiltersAddEdit({ filter, onClose }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const editMode = !!filter;
|
||||||
|
const { context, expiresAt, id, keywords, title, filterAction } =
|
||||||
|
filter || {};
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const [editKeywords, setEditKeywords] = useState(keywords || []);
|
||||||
|
const keywordsRef = useRef();
|
||||||
|
|
||||||
|
// Hacky way of handling removed keywords for both existing and new ones
|
||||||
|
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
|
||||||
|
const [removedNewKeywordIndices, setRemovedNewKeywordIndices] = useState([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sheet" id="filters-add-edit-modal">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const title = formData.get('title');
|
||||||
|
const keywordIDs = formData.getAll('keyword_attributes[][id]');
|
||||||
|
const keywordKeywords = formData.getAll(
|
||||||
|
'keyword_attributes[][keyword]',
|
||||||
|
);
|
||||||
|
// const keywordWholeWords = formData.getAll(
|
||||||
|
// 'keyword_attributes[][whole_word]',
|
||||||
|
// );
|
||||||
|
// Not using getAll because it skips the empty checkboxes
|
||||||
|
const keywordWholeWords = [
|
||||||
|
...keywordsRef.current.querySelectorAll(
|
||||||
|
'input[name="keyword_attributes[][whole_word]"]',
|
||||||
|
),
|
||||||
|
].map((i) => i.checked);
|
||||||
|
const keywordsAttributes = keywordKeywords.map((k, i) => ({
|
||||||
|
id: keywordIDs[i] || undefined,
|
||||||
|
keyword: k,
|
||||||
|
wholeWord: keywordWholeWords[i],
|
||||||
|
}));
|
||||||
|
// if (editMode && keywords?.length) {
|
||||||
|
// // Find which one got deleted and add to keywordsAttributes
|
||||||
|
// keywords.forEach((k) => {
|
||||||
|
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
|
||||||
|
// keywordsAttributes.push({
|
||||||
|
// ...k,
|
||||||
|
// _destroy: true,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
if (editMode && removedKeywordIDs?.length) {
|
||||||
|
removedKeywordIDs.forEach((id) => {
|
||||||
|
keywordsAttributes.push({
|
||||||
|
id,
|
||||||
|
_destroy: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const context = formData.getAll('context');
|
||||||
|
let expiresIn = formData.get('expires_in');
|
||||||
|
const filterAction = formData.get('filter_action');
|
||||||
|
console.log({
|
||||||
|
title,
|
||||||
|
keywordIDs,
|
||||||
|
keywords: keywordKeywords,
|
||||||
|
wholeWords: keywordWholeWords,
|
||||||
|
keywordsAttributes,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (!title || !context?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIState('loading');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let filterResult;
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
if (expiresIn === '' || expiresIn === null) {
|
||||||
|
// No value
|
||||||
|
// Preserve existing expiry if not specified
|
||||||
|
// Seconds from now to expiresAtDate
|
||||||
|
// Other clients don't do this
|
||||||
|
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
|
||||||
|
} else if (expiresIn === '0' || expiresIn === 0) {
|
||||||
|
// 0 = Never
|
||||||
|
expiresIn = null;
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn;
|
||||||
|
}
|
||||||
|
filterResult = await masto.v2.filters.$select(id).update({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn || null;
|
||||||
|
filterResult = await masto.v2.filters.create({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log({ filterResult });
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
filter: filterResult,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setUIState('error');
|
||||||
|
alert(
|
||||||
|
editMode
|
||||||
|
? 'Unable to edit filter'
|
||||||
|
: 'Unable to create filter',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="filter-form-row">
|
||||||
|
<label>
|
||||||
|
<b>Title</b>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
defaultValue={title}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-keywords" ref={keywordsRef}>
|
||||||
|
{editKeywords.length ? (
|
||||||
|
<ul class="filter-keywords">
|
||||||
|
{editKeywords.map((k, index) => {
|
||||||
|
const { id, keyword, wholeWord } = k;
|
||||||
|
const removed =
|
||||||
|
removedKeywordIDs.includes(id) ||
|
||||||
|
removedNewKeywordIndices.includes(index);
|
||||||
|
if (removed) return null;
|
||||||
|
return (
|
||||||
|
<li key={`${index}-${id}`}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="keyword_attributes[][id]"
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][keyword]"
|
||||||
|
type="text"
|
||||||
|
defaultValue={keyword}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="filter-keyword-actions">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][whole_word]"
|
||||||
|
type="checkbox"
|
||||||
|
value={id} // Hacky way to map checkbox boolean to the keyword id
|
||||||
|
defaultChecked={wholeWord}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
Whole word
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger small"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
if (id) {
|
||||||
|
removedKeywordIDs.push(id);
|
||||||
|
setRemovedKeywordIDs([...removedKeywordIDs]);
|
||||||
|
} else {
|
||||||
|
// If no id, remove by index
|
||||||
|
removedNewKeywordIndices.push(index);
|
||||||
|
setRemovedNewKeywordIndices([
|
||||||
|
...removedNewKeywordIndices,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div class="filter-keywords">
|
||||||
|
<div class="insignificant">No keywords. Add one.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer class="filter-keywords-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
setEditKeywords([
|
||||||
|
...editKeywords,
|
||||||
|
{
|
||||||
|
keyword: '',
|
||||||
|
wholeWord: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTimeout(() => {
|
||||||
|
// Focus last input
|
||||||
|
const fields =
|
||||||
|
keywordsRef.current.querySelectorAll(
|
||||||
|
'input[type="text"]',
|
||||||
|
);
|
||||||
|
fields[fields.length - 1]?.focus?.();
|
||||||
|
}, 10);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add keyword
|
||||||
|
</button>{' '}
|
||||||
|
{editKeywords?.length > 1 && (
|
||||||
|
<small class="insignificant">
|
||||||
|
{editKeywords.length} keyword
|
||||||
|
{editKeywords.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-cols">
|
||||||
|
<div class="filter-form-col">
|
||||||
|
<div>
|
||||||
|
<b>Filter from…</b>
|
||||||
|
</div>
|
||||||
|
{FILTER_CONTEXT.map((ctx) => (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={
|
||||||
|
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
|
||||||
|
? 'insignificant'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="context"
|
||||||
|
value={ctx}
|
||||||
|
defaultChecked={!!context ? context.includes(ctx) : true}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
{FILTER_CONTEXT_LABELS[ctx]}
|
||||||
|
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
|
||||||
|
</label>{' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p>
|
||||||
|
<small class="insignificant">* Not implemented yet</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-col">
|
||||||
|
{editMode && (
|
||||||
|
<>
|
||||||
|
Status:{' '}
|
||||||
|
<b>
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label for="filters-expires_in">
|
||||||
|
{editMode ? 'Change expiry' : 'Expiry'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filters-expires_in"
|
||||||
|
name="expires_in"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
defaultValue={editMode ? undefined : 0}
|
||||||
|
>
|
||||||
|
{editMode && <option></option>}
|
||||||
|
{EXPIRY_DURATIONS.map((v) => (
|
||||||
|
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Filtered post will be…
|
||||||
|
<br />
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="warn"
|
||||||
|
defaultChecked={filterAction === 'warn' || !editMode}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
minimized
|
||||||
|
</label>{' '}
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="hide"
|
||||||
|
defaultChecked={filterAction === 'hide'}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
hidden
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="filter-form-footer">
|
||||||
|
<span>
|
||||||
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
|
{editMode ? 'Save' : 'Create'}
|
||||||
|
</button>{' '}
|
||||||
|
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||||
|
</span>
|
||||||
|
{editMode && (
|
||||||
|
<MenuConfirm
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
align="end"
|
||||||
|
menuItemClassName="danger"
|
||||||
|
confirmLabel="Delete this filter?"
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v2.filters.$select(id).remove();
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
alert('Unable to delete filter.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger"
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
Delete…
|
||||||
|
</button>
|
||||||
|
</MenuConfirm>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpiryStatus({ expiresAt, showNeverExpires }) {
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const expired = hasExpiry && expiresAtDate <= new Date();
|
||||||
|
|
||||||
|
// If less than a minute left, re-render interval every second, else every minute
|
||||||
|
const [_, rerender] = useReducer((c) => c + 1, 0);
|
||||||
|
useInterval(rerender, expired || 30_000);
|
||||||
|
|
||||||
|
return expired ? (
|
||||||
|
'Expired'
|
||||||
|
) : hasExpiry ? (
|
||||||
|
<>
|
||||||
|
Expiring <RelativeTime datetime={expiresAtDate} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
showNeverExpires && 'Never expires'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Filters;
|
|
@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function FollowedHashtags() {
|
function FollowedHashtags() {
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
useTitle(`Followed Hashtags`, `/ft`);
|
useTitle(`Followed Hashtags`, `/fh`);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
const [followedHashtags, setFollowedHashtags] = useState([]);
|
const [followedHashtags, setFollowedHashtags] = useState([]);
|
||||||
|
|
Loading…
Reference in a new issue