mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
+2
-7
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { isHomeView } from './lib/utils/view-utils';
|
import { isHomeView } from './lib/utils/view-utils';
|
||||||
import { useSubplebbits } from '@plebbit/plebbit-react-hooks';
|
|
||||||
import { useDefaultSubplebbitAddresses } from './hooks/use-default-subplebbits';
|
|
||||||
import useTheme from './hooks/use-theme';
|
import useTheme from './hooks/use-theme';
|
||||||
import styles from './app.module.css';
|
import styles from './app.module.css';
|
||||||
import Catalog from './views/catalog';
|
import Catalog from './views/catalog';
|
||||||
@@ -21,9 +19,6 @@ const App = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isInHomeView = isHomeView(location.pathname);
|
const isInHomeView = isHomeView(location.pathname);
|
||||||
|
|
||||||
const subplebbitAddresses = useDefaultSubplebbitAddresses();
|
|
||||||
const { subplebbits } = useSubplebbits({ subplebbitAddresses });
|
|
||||||
|
|
||||||
// add theme className to body so it can set the correct body background in index.css
|
// add theme className to body so it can set the correct body background in index.css
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +35,7 @@ const App = () => {
|
|||||||
|
|
||||||
const boardLayout = (
|
const boardLayout = (
|
||||||
<>
|
<>
|
||||||
<BoardNav subplebbits={subplebbits} />
|
<BoardNav />
|
||||||
<BoardBanner />
|
<BoardBanner />
|
||||||
<MobileBoardButtons />
|
<MobileBoardButtons />
|
||||||
<PostForm />
|
<PostForm />
|
||||||
@@ -53,7 +48,7 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={`${styles.app} ${isInHomeView ? 'yotsuba' : theme}`}>
|
<div className={`${styles.app} ${isInHomeView ? 'yotsuba' : theme}`}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path='/' element={<Home subplebbits={subplebbits} />} />
|
<Route path='/' element={<Home />} />
|
||||||
<Route element={boardLayout}>
|
<Route element={boardLayout}>
|
||||||
<Route path='/p/:subplebbitAddress' element={<Board />} />
|
<Route path='/p/:subplebbitAddress' element={<Board />} />
|
||||||
<Route path='/p/:subplebbitAddress/c/:commentCid' element={<PostPage />} />
|
<Route path='/p/:subplebbitAddress/c/:commentCid' element={<PostPage />} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import styles from './board-banner.module.css';
|
import styles from './board-banner.module.css';
|
||||||
|
|
||||||
const totalBanners = 52;
|
const totalBanners = 53;
|
||||||
|
|
||||||
const ImageBanner = () => {
|
const ImageBanner = () => {
|
||||||
const [imagePath] = useState(() => {
|
const [imagePath] = useState(() => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Subplebbit } from '@plebbit/plebbit-react-hooks';
|
|
||||||
import { isCatalogView } from '../../lib/utils/view-utils';
|
import { isCatalogView } from '../../lib/utils/view-utils';
|
||||||
import styles from './board-nav.module.css';
|
import styles from './board-nav.module.css';
|
||||||
|
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
||||||
|
|
||||||
interface BoardNavProps {
|
interface BoardNavProps {
|
||||||
subplebbits: (Subplebbit | undefined)[];
|
subplebbitAddresses: string[];
|
||||||
currentSubplebbit?: string | undefined;
|
subplebbitAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoardNavDesktop = ({ subplebbits }: BoardNavProps) => {
|
const BoardNavDesktop = ({ subplebbitAddresses }: BoardNavProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
||||||
|
|
||||||
@@ -17,16 +17,12 @@ const BoardNavDesktop = ({ subplebbits }: BoardNavProps) => {
|
|||||||
<div className={styles.boardNavDesktop}>
|
<div className={styles.boardNavDesktop}>
|
||||||
<span className={styles.boardList}>
|
<span className={styles.boardList}>
|
||||||
[
|
[
|
||||||
{subplebbits.map((subplebbit: any, index: number) => {
|
{subplebbitAddresses.map((address: string, index: number) => {
|
||||||
const address = subplebbit?.address || '';
|
|
||||||
const title = subplebbit?.title || '';
|
|
||||||
return (
|
return (
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
{index === 0 ? null : ' '}
|
{index === 0 ? null : ' '}
|
||||||
<Link to={`/p/${address}${isInCatalogView ? '/catalog' : ''}`} title={title || ''}>
|
<Link to={`/p/${address}${isInCatalogView ? '/catalog' : ''}`}>{address.includes('.') ? address : address.slice(0, 10).concat('...')}</Link>
|
||||||
{address.includes('.') ? address : title || address.slice(0, 10).concat('...')}
|
{index !== address.length - 1 ? ' /' : null}
|
||||||
</Link>
|
|
||||||
{index !== subplebbits.length - 1 ? ' /' : null}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -39,24 +35,23 @@ const BoardNavDesktop = ({ subplebbits }: BoardNavProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardNavMobile = ({ subplebbits, currentSubplebbit }: BoardNavProps) => {
|
const BoardNavMobile = ({ subplebbitAddresses, subplebbitAddress }: BoardNavProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const displaySubplebbitAddress = currentSubplebbit && currentSubplebbit.length > 30 ? currentSubplebbit.slice(0, 30).concat('...') : currentSubplebbit;
|
const displaySubplebbitAddress = subplebbitAddress && subplebbitAddress.length > 30 ? subplebbitAddress.slice(0, 30).concat('...') : subplebbitAddress;
|
||||||
|
|
||||||
const currentSubplebbitIsInList = subplebbits.some((subplebbit: any) => subplebbit?.address === currentSubplebbit);
|
const currentSubplebbitIsInList = subplebbitAddresses.some((address: string) => address === subplebbitAddress);
|
||||||
|
|
||||||
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
|
||||||
|
|
||||||
const boardSelect = (
|
const boardSelect = (
|
||||||
<select value={currentSubplebbit || 'all'} onChange={(e) => navigate(`/p/${e.target.value}${isInCatalogView ? '/catalog' : ''}`)}>
|
<select value={subplebbitAddress || 'all'} onChange={(e) => navigate(`/p/${e.target.value}${isInCatalogView ? '/catalog' : ''}`)}>
|
||||||
{!currentSubplebbitIsInList && currentSubplebbit && <option value={currentSubplebbit}>{displaySubplebbitAddress}</option>}
|
{!currentSubplebbitIsInList && subplebbitAddress && <option value={subplebbitAddress}>{displaySubplebbitAddress}</option>}
|
||||||
<option value='all'>{t('all')}</option>
|
<option value='all'>{t('all')}</option>
|
||||||
<option value='subscriptions'>{t('subscriptions')}</option>
|
<option value='subscriptions'>{t('subscriptions')}</option>
|
||||||
{subplebbits.map((subplebbit: any, index: number) => {
|
{subplebbitAddresses.map((subplebbit: any, index: number) => {
|
||||||
const address = subplebbit?.address || '';
|
const address = subplebbit?.address || '';
|
||||||
const title = subplebbit?.title || '';
|
const subplebbitAddress = address?.includes('.') ? address : address?.slice(0, 10).concat('...');
|
||||||
const subplebbitAddress = address?.includes('.') ? address : title || address?.slice(0, 10).concat('...');
|
|
||||||
return (
|
return (
|
||||||
<option key={index} value={address}>
|
<option key={index} value={address}>
|
||||||
{subplebbitAddress}
|
{subplebbitAddress}
|
||||||
@@ -80,12 +75,13 @@ const BoardNavMobile = ({ subplebbits, currentSubplebbit }: BoardNavProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardNav = ({ subplebbits }: BoardNavProps) => {
|
const BoardNav = () => {
|
||||||
const { subplebbitAddress: address } = useParams();
|
const subplebbitAddresses = useDefaultSubplebbitAddresses();
|
||||||
|
const { subplebbitAddress } = useParams();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BoardNavDesktop subplebbits={subplebbits} />
|
<BoardNavDesktop subplebbitAddresses={subplebbitAddresses} />
|
||||||
<BoardNavMobile subplebbits={subplebbits} currentSubplebbit={address} />
|
<BoardNavMobile subplebbitAddresses={subplebbitAddresses} subplebbitAddress={subplebbitAddress} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Comment } from '@plebbit/plebbit-react-hooks';
|
import { Comment } from '@plebbit/plebbit-react-hooks';
|
||||||
@@ -29,7 +29,13 @@ const CatalogPostMedia = ({ commentMediaInfo, link }: { commentMediaInfo: any; l
|
|||||||
return thumbnailComponent;
|
return thumbnailComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CatalogPost = ({ post }: { post: Comment }) => {
|
interface CatalogPostProps {
|
||||||
|
post: Comment;
|
||||||
|
openMenu: boolean;
|
||||||
|
toggleMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CatalogPost = ({ openMenu, post, toggleMenu }: CatalogPostProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cid, content, isDescription, isRules, link, linkHeight, linkWidth, locked, pinned, replyCount, subplebbitAddress, title } = post || {};
|
const { cid, content, isDescription, isRules, link, linkHeight, linkWidth, locked, pinned, replyCount, subplebbitAddress, title } = post || {};
|
||||||
const commentMediaInfo = getCommentMediaInfo(post);
|
const commentMediaInfo = getCommentMediaInfo(post);
|
||||||
@@ -65,7 +71,18 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const [menuBtnRotated, setMenuBtnRotated] = useState(false);
|
useEffect(() => {
|
||||||
|
const handlePageClick = () => {
|
||||||
|
if (openMenu) {
|
||||||
|
toggleMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handlePageClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handlePageClick);
|
||||||
|
};
|
||||||
|
}, [openMenu, toggleMenu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.post}>
|
<div className={styles.post}>
|
||||||
@@ -93,8 +110,11 @@ const CatalogPost = ({ post }: { post: Comment }) => {
|
|||||||
<span
|
<span
|
||||||
className={styles.postMenuBtn}
|
className={styles.postMenuBtn}
|
||||||
title='Thread Menu'
|
title='Thread Menu'
|
||||||
onClick={() => setMenuBtnRotated(!menuBtnRotated)}
|
onClick={(e) => {
|
||||||
style={{ transform: menuBtnRotated ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
e.stopPropagation();
|
||||||
|
toggleMenu();
|
||||||
|
}}
|
||||||
|
style={{ transform: openMenu ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
</span>
|
</span>
|
||||||
@@ -116,8 +136,19 @@ interface CatalogRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CatalogRow = ({ row }: CatalogRowProps) => {
|
const CatalogRow = ({ row }: CatalogRowProps) => {
|
||||||
const posts = row.map((post, index) => <CatalogPost key={index} post={post} />);
|
const [postWithMenuOpen, setPostWithMenuOpen] = useState<number | null>(null);
|
||||||
return <div className={styles.row}>{posts}</div>;
|
|
||||||
|
const handleToggleMenu = (index: number) => {
|
||||||
|
setPostWithMenuOpen(postWithMenuOpen === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
{row.map((post, index) => (
|
||||||
|
<CatalogPost key={index} post={post} openMenu={postWithMenuOpen === index} toggleMenu={() => handleToggleMenu(index)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CatalogRow;
|
export default CatalogRow;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnailSmall span, .thumbnailSmall a {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
word-wrap: break-word;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styles from './comment-media.module.css';
|
import styles from './comment-media.module.css';
|
||||||
import { CommentMediaInfo } from '../../lib/utils/media-utils';
|
import { CommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
import Embed from '../embed';
|
import Embed, { canEmbed } from '../embed';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import useWindowWidth from '../../hooks/use-window-width';
|
||||||
|
import { getHostname } from '../../lib/utils/url-utils';
|
||||||
|
|
||||||
interface MediaProps {
|
interface MediaProps {
|
||||||
commentMediaInfo?: CommentMediaInfo;
|
commentMediaInfo?: CommentMediaInfo;
|
||||||
isMobile: boolean;
|
|
||||||
isOutOfFeed?: boolean; // virtuoso wrapper unneeded
|
isOutOfFeed?: boolean; // virtuoso wrapper unneeded
|
||||||
isReply: boolean;
|
isReply: boolean;
|
||||||
linkHeight?: number;
|
linkHeight?: number;
|
||||||
@@ -35,10 +36,11 @@ const ThumbnailSmall = ({ style, children, thumbnailSmallPadding }: ThumbnailPro
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Thumbnail = ({ commentMediaInfo, isMobile, isOutOfFeed, isReply, linkHeight, linkWidth, setShowThumbnail }: MediaProps) => {
|
const Thumbnail = ({ commentMediaInfo, isOutOfFeed, isReply, linkHeight, linkWidth, setShowThumbnail }: MediaProps) => {
|
||||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||||
|
|
||||||
let displayWidth, displayHeight;
|
let displayWidth, displayHeight;
|
||||||
|
const isMobile = useWindowWidth() < 640;
|
||||||
const maxThumbnailSize = isMobile || isReply ? 125 : 250;
|
const maxThumbnailSize = isMobile || isReply ? 125 : 250;
|
||||||
|
|
||||||
if (linkWidth && linkHeight) {
|
if (linkWidth && linkHeight) {
|
||||||
@@ -51,8 +53,8 @@ const Thumbnail = ({ commentMediaInfo, isMobile, isOutOfFeed, isReply, linkHeigh
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'audio') {
|
if (type === 'audio') {
|
||||||
displayWidth = '250px';
|
displayWidth = '100%';
|
||||||
displayHeight = '75px';
|
displayHeight = '100%';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOutOfFeed) {
|
if (isOutOfFeed) {
|
||||||
@@ -63,6 +65,7 @@ const Thumbnail = ({ commentMediaInfo, isMobile, isOutOfFeed, isReply, linkHeigh
|
|||||||
let thumbnailComponent: React.ReactNode = null;
|
let thumbnailComponent: React.ReactNode = null;
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||||
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||||
|
|
||||||
if (type === 'image') {
|
if (type === 'image') {
|
||||||
thumbnailComponent = <img src={url} alt='' onClick={() => setShowThumbnail(false)} />;
|
thumbnailComponent = <img src={url} alt='' onClick={() => setShowThumbnail(false)} />;
|
||||||
@@ -81,18 +84,31 @@ const Thumbnail = ({ commentMediaInfo, isMobile, isOutOfFeed, isReply, linkHeigh
|
|||||||
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
||||||
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
|
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
|
||||||
|
|
||||||
|
const linkWithoutThumbnail = url && new URL(url);
|
||||||
|
|
||||||
return isMobile || isReply ? (
|
return isMobile || isReply ? (
|
||||||
<ThumbnailSmall style={thumbnailDimensions} thumbnailSmallPadding={thumbnailSmallPadding}>
|
<ThumbnailSmall style={thumbnailDimensions} thumbnailSmallPadding={thumbnailSmallPadding}>
|
||||||
{thumbnailComponent}
|
{thumbnailComponent}
|
||||||
|
{isMobile &&
|
||||||
|
!hasThumbnail &&
|
||||||
|
linkWithoutThumbnail &&
|
||||||
|
(canEmbed(linkWithoutThumbnail) ? (
|
||||||
|
<span onClick={() => setShowThumbnail(false)}>{getHostname(url)}</span>
|
||||||
|
) : (
|
||||||
|
<a href={url} target='_blank' rel='noreferrer'>
|
||||||
|
{getHostname(url) || (url.length > 30 ? url.slice(0, 30) + '...' : url)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</ThumbnailSmall>
|
</ThumbnailSmall>
|
||||||
) : (
|
) : (
|
||||||
<ThumbnailBig style={thumbnailDimensions}>{thumbnailComponent}</ThumbnailBig>
|
<ThumbnailBig style={thumbnailDimensions}>{thumbnailComponent}</ThumbnailBig>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Media = ({ commentMediaInfo, isMobile, isReply, setShowThumbnail }: MediaProps) => {
|
const Media = ({ commentMediaInfo, isReply, setShowThumbnail }: MediaProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||||
|
const isMobile = useWindowWidth() < 640;
|
||||||
const mediaClass = isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp;
|
const mediaClass = isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,23 +143,26 @@ const Media = ({ commentMediaInfo, isMobile, isReply, setShowThumbnail }: MediaP
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CommentMedia = ({ commentMediaInfo, isMobile, isOutOfFeed, isReply, linkHeight, linkWidth, showThumbnail, setShowThumbnail }: MediaProps) => {
|
const CommentMedia = ({ commentMediaInfo, isOutOfFeed, isReply, linkHeight, linkWidth, showThumbnail, setShowThumbnail }: MediaProps) => {
|
||||||
|
const isMobile = useWindowWidth() < 640;
|
||||||
|
const { type, url } = commentMediaInfo || {};
|
||||||
return (
|
return (
|
||||||
<span className={styles.content}>
|
<span className={styles.content}>
|
||||||
<span className={`${showThumbnail ? styles.show : styles.hide} ${styles.thumbnail}`}>
|
<span className={`${showThumbnail ? styles.show : styles.hide} ${styles.thumbnail}`}>
|
||||||
<Thumbnail
|
{url && (
|
||||||
commentMediaInfo={commentMediaInfo}
|
<Thumbnail
|
||||||
isMobile={isMobile}
|
commentMediaInfo={commentMediaInfo}
|
||||||
isOutOfFeed={isOutOfFeed}
|
isOutOfFeed={isOutOfFeed}
|
||||||
isReply={isReply}
|
isReply={isReply}
|
||||||
linkHeight={linkHeight}
|
linkHeight={linkHeight}
|
||||||
linkWidth={linkWidth}
|
linkWidth={linkWidth}
|
||||||
showThumbnail={showThumbnail}
|
showThumbnail={showThumbnail}
|
||||||
setShowThumbnail={setShowThumbnail}
|
setShowThumbnail={setShowThumbnail}
|
||||||
/>
|
/>
|
||||||
{isMobile && commentMediaInfo?.type && <div className={styles.fileInfo}>{commentMediaInfo.type}</div>}
|
)}
|
||||||
|
{isMobile && type && <div className={styles.fileInfo}>{type}</div>}
|
||||||
</span>
|
</span>
|
||||||
{!showThumbnail && <Media commentMediaInfo={commentMediaInfo} isMobile={isMobile} isReply={isReply} setShowThumbnail={setShowThumbnail} />}
|
{!showThumbnail && <Media commentMediaInfo={commentMediaInfo} isReply={isReply} setShowThumbnail={setShowThumbnail} />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,3 +68,14 @@
|
|||||||
height: 50vh !important;
|
height: 50vh !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.soundcloudEmbed {
|
||||||
|
height: 166px; /* default height of soundcloud embeds */
|
||||||
|
width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.soundcloudEmbed {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ const Embed = ({ url }: EmbedProps) => {
|
|||||||
if (spotifyHosts.has(parsedUrl.host)) {
|
if (spotifyHosts.has(parsedUrl.host)) {
|
||||||
return <SpotifyEmbed parsedUrl={parsedUrl} />;
|
return <SpotifyEmbed parsedUrl={parsedUrl} />;
|
||||||
}
|
}
|
||||||
|
if (soundcloudHosts.has(parsedUrl.host)) {
|
||||||
|
return <SoundcloudEmbed parsedUrl={parsedUrl} />;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EmbedComponentProps {
|
interface EmbedComponentProps {
|
||||||
@@ -258,6 +261,24 @@ const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const soundcloudHosts = new Set(['soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com', 'api.soundcloud.com', 'w.soundcloud.com']);
|
||||||
|
|
||||||
|
// not officially documented https://stackoverflow.com/questions/20870270/how-to-get-soundcloud-embed-code-by-soundcloud-com-url
|
||||||
|
const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className={styles.soundcloudEmbed}
|
||||||
|
height='100%'
|
||||||
|
width='100%'
|
||||||
|
referrerPolicy='no-referrer'
|
||||||
|
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||||
|
allowFullScreen
|
||||||
|
title={parsedUrl.href}
|
||||||
|
src={`https://w.soundcloud.com/player/?url=${parsedUrl.href}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const canEmbedHosts = new Set<string>([
|
const canEmbedHosts = new Set<string>([
|
||||||
...youtubeHosts,
|
...youtubeHosts,
|
||||||
...xHosts,
|
...xHosts,
|
||||||
@@ -267,6 +288,7 @@ const canEmbedHosts = new Set<string>([
|
|||||||
...instagramHosts,
|
...instagramHosts,
|
||||||
...odyseeHosts,
|
...odyseeHosts,
|
||||||
...bitchuteHosts,
|
...bitchuteHosts,
|
||||||
|
...soundcloudHosts,
|
||||||
...streamableHosts,
|
...streamableHosts,
|
||||||
...spotifyHosts,
|
...spotifyHosts,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -55,12 +55,10 @@ const Markdown = ({ content }: MarkdownProps) => {
|
|||||||
|
|
||||||
remarkPlugins.push([blockquoteToGreentext]);
|
remarkPlugins.push([blockquoteToGreentext]);
|
||||||
|
|
||||||
const doubleNewlineContent = content?.replace(/\n/g, ' \n\n');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.markdown}>
|
<span className={styles.markdown}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
children={doubleNewlineContent}
|
children={content}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={[[rehypeSanitize, customSchema]]}
|
rehypePlugins={[[rehypeSanitize, customSchema]]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import useWindowWidth from '../../hooks/use-window-width';
|
|||||||
import styles from './post.module.css';
|
import styles from './post.module.css';
|
||||||
import Markdown from '../markdown';
|
import Markdown from '../markdown';
|
||||||
import CommentMedia from '../comment-media';
|
import CommentMedia from '../comment-media';
|
||||||
|
import { canEmbed } from '../embed';
|
||||||
|
|
||||||
interface PostProps {
|
interface PostProps {
|
||||||
index?: number;
|
index?: number;
|
||||||
@@ -35,6 +36,7 @@ const PostDesktop = ({ post, roles, showAllReplies }: PostProps) => {
|
|||||||
const displayContent = content && !isInPostPage && content.length > 1000 ? content?.slice(0, 1000) + '(...)' : content;
|
const displayContent = content && !isInPostPage && content.length > 1000 ? content?.slice(0, 1000) + '(...)' : content;
|
||||||
|
|
||||||
const commentMediaInfo = getCommentMediaInfo(post);
|
const commentMediaInfo = getCommentMediaInfo(post);
|
||||||
|
const { type, url } = commentMediaInfo || {};
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
const [showThumbnail, setShowThumbnail] = useState(true);
|
const [showThumbnail, setShowThumbnail] = useState(true);
|
||||||
|
|
||||||
@@ -56,15 +58,15 @@ const PostDesktop = ({ post, roles, showAllReplies }: PostProps) => {
|
|||||||
<span className={`${styles.hideButton} ${styles.hideThread}`} />
|
<span className={`${styles.hideButton} ${styles.hideThread}`} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{commentMediaInfo?.url && (
|
{url && (
|
||||||
<div className={styles.file}>
|
<div className={styles.file}>
|
||||||
<div className={styles.fileText}>
|
<div className={styles.fileText}>
|
||||||
{t('link')}:{' '}
|
{t('link')}:{' '}
|
||||||
<a href={commentMediaInfo?.url} target='_blank' rel='noopener noreferrer'>
|
<a href={url} target='_blank' rel='noopener noreferrer'>
|
||||||
{commentMediaInfo.url.length > 30 ? commentMediaInfo?.url?.slice(0, 30) + '...' : commentMediaInfo?.url}
|
{url.length > 30 ? url.slice(0, 30) + '...' : url}
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
({commentMediaInfo?.type})
|
({type})
|
||||||
{!showThumbnail && (commentMediaInfo?.type === 'iframe' || commentMediaInfo?.type === 'video' || commentMediaInfo?.type === 'audio') && (
|
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{' '}
|
||||||
[
|
[
|
||||||
@@ -74,11 +76,20 @@ const PostDesktop = ({ post, roles, showAllReplies }: PostProps) => {
|
|||||||
]
|
]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showThumbnail && !hasThumbnail && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
[
|
||||||
|
<span className={styles.closeMedia} onClick={() => setShowThumbnail(false)}>
|
||||||
|
{t('open')}
|
||||||
|
</span>
|
||||||
|
]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasThumbnail && (
|
{(hasThumbnail || (!hasThumbnail && !showThumbnail)) && (
|
||||||
<CommentMedia
|
<CommentMedia
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
isMobile={false}
|
|
||||||
isOutOfFeed={isDescription || isRules} // virtuoso wrapper unneeded
|
isOutOfFeed={isDescription || isRules} // virtuoso wrapper unneeded
|
||||||
isReply={false}
|
isReply={false}
|
||||||
linkHeight={linkHeight}
|
linkHeight={linkHeight}
|
||||||
@@ -182,6 +193,8 @@ const ReplyDesktop = ({ reply, roles }: PostProps) => {
|
|||||||
const authorRole = roles?.[address]?.role;
|
const authorRole = roles?.[address]?.role;
|
||||||
|
|
||||||
const commentMediaInfo = getCommentMediaInfo(reply);
|
const commentMediaInfo = getCommentMediaInfo(reply);
|
||||||
|
const { type, url } = commentMediaInfo || {};
|
||||||
|
const embedUrl = url && new URL(url);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
const [showThumbnail, setShowThumbnail] = useState(true);
|
const [showThumbnail, setShowThumbnail] = useState(true);
|
||||||
|
|
||||||
@@ -231,14 +244,14 @@ const ReplyDesktop = ({ reply, roles }: PostProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{link && (
|
{url && (
|
||||||
<div className={styles.file}>
|
<div className={styles.file}>
|
||||||
<div className={styles.fileText}>
|
<div className={styles.fileText}>
|
||||||
{t('link')}:{' '}
|
{t('link')}:{' '}
|
||||||
<a href={link} target='_blank' rel='noopener noreferrer'>
|
<a href={link} target='_blank' rel='noopener noreferrer'>
|
||||||
{link.length > 30 ? link?.slice(0, 30) + '...' : link}
|
{link.length > 30 ? link?.slice(0, 30) + '...' : link}
|
||||||
</a>
|
</a>
|
||||||
{!showThumbnail && (commentMediaInfo?.type === 'iframe' || commentMediaInfo?.type === 'video' || commentMediaInfo?.type === 'audio') && (
|
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{' '}
|
||||||
[
|
[
|
||||||
@@ -248,11 +261,20 @@ const ReplyDesktop = ({ reply, roles }: PostProps) => {
|
|||||||
]
|
]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
[
|
||||||
|
<span className={styles.closeMedia} onClick={() => setShowThumbnail(false)}>
|
||||||
|
{t('open')}
|
||||||
|
</span>
|
||||||
|
]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasThumbnail && (
|
{(hasThumbnail || (!hasThumbnail && !showThumbnail)) && (
|
||||||
<CommentMedia
|
<CommentMedia
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
isMobile={false}
|
|
||||||
isReply={true}
|
isReply={true}
|
||||||
linkHeight={linkHeight}
|
linkHeight={linkHeight}
|
||||||
linkWidth={linkWidth}
|
linkWidth={linkWidth}
|
||||||
@@ -344,10 +366,9 @@ const PostMobile = ({ post, roles, showAllReplies }: PostProps) => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hasThumbnail && (
|
{(hasThumbnail || link) && (
|
||||||
<CommentMedia
|
<CommentMedia
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
isMobile={true}
|
|
||||||
isOutOfFeed={isDescription || isRules} // virtuoso wrapper unneeded
|
isOutOfFeed={isDescription || isRules} // virtuoso wrapper unneeded
|
||||||
isReply={false}
|
isReply={false}
|
||||||
linkHeight={linkHeight}
|
linkHeight={linkHeight}
|
||||||
@@ -427,10 +448,9 @@ const ReplyMobile = ({ reply, roles }: PostProps) => {
|
|||||||
<span className={styles.replyToPost}>{shortCid}</span>
|
<span className={styles.replyToPost}>{shortCid}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hasThumbnail && (
|
{(hasThumbnail || link) && (
|
||||||
<CommentMedia
|
<CommentMedia
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
isMobile={true}
|
|
||||||
isReply={false}
|
isReply={false}
|
||||||
linkHeight={linkHeight}
|
linkHeight={linkHeight}
|
||||||
linkWidth={linkWidth}
|
linkWidth={linkWidth}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import SubplebbitRules from '../../components/subplebbit-rules';
|
|||||||
|
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
|
|
||||||
const Subplebbit = () => {
|
const Board = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { subplebbitAddress } = useParams<{ subplebbitAddress: string }>();
|
const { subplebbitAddress } = useParams<{ subplebbitAddress: string }>();
|
||||||
const subplebbitAddresses = useMemo(() => [subplebbitAddress], [subplebbitAddress]) as string[];
|
const subplebbitAddresses = useMemo(() => [subplebbitAddress], [subplebbitAddress]) as string[];
|
||||||
@@ -58,7 +58,7 @@ const Subplebbit = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{createdAt && (
|
{feed.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{rules && rules.length > 0 && <SubplebbitRules subplebbitAddress={subplebbitAddress} createdAt={createdAt} rules={rules} />}
|
{rules && rules.length > 0 && <SubplebbitRules subplebbitAddress={subplebbitAddress} createdAt={createdAt} rules={rules} />}
|
||||||
{description && description.length > 0 && (
|
{description && description.length > 0 && (
|
||||||
@@ -82,4 +82,4 @@ const Subplebbit = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Subplebbit;
|
export default Board;
|
||||||
|
|||||||
@@ -12,60 +12,54 @@ import styles from './catalog.module.css';
|
|||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
|
|
||||||
const useFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => {
|
const useFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => {
|
||||||
const modifiedFeed = useMemo(() => {
|
const { t } = useTranslation();
|
||||||
|
const { address, createdAt, description, rules, shortAddress, suggested, title } = subplebbit || {};
|
||||||
|
const { avatarUrl } = suggested || {};
|
||||||
|
|
||||||
|
const feedWithDescriptionAndRules = useMemo(() => {
|
||||||
if (!isFeedLoaded) {
|
if (!isFeedLoaded) {
|
||||||
return []; // prevent rules and description from appearing while feed is loading
|
return []; // prevent rules and description from appearing while feed is loading
|
||||||
}
|
}
|
||||||
if (!subplebbit?.description && !subplebbit?.rules) {
|
if (!description && !rules) {
|
||||||
return feed;
|
return feed;
|
||||||
}
|
}
|
||||||
const _feed = [...feed];
|
const _feed = [...feed];
|
||||||
if (subplebbit?.description) {
|
if (description && description.length > 0) {
|
||||||
_feed.unshift({
|
_feed.unshift({
|
||||||
isDescription: true,
|
isDescription: true,
|
||||||
subplebbitAddress: subplebbit?.address,
|
subplebbitAddress: address,
|
||||||
timestamp: subplebbit?.createdAt,
|
timestamp: createdAt,
|
||||||
author: { displayName: '## Board Mods' },
|
author: { displayName: '## Board Mods' },
|
||||||
content: subplebbit?.description,
|
content: description,
|
||||||
link: subplebbit?.suggested?.avatarUrl,
|
link: avatarUrl,
|
||||||
title: 'Welcome to ' + (subplebbit?.title || `p/${subplebbit?.shortAddress}`),
|
title: 'Welcome to ' + (title || `p/${shortAddress}`),
|
||||||
pinned: true,
|
pinned: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (subplebbit?.rules) {
|
if (rules && rules.length > 0) {
|
||||||
_feed.unshift({
|
_feed.unshift({
|
||||||
isRules: true,
|
isRules: true,
|
||||||
subplebbitAddress: subplebbit?.address,
|
subplebbitAddress: address,
|
||||||
timestamp: subplebbit?.createdAt,
|
timestamp: createdAt,
|
||||||
author: { displayName: '## Board Mods' },
|
author: { displayName: '## Board Mods' },
|
||||||
content: subplebbit?.rules.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n'),
|
content: rules.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n'),
|
||||||
title: 'Rules',
|
title: 'Rules',
|
||||||
pinned: true,
|
pinned: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return _feed;
|
return _feed;
|
||||||
}, [
|
}, [feed, description, rules, address, isFeedLoaded, createdAt, title, shortAddress, avatarUrl]);
|
||||||
feed,
|
|
||||||
subplebbit?.description,
|
|
||||||
subplebbit?.rules,
|
|
||||||
subplebbit?.address,
|
|
||||||
isFeedLoaded,
|
|
||||||
subplebbit?.createdAt,
|
|
||||||
subplebbit?.title,
|
|
||||||
subplebbit?.shortAddress,
|
|
||||||
subplebbit?.suggested?.avatarUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Memoize rows calculation, ensuring it updates on changes to the modified feed or column count
|
// Memoize rows calculation, ensuring it updates on changes to the modified feed or column count
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (let i = 0; i < modifiedFeed.length; i += columnCount) {
|
for (let i = 0; i < feedWithDescriptionAndRules.length; i += columnCount) {
|
||||||
rows.push(modifiedFeed.slice(i, i + columnCount));
|
rows.push(feedWithDescriptionAndRules.slice(i, i + columnCount));
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [modifiedFeed, columnCount]);
|
}, [feedWithDescriptionAndRules, columnCount]);
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { useRef } from 'react';
|
|||||||
import styles from './home.module.css';
|
import styles from './home.module.css';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Subplebbit } from '@plebbit/plebbit-react-hooks';
|
import { Subplebbit, useSubplebbits } from '@plebbit/plebbit-react-hooks';
|
||||||
import packageJson from '../../../package.json';
|
import packageJson from '../../../package.json';
|
||||||
|
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
||||||
interface HomeProps {
|
|
||||||
subplebbits: (Subplebbit | undefined)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidAddress = (address: string): boolean => {
|
const isValidAddress = (address: string): boolean => {
|
||||||
if (address.includes('/') || address.includes('\\') || address.includes(' ')) {
|
if (address.includes('/') || address.includes('\\') || address.includes(' ')) {
|
||||||
@@ -69,7 +66,7 @@ const InfoBox = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Boards = ({ subplebbits }: HomeProps) => {
|
const Boards = ({ subplebbits }: { subplebbits: (Subplebbit | undefined)[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,8 +80,8 @@ const Boards = ({ subplebbits }: HomeProps) => {
|
|||||||
<h3>Default SFW</h3>
|
<h3>Default SFW</h3>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{subplebbits
|
{subplebbits
|
||||||
.filter((subplebbit): subplebbit is Subplebbit => subplebbit !== undefined)
|
.filter((subplebbit: Subplebbit | undefined): subplebbit is Subplebbit => subplebbit !== undefined)
|
||||||
.map((subplebbit) => {
|
.map((subplebbit: Subplebbit) => {
|
||||||
const address = subplebbit.address;
|
const address = subplebbit.address;
|
||||||
return (
|
return (
|
||||||
<div className={styles.subplebbit} key={address}>
|
<div className={styles.subplebbit} key={address}>
|
||||||
@@ -221,7 +218,10 @@ const Footer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Home = ({ subplebbits }: HomeProps) => {
|
const Home = () => {
|
||||||
|
const subplebbitAddresses = useDefaultSubplebbitAddresses();
|
||||||
|
const { subplebbits } = useSubplebbits({ subplebbitAddresses });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Link to='/'>
|
<Link to='/'>
|
||||||
|
|||||||
Reference in New Issue
Block a user