feat: now home page
BIN
public/images/animals.jpg
Normal file
After Width: | Height: | Size: 486 KiB |
BIN
public/images/art.jpg
Normal file
After Width: | Height: | Size: 710 KiB |
BIN
public/images/bitcoin.jpg
Normal file
After Width: | Height: | Size: 292 KiB |
BIN
public/images/gardening.jpg
Normal file
After Width: | Height: | Size: 510 KiB |
BIN
public/images/lifestyle.jpg
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
public/images/nostr.jpg
Normal file
After Width: | Height: | Size: 366 KiB |
BIN
public/images/photography.jpg
Normal file
After Width: | Height: | Size: 457 KiB |
41
src/components/Home/Home.css
Normal file
@ -0,0 +1,41 @@
|
||||
.home {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
max-width: 50em;
|
||||
padding: 2em;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.home .topics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home .topic {
|
||||
background-color: #222;
|
||||
border-radius: 16px;
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
height: 6em;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.home .topic-title {
|
||||
font-size: 30px;
|
||||
padding-bottom: 0.5em;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.home .tag {
|
||||
display: inline;
|
||||
padding: 0.2em 0.6em;
|
||||
margin-right: 0.2em;
|
||||
border-radius: 24px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
line-height: 2.2em;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
}
|
38
src/components/Home/index.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { topics } from '../env';
|
||||
import useNav from '../../utils/useNav';
|
||||
import './Home.css';
|
||||
|
||||
const Home = () => {
|
||||
const { nav, currentSettings } = useNav();
|
||||
|
||||
const topicKeys = Object.keys(topics);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h2>Topics</h2>
|
||||
<div className="topics">
|
||||
{topicKeys.map(tk => (
|
||||
<div
|
||||
className="topic"
|
||||
style={{ backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, .8) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0) 100%), url('/images/${tk}.jpg')` }}
|
||||
onClick={() => nav({ ...currentSettings, tags: topics[tk].tags })}
|
||||
>
|
||||
<div className="topic-title">{tk}</div>
|
||||
{/*
|
||||
<div>
|
||||
{topics[tk].tags.slice(0, 5).map(t => (
|
||||
<>
|
||||
<span className="tag">{t}</span>{' '}
|
||||
</>
|
||||
))}
|
||||
{topics[tk].tags.length > 5 ? '...' : ''}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
51
src/components/MasonryView/MasonryImage.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { MouseEventHandler, SyntheticEvent, useState } from 'react';
|
||||
import { NostrImage, createImgProxyUrl, isVideo } from '../nostrImageDownload';
|
||||
import LazyLoad from 'react-lazy-load';
|
||||
|
||||
interface GridImageProps {
|
||||
image: NostrImage;
|
||||
onClick?: MouseEventHandler | undefined;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const GridImage = ({ image, onClick, index }: GridImageProps) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const mediaIsVideo = isVideo(image.url);
|
||||
|
||||
return (
|
||||
<a id={'g' + index}>
|
||||
<LazyLoad height={200}>
|
||||
{mediaIsVideo ? (
|
||||
<video
|
||||
className={`image ${loaded ? 'show' : ''}`}
|
||||
data-node-id={image.noteId}
|
||||
key={image.url}
|
||||
controls={false}
|
||||
autoPlay={false}
|
||||
onClick={onClick}
|
||||
src={image.url + '#t=0.1'}
|
||||
playsInline
|
||||
onLoad={() => setLoaded(true)}
|
||||
></video>
|
||||
) : (
|
||||
<img
|
||||
data-node-id={image.noteId}
|
||||
onError={(e: SyntheticEvent<HTMLImageElement>) => {
|
||||
console.log('not found: ', e.currentTarget.src);
|
||||
e.currentTarget.src = '/notfound.png';
|
||||
}}
|
||||
className={`image ${loaded ? 'show' : ''}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
key={image.url}
|
||||
onClick={onClick}
|
||||
src={createImgProxyUrl(image.url, 200, -1)}
|
||||
></img>
|
||||
)}
|
||||
</LazyLoad>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridImage;
|
79
src/components/MasonryView/MasonryView.css
Normal file
@ -0,0 +1,79 @@
|
||||
@keyframes showGridImage {
|
||||
from {
|
||||
opacity: 0;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.gridview {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
height: 100dvh;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.imagegrid {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.imagegrid img.image {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.imagegrid video.image {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.imagegrid .image.show {
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: ease-in;
|
||||
animation-name: showGridImage;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.imagegrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.imagegrid .image:hover {
|
||||
filter: brightness(1.1);
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 2em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.profile-header h2 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.profile-header .author-info {
|
||||
position: relative;
|
||||
bottom: initial;
|
||||
left: initial;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.profile-header .author-info .author-name {
|
||||
display: block;
|
||||
}
|
||||
}
|
131
src/components/MasonryView/MasonryView.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { NostrImage, urlFix } from '../nostrImageDownload';
|
||||
import './MasonryView.css';
|
||||
import { Settings } from '../../utils/useNav';
|
||||
import AuthorProfile from '../AuthorProfile/AuthorProfile';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import useProfile from '../../utils/useProfile';
|
||||
import { ViewMode } from '../SlideShow';
|
||||
import { useGlobalState } from '../../utils/globalState';
|
||||
import { Dictionary, groupBy } from 'lodash';
|
||||
|
||||
type MasonryViewProps = {
|
||||
settings: Settings;
|
||||
images: NostrImage[];
|
||||
currentImage: number | undefined;
|
||||
setCurrentImage: React.Dispatch<React.SetStateAction<number | undefined>>;
|
||||
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
||||
};
|
||||
|
||||
const MasonryView = ({ settings, images, currentImage, setCurrentImage, setViewMode }: MasonryViewProps) => {
|
||||
const { activeProfile, title } = useProfile(settings);
|
||||
const [_, setState] = useGlobalState();
|
||||
|
||||
const numColumns = 4;
|
||||
|
||||
const sortedImages = useMemo(
|
||||
() => {
|
||||
const sorted = images.sort((a, b) => (b.timestamp && a.timestamp ? b.timestamp - a.timestamp : 0)); // sort by timestamp descending
|
||||
const grouped = groupBy(sorted, (i: number) => Math.floor(i / numColumns)) as Dictionary<NostrImage[]>;
|
||||
console.log(grouped);
|
||||
return grouped;
|
||||
},
|
||||
[images] // settings is not used here, but we need to include it to trigger a re-render when it changes
|
||||
);
|
||||
console.log(sortedImages);
|
||||
|
||||
const showNextImage = () => {
|
||||
setCurrentImage(idx => (idx !== undefined ? idx + 1 : 0));
|
||||
};
|
||||
|
||||
const showPreviousImage = () => {
|
||||
setCurrentImage(idx => (idx !== undefined && idx > 0 ? idx - 1 : idx));
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
console.log(event);
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
showNextImage();
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
showPreviousImage();
|
||||
}
|
||||
/*
|
||||
if (event.key === 'Escape') {
|
||||
setCurrentImage(undefined);
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => {
|
||||
showNextImage();
|
||||
},
|
||||
onSwipedRight: () => {
|
||||
showPreviousImage();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.body.addEventListener('keydown', onKeyDown);
|
||||
setState({ activeImage: undefined });
|
||||
|
||||
if (currentImage) {
|
||||
console.log('setting hash to #g' + currentImage);
|
||||
window.location.hash = '#g' + currentImage;
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="gridview" {...swipeHandlers}>
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
</Helmet>
|
||||
{/*
|
||||
{currentImage !== undefined ? (
|
||||
<DetailsView images={sortedImages} currentImage={currentImage} setCurrentImage={setCurrentImage} />
|
||||
) : null}
|
||||
*/}
|
||||
{(activeProfile || settings.tags.length == 1) && (
|
||||
<div className="profile-header">
|
||||
{activeProfile ? (
|
||||
<AuthorProfile
|
||||
src={urlFix(activeProfile.image || '')}
|
||||
author={activeProfile.displayName || activeProfile.name}
|
||||
npub={activeProfile.npub}
|
||||
setViewMode={setViewMode}
|
||||
followButton
|
||||
externalLink
|
||||
></AuthorProfile>
|
||||
) : (
|
||||
settings.tags.map(t => <h2>#{t}</h2>)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
<div className="imagegrid">
|
||||
{sortedImages[0] && sortedImages[0].map((image, idx) => (
|
||||
<GridImage
|
||||
index={idx}
|
||||
key={image.url}
|
||||
image={image}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setCurrentImage(idx);
|
||||
setViewMode('scroll');
|
||||
}}
|
||||
></GridImage>
|
||||
))}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasonryView;
|