kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'implement-explorer' into 'main'
Implement explorer See merge request soapbox-pub/soapbox!3337merge-requests/3350/head
commit
8edad06159
|
@ -141,6 +141,7 @@
|
|||
"reselect": "^5.0.0",
|
||||
"sass": "^1.79.5",
|
||||
"stringz": "^2.0.0",
|
||||
"swiper": "^11.2.5",
|
||||
"type-fest": "^4.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^6.0.2",
|
||||
|
|
|
@ -49,9 +49,9 @@ const clearSearchResults = () => ({
|
|||
type: SEARCH_RESULTS_CLEAR,
|
||||
});
|
||||
|
||||
const submitSearch = (filter?: SearchFilter) =>
|
||||
const submitSearch = (filter?: SearchFilter, newValue?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const value = getState().search.value;
|
||||
const value = newValue ?? getState().search.value;
|
||||
const type = filter || getState().search.filter || 'statuses';
|
||||
const accountId = getState().search.accountId;
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import pencilIcon from '@tabler/icons/outline/pencil.svg';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx';
|
||||
import { InstanceFavicon } from 'soapbox/components/instance-favicon.tsx';
|
||||
import Markup from 'soapbox/components/markup.tsx';
|
||||
import Avatar from 'soapbox/components/ui/avatar.tsx';
|
||||
import Emoji from 'soapbox/components/ui/emoji.tsx';
|
||||
|
@ -25,55 +26,10 @@ import RelativeTimestamp from './relative-timestamp.tsx';
|
|||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status.ts';
|
||||
import type { Account as AccountSchema } from 'soapbox/schemas/index.ts';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
account: AccountSchema;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
||||
});
|
||||
|
||||
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
|
||||
const history = useHistory();
|
||||
const [missing, setMissing] = useState<boolean>(false);
|
||||
|
||||
const handleError = () => setMissing(true);
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const timelineUrl = `/timeline/${account.domain}`;
|
||||
if (!(e.ctrlKey || e.metaKey)) {
|
||||
history.push(timelineUrl);
|
||||
} else {
|
||||
window.open(timelineUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
if (missing || !account.pleroma?.favicon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className='size-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<img
|
||||
src={account.pleroma.favicon}
|
||||
alt=''
|
||||
title={account.domain}
|
||||
className='max-h-full w-full'
|
||||
onError={handleError}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProfilePopper {
|
||||
condition: boolean;
|
||||
wrapper: (children: React.ReactNode) => React.ReactNode;
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { Account as AccountSchema } from 'soapbox/schemas/index.ts';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
account: AccountSchema;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
|
||||
const history = useHistory();
|
||||
const [missing, setMissing] = useState<boolean>(false);
|
||||
|
||||
const handleError = () => setMissing(true);
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const timelineUrl = `/timeline/${account.domain}`;
|
||||
if (!(e.ctrlKey || e.metaKey)) {
|
||||
history.push(timelineUrl);
|
||||
} else {
|
||||
window.open(timelineUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
if (missing || !account.pleroma?.favicon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className='size-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<img
|
||||
src={account.pleroma.favicon}
|
||||
alt=''
|
||||
title={account.domain}
|
||||
className='max-h-full w-full'
|
||||
onError={handleError}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
|
@ -13,7 +13,6 @@ import plusIcon from '@tabler/icons/outline/plus.svg';
|
|||
import settingsIcon from '@tabler/icons/outline/settings.svg';
|
||||
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
|
||||
import userIcon from '@tabler/icons/outline/user.svg';
|
||||
import worldIcon from '@tabler/icons/outline/world.svg';
|
||||
import xIcon from '@tabler/icons/outline/x.svg';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
@ -268,15 +267,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.publicTimeline && features.federating && (
|
||||
<SidebarLink
|
||||
to='/timeline/global'
|
||||
icon={worldIcon}
|
||||
text={<FormattedMessage id='tabs_bar.global' defaultMessage='Global' />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{features.blocks && (
|
||||
|
|
|
@ -27,7 +27,16 @@ const SidebarNavigationLink = forwardRef((props: ISidebarNavigationLink, ref: Re
|
|||
const { icon, activeIcon, text, to = '', count, countMax, onClick } = props;
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isActive = pathname === to;
|
||||
const isDefault = to === '' || to === '/';
|
||||
|
||||
let isActive;
|
||||
|
||||
if (isDefault) {
|
||||
isActive = pathname === to;
|
||||
} else {
|
||||
isActive = pathname.includes(to);
|
||||
}
|
||||
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
if (onClick) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import settingsFilledIcon from '@tabler/icons/filled/settings.svg';
|
|||
import userFilledIcon from '@tabler/icons/filled/user.svg';
|
||||
import bellIcon from '@tabler/icons/outline/bell.svg';
|
||||
import bookmarkIcon from '@tabler/icons/outline/bookmark.svg';
|
||||
import compassIcon from '@tabler/icons/outline/brand-safari.svg';
|
||||
import calendarEventIcon from '@tabler/icons/outline/calendar-event.svg';
|
||||
import circlesIcon from '@tabler/icons/outline/circles.svg';
|
||||
import codeIcon from '@tabler/icons/outline/code.svg';
|
||||
|
@ -14,11 +15,9 @@ import homeIcon from '@tabler/icons/outline/home.svg';
|
|||
import listIcon from '@tabler/icons/outline/list.svg';
|
||||
import mailIcon from '@tabler/icons/outline/mail.svg';
|
||||
import messagesIcon from '@tabler/icons/outline/messages.svg';
|
||||
import searchIcon from '@tabler/icons/outline/search.svg';
|
||||
import settingsIcon from '@tabler/icons/outline/settings.svg';
|
||||
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
|
||||
import userIcon from '@tabler/icons/outline/user.svg';
|
||||
import worldIcon from '@tabler/icons/outline/world.svg';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
@ -31,7 +30,6 @@ import ComposeButton from 'soapbox/features/ui/components/compose-button.tsx';
|
|||
import ProfileDropdown from 'soapbox/features/ui/components/profile-dropdown.tsx';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||
import { useInstance } from 'soapbox/hooks/useInstance.ts';
|
||||
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
|
||||
import { useSettings } from 'soapbox/hooks/useSettings.ts';
|
||||
import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications.ts';
|
||||
|
@ -52,7 +50,6 @@ const SidebarNavigation = () => {
|
|||
const intl = useIntl();
|
||||
const { unreadChatsCount } = useStatContext();
|
||||
|
||||
const { instance } = useInstance();
|
||||
const features = useFeatures();
|
||||
const { isDeveloper } = useSettings();
|
||||
const { account } = useOwnAccount();
|
||||
|
@ -62,8 +59,6 @@ const SidebarNavigation = () => {
|
|||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
const settingsNotifications = useSettingsNotifications();
|
||||
|
||||
const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated;
|
||||
|
||||
const makeMenu = (): Menu => {
|
||||
const menu: Menu = [];
|
||||
|
||||
|
@ -171,9 +166,9 @@ const SidebarNavigation = () => {
|
|||
)}
|
||||
|
||||
<SidebarNavigationLink
|
||||
to='/search'
|
||||
icon={searchIcon}
|
||||
text={<FormattedMessage id='tabs_bar.search' defaultMessage='Discover' />}
|
||||
to='/explore'
|
||||
icon={compassIcon}
|
||||
text={<FormattedMessage id='tabs_bar.search' defaultMessage='Explore' />}
|
||||
/>
|
||||
|
||||
{account && (
|
||||
|
@ -220,16 +215,6 @@ const SidebarNavigation = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{(features.publicTimeline) && (
|
||||
features.federating && (account || !restrictUnauth.timelines.federated)) && (
|
||||
<SidebarNavigationLink
|
||||
to='/timeline/global'
|
||||
icon={worldIcon}
|
||||
text={<FormattedMessage id='tabs_bar.global' defaultMessage='Global' />}
|
||||
/>
|
||||
|
||||
)}
|
||||
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu items={menu} placement='top'>
|
||||
<SidebarNavigationLink
|
||||
|
|
|
@ -3,12 +3,12 @@ import circlesFilledIcon from '@tabler/icons/filled/circles.svg';
|
|||
import homeFilledIcon from '@tabler/icons/filled/home.svg';
|
||||
import mailFilledIcon from '@tabler/icons/filled/mail.svg';
|
||||
import bellIcon from '@tabler/icons/outline/bell.svg';
|
||||
import compassIcon from '@tabler/icons/outline/brand-safari.svg';
|
||||
import circlesIcon from '@tabler/icons/outline/circles.svg';
|
||||
import dashboardIcon from '@tabler/icons/outline/dashboard.svg';
|
||||
import homeIcon from '@tabler/icons/outline/home.svg';
|
||||
import mailIcon from '@tabler/icons/outline/mail.svg';
|
||||
import messagesIcon from '@tabler/icons/outline/messages.svg';
|
||||
import searchIcon from '@tabler/icons/outline/search.svg';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link.tsx';
|
||||
|
@ -83,13 +83,6 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ThumbNavigationLink
|
||||
src={searchIcon}
|
||||
text={<FormattedMessage id='navigation.search' defaultMessage='Discover' />}
|
||||
to='/search'
|
||||
exact
|
||||
/>
|
||||
|
||||
{account && (
|
||||
<ThumbNavigationLink
|
||||
src={bellIcon}
|
||||
|
@ -101,6 +94,13 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ThumbNavigationLink
|
||||
src={compassIcon}
|
||||
text={<FormattedMessage id='navigation.search' defaultMessage='Explore' />}
|
||||
to='/explore'
|
||||
exact
|
||||
/>
|
||||
|
||||
{account && renderMessagesLink()}
|
||||
|
||||
{(account && account.staff) && (
|
||||
|
|
|
@ -68,7 +68,6 @@ const Input = forwardRef<HTMLInputElement, IInput>(
|
|||
clsx('relative', {
|
||||
'rounded-md': theme !== 'search',
|
||||
'rounded-full': theme === 'search',
|
||||
'mt-1': !String(outerClassName).includes('mt-'),
|
||||
[String(outerClassName)]: typeof outerClassName !== 'undefined',
|
||||
})
|
||||
}
|
||||
|
|
|
@ -259,7 +259,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
|
||||
const onSearch = () => {
|
||||
dispatch(setSearchAccount(account.id));
|
||||
history.push('/search');
|
||||
history.push('/explore');
|
||||
};
|
||||
|
||||
const onAvatarClick = () => {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
|||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'aliases.search', defaultMessage: 'Search your old account' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Discover' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Explore' },
|
||||
});
|
||||
|
||||
const Search: React.FC = () => {
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import xIcon from '@tabler/icons/outline/x.svg';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
FormattedMessage,
|
||||
} from 'react-intl';
|
||||
|
||||
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search.ts';
|
||||
import {
|
||||
expandSearch,
|
||||
setSearchAccount,
|
||||
} from 'soapbox/actions/search.ts';
|
||||
import { expandTrendingStatuses, fetchTrendingStatuses } from 'soapbox/actions/trending-statuses.ts';
|
||||
import { useAccount } from 'soapbox/api/hooks/index.ts';
|
||||
import Hashtag from 'soapbox/components/hashtag.tsx';
|
||||
|
@ -11,7 +16,6 @@ import IconButton from 'soapbox/components/icon-button.tsx';
|
|||
import ScrollableList from 'soapbox/components/scrollable-list.tsx';
|
||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||
import Spinner from 'soapbox/components/ui/spinner.tsx';
|
||||
import Tabs from 'soapbox/components/ui/tabs.tsx';
|
||||
import Text from 'soapbox/components/ui/text.tsx';
|
||||
import AccountContainer from 'soapbox/containers/account-container.tsx';
|
||||
import StatusContainer from 'soapbox/containers/status-container.tsx';
|
||||
|
@ -24,18 +28,10 @@ import { useSuggestions } from 'soapbox/queries/suggestions.ts';
|
|||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search.ts';
|
||||
|
||||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
const SearchResults = () => {
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: suggestions } = useSuggestions();
|
||||
|
@ -60,34 +56,6 @@ const SearchResults = () => {
|
|||
|
||||
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
|
||||
|
||||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [];
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.statuses),
|
||||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => selectFilter('accounts'),
|
||||
name: 'accounts',
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
);
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
const getCurrentIndex = (id: string): number => {
|
||||
return resultsIds?.keySeq().findIndex(key => key === id);
|
||||
};
|
||||
|
@ -215,7 +183,7 @@ const SearchResults = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{filterByAccount ? (
|
||||
{filterByAccount && (
|
||||
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
|
||||
<IconButton iconClassName='h-5 w-5' src={xIcon} onClick={handleUnsetAccount} />
|
||||
<Text truncate>
|
||||
|
@ -226,8 +194,6 @@ const SearchResults = () => {
|
|||
/>
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<div className='px-4'>{renderFilterBar()}</div>
|
||||
)}
|
||||
|
||||
{noResultsMessage || (
|
||||
|
|
|
@ -76,7 +76,7 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
|
|||
dispatch(setSearchAccount(null));
|
||||
dispatch(submitSearch());
|
||||
|
||||
history.push('/search');
|
||||
history.push('/explore');
|
||||
} else {
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import searchIcon from '@tabler/icons/outline/search.svg';
|
|||
import xIcon from '@tabler/icons/outline/x.svg';
|
||||
import clsx from 'clsx';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -17,6 +17,7 @@ import {
|
|||
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input.tsx';
|
||||
import Input from 'soapbox/components/ui/input.tsx';
|
||||
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
||||
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||
import { selectAccount } from 'soapbox/selectors/index.ts';
|
||||
|
@ -55,6 +56,8 @@ const Search = (props: ISearch) => {
|
|||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const { addToken } = useSearchTokens();
|
||||
|
||||
const value = useAppSelector((state) => state.search.value);
|
||||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
|
@ -67,6 +70,7 @@ const Search = (props: ISearch) => {
|
|||
const { value } = event.target;
|
||||
|
||||
dispatch(changeSearch(value));
|
||||
setInputValue(value);
|
||||
|
||||
if (autoSubmit) {
|
||||
debouncedSubmit();
|
||||
|
@ -83,11 +87,12 @@ const Search = (props: ISearch) => {
|
|||
|
||||
const handleSubmit = () => {
|
||||
if (openInRoute) {
|
||||
addToken(value);
|
||||
dispatch(setSearchAccount(null));
|
||||
dispatch(submitSearch());
|
||||
|
||||
history.push('/search');
|
||||
history.push('/explore');
|
||||
} else {
|
||||
addToken(value);
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
};
|
||||
|
@ -124,7 +129,7 @@ const Search = (props: ISearch) => {
|
|||
type: 'text',
|
||||
id: 'search',
|
||||
placeholder: intl.formatMessage(messages.placeholder),
|
||||
value,
|
||||
value: inputValue,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onFocus: handleFocus,
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
|
||||
import rocketIcon from '@tabler/icons/outline/rocket.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||
import IconButton from 'soapbox/components/ui/icon-button.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
||||
import Text from 'soapbox/components/ui/text.tsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
welcomeTitle: { id: 'column.explore.welcome_card.title', defaultMessage: 'Welcome to Explore' },
|
||||
welcomeText: { id: 'column.explore.welcome_card.text', defaultMessage: 'Explore the world of decentralized social media, dive into {nostrLink} or cross {bridgeLink} to other networks, and connect with a global community. All in one place.' },
|
||||
nostrTitle: { id: 'column.explore.nostr', defaultMessage: 'Nostr' },
|
||||
bridgeTitle: { id: 'column.explore.bridge', defaultMessage: 'Bridges' },
|
||||
});
|
||||
|
||||
const ExploreCards = () => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const intl = useIntl();
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen((prev) => {
|
||||
const newValue = !prev;
|
||||
localStorage.setItem('soapbox:explore:card:status', JSON.stringify(!isOpen));
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const value = localStorage.getItem('soapbox:explore:card:status');
|
||||
if (value !== null) {
|
||||
setIsOpen(JSON.parse(value));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className='mx-4 mt-4' space={2}>
|
||||
<Stack
|
||||
space={4}
|
||||
className={`rounded-xl bg-gradient-to-r from-primary-500 to-primary-700 ${isOpen ? 'mt-0 px-5 pb-8 pt-4' : 'p-4'}`}
|
||||
>
|
||||
<HStack justifyContent='between' className='text-white'>
|
||||
<HStack space={2}>
|
||||
<SvgIcon src={rocketIcon} />
|
||||
<p className='text-xl font-bold'>
|
||||
{intl.formatMessage(messages.welcomeTitle)}
|
||||
</p>
|
||||
</HStack>
|
||||
<IconButton
|
||||
src={arrowIcon}
|
||||
theme='transparent'
|
||||
onClick={handleClick}
|
||||
className={`transition-transform duration-300 ${
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text className={`text-white ${isOpen ? 'max-h-96 opacity-100' : 'hidden max-h-0 opacity-0'}`}>
|
||||
{intl.formatMessage(messages.welcomeText, {
|
||||
nostrLink: <a className='font-medium text-secondary-400 underline' href='https://soapbox.pub/blog/nostr101/' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.nostrTitle)}</a>,
|
||||
bridgeLink: <a className='font-medium text-secondary-400 underline' href='https://soapbox.pub/blog/mostr-fediverse-nostr-bridge/' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.bridgeTitle)}</a>,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ExploreCards;
|
|
@ -0,0 +1,226 @@
|
|||
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
|
||||
import xIcon from '@tabler/icons/outline/x.svg';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeSearch, submitSearch } from 'soapbox/actions/search.ts';
|
||||
import Divider from 'soapbox/components/ui/divider.tsx';
|
||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||
import IconButton from 'soapbox/components/ui/icon-button.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import {
|
||||
WordFilter,
|
||||
LanguageFilter,
|
||||
MediaFilter,
|
||||
PlatformFilters,
|
||||
ToggleRepliesFilter,
|
||||
} from 'soapbox/features/explore/components/filters.tsx';
|
||||
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||
|
||||
const messages = defineMessages({
|
||||
allMedia: { id: 'column.explore.media_filters.all_media', defaultMessage: 'All media' },
|
||||
imageOnly: { id: 'column.explore.media_filters.image', defaultMessage: 'Image only' },
|
||||
videoOnly: { id: 'column.explore.media_filters.video', defaultMessage: 'Video only' },
|
||||
noMedia: { id: 'column.explore.media_filters.no_media', defaultMessage: 'No media' },
|
||||
showReplies: { id: 'home.column_settings.show_replies', defaultMessage: 'Show replies' },
|
||||
nostr: { id: 'column.explore.filters.nostr', defaultMessage: 'Nostr' },
|
||||
atproto: { id: 'column.explore.filters.bluesky', defaultMessage: 'Bluesky' },
|
||||
activitypub: { id: 'column.explore.filters.fediverse', defaultMessage: 'Fediverse' },
|
||||
removeFilter: { id: 'column.explore.filters.remove_filter', defaultMessage: 'Remove filter' },
|
||||
});
|
||||
|
||||
const ExploreFilter = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { tokens, addToken, removeToken, removeTokens } = useSearchTokens();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen((prev) => {
|
||||
const newValue = !prev;
|
||||
localStorage.setItem('soapbox:explore:filter:status', JSON.stringify(newValue));
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce((value: string) => {
|
||||
dispatch(changeSearch(value));
|
||||
dispatch(submitSearch(undefined, value));
|
||||
}, 300),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
debouncedSearch([...tokens].join(' '));
|
||||
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
|
||||
}, [tokens, dispatch],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const isOpenStatus = localStorage.getItem('soapbox:explore:filter:status');
|
||||
if (isOpenStatus !== null) {
|
||||
setIsOpen(JSON.parse(isOpenStatus));
|
||||
}
|
||||
}
|
||||
, []);
|
||||
|
||||
const filters = new Set<string>(['protocol:nostr', 'protocol:atproto', 'protocol:activitypub']);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('-protocol:')) {
|
||||
filters.delete(token.replace(/^-/, ''));
|
||||
}
|
||||
filters.add(token);
|
||||
}
|
||||
|
||||
function renderMediaFilter(): JSX.Element | null {
|
||||
const clearMediaFilters = () => removeTokens(['media:true', '-video:true', 'video:true', '-media:true']);
|
||||
|
||||
if (tokens.has('media:true') && tokens.has('-video:true')) {
|
||||
return (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.imageOnly)}
|
||||
textColor='text-blue-500'
|
||||
borderColor='border-blue-500'
|
||||
onRemove={clearMediaFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokens.has('video:true')) {
|
||||
return (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.videoOnly)}
|
||||
textColor='text-blue-500'
|
||||
borderColor='border-blue-500'
|
||||
onRemove={clearMediaFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokens.has('-media:true')) {
|
||||
return (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.noMedia)}
|
||||
textColor='text-blue-500'
|
||||
borderColor='border-blue-500'
|
||||
onRemove={clearMediaFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className='px-4' space={3}>
|
||||
<HStack alignItems='start' justifyContent='between' space={1}>
|
||||
<HStack className='flex-wrap whitespace-normal' alignItems='center'>
|
||||
{!tokens.has('-protocol:nostr') ? (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.nostr)}
|
||||
textColor='text-purple-500'
|
||||
borderColor='border-purple-500'
|
||||
onRemove={() => addToken('-protocol:nostr')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!tokens.has('-protocol:atproto') ? (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.atproto)}
|
||||
textColor='text-blue-500'
|
||||
borderColor='border-blue-500'
|
||||
onRemove={() => addToken('-protocol:atproto')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!tokens.has('-protocol:activitypub') ? (
|
||||
<FilterToken
|
||||
label={intl.formatMessage(messages.activitypub)}
|
||||
textColor='text-indigo-500'
|
||||
borderColor='border-indigo-500'
|
||||
onRemove={() => addToken('-protocol:activitypub')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{renderMediaFilter()}
|
||||
|
||||
{[...filters].filter((token) => token.startsWith('language:')).map((token) => (
|
||||
<FilterToken
|
||||
key={token}
|
||||
label={token.replace('language:', '')}
|
||||
textColor='text-gray-500'
|
||||
borderColor='border-gray-500'
|
||||
onRemove={() => removeToken(token)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{[...filters].filter((token) => !token.includes(':')).map((token) => (
|
||||
<FilterToken
|
||||
key={token}
|
||||
label={token}
|
||||
textColor='text-green-500'
|
||||
borderColor='border-green-600'
|
||||
onRemove={() => removeToken(token)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<IconButton
|
||||
src={arrowIcon}
|
||||
theme='transparent'
|
||||
className={`transition-transform duration-300 ${ isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Stack className={`overflow-hidden transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`} space={3}>
|
||||
<ToggleRepliesFilter />
|
||||
<MediaFilter />
|
||||
<LanguageFilter />
|
||||
<PlatformFilters />
|
||||
|
||||
<Divider />
|
||||
|
||||
<WordFilter />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilterToken {
|
||||
label: string;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FilterToken: React.FC<IFilterToken> = ({ label, textColor, borderColor, onRemove }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChangeFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group m-1 flex items-center whitespace-normal break-words rounded-full border-2 bg-transparent px-3 pr-1 text-base font-medium shadow-sm hover:cursor-pointer ${borderColor} ${textColor}`}>
|
||||
{label}
|
||||
<IconButton
|
||||
iconClassName='!w-4' className={`!py-0 group-hover:block ${textColor}`} src={xIcon}
|
||||
onClick={handleChangeFilters}
|
||||
aria-label={intl.formatMessage(messages.removeFilter)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreFilter;
|
|
@ -0,0 +1,387 @@
|
|||
import refreshIcon from '@tabler/icons/outline/refresh.svg';
|
||||
import searchIcon from '@tabler/icons/outline/search.svg';
|
||||
import xIcon from '@tabler/icons/outline/x.svg';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Button from 'soapbox/components/ui/button.tsx';
|
||||
import Checkbox from 'soapbox/components/ui/checkbox.tsx';
|
||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||
import IconButton from 'soapbox/components/ui/icon-button.tsx';
|
||||
import Input from 'soapbox/components/ui/input.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
||||
import Text from 'soapbox/components/ui/text.tsx';
|
||||
import Toggle from 'soapbox/components/ui/toggle.tsx';
|
||||
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
|
||||
import toast from 'soapbox/toast.tsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
showReplies: { id: 'home.column_settings.show_replies', defaultMessage: 'Show replies' },
|
||||
media: { id: 'column.explore.filters.media', defaultMessage: 'Media' },
|
||||
language: { id: 'column.explore.filters.language', defaultMessage: 'Language' },
|
||||
platforms: { id: 'column.explore.filters.platforms', defaultMessage: 'Platforms' },
|
||||
createYourFilter: { id: 'column.explore.filters.create_your_filter', defaultMessage: 'Create your filter' },
|
||||
resetFilter: { id: 'column.explore.filters.reset', defaultMessage: 'Reset Filters' },
|
||||
filterByWords: { id: 'column.explore.filters.filter_by_words', defaultMessage: 'Filter word' },
|
||||
negative: { id: 'column.explore.filters.invert', defaultMessage: 'Invert' },
|
||||
nostr: { id: 'column.explore.filters.nostr', defaultMessage: 'Nostr' },
|
||||
atproto: { id: 'column.explore.filters.bluesky', defaultMessage: 'Bluesky' },
|
||||
activitypub: { id: 'column.explore.filters.fediverse', defaultMessage: 'Fediverse' },
|
||||
cancel: { id: 'column.explore.filters.cancel', defaultMessage: 'Cancel' },
|
||||
addFilter: { id: 'column.explore.filters.add_filter', defaultMessage: 'Add Filter' },
|
||||
allMedia: { id: 'column.explore.media_filters.all_media', defaultMessage: 'All media' },
|
||||
imageOnly: { id: 'column.explore.media_filters.image', defaultMessage: 'Image only' },
|
||||
videoOnly: { id: 'column.explore.media_filters.video', defaultMessage: 'Video only' },
|
||||
noMedia: { id: 'column.explore.media_filters.no_media', defaultMessage: 'No media' },
|
||||
clearSearch: { id: 'column.explore.filters.clear_input', defaultMessage: 'Clear filter input' },
|
||||
empty: { id: 'column.explore.filters.empty', defaultMessage: 'Hey there... You forget to write the filter!' },
|
||||
});
|
||||
|
||||
const languages = {
|
||||
default: 'Global',
|
||||
en: 'English',
|
||||
ar: 'العربية',
|
||||
bg: 'Български',
|
||||
bn: 'বাংলা',
|
||||
ca: 'Català',
|
||||
co: 'Corsu',
|
||||
cs: 'Čeština',
|
||||
cy: 'Cymraeg',
|
||||
da: 'Dansk',
|
||||
de: 'Deutsch',
|
||||
el: 'Ελληνικά',
|
||||
eo: 'Esperanto',
|
||||
es: 'Español',
|
||||
eu: 'Euskara',
|
||||
fa: 'فارسی',
|
||||
fi: 'Suomi',
|
||||
fr: 'Français',
|
||||
ga: 'Gaeilge',
|
||||
gl: 'Galego',
|
||||
he: 'עברית',
|
||||
hi: 'हिन्दी',
|
||||
hr: 'Hrvatski',
|
||||
hu: 'Magyar',
|
||||
hy: 'Հայերեն',
|
||||
id: 'Bahasa Indonesia',
|
||||
io: 'Ido',
|
||||
is: 'íslenska',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
jv: 'ꦧꦱꦗꦮ',
|
||||
ka: 'ქართული',
|
||||
kk: 'Қазақша',
|
||||
ko: '한국어',
|
||||
lt: 'Lietuvių',
|
||||
lv: 'Latviešu',
|
||||
ml: 'മലയാളം',
|
||||
ms: 'Bahasa Melayu',
|
||||
nl: 'Nederlands',
|
||||
no: 'Norsk',
|
||||
oc: 'Occitan',
|
||||
pl: 'Polski',
|
||||
pt: 'Português',
|
||||
ro: 'Română',
|
||||
ru: 'Русский',
|
||||
sk: 'Slovenčina',
|
||||
sl: 'Slovenščina',
|
||||
sq: 'Shqip',
|
||||
sr: 'Српски',
|
||||
sv: 'Svenska',
|
||||
ta: 'தமிழ்',
|
||||
te: 'తెలుగు',
|
||||
th: 'ไทย',
|
||||
tr: 'Türkçe',
|
||||
uk: 'Українська',
|
||||
zh: '中文',
|
||||
};
|
||||
|
||||
const ProtocolCheckBox: React.FC<{ protocol: 'nostr' | 'atproto' | 'activitypub' }> = ({ protocol }) => {
|
||||
const intl = useIntl();
|
||||
const { tokens, addToken, removeToken } = useSearchTokens();
|
||||
|
||||
const token = `-protocol:${protocol}`;
|
||||
const checked = !tokens.has(token);
|
||||
const message = messages[protocol];
|
||||
|
||||
const handleProtocolFilter = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { checked, name } = e.target;
|
||||
|
||||
const token = `-protocol:${name}`;
|
||||
|
||||
if (checked) {
|
||||
removeToken(token);
|
||||
} else {
|
||||
addToken(token);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Checkbox
|
||||
name={protocol}
|
||||
checked={checked}
|
||||
onChange={handleProtocolFilter}
|
||||
aria-label={intl.formatMessage(message)}
|
||||
/>
|
||||
<Text size='md'>
|
||||
{intl.formatMessage(message)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformFilters = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
|
||||
<Text size='md' weight='bold'>
|
||||
{intl.formatMessage(messages.platforms)}
|
||||
</Text>
|
||||
|
||||
<ProtocolCheckBox protocol='nostr' />
|
||||
<ProtocolCheckBox protocol='atproto' />
|
||||
<ProtocolCheckBox protocol='activitypub' />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
const WordFilter = () => {
|
||||
const intl = useIntl();
|
||||
const { addToken, clearTokens } = useSearchTokens();
|
||||
|
||||
const [word, setWord] = useState('');
|
||||
const [negative, setNegative] = useState(false);
|
||||
const hasValue = !!word;
|
||||
|
||||
const handleReset = () => {
|
||||
clearTokens();
|
||||
};
|
||||
|
||||
const handleClearValue = () => {
|
||||
setWord('');
|
||||
};
|
||||
|
||||
const handleAddFilter = () => {
|
||||
if (word) {
|
||||
addToken(`${negative ? '-' : ''}${word}`);
|
||||
handleClearValue();
|
||||
} else {
|
||||
toast.error(intl.formatMessage(messages.empty));
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const key = e.key;
|
||||
|
||||
switch (key) {
|
||||
case 'Enter':
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleAddFilter();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleClearValue();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWord(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={3}>
|
||||
<HStack justifyContent='between' alignItems='center'>
|
||||
<Text size='md' weight='bold'>
|
||||
{intl.formatMessage(messages.createYourFilter)}
|
||||
</Text>
|
||||
|
||||
<IconButton src={refreshIcon} iconClassName='w-4' className='px-4' text={intl.formatMessage(messages.resetFilter)} theme='secondary' onClick={handleReset} />
|
||||
</HStack>
|
||||
|
||||
<Stack space={2}>
|
||||
<Text size='md'>
|
||||
{intl.formatMessage(messages.filterByWords)}
|
||||
</Text>
|
||||
|
||||
<HStack space={6}>
|
||||
<div className='relative w-full items-center p-0.5'>
|
||||
<Input theme='search' value={word} className='h-9' onChange={handleOnChange} onKeyDown={onKeyDown} />
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3 rtl:left-0 rtl:right-auto'
|
||||
>
|
||||
<SvgIcon
|
||||
src={searchIcon}
|
||||
className={clsx('size-4 text-gray-600', { hidden: hasValue })}
|
||||
/>
|
||||
|
||||
<SvgIcon
|
||||
src={xIcon}
|
||||
onClick={() => setWord('')}
|
||||
aria-label={intl.formatMessage(messages.clearSearch)}
|
||||
className={clsx('size-4 text-gray-600', { hidden: !hasValue })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Checkbox
|
||||
name='negative'
|
||||
checked={negative}
|
||||
onChange={() => setNegative(!negative)}
|
||||
/>
|
||||
<Text size='md'>
|
||||
{intl.formatMessage(messages.negative)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
<HStack className='w-full p-0.5' space={2}>
|
||||
<Button
|
||||
className='w-1/2' theme='muted' onClick={handleClearValue}
|
||||
>
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
|
||||
<Button className='w-1/2' theme='secondary' onClick={handleAddFilter}>
|
||||
{intl.formatMessage(messages.addFilter)}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
const MediaFilter = () => {
|
||||
const intl = useIntl();
|
||||
const { tokens, addTokens, removeTokens } = useSearchTokens();
|
||||
|
||||
const mediaFilters = {
|
||||
all: {
|
||||
tokens: [],
|
||||
label: intl.formatMessage(messages.allMedia),
|
||||
},
|
||||
image: {
|
||||
tokens: ['media:true', '-video:true'],
|
||||
label: intl.formatMessage(messages.imageOnly),
|
||||
},
|
||||
video: {
|
||||
tokens: ['video:true'],
|
||||
label: intl.formatMessage(messages.videoOnly),
|
||||
},
|
||||
none: {
|
||||
tokens: ['-media:true'],
|
||||
label: intl.formatMessage(messages.noMedia),
|
||||
},
|
||||
};
|
||||
|
||||
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
|
||||
const filter = e.target.value as keyof typeof mediaFilters;
|
||||
removeTokens(['media:true', '-video:true', 'video:true', '-media:true']);
|
||||
addTokens(mediaFilters[filter].tokens);
|
||||
};
|
||||
|
||||
// FIXME: The `items` prop of `SelectDropdown` should become an array of objects.
|
||||
const items = Object
|
||||
.entries(mediaFilters)
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const currentFilter = Object
|
||||
.entries(mediaFilters)
|
||||
.find(([, f]) => f.tokens.every(token => tokens.has(token)))?.[0] || 'all';
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Text size='md' weight='bold'>
|
||||
{intl.formatMessage(messages.media)}
|
||||
</Text>
|
||||
|
||||
<SelectDropdown
|
||||
className='max-w-[130px]'
|
||||
items={items}
|
||||
defaultValue={currentFilter}
|
||||
onChange={handleSelectChange}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
const LanguageFilter = () => {
|
||||
const intl = useIntl();
|
||||
const { tokens, addToken, removeToken } = useSearchTokens();
|
||||
|
||||
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
|
||||
const language = e.target.value;
|
||||
|
||||
for (const token in tokens) {
|
||||
if (token.startsWith('language:')) {
|
||||
removeToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
addToken(`language:${language}`);
|
||||
};
|
||||
|
||||
const token = [...tokens].find((token) => token.startsWith('language:'));
|
||||
const [, language = 'default'] = token?.split(':') ?? [];
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Text size='md' weight='bold'>
|
||||
{intl.formatMessage(messages.language)}
|
||||
</Text>
|
||||
|
||||
<SelectDropdown
|
||||
className='max-w-[130px]'
|
||||
items={languages}
|
||||
defaultValue={language}
|
||||
onChange={handleSelectChange}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
const ToggleRepliesFilter = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { tokens, addToken, removeToken } = useSearchTokens();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (tokens.has('reply:false')) {
|
||||
removeToken('reply:false');
|
||||
} else {
|
||||
addToken('reply:false');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
|
||||
<Text size='md' weight='bold'>
|
||||
{intl.formatMessage(messages.showReplies)}
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={!tokens.has('reply:false')}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export { WordFilter, PlatformFilters, MediaFilter, LanguageFilter, ToggleRepliesFilter };
|
|
@ -0,0 +1,153 @@
|
|||
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
|
||||
import { useAccount } from 'soapbox/api/hooks/index.ts';
|
||||
import { InstanceFavicon } from 'soapbox/components/instance-favicon.tsx';
|
||||
import Avatar from 'soapbox/components/ui/avatar.tsx';
|
||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||
import IconButton from 'soapbox/components/ui/icon-button.tsx';
|
||||
import Spinner from 'soapbox/components/ui/spinner.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import Text from 'soapbox/components/ui/text.tsx';
|
||||
import ActionButton from 'soapbox/features/ui/components/action-button.tsx';
|
||||
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||
import {
|
||||
useSuggestions,
|
||||
} from 'soapbox/queries/suggestions.ts';
|
||||
|
||||
import 'swiper/swiper-bundle.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.explore.popular_accounts', defaultMessage: 'Popular Accounts' },
|
||||
collapse: { id: 'column.explore.popular_accounts.collapse', defaultMessage: 'Collapse popular accounts' },
|
||||
expand: { id: 'column.explore.popular_accounts.expand', defaultMessage: 'Expand popular accounts' },
|
||||
error: { id: 'column.explore.popular_accounts.error', defaultMessage: 'Could not load popular accounts. Please try again later.' },
|
||||
});
|
||||
|
||||
const PopularAccounts = ({ id }: { id: string }) => {
|
||||
const { account } = useAccount(id);
|
||||
const { logo } = useSoapboxConfig();
|
||||
|
||||
return (
|
||||
<Stack className='rounded-lg' >
|
||||
<Stack
|
||||
justifyContent='between' className='h-72 min-w-44 rounded-lg border border-primary-300 shadow-card-inset'
|
||||
style={{
|
||||
backgroundImage: `url(${account?.header ?? logo})`,
|
||||
backgroundSize: `${account?.header ? 'cover' : 'auto' }`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
space={3}
|
||||
>
|
||||
|
||||
{account && (<>
|
||||
|
||||
<HStack className='p-2'>
|
||||
<HStack
|
||||
alignItems='center' space={1} className='max-w-28 rounded-full border bg-primary-500 px-2 py-0.5 !text-white'
|
||||
>
|
||||
<InstanceFavicon account={account} />
|
||||
<Text className='!text-white' size='xs' truncate>
|
||||
{account.domain}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Stack alignItems='center' justifyContent='center' className='pb-6' space={2}>
|
||||
<Link to={`/@${account.acct}`} className='flex flex-col items-center justify-center gap-2' >
|
||||
<Avatar className='border border-white' src={account.avatar} size={60} />
|
||||
<Text className='w-32 text-center text-white' truncate>
|
||||
{account.display_name}
|
||||
</Text>
|
||||
</Link>
|
||||
<ActionButton account={account} />
|
||||
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const AccountsCarousel = () => {
|
||||
const intl = useIntl();
|
||||
const isMobile = useIsMobile();
|
||||
const { data: suggestions, isFetching, error } = useSuggestions();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen((prev) => {
|
||||
const newValue = !prev;
|
||||
localStorage.setItem('soapbox:explore:accounts:status', JSON.stringify(newValue));
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const isOpenStatus = localStorage.getItem('soapbox:explore:accounts:status');
|
||||
if (isOpenStatus) {
|
||||
setIsOpen(JSON.parse(isOpenStatus));
|
||||
}
|
||||
}
|
||||
, []);
|
||||
|
||||
if (isFetching) {
|
||||
return <Stack><Spinner /></Stack>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Stack space={4} className='px-4'>
|
||||
<Text theme='muted'>{intl.formatMessage(messages.error)}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!suggestions || !suggestions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={4} className={`px-4 ${isOpen && 'pb-4'}`}>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text size='xl' weight='bold'>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
<IconButton
|
||||
src={arrowIcon}
|
||||
theme='transparent'
|
||||
className={`transition-transform duration-300 ${ isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
onClick={handleClick}
|
||||
aria-label={isOpen ? intl.formatMessage(messages.collapse) : intl.formatMessage(messages.expand)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack className={`transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[1000px] opacity-100' : 'hidden max-h-0 opacity-0'}`}>
|
||||
<Swiper
|
||||
spaceBetween={10}
|
||||
slidesPerView={isMobile ? 2 : 3}
|
||||
grabCursor
|
||||
loop
|
||||
className='w-full'
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<SwiperSlide key={suggestion.account}>
|
||||
<PopularAccounts id={suggestion.account} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountsCarousel;
|
|
@ -0,0 +1,167 @@
|
|||
import globeIcon from '@tabler/icons/outline/globe.svg';
|
||||
import trendIcon from '@tabler/icons/outline/trending-up.svg';
|
||||
import userIcon from '@tabler/icons/outline/user.svg';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { clearSearch, setFilter } from 'soapbox/actions/search.ts';
|
||||
import { Column } from 'soapbox/components/ui/column.tsx';
|
||||
import Divider from 'soapbox/components/ui/divider.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import Tabs from 'soapbox/components/ui/tabs.tsx';
|
||||
import SearchResults from 'soapbox/features/compose/components/search-results.tsx';
|
||||
import Search from 'soapbox/features/compose/components/search.tsx';
|
||||
import ExploreCards from 'soapbox/features/explore/components/explore-cards.tsx';
|
||||
import ExploreFilter from 'soapbox/features/explore/components/exploreFilter.tsx';
|
||||
import AccountsCarousel from 'soapbox/features/explore/components/popular-accounts.tsx';
|
||||
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||
import { PublicTimeline } from 'soapbox/features/ui/util/async-components.ts';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||
import { SearchFilter } from 'soapbox/reducers/search.ts';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.explore', defaultMessage: 'Explore' },
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'Accounts' },
|
||||
statuses: { id: 'search_results.posts', defaultMessage: 'Posts' },
|
||||
trends: { id: 'search_results.trends', defaultMessage: 'Trends' },
|
||||
filters: { id: 'column.explore.filters', defaultMessage: 'Filters:' },
|
||||
});
|
||||
|
||||
const PostsTab = () => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const { tokens } = useSearchTokens();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
{pathname === '/explore' && (
|
||||
<>
|
||||
{features.nostr && (
|
||||
<>
|
||||
<ExploreCards />
|
||||
<Divider text={intl.formatMessage(messages.filters)} />
|
||||
<ExploreFilter />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tokens.size ? <SearchResults /> : <PublicTimeline />}
|
||||
</>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const TrendsTab = () => {
|
||||
return (
|
||||
<Stack>
|
||||
<SearchResults />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsTab = () => {
|
||||
return (
|
||||
<Stack space={4} className='pt-1'>
|
||||
<AccountsCarousel />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack space={3}>
|
||||
<div className='px-4'>
|
||||
<Search autoSubmit />
|
||||
</div>
|
||||
|
||||
<SearchResults />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ExplorePage = () => {
|
||||
const features = useFeatures();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const path = useLocation().pathname;
|
||||
|
||||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (path === '/explore') return 'posts';
|
||||
if (path === '/explore/trends') return 'statuses';
|
||||
return 'accounts';
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue === 'accounts') {
|
||||
dispatch(setFilter('accounts'));
|
||||
}
|
||||
}, [selectedValue, dispatch]);
|
||||
|
||||
const [selectedFilter, setSelectedFilter] = useState(selectedValue);
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.statuses),
|
||||
action: () => handleTabs(''),
|
||||
name: 'posts',
|
||||
icon: globeIcon,
|
||||
},
|
||||
...(features.nostr ? [{
|
||||
text: intl.formatMessage(messages.trends),
|
||||
action: () => handleTabs('/trends', 'statuses'),
|
||||
name: 'statuses',
|
||||
icon: trendIcon,
|
||||
}] : []),
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => handleTabs('/accounts', 'accounts'),
|
||||
name: 'accounts',
|
||||
icon: userIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabs = (path: string, filter?: SearchFilter) => {
|
||||
if (filter) {
|
||||
selectFilter(filter);
|
||||
dispatch(clearSearch());
|
||||
} else {
|
||||
selectFilter('statuses');
|
||||
}
|
||||
setSelectedFilter(filter ?? 'posts');
|
||||
navigate(`/explore${path}`);
|
||||
};
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false} slim>
|
||||
|
||||
<Stack space={2}>
|
||||
|
||||
<div className='relative px-4'>
|
||||
{renderFilterBar()}
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route exact path='/explore' component={PostsTab} />
|
||||
{features.nostr && <Route path='/explore/trends' component={TrendsTab} />}
|
||||
<Route path='/explore/accounts' component={AccountsTab} />
|
||||
</Switch>
|
||||
|
||||
</Stack>
|
||||
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExplorePage;
|
|
@ -0,0 +1,67 @@
|
|||
import { produce, enableMapSet } from 'immer';
|
||||
import { create } from 'zustand';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
interface SearchTokensState {
|
||||
tokens: Set<string>;
|
||||
addToken(token: string): void;
|
||||
addTokens(tokens: string[]): void;
|
||||
removeToken(token: string): void;
|
||||
removeTokens(tokens: string[]): void;
|
||||
clearTokens(): void;
|
||||
}
|
||||
|
||||
export const useSearchTokens = create<SearchTokensState>()(
|
||||
(setState) => ({
|
||||
tokens: new Set(),
|
||||
|
||||
addToken(token: string): void {
|
||||
if (token) {
|
||||
setState((state) => {
|
||||
return produce(state, (draft) => {
|
||||
draft.tokens.add(token);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
addTokens(tokens: string[]): void {
|
||||
setState((state) => {
|
||||
return produce(state, (draft) => {
|
||||
for (const token of tokens) {
|
||||
if (token) {
|
||||
draft.tokens.add(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeToken(token: string): void {
|
||||
setState((state) => {
|
||||
return produce(state, (draft) => {
|
||||
draft.tokens.delete(token);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeTokens(tokens: string[]): void {
|
||||
setState((state) => {
|
||||
return produce(state, (draft) => {
|
||||
for (const token of tokens) {
|
||||
draft.tokens.delete(token);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
clearTokens(): void {
|
||||
setState((state) => {
|
||||
return produce(state, (draft) => {
|
||||
draft.tokens.clear();
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
|
@ -120,6 +120,7 @@ interface ISelectDropdown {
|
|||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
/** @deprecated FIXME: JavaScript does not guarantee key ordering of objects. This should be turned into an array of tuples. */
|
||||
items: Record<string, string>;
|
||||
defaultValue?: string;
|
||||
onChange?: React.ChangeEventHandler;
|
||||
|
|
|
@ -13,7 +13,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
|||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Discover' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Explore' },
|
||||
});
|
||||
|
||||
const Search = () => {
|
||||
|
|
|
@ -70,6 +70,7 @@ const PublicTimeline = () => {
|
|||
|
||||
return (
|
||||
<Column
|
||||
withHeader={false}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
action={features.publicTimelineLanguage ? <LanguageDropdown language={language} setLanguage={setLanguage} /> : null}
|
||||
slim
|
||||
|
@ -77,7 +78,7 @@ const PublicTimeline = () => {
|
|||
<PinnedHostsPicker />
|
||||
|
||||
{showExplanationBox && (
|
||||
<div className='mb-4 black:mx-4'>
|
||||
<div className='mb-4 p-2 black:mx-4'>
|
||||
<Accordion
|
||||
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
|
||||
action={dismissExplanationBox}
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Column } from 'soapbox/components/ui/column.tsx';
|
||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||
import SearchResults from 'soapbox/features/compose/components/search-results.tsx';
|
||||
import Search from 'soapbox/features/compose/components/search.tsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.search', defaultMessage: 'Discover' },
|
||||
});
|
||||
|
||||
const SearchPage = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} slim>
|
||||
<Stack space={4}>
|
||||
<div className='px-4'>
|
||||
<Search autoSubmit />
|
||||
</div>
|
||||
<SearchResults />
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
|
@ -38,7 +38,7 @@ const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
|||
<Widget
|
||||
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
|
||||
action={
|
||||
<Link className='text-right' to='/search' onClick={setHashtagsFilter}>
|
||||
<Link className='text-right' to='/explore' onClick={setHashtagsFilter}>
|
||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||
{intl.formatMessage(messages.viewAll)}
|
||||
</Text>
|
||||
|
|
|
@ -39,7 +39,7 @@ import LandingPage from 'soapbox/pages/landing-page.tsx';
|
|||
import ManageGroupsPage from 'soapbox/pages/manage-groups-page.tsx';
|
||||
import ProfilePage from 'soapbox/pages/profile-page.tsx';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page.tsx';
|
||||
import SearchPage from 'soapbox/pages/search-page.tsx';
|
||||
import ExplorePage from 'soapbox/pages/search-page.tsx';
|
||||
import StatusPage from 'soapbox/pages/status-page.tsx';
|
||||
import WidePage from 'soapbox/pages/wide-page.tsx';
|
||||
|
||||
|
@ -47,8 +47,8 @@ import FloatingActionButton from './components/floating-action-button.tsx';
|
|||
import Navbar from './components/navbar.tsx';
|
||||
import {
|
||||
Status,
|
||||
PublicTimeline,
|
||||
RemoteTimeline,
|
||||
PublicTimeline,
|
||||
AccountTimeline,
|
||||
AccountGallery,
|
||||
HomeTimeline,
|
||||
|
@ -67,7 +67,7 @@ import {
|
|||
Filters,
|
||||
EditFilter,
|
||||
PinnedStatuses,
|
||||
Search,
|
||||
Explore,
|
||||
ListTimeline,
|
||||
Lists,
|
||||
Bookmarks,
|
||||
|
@ -208,11 +208,11 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
<Redirect from='/web/:path' to='/:path' />
|
||||
<Redirect from='/timelines/home' to='/' />
|
||||
<Redirect from='/timelines/public/local' to='/timeline/local' />
|
||||
<Redirect from='/timelines/public' to='/timeline/global' />
|
||||
<Redirect from='/timelines/public' to='/explore' />
|
||||
<Redirect from='/timelines/direct' to='/messages' />
|
||||
|
||||
{/* Pleroma FE web routes */}
|
||||
<Redirect from='/main/all' to='/timeline/global' />
|
||||
<Redirect from='/main/all' to='/explore' />
|
||||
<Redirect from='/main/public' to='/timeline/local' />
|
||||
<Redirect from='/main/friends' to='/' />
|
||||
<Redirect from='/tag/:id' to='/tags/:id' />
|
||||
|
@ -250,7 +250,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
<Redirect from='/auth/mfa' to='/settings/mfa' />
|
||||
<Redirect from='/auth/password/new' to='/reset-password' />
|
||||
<Redirect from='/auth/password/edit' to={`/edit-password${search}`} />
|
||||
<Redirect from='/timeline/fediverse' to='/timeline/global' />
|
||||
<Redirect from='/timeline/fediverse' to='/explore' />
|
||||
|
||||
<WrappedRoute path='/tags/:id' publicRoute page={DefaultPage} component={HashtagTimeline} content={children} />
|
||||
|
||||
|
@ -260,7 +260,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
|
||||
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
|
||||
|
||||
<WrappedRoute path='/search' page={SearchPage} component={Search} content={children} publicRoute />
|
||||
<WrappedRoute path='/explore' page={ExplorePage} component={Explore} content={children} publicRoute />
|
||||
{features.suggestionsLocal && <WrappedRoute path='/suggestions/local' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} componentParams={{ local: true }} />}
|
||||
{features.suggestions && <WrappedRoute path='/suggestions' exact publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
|
||||
{features.profileDirectory && <WrappedRoute path='/directory' exact publicRoute page={DefaultPage} component={Directory} content={children} />}
|
||||
|
|
|
@ -57,7 +57,7 @@ export const BirthdaysModal = lazy(() => import('soapbox/features/ui/components/
|
|||
export const BirthdayPanel = lazy(() => import('soapbox/components/birthday-panel.tsx'));
|
||||
export const ListEditor = lazy(() => import('soapbox/features/list-editor/index.tsx'));
|
||||
export const ListAdder = lazy(() => import('soapbox/features/list-adder/index.tsx'));
|
||||
export const Search = lazy(() => import('soapbox/features/search/index.tsx'));
|
||||
export const Explore = lazy(() => import('soapbox/features/explore/index.tsx'));
|
||||
export const LoginPage = lazy(() => import('soapbox/features/auth-login/components/login-page.tsx'));
|
||||
export const ExternalLogin = lazy(() => import('soapbox/features/external-login/index.tsx'));
|
||||
export const LogoutPage = lazy(() => import('soapbox/features/auth-login/components/logout.tsx'));
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import type { NostrSigner } from '@nostrify/nostrify';
|
||||
|
||||
declare module '*.css';
|
||||
declare module 'swiper/css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: NostrSigner;
|
||||
|
|
|
@ -368,6 +368,35 @@
|
|||
"column.event_map": "Event location",
|
||||
"column.event_participants": "Event participants",
|
||||
"column.events": "Events",
|
||||
"column.explore": "Explore",
|
||||
"column.explore.bridge": "Bridges",
|
||||
"column.explore.filters": "Filters:",
|
||||
"column.explore.filters.add_filter": "Add Filter",
|
||||
"column.explore.filters.bluesky": "Bluesky",
|
||||
"column.explore.filters.cancel": "Cancel",
|
||||
"column.explore.filters.clear_input": "Clear filter input",
|
||||
"column.explore.filters.create_your_filter": "Create your filter",
|
||||
"column.explore.filters.empty": "Hey there... You forget to write the filter!",
|
||||
"column.explore.filters.fediverse": "Fediverse",
|
||||
"column.explore.filters.filter_by_words": "Filter word",
|
||||
"column.explore.filters.invert": "Invert",
|
||||
"column.explore.filters.language": "Language",
|
||||
"column.explore.filters.media": "Media",
|
||||
"column.explore.filters.nostr": "Nostr",
|
||||
"column.explore.filters.platforms": "Platforms",
|
||||
"column.explore.filters.remove_filter": "Remove filter",
|
||||
"column.explore.filters.reset": "Reset Filters",
|
||||
"column.explore.media_filters.all_media": "All media",
|
||||
"column.explore.media_filters.image": "Image only",
|
||||
"column.explore.media_filters.no_media": "No media",
|
||||
"column.explore.media_filters.video": "Video only",
|
||||
"column.explore.nostr": "Nostr",
|
||||
"column.explore.popular_accounts": "Popular Accounts",
|
||||
"column.explore.popular_accounts.collapse": "Collapse popular accounts",
|
||||
"column.explore.popular_accounts.error": "Could not load popular accounts. Please try again later.",
|
||||
"column.explore.popular_accounts.expand": "Expand popular accounts",
|
||||
"column.explore.welcome_card.text": "Explore the world of decentralized social media, dive into {nostrLink} or cross {bridgeLink} to other networks, and connect with a global community. All in one place.",
|
||||
"column.explore.welcome_card.title": "Welcome to Explore",
|
||||
"column.export_data": "Export data",
|
||||
"column.familiar_followers": "People you know following {name}",
|
||||
"column.favourited_statuses": "Liked posts",
|
||||
|
@ -430,7 +459,6 @@
|
|||
"column.reblogs": "Reposts",
|
||||
"column.registration": "Sign Up",
|
||||
"column.scheduled_statuses": "Scheduled Posts",
|
||||
"column.search": "Discover",
|
||||
"column.settings_store": "Settings store",
|
||||
"column.soapbox_config": "Soapbox config",
|
||||
"column.test": "Test timeline",
|
||||
|
@ -1122,7 +1150,7 @@
|
|||
"navigation.direct_messages": "Messages",
|
||||
"navigation.home": "Home",
|
||||
"navigation.notifications": "Notifications",
|
||||
"navigation.search": "Discover",
|
||||
"navigation.search": "Explore",
|
||||
"navigation_bar.account_aliases": "Account aliases",
|
||||
"navigation_bar.account_migration": "Move account",
|
||||
"navigation_bar.blocks": "Blocks",
|
||||
|
@ -1421,10 +1449,10 @@
|
|||
"scheduled_status.cancel": "Cancel",
|
||||
"search.action": "Search for “{query}”",
|
||||
"search.placeholder": "Search",
|
||||
"search_results.accounts": "People",
|
||||
"search_results.accounts": "Accounts",
|
||||
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
"search_results.statuses": "Posts",
|
||||
"search_results.posts": "Posts",
|
||||
"search_results.trends": "Trends",
|
||||
"security.codes.fail": "Failed to fetch backup codes",
|
||||
"security.confirm.fail": "Incorrect code or password. Try again.",
|
||||
"security.delete_account.fail": "Account deletion failed.",
|
||||
|
@ -1614,13 +1642,12 @@
|
|||
"sw.url": "Script URL",
|
||||
"tabs_bar.dashboard": "Dashboard",
|
||||
"tabs_bar.follows": "Follows",
|
||||
"tabs_bar.global": "Global",
|
||||
"tabs_bar.groups": "Groups",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.more": "More",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"tabs_bar.profile": "Profile",
|
||||
"tabs_bar.search": "Discover",
|
||||
"tabs_bar.search": "Explore",
|
||||
"tabs_bar.settings": "Settings",
|
||||
"textarea.counter.label": "{count} characters remaining",
|
||||
"theme_editor.colors.accent": "Accent",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import Layout from 'soapbox/components/ui/layout.tsx';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer.tsx';
|
||||
import {
|
||||
|
@ -5,18 +7,20 @@ import {
|
|||
TrendsPanel,
|
||||
SignUpPanel,
|
||||
CtaBanner,
|
||||
LatestAccountsPanel,
|
||||
SuggestedGroupsPanel,
|
||||
} from 'soapbox/features/ui/util/async-components.ts';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||
|
||||
interface ISearchPage {
|
||||
interface IExplorePage {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SearchPage: React.FC<ISearchPage> = ({ children }) => {
|
||||
const ExplorePage: React.FC<IExplorePage> = ({ children }) => {
|
||||
const me = useAppSelector(state => state.me);
|
||||
const features = useFeatures();
|
||||
const accountsPath = useLocation().pathname === '/explore/accounts';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -37,8 +41,9 @@ const SearchPage: React.FC<ISearchPage> = ({ children }) => {
|
|||
<TrendsPanel limit={5} />
|
||||
)}
|
||||
|
||||
{features.suggestions && (
|
||||
<WhoToFollowPanel limit={3} />
|
||||
{features.suggestions && (accountsPath
|
||||
? <LatestAccountsPanel limit={3} />
|
||||
: <WhoToFollowPanel limit={3} />
|
||||
)}
|
||||
|
||||
{features.groups && (
|
||||
|
@ -51,4 +56,4 @@ const SearchPage: React.FC<ISearchPage> = ({ children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
export default ExplorePage;
|
||||
|
|
|
@ -22,6 +22,10 @@ const config: Config = {
|
|||
boxShadow: ({ theme }) => ({
|
||||
'3xl': '0 25px 75px -15px rgba(0, 0, 0, 0.25)',
|
||||
'inset-ring': `inset 0 0 0 2px ${theme('colors.accent-blue')}`,
|
||||
'card': `rgba(0, 0, 0, 0.35, 0.1) 0px 4px 16px,
|
||||
rgba(0, 0, 0, 0.35, 0.1) 0px 8px 24px,
|
||||
rgba(0, 0, 0, 0.35, 0.1) 0px 16px 56px`,
|
||||
'card-inset': 'rgba(0, 0, 0, 0.60) 0px -120px 36px -28px inset',
|
||||
}),
|
||||
fontSize: {
|
||||
base: '0.9375rem',
|
||||
|
|
|
@ -7989,6 +7989,11 @@ svgo@^3.0.2:
|
|||
csso "^5.0.5"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
swiper@^11.2.5:
|
||||
version "11.2.5"
|
||||
resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.2.5.tgz#8168b85ff858773e674823acb8962a1c5cdee689"
|
||||
integrity sha512-nG0kbIyBfeE2BPFt9nPUX03qUBF75o6+enzjIT/DfCmbh8ORlwhc4eZz1+4H/yseAgb3H+OoEYzmb64i0tYNnQ==
|
||||
|
||||
symbol-tree@^3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||
|
|
Ładowanie…
Reference in New Issue