feat: Added details dialog

This commit is contained in:
florian 2023-07-29 13:44:56 +02:00
parent fbe06a623d
commit 13a61bce9c
17 changed files with 395 additions and 98 deletions

BIN
public/notfound.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -1,28 +1,15 @@
import { useParams, useSearchParams } from 'react-router-dom';
import SlideShow from './components/SlideShow';
import './App.css';
import Disclaimer from './components/Disclaimer';
import useDisclaimerState from './utils/useDisclaimerState';
import { defaultHashTags } from './components/env';
const App = () => {
const { disclaimerAccepted, setDisclaimerAccepted } = useDisclaimerState();
const { tags, npub } = useParams();
const [searchParams] = useSearchParams();
const nsfw = searchParams.get('nsfw') === 'true';
console.log(`tags = ${tags}, npub = ${npub}, nsfw = ${nsfw}`);
let useTags = tags?.split(',') || [];
if (npub == undefined && (useTags == undefined || useTags.length == 0)) {
useTags = defaultHashTags;
}
return (
<>
{disclaimerAccepted ? (
<SlideShow tags={useTags} npubs={npub ? [npub] : []} showNsfw={nsfw} />
<SlideShow />
) : (
<Disclaimer disclaimerAccepted={disclaimerAccepted} setDisclaimerAccepted={setDisclaimerAccepted} />
)}

View File

@ -1,18 +1,17 @@
import { useNavigate } from 'react-router-dom';
import './Disclaimer.css';
import { MouseEvent } from 'react';
import useNav from '../utils/useNav';
const AdultContentInfo = () => {
const navigate = useNavigate();
const { nav, currentSettings } = useNav();
const proceed = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const nsfwPostfix = '?nsfw=true';
navigate(`${window.location.pathname}${nsfwPostfix}`);
nav({ ...currentSettings, showNsfw: true });
};
const goBack = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
navigate(`/`);
nav({ npubs: [], tags: [], showNsfw: false });
};
return (

View File

@ -1,29 +1,37 @@
import './SlideShow.css';
import useImageLoaded from '../utils/useImageLoaded';
import { createImgProxyUrl } from './nostrImageDownload';
import useNav from '../utils/useNav';
type AvatarImageProps = {
src?: string;
author?: string;
npub?: string;
setShowGrid?: (showGrid: boolean) => void;
};
const AuthorProfile = ({ src, author, npub }: AvatarImageProps) => {
const AuthorProfile = ({ src, author, npub, setShowGrid }: AvatarImageProps) => {
const avatarLoaded = useImageLoaded(src);
const { nav, currentSettings } = useNav();
return (
<div className="author-info">
<a href={`https://nostrapp.link/#${npub}`} target="_blank">
<div>
{avatarLoaded && (
<div
className="author-image"
style={{
backgroundImage: `url(${src})`,
}}
></div>
)}
{author}
</div>
</a>
<div
className="author-info"
onClick={() => {
setShowGrid && setShowGrid(true);
npub && nav({ ...currentSettings, tags: [], npubs: [npub] });
}}
>
<div>
{avatarLoaded && (
<div
className="author-image"
style={{
backgroundImage: src ? `url(${createImgProxyUrl(src, 80, 80)})` : 'none',
}}
></div>
)}
{author}
</div>
</div>
);
};

View File

@ -0,0 +1,37 @@
import useNav from '../../utils/useNav';
import { createImgProxyUrl } from '../nostrImageDownload';
import './DetailsView.css';
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
type DetailsAuthorProps = {
profile?: NDKUserProfile;
npub?: string;
setActiveImageIdx: (idx: number | undefined) => void;
};
const DetailsAuthor = ({ profile, npub, setActiveImageIdx }: DetailsAuthorProps) => {
const { nav, currentSettings } = useNav();
return (
<div
className="details-author"
onClick={() => {
setActiveImageIdx(undefined);
npub && nav({ ...currentSettings, tags: [], npubs: [npub] });
}}
>
<>
<div
className="author-image"
style={{
backgroundImage: profile?.image ? `url(${createImgProxyUrl(profile?.image, 80, 80)})` : 'none',
}}
></div>
{profile?.displayName || profile?.name}
</>
</div>
);
};
export default DetailsAuthor;

View File

@ -0,0 +1,96 @@
.details {
/*
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
justify-content: center;
align-items: center;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
font-size: 1.2rem;
animation: fadeIn 0.5s ease-in-out;
z-index: 500;
padding: 2em;
flex-direction: column;
gap: 24px;
width: 90%;
height: 85%;
*/
position: absolute;
z-index: 500;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 2em;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
justify-content: center;
}
.details-contents {
display: grid;
grid-template-columns: max(60vw, 40vw) auto;
gap: 24px;
justify-items: center;
}
.details-contents .tag {
display: inline;
padding: 0.2em 0.6em;
margin-right: 0.2em;
border-radius: 24px;
background-color: #444;
color: white;
line-height: 2.2em;
font-size: 1rem;
cursor: pointer;
}
.details-contents .tag:hover {
background-color: #555;
}
.details-contents .detail-image {
object-fit: contain;
max-width: 100%;
max-height: 90vh;
border-radius: 12px;
}
.detail-description {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 30em;
width: 25em;
overflow-y: auto;
overflow-x: hidden;
}
.details-author {
display: flex;
flex-direction: row;
gap: 12px;
align-items: flex-start;
cursor: pointer;
}
@media screen and (max-width: 768px) {
.details {
overflow-y: scroll;
}
.details-contents {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.detail-description {
max-width: 100%;
width: 100%;
}
}

View File

@ -0,0 +1,57 @@
import { NostrImage } from '../nostrImageDownload';
import './DetailsView.css';
import { useNDK } from '@nostr-dev-kit/ndk-react';
import DetailsAuthor from './Author';
import { useMemo } from 'react';
import uniq from 'lodash/uniq';
import useNav from '../../utils/useNav';
type DetailsViewProps = {
images: NostrImage[];
activeImageIdx: number | undefined;
setActiveImageIdx: (idx: number | undefined) => void;
};
const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewProps) => {
const { getProfile } = useNDK();
const currentImage = useMemo(
() => (activeImageIdx !== undefined ? images[activeImageIdx] : undefined),
[images, activeImageIdx]
);
const activeProfile = currentImage?.author !== undefined ? getProfile(currentImage?.author) : undefined;
const { nav, currentSettings } = useNav();
return (
<div className="details">
<div className="details-contents">
<img className="detail-image" src={currentImage?.url}></img>
<div className="detail-description">
<DetailsAuthor
profile={activeProfile}
npub={currentImage?.author}
setActiveImageIdx={setActiveImageIdx}
></DetailsAuthor>
<div>{currentImage?.content}</div>
<div>
{uniq(currentImage?.tags).map(t => (
<>
<span
className="tag"
onClick={() => {
setActiveImageIdx(undefined);
nav({ ...currentSettings, tags: [t], npubs: [] });
}}
>
{t}
</span>{' '}
</>
))}
</div>
</div>
</div>
</div>
);
};
export default DetailsView;

View File

@ -0,0 +1,27 @@
import { SyntheticEvent, useState } from 'react';
import { NostrImage, createImgProxyUrl } from '../nostrImageDownload';
interface GridImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
image: NostrImage;
}
const GridImage = ({ image, ...props }: GridImageProps) => {
const [loaded, setLoaded] = useState(false);
return (
<img
data-node-id={image.noteId}
onError={(e: SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = '/notfound.png';
}}
className={`image ${loaded ? 'show' : ''}`}
onLoad={() => setLoaded(true)}
loading="lazy"
key={image.url}
src={createImgProxyUrl(image.url)}
{...props}
></img>
);
};
export default GridImage;

View File

@ -1,10 +1,27 @@
@keyframes showImage {
from {
opacity: 0;
visibility: visible;
}
to {
opacity: 1;
}
}
.gridview {
display: flex;
flex-direction: column;
align-items: start;
height: 100vh;
}
.imagegrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 1rem;
padding: 1rem;
width: 100vw;
height: 100vh;
overflow: scroll;
box-sizing: border-box;
}
@ -15,6 +32,15 @@
object-fit: cover;
height: 200px;
cursor: pointer;
visibility: hidden;
}
.imagegrid .image.show {
animation-duration: 0.5s;
animation-timing-function: ease-in;
animation-name: showImage;
visibility: visible;
}
@media screen and (max-width: 600px) {

View File

@ -1,8 +1,9 @@
import { useMemo, useState } from 'react';
import Settings from '../Settings';
import { NostrImage, createImgProxyUrl, isVideo } from '../nostrImageDownload';
import { useEffect, useMemo, useState } from 'react';
import { NostrImage, isVideo } from '../nostrImageDownload';
import './GridView.css';
import Slide from '../SlideView/Slide';
import DetailsView from './DetailsView';
import GridImage from './GridImage';
import { Settings } from '../../utils/useNav';
type GridViewProps = {
settings: Settings;
@ -10,7 +11,7 @@ type GridViewProps = {
};
const GridView = ({ settings, images }: GridViewProps) => {
const [activeImage, setActiveImage] = useState<NostrImage | undefined>();
const [activeImageIdx, setActiveImageIdx] = useState<number | undefined>();
const sortedImages = useMemo(
() =>
@ -20,20 +21,33 @@ const GridView = ({ settings, images }: GridViewProps) => {
[images]
);
const onKeyDown = (event: KeyboardEvent) => {
console.log(event);
if (event.key === 'ArrowRight') {
setActiveImageIdx(idx => (idx !== undefined && idx < sortedImages.length - 1 ? idx + 1 : idx));
}
if (event.key === 'ArrowLeft') {
setActiveImageIdx(idx => (idx !== undefined && idx > 0 ? idx - 1 : idx));
}
if (event.key === 'Escape') {
setActiveImageIdx(undefined);
}
};
useEffect(() => {
document.body.addEventListener('keydown', onKeyDown);
return () => {
document.body.removeEventListener('keydown', onKeyDown);
};
}, []);
return (
<>
{activeImage && (
<Slide
url={activeImage.url}
noteId={activeImage.noteId}
type={activeImage.type}
paused={false}
onAnimationEnded={() => setActiveImage(undefined)}
animationDuration={4}
></Slide>
)}
<div className="gridview">
{activeImageIdx !== undefined ? (
<DetailsView images={sortedImages} activeImageIdx={activeImageIdx} setActiveImageIdx={setActiveImageIdx} />
) : null}
<div className="imagegrid">
{sortedImages.map(image =>
{sortedImages.map((image, idx) =>
isVideo(image.url) ? (
<video
className="image"
@ -44,18 +58,11 @@ const GridView = ({ settings, images }: GridViewProps) => {
preload="none"
/>
) : (
<img
onClick={() => setActiveImage(image)}
data-node-id={image.noteId}
className="image"
loading="lazy"
key={image.url}
src={createImgProxyUrl(image.url)}
></img>
<GridImage key={image.url} image={image} onClick={() => setActiveImageIdx(idx)}></GridImage>
)
)}
</div>
</>
</div>
);
};

View File

@ -1,38 +1,29 @@
import { FormEvent, useState } from 'react';
import './Settings.css';
import { useNavigate } from 'react-router-dom';
type Settings = {
showNsfw: boolean;
tags: string[];
npubs: string[];
};
import useNav, { Settings } from '../utils/useNav';
type SettingsProps = {
onClose: () => void;
settings: Settings;
};
const Settings = ({ onClose, settings }: SettingsProps) => {
const SettingsDialog = ({ onClose, settings }: SettingsProps) => {
const [showNsfw, setShowNsfw] = useState(settings.showNsfw || false);
const [tags, setTags] = useState(settings.tags || []);
const [npubs, setNpubs] = useState(settings.npubs || []);
const navigate = useNavigate();
const { nav, currentSettings } = useNav();
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const nsfwPostfix = showNsfw ? '?nsfw=true' : '';
const validTags = tags.filter(t => t.length > 0);
const validNpubs = npubs.filter(t => t.length > 0);
if (validTags.length > 0) {
navigate(`/tags/${validTags.join('%2C')}${nsfwPostfix}`);
nav({ ...currentSettings, tags: validTags, npubs: [], showNsfw });
} else if (validNpubs.length == 1) {
navigate(`/p/${validNpubs[0]}${nsfwPostfix}`);
nav({ ...currentSettings, tags: [], npubs: validNpubs, showNsfw });
} else {
navigate(`/${nsfwPostfix}`);
nav({ ...currentSettings, tags: [], npubs: [], showNsfw });
}
onClose();
};
@ -79,4 +70,4 @@ const Settings = ({ onClose, settings }: SettingsProps) => {
);
};
export default Settings;
export default SettingsDialog;

View File

@ -57,16 +57,9 @@
}
}
.author-info a {
color: white;
}
.author-info a:hover {
.author-info {
cursor: pointer;
color: white;
}
.author-info {
position: absolute;
bottom: 1em;
left: 1em;
@ -85,6 +78,7 @@
animation-duration: 0.5s;
animation-timing-function: ease-in;
animation-name: showAuthor;
background-color: #444;
}
.slide {

View File

@ -11,7 +11,7 @@ import {
isVideo,
prepareContent,
} from './nostrImageDownload';
import { defaultRelays, nfswTags, nsfwNPubs } from './env';
import { blockedPublicKeys, defaultRelays, nfswTags, nsfwNPubs } from './env';
import Settings from './Settings';
import SlideView from './SlideView';
import GridView from './GridView';
@ -23,6 +23,7 @@ import IconSettings from './Icons/IconSettings';
import IconPlay from './Icons/IconPlay';
import IconGrid from './Icons/IconGrid';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import useNav from '../utils/useNav';
/*
FEATURES:
@ -45,13 +46,14 @@ FEATURES:
- Prevent duplicate images (shuffle? histroy?)
*/
const SlideShow = (settings: Settings) => {
const SlideShow = () => {
const { ndk, loadNdk } = useNDK();
const [posts, setPosts] = useState<NDKEvent[]>([]);
const images = useRef<NostrImage[]>([]);
const fetchTimeoutHandle = useRef(0);
const [showGrid, setShowGrid] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const { currentSettings: settings } = useNav();
const fetch = () => {
const postSubscription = ndk.subscribe(buildFilter(settings.tags, settings.npubs));
@ -59,6 +61,7 @@ const SlideShow = (settings: Settings) => {
postSubscription.on('event', (event: NDKEvent) => {
setPosts(oldPosts => {
if (
!blockedPublicKeys.includes(event.pubkey.toLowerCase()) && // remove blocked authors
!isReply(event) &&
oldPosts.findIndex(p => p.id === event.id) === -1 && // not duplicate
(settings.showNsfw || !isNsfwRelated(event))
@ -112,9 +115,12 @@ const SlideShow = (settings: Settings) => {
if (event.key === 'g' || event.key === 'G') {
setShowGrid(p => !p);
}
if (event.key === 'Escape') {
if (event.key === 's' || event.key === 'S') {
setShowSettings(s => !s);
}
if (event.key === 'Escape') {
setShowSettings(false);
}
/*
if (event.key === "f" || event.key === "F") {
document?.getElementById("root")?.requestFullscreen();
@ -124,9 +130,9 @@ const SlideShow = (settings: Settings) => {
useEffect(() => {
loadNdk(defaultRelays);
window.addEventListener('keydown', onKeyDown);
document.body.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
document.body.removeEventListener('keydown', onKeyDown);
};
}, []);
@ -163,7 +169,7 @@ const SlideShow = (settings: Settings) => {
{showGrid ? (
<GridView images={images.current} settings={settings}></GridView>
) : (
<SlideView images={images.current} settings={settings}></SlideView>
<SlideView images={images.current} settings={settings} setShowGrid={setShowGrid}></SlideView>
)}
</>
);

View File

@ -7,16 +7,17 @@ import { useNDK } from '@nostr-dev-kit/ndk-react';
import useDebouncedEffect from '../../utils/useDebouncedEffect';
import { useSwipeable } from 'react-swipeable';
import { Helmet } from 'react-helmet';
import Settings from '../Settings';
import IconPause from '../Icons/IconPause';
import IconSpinner from '../Icons/IconSpinner';
import { Settings } from '../../utils/useNav';
type SlideViewProps = {
settings: Settings;
images: NostrImage[];
setShowGrid: (showGrid: boolean) => void;
};
const SlideView = ({ settings, images }: SlideViewProps) => {
const SlideView = ({ settings, images, setShowGrid }: SlideViewProps) => {
const { getProfile } = useNDK();
const [activeImages, setActiveImages] = useState<NostrImage[]>([]);
const history = useRef<NostrImage[]>([]);
@ -130,7 +131,7 @@ const SlideView = ({ settings, images }: SlideViewProps) => {
useEffect(() => {
document.body.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
document.body.removeEventListener('keydown', onKeyDown);
console.log(`cleaining timeout in useEffect[] destructor `);
clearTimeout(viewTimeoutHandle.current);
};
@ -189,6 +190,7 @@ const SlideView = ({ settings, images }: SlideViewProps) => {
)}
{activeProfile && (
<AuthorProfile
setShowGrid={setShowGrid}
src={urlFix(activeProfile.image || '')}
author={activeProfile.displayName || activeProfile.name}
npub={activeNpub}

View File

@ -45,6 +45,7 @@ export const nfswTags = [
'thighstr',
'tits',
'titstr',
'lolita',
];
export const nsfwNPubs = [
@ -78,13 +79,21 @@ export const nsfwNPubs = [
'npub1ulafm4d3n7ukl7yzg4hfnhfjut74nym5p83e3d67l3j62yc6ysqqrancw2', // naked
'npub1ve4ztpqvlgu3v6hgrvc4lrdl2ernue7lq2h8tcgaksrkxlm7gnsqkjmz4e', // bluntkaraoke
'npub1wmsn8fch7kwt987jcdx06uuapn6pwzau59pvy0ql5d3xlmnxa2csj3c5p4', // StefsPicks
'npub1xfu7047thly6aghl79z97kckkvwfvtcx88n6wq7c2tlng484d8xqv0kuvv', // Erandis Vol
'npub1y77j6jm5hw34xl5m85aumltv88arh2s7q383allkpfe4muarzc5qzfgru0', // sexy-models
'npub1ylrnf0xfp9wsmqthxlqjqyqj9yy27pnchjwjq93v3mq66ts7ftjs6x7dcq', // Welcome To The Jungle
'npub1csk2wg33ee9kutyps4nmevyv3putfegj7yd0emsp44ph32wvmamqs7uyan', // Lilura
'npub10m75ad8pc6wtlt67f6wjeug4hpqurc68842ve5ne47u9lkjqa0lq8ja88s', // 313Chris:hellokitty_headbang:
];
export const nsfwPubKeys = nsfwNPubs.map(npub => (nip19.decode(npub).data as string).toLowerCase());
export const nsfwPublicKeys = nsfwNPubs.map(npub => (nip19.decode(npub).data as string).toLowerCase());
export const blockedNPubs = [
'npub1awxh85c5wasj60d42uvmzuza2uvjazff9m7skg2vf7x2f8gykwkqykxktf', // AIイラスト',
'npub1xfu7047thly6aghl79z97kckkvwfvtcx88n6wq7c2tlng484d8xqv0kuvv', // Erandis Vol
'npub1kf8sau5dejmcmfmzzj256rv728p5w7s0wytdyz8ypa0ne0y6k0vswhgu9w', // noname
];
export const blockedPublicKeys = blockedNPubs.map(npub => (nip19.decode(npub).data as string).toLowerCase());
export const spamAccounts = [];
@ -97,5 +106,6 @@ export const defaultRelays = [
'wss://nostr.wine',
// "wss://nostr1.current.fyi/",
'wss://purplepag.es/', // needed for user profiles
'wss://n-word.sharivegas.com/', // needed for mostr.pub profiles
//"wss://feeds.nostr.band/pics",
];

View File

@ -1,6 +1,6 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { nfswTags, nsfwPubKeys } from './env';
import { nfswTags, nsfwPublicKeys } from './env';
export type NostrImage = {
url: string;
@ -67,7 +67,7 @@ export const isNsfwRelated = (event: NDKEvent) => {
return (
hasContentWarning(event) || // block content warning
hasNsfwTag(event) || // block nsfw tags
nsfwPubKeys.includes(event.pubkey.toLowerCase()) // block nsfw authors
nsfwPublicKeys.includes(event.pubkey.toLowerCase()) // block nsfw authors
);
};

50
src/utils/useNav.ts Normal file
View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { defaultHashTags } from '../components/env';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
export type Settings = {
showNsfw: boolean;
tags: string[];
npubs: string[];
};
const useNav = () => {
const { tags, npub } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const currentSettings: Settings = useMemo(() => {
const nsfw = searchParams.get('nsfw') === 'true';
console.log(`tags = ${tags}, npub = ${npub}, nsfw = ${nsfw}`);
let useTags = tags?.split(',') || [];
if (npub == undefined && (useTags == undefined || useTags.length == 0)) {
useTags = defaultHashTags;
}
return {
tags: useTags,
npubs: npub ? [npub] : [],
showNsfw: nsfw,
};
}, [tags, npub, searchParams]);
const nav = (settings: Settings) => {
const nsfwPostfix = settings.showNsfw ? '?nsfw=true' : '';
const validTags = settings.tags.filter(t => t.length > 0);
const validNpubs = settings.npubs.filter(t => t.length > 0);
if (validTags.length > 0) {
navigate(`/tags/${validTags.join('%2C')}${nsfwPostfix}`);
} else if (validNpubs.length == 1) {
navigate(`/p/${validNpubs[0]}${nsfwPostfix}`);
} else {
navigate(`/${nsfwPostfix}`);
}
};
return { nav, currentSettings };
};
export default useNav;