Merge pull request #982 from stackernews/nav

Improved navigation with dedicated mobile navigation
This commit is contained in:
Keyan 2024-03-27 16:53:19 -05:00 committed by GitHub
commit 4c2fcec69b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 991 additions and 512 deletions

View File

@ -7,14 +7,16 @@ function queryParts (q) {
const queryArr = q.replace(regex, '').trim().split(/\s+/)
const url = queryArr.find(word => word.startsWith('url:'))
const nym = queryArr.find(word => word.startsWith('nym:'))
const exclude = [url, nym]
const nym = queryArr.find(word => word.startsWith('@'))
const territory = queryArr.find(word => word.startsWith('~'))
const exclude = [url, nym, territory]
const query = queryArr.filter(word => !exclude.includes(word)).join(' ')
return {
quotes: [...q.matchAll(regex)].map(m => m[1]),
nym,
url,
territory,
query
}
}
@ -169,7 +171,7 @@ export default {
items
}
},
search: async (parent, { q, sub, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems = null
let termQueries = []
@ -193,7 +195,7 @@ export default {
break
}
const { query: _query, quotes, nym, url } = queryParts(q)
const { query: _query, quotes, nym, url, territory } = queryParts(q)
let query = _query
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
@ -206,11 +208,11 @@ export default {
}
if (nym) {
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(4).toLowerCase()}*` } })
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
if (sub) {
whatArr.push({ match: { 'sub.name': sub } })
if (territory) {
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
}
termQueries.push({

View File

@ -82,7 +82,7 @@ export function getGetServerSideProps (
const callback = process.env.PUBLIC_URL + req.url
return {
redirect: {
destination: `/login?callbackUrl=${encodeURIComponent(callback)}`
destination: `/signup?callbackUrl=${encodeURIComponent(callback)}`
}
}
}
@ -111,9 +111,9 @@ export function getGetServerSideProps (
}
if (error || !data || (notFound && notFound(data, vars, me))) {
return {
notFound: true
}
res.writeHead(301, {
Location: '/404'
}).end()
}
props = {

View File

@ -159,7 +159,7 @@ function AnonInfo () {
return (
<AnonIcon
className='fill-muted ms-2 theme' height={22} width={22}
className='ms-2 fill-theme-color' height={22} width={22}
onClick={
(e) =>
showModal(onClose =>

View File

@ -158,7 +158,7 @@ export default function Footer ({ links = true }) {
return (
<footer>
<Container className='mb-3 mt-4'>
<Container className='mb-3'>
{links &&
<>
<div className='mb-1'>

View File

@ -10,7 +10,7 @@ export default function Hat ({ user, badge, className = 'ms-1', height = 16, wid
if (!user || Number(user.id) === AD_USER_ID) return null
if (Number(user.id) === ANON_USER_ID) {
return (
<HatTooltip overlayText={badge ? 'anonymous' : 'posted anonymously'}>
<HatTooltip overlayText='anonymous'>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>

View File

@ -1,356 +0,0 @@
import Dropdown from 'react-bootstrap/Dropdown'
import Navbar from 'react-bootstrap/Navbar'
import Nav from 'react-bootstrap/Nav'
import Link from 'next/link'
import styles from './header.module.css'
import { useRouter } from 'next/router'
import Button from 'react-bootstrap/Button'
import Container from 'react-bootstrap/Container'
import Price from './price'
import { useMe } from './me'
import Head from 'next/head'
import { signOut } from 'next-auth/react'
import { useCallback, useEffect } from 'react'
import { randInRange } from '@/lib/rand'
import { abbrNum, msatsToSats } from '@/lib/format'
import NoteIcon from '@/svgs/notification-4-fill.svg'
import { useQuery } from '@apollo/client'
import LightningIcon from '@/svgs/bolt.svg'
import SearchIcon from '@/svgs/search-line.svg'
import BackArrow from '@/svgs/arrow-left-line.svg'
import { BALANCE_LIMIT_MSATS, SSR } from '@/lib/constants'
import { useLightning } from './lightning'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import AnonIcon from '@/svgs/spy-fill.svg'
import Hat from './hat'
import HiddenWalletSummary from './hidden-wallet-summary'
import { clearNotifications } from '@/lib/badge'
import { useServiceWorker } from './serviceworker'
import SubSelect from './sub-select'
function WalletSummary ({ me }) {
if (!me) return null
if (me.privates?.hideWalletBalance) {
return <HiddenWalletSummary abbreviate fixedWidth />
}
return `${abbrNum(me.privates?.sats)}`
}
function Back () {
const router = useRouter()
return router.asPath !== '/' &&
<a
role='button' tabIndex='0' className='nav-link standalone p-0' onClick={() => {
if (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack) {
router.back()
} else {
router.push('/')
}
}}
>
<BackArrow className='theme me-1 me-md-2' width={22} height={22} />
</a>
}
function NotificationBell () {
const { data } = useQuery(HAS_NOTIFICATIONS, SSR
? {}
: {
pollInterval: 30000,
nextFetchPolicy: 'cache-and-network',
onCompleted: ({ hasNewNotes }) => {
if (!hasNewNotes) {
clearNotifications()
}
}
})
return (
<>
<Head>
<link rel='shortcut icon' href={data?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className='ps-0 position-relative'>
<NoteIcon height={22} width={22} className='theme' style={{ marginTop: '-4px' }} />
{data?.hasNewNotes &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Nav.Link>
</Link>
</>
)
}
function NavProfileMenu ({ me, dropNavKey }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey={me.name} as='span' className='p-0'>
{`@${me.name}`}<Hat user={me} />
</Nav.Link>
</Dropdown.Toggle>
<Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
</Link>
<Dropdown.Divider />
<Link href='/referrals/month' passHref legacyBehavior>
<Dropdown.Item eventKey='referrals'>referrals</Dropdown.Item>
</Link>
<Dropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref legacyBehavior>
<Dropdown.Item eventKey='settings'>settings</Dropdown.Item>
</Link>
</div>
<Dropdown.Divider />
<Dropdown.Item
onClick={async () => {
try {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription()
}
} catch (err) {
// don't prevent signout because of an unsubscription error
console.error(err)
}
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
{!me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
)
}
function StackerCorner ({ dropNavKey }) {
const me = useMe()
const walletLimitReached = me.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return (
<div className='d-flex ms-auto'>
<NotificationBell />
<NavProfileMenu me={me} dropNavKey={dropNavKey} />
<Nav.Item>
<Link href='/wallet' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}>
<WalletSummary me={me} />
</Nav.Link>
</Link>
</Nav.Item>
</div>
)
}
function LurkerCorner ({ path }) {
const router = useRouter()
const strike = useLightning()
useEffect(() => {
if (!window.localStorage.getItem('striked')) {
const to = setTimeout(() => {
strike()
window.localStorage.setItem('striked', 'yep')
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
}, [])
const handleLogin = useCallback(async pathname => await router.push({
pathname,
query: { callbackUrl: window.location.origin + router.asPath }
}), [router])
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
<div className='ms-auto'>
<Button
className='align-items-center px-3 py-1 me-2'
id='signup'
style={{ borderWidth: '2px' }}
variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')}
>
login
</Button>
<Button
className='align-items-center ps-2 py-1 pe-3'
style={{ borderWidth: '2px' }}
id='login'
onClick={() => handleLogin('/signup')}
>
<LightningIcon
width={17}
height={17}
className='me-1'
/>sign up
</Button>
</div>
}
const PREPEND_SUBS = ['home']
const APPEND_SUBS = [{ label: '--------', items: ['create'] }]
function NavItems ({ className, sub: subName, prefix }) {
const sub = subName || 'home'
return (
<>
<Nav.Item className={`me-1 ${className}`}>
<SubSelect
sub={sub} prependSubs={PREPEND_SUBS} appendSubs={APPEND_SUBS} noForm
groupClassName='mb-0'
/>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/'} passHref legacyBehavior>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref legacyBehavior>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
{sub !== 'jobs' &&
<Nav.Item className={className}>
<Link
href={{
pathname: '/~/top/[type]/[when]',
query: { type: 'posts', when: 'day', sub: subName }
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
</>
)
}
function PostItem ({ className, prefix }) {
const me = useMe()
if (me) {
return (
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
post
</Link>
)
}
return (
<Link
href={prefix + '/post'}
className={`${className} ${styles.postAnon} btn btn-md btn-outline-grey-darkmode d-flex align-items-center px-3 py-0 py-lg-1`}
>
<AnonIcon className='me-1 fill-secondary' width={16} height={16} /> post
</Link>
)
}
export default function Header ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const prefix = sub ? `/~${sub}` : ''
const topNavKey = path.split('/')[sub ? 2 : 1] ?? ''
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
const me = useMe()
return (
<Container as='header' className='px-sm-0'>
<Navbar className='pb-0 pb-lg-2'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={`${styles.brand} d-flex me-0 me-md-2`}>
SN
</Navbar.Brand>
</Link>
</div>
<NavItems className='d-none d-lg-flex mx-2' prefix={prefix} sub={sub} />
<PostItem className='d-none d-lg-flex mx-2' prefix={prefix} />
<Link href={prefix + '/search'} passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative d-none d-lg-flex align-items-center pe-0 ms-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
<Nav.Item className={`${styles.price} ms-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
<Price className='nav-link text-monospace' />
</Nav.Item>
{me ? <StackerCorner dropNavKey={dropNavKey} /> : <LurkerCorner path={path} />}
</Nav>
</Navbar>
<Navbar className='pt-0 pb-2 d-lg-none'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<NavItems prefix={prefix} sub={sub} />
<Link href={prefix + '/search'} passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative me-auto ms-auto me-sm-1 d-flex'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
<PostItem className='me-0' prefix={prefix} />
</Nav>
</Navbar>
</Container>
)
}
export function HeaderStatic () {
return (
<Container as='header' className='px-sm-0'>
<Navbar className='pb-0 pb-lg-1'>
<Nav
className={styles.navbarNav}
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={styles.brand}>
SN
</Navbar.Brand>
</Link>
<Link href='/search' passHref legacyBehavior>
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
<SearchIcon className='theme' width={22} height={22} />
</Nav.Link>
</Link>
</div>
</Nav>
</Navbar>
</Container>
)
}

View File

@ -1,12 +1,11 @@
.brand {
font-family: lightning;
font-size: 2rem;
padding: 0;
line-height: 100%;
margin-bottom: -.3rem;
margin-right: 0;
text-shadow: 0 0 10px var(--bs-primary);
color: var(--theme-brandColor) !important;
}
.brand svg {
fill: var(--theme-brandColor);
filter: drop-shadow(0 0 6px var(--bs-primary));
}
.postAnon {
@ -44,6 +43,10 @@
fill: var(--theme-navLinkActive);
}
.hide {
display: none;
}
.dropdown svg {
vertical-align: text-top;
}
@ -79,6 +82,7 @@
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
}
.price > div {

View File

@ -16,7 +16,7 @@ export default function HiddenWalletSummary ({ abbreviate, fixedWidth }) {
return (
<span
ref={ref} style={{ width: fixedWidth ? width : undefined }}
className='d-inline-block text-monospace' align='right' onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}
className='text-monospace' align='right' onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}
>
{hover ? (abbreviate ? abbrNum(me.privates?.sats) : numWithUnits(me.privates?.sats, { abbreviate: false, format: true })) : '******'}
</span>

View File

@ -1,5 +1,4 @@
.title {
font-weight: 500;
white-space: normal;
max-width: 100%;
display: inline-block;

View File

@ -1,4 +1,6 @@
import Header, { HeaderStatic } from './header'
import Navigation from './nav'
import NavFooter from './nav/mobile/footer'
import NavStatic from './nav/static'
import Container from 'react-bootstrap/Container'
import Footer from './footer'
import Seo, { SeoSearch } from './seo'
@ -12,51 +14,53 @@ export default function Layout ({
return (
<>
{seo && <Seo sub={sub} item={item} user={user} />}
<Header sub={sub} />
<Navigation sub={sub} />
{contain
? (
<Container as='main' className={`px-sm-0 ${containClassName}`}>
<Container as='main' className={`px-sm-0 ${styles.contain}`}>
{children}
</Container>
)
: children}
{footer && <Footer links={footerLinks} />}
<NavFooter sub={sub} />
</>
)
}
export function SearchLayout ({ sub, children, ...props }) {
return (
<Layout sub={sub} seo={false} contain={false} {...props}>
<Layout sub={sub} seo={false} footer={false} {...props}>
<SeoSearch sub={sub} />
<Search sub={sub} />
<Container as='main' className='pt-3 pb-4 px-sm-0'>
{children}
</Container>
{children}
</Layout>
)
}
export function StaticLayout ({ children, footer = true, footerLinks, ...props }) {
export function StaticLayout ({ children, footer = true, footerLinks = false, ...props }) {
return (
<div className={styles.page}>
<HeaderStatic />
<main className={`${styles.content} pt-5`}>
{children}
</main>
<>
<NavStatic />
<div className={styles.page}>
<main className={`${styles.content} ${styles.contain} py-3`}>
{children}
</main>
</div>
{footer && <Footer links={footerLinks} />}
</div>
<NavFooter />
</>
)
}
export function CenterLayout ({ children, ...props }) {
return (
<div className={styles.page}>
<Layout contain={false} {...props}>
<Layout contain={false} footer={false} {...props}>
<div className={styles.page}>
<main className={styles.content}>
{children}
</main>
</Layout>
</div>
</div>
</Layout>
)
}

View File

@ -3,8 +3,8 @@
display: flex;
flex-flow: column;
height: 100%;
min-height: 100vh;
align-items: center;
margin: auto;
}
.content {
@ -16,10 +16,17 @@
width: 100%;
padding-right: 15px;
padding-left: 15px;
margin-top: 1.5rem;
margin: 1.5rem 0;
flex-direction: column;
}
.contain {
flex-grow: 1;
display: flex;
flex-direction: column;
padding-bottom: 2rem;
}
.content form {
width: 100%;
}

367
components/nav/common.js Normal file
View File

@ -0,0 +1,367 @@
import Link from 'next/link'
import { Button, Dropdown, Nav, Navbar } from 'react-bootstrap'
import styles from '../header.module.css'
import { useRouter } from 'next/router'
import BackArrow from '../../svgs/arrow-left-line.svg'
import { useCallback, useEffect, useState } from 'react'
import Price from '../price'
import SubSelect from '../sub-select'
import { ANON_USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
import HiddenWalletSummary from '../hidden-wallet-summary'
import { abbrNum, msatsToSats } from '../../lib/format'
import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react'
import Hat from '../hat'
import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning'
import LightningIcon from '../../svgs/bolt.svg'
import SearchIcon from '../../svgs/search-line.svg'
import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes'
export function Brand ({ className }) {
return (
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={classNames(styles.brand, className)}>
<SnIcon width={36} height={36} />
</Navbar.Brand>
</Link>
)
}
export function hasNavSelect ({ path, pathname }) {
return (
pathname.startsWith('/~') &&
!path.endsWith('/post') &&
!path.endsWith('/edit')
)
}
export function Back () {
const router = useRouter()
const [back, setBack] = useState(router.asPath !== '/')
useEffect(() => {
setBack(router.asPath !== '/' && (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack))
}, [router.asPath])
if (!back) return null
return (
<a
role='button' tabIndex='0' className='nav-link p-0 me-2' onClick={() => {
if (back) {
router.back()
} else {
router.push('/')
}
}}
>
<BackArrow className='theme me-1 me-md-2' width={24} height={24} />
</a>
)
}
export function BackOrBrand ({ className }) {
const router = useRouter()
const [back, setBack] = useState(router.asPath !== '/')
useEffect(() => {
setBack(router.asPath !== '/' && (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack))
}, [router.asPath])
return (
<div className='d-flex align-items-center'>
{back ? <Back /> : <Brand className={className} />}
</div>
)
}
export function SearchItem ({ prefix, className }) {
return (
<Link href='/search' passHref legacyBehavior>
<Nav.Link eventKey='search' className={className}>
<SearchIcon className='theme' width={22} height={28} />
</Nav.Link>
</Link>
)
}
export function NavPrice ({ className }) {
return (
<Nav.Item className={classNames(styles.price, className)}>
<Price className='nav-link text-monospace' />
</Nav.Item>
)
}
const PREPEND_SUBS = ['home']
const APPEND_SUBS = [{ label: '--------', items: ['create'] }]
export function NavSelect ({ sub: subName, className, size }) {
const sub = subName || 'home'
return (
<Nav.Item className={className}>
<SubSelect
sub={sub} prependSubs={PREPEND_SUBS} appendSubs={APPEND_SUBS} noForm
groupClassName='mb-0' size={size}
/>
</Nav.Item>
)
}
export function NavNotifications ({ className }) {
const hasNewNotes = useHasNewNotes()
return (
<>
<Head>
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
<NoteIcon height={28} width={20} className='theme' />
{hasNewNotes &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Nav.Link>
</Link>
</>
)
}
export function WalletSummary () {
const me = useMe()
if (!me) return null
if (me.privates?.hideWalletBalance) {
return <HiddenWalletSummary abbreviate fixedWidth />
}
return `${abbrNum(me.privates?.sats)}`
}
export function NavWalletSummary () {
const me = useMe()
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return (
<Nav.Item>
<Link href='/wallet' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}>
<WalletSummary me={me} />
</Nav.Link>
</Link>
</Nav.Item>
)
}
export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<Nav.Link eventKey={me.name} as='span' className='p-0'>
{`@${me.name}`}<Hat user={me} />
</Nav.Link>
</Dropdown.Toggle>
<Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
</Link>
<Dropdown.Divider />
<Link href='/referrals/month' passHref legacyBehavior>
<Dropdown.Item eventKey='referrals'>referrals</Dropdown.Item>
</Link>
<Dropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref legacyBehavior>
<Dropdown.Item eventKey='settings'>settings</Dropdown.Item>
</Link>
</div>
<Dropdown.Divider />
<Dropdown.Item
onClick={async () => {
try {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription()
}
} catch (err) {
// don't prevent signout because of an unsubscription error
console.error(err)
}
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
{!me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
)
}
export function SignUpButton ({ className = 'py-0' }) {
const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({
pathname,
query: { callbackUrl: window.location.origin + router.asPath }
}), [router])
return (
<Button
className={classNames('align-items-center ps-2 pe-3', className)}
style={{ borderWidth: '2px' }}
id='login'
onClick={() => handleLogin('/signup')}
>
<LightningIcon
width={17}
height={17}
className='me-1'
/>sign up
</Button>
)
}
export default function LoginButton ({ className }) {
const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({
pathname,
query: { callbackUrl: window.location.origin + router.asPath }
}), [router])
return (
<Button
className='align-items-center px-3 py-1 mb-2'
id='signup'
style={{ borderWidth: '2px' }}
variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')}
>
login
</Button>
)
}
export function LoginButtons () {
return (
<>
<LoginButton />
<SignUpButton className='py-1' />
</>
)
}
export function AnonDropdown ({ path }) {
const strike = useLightning()
useEffect(() => {
if (!window.localStorage.getItem('striked')) {
const to = setTimeout(() => {
strike()
window.localStorage.setItem('striked', 'yep')
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
}, [])
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: ANON_USER_ID }} />
</Nav.Link>
</Dropdown.Toggle>
<Dropdown.Menu className='p-3'>
<LoginButtons />
</Dropdown.Menu>
</Dropdown>
</div>
)
}
export function Sorts ({ sub, prefix, className }) {
return (
<>
<Nav.Item className={className}>
<Link href={prefix + '/'} passHref legacyBehavior>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref legacyBehavior>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
</Nav.Item>
{/* <Nav.Item className={className}>
<Link href={prefix + '/random'} passHref legacyBehavior>
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
</Link>
</Nav.Item> */}
{sub !== 'jobs' &&
<Nav.Item className={className}>
<Link
href={{
pathname: '/~/top/[type]/[when]',
query: { type: 'posts', when: 'day', sub }
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
</>
)
}
export function PostItem ({ className, prefix }) {
return (
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary py-md-1`}>
post
</Link>
)
}
export function MeCorner ({ dropNavKey, me, className }) {
return (
<div className={className}>
<NavNotifications />
<MeDropdown me={me} dropNavKey={dropNavKey} />
<NavWalletSummary />
</div>
)
}
export function AnonCorner ({ dropNavKey, className }) {
return (
<div className={className}>
<AnonDropdown dropNavKey={dropNavKey} />
</div>
)
}

View File

@ -0,0 +1,14 @@
import { Container } from 'react-bootstrap'
import TopBar from './top-bar'
import SecondBar from './second-bar'
export default function Header (props) {
return (
<div className='d-none d-md-block'>
<Container as='header' className='px-0'>
<TopBar {...props} />
<SecondBar {...props} />
</Container>
</div>
)
}

View File

@ -0,0 +1,20 @@
import { Nav, Navbar } from 'react-bootstrap'
import { NavSelect, PostItem, Sorts, hasNavSelect } from '../common'
import styles from '../../header.module.css'
export default function SecondBar (props) {
const { prefix, topNavKey, sub } = props
if (!hasNavSelect(props)) return null
return (
<Navbar className='pt-0 pb-3'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<NavSelect sub={sub} size='medium' className='me-1' />
<Sorts {...props} />
<PostItem className='ms-auto me-0 d-none d-md-flex' prefix={prefix} />
</Nav>
</Navbar>
)
}

View File

@ -0,0 +1,24 @@
import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
import { useMe } from '../../me'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const me = useMe()
return (
<Navbar>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<Back />
<Brand className='me-1' />
<SearchItem prefix={prefix} className='me-0 ms-0 ms-md-auto d-none d-md-flex' />
<NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' />
{me
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
: <AnonCorner path={path} className='d-none d-md-flex' />}
</Nav>
</Navbar>
)
}

25
components/nav/index.js Normal file
View File

@ -0,0 +1,25 @@
import { useRouter } from 'next/router'
import DesktopHeader from './desktop/header'
import MobileHeader from './mobile/header'
import StickyBar from './sticky-bar'
export default function Navigation ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const props = {
prefix: sub ? `/~${sub}` : '',
path,
pathname: router.pathname,
topNavKey: path.split('/')[sub ? 2 : 1] ?? '',
dropNavKey: path.split('/').slice(sub ? 2 : 1).join('/'),
sub
}
return (
<>
<DesktopHeader {...props} />
<MobileHeader {...props} />
<StickyBar {...props} />
</>
)
}

View File

@ -0,0 +1,34 @@
import { Nav, Navbar } from 'react-bootstrap'
import { Brand, NavNotifications, PostItem, SearchItem } from '../common'
import { useMe } from '../../me'
import styles from './footer.module.css'
import classNames from 'classnames'
import Offcanvas from './offcanvas'
import { useRouter } from 'next/router'
export default function BottomBar ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
const props = {
prefix: sub ? `/~${sub}` : '',
path,
topNavKey: path.split('/')[sub ? 2 : 1] ?? '',
dropNavKey: path.split('/').slice(sub ? 2 : 1).join('/'),
sub
}
const me = useMe()
return (
<div className={classNames('d-block d-md-none', styles.footer)}>
<Navbar className='container px-0'>
<Nav className={styles.footerNav}>
<Offcanvas me={me} {...props} />
<SearchItem {...props} />
<Brand />
<PostItem {...props} className='btn-sm' />
<NavNotifications />
</Nav>
</Navbar>
</div>
)
}

View File

@ -0,0 +1,17 @@
.footer {
position: sticky;
bottom: 0;
width: 100%;
background-color: var(--bs-body-bg);
border-top: 1px solid var(--theme-toolbarActive);
z-index: 1000;
}
.footerNav {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
justify-items: center;
align-items: center;
width: 100%;
}

View File

@ -0,0 +1,14 @@
import { Container } from 'react-bootstrap'
import TopBar from './top-bar'
import SecondBar from './second-bar'
export default function Header (props) {
return (
<div className='d-block d-md-none'>
<Container as='header' className='px-sm-0'>
<TopBar {...props} />
<SecondBar {...props} />
</Container>
</div>
)
}

View File

@ -0,0 +1,112 @@
import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link'
import { useServiceWorker } from '@/components/serviceworker'
import { signOut } from 'next-auth/react'
import { LoginButtons, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg'
export default function OffCanvas ({ me, dropNavKey }) {
const [show, setShow] = useState(false)
const handleClose = () => setShow(false)
const handleShow = () => setShow(true)
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const MeImage = ({ onClick }) => me
? (
<Image
src={me?.photoId ? `${MEDIA_URL}/${me.photoId}` : '/dorian400.jpg'} width='28' height='28'
style={{ clipPath: 'polygon(0 0, 83% 0, 100% 100%, 17% 100%)' }}
onClick={onClick}
/>
)
: <span className='text-muted'><AnonIcon onClick={onClick} width='22' height='22' /></span>
return (
<>
<MeImage onClick={handleShow} />
<Offcanvas style={{ maxWidth: '250px', zIndex: '10000' }} show={show} onHide={handleClose} placement='start'>
<Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
</Offcanvas.Header>
<Offcanvas.Body className='pb-0'>
<div style={{
'--bs-dropdown-item-padding-y': '.5rem',
'--bs-dropdown-item-padding-x': 0,
'--bs-dropdown-divider-bg': '#ced4da',
'--bs-dropdown-divider-margin-y': '0.5rem',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{me
? (
<>
<Link href={'/' + me.name} passHref legacyBehavior>
<Dropdown.Item active={me.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ms-1'>
<span className='invisible'>{' '}</span>
</div>}
</Dropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
</Link>
<Dropdown.Divider />
<Link href='/referrals/month' passHref legacyBehavior>
<Dropdown.Item eventKey='referrals'>referrals</Dropdown.Item>
</Link>
<Dropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref legacyBehavior>
<Dropdown.Item eventKey='settings'>settings</Dropdown.Item>
</Link>
</div>
<Dropdown.Divider />
<Dropdown.Item
onClick={async () => {
try {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription()
}
} catch (err) {
// don't prevent signout because of an unsubscription error
console.error(err)
}
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</>
)
: <LoginButtons />}
<Navbar className='container d-flex flex-row px-0 p-2 mt-auto text-muted'>
<Nav>
<Link href={`/${me?.name || 'anon'}`} className='d-flex flex-row p-2 mt-auto text-muted'>
<MeImage />
<div className='ms-2'>
@{me?.name || 'anon'}
</div>
</Link>
</Nav>
</Navbar>
</div>
</Offcanvas.Body>
</Offcanvas>
</>
)
}

View File

@ -0,0 +1,19 @@
import { Nav, Navbar } from 'react-bootstrap'
import { NavPrice, Sorts, hasNavSelect } from '../common'
import styles from '../../header.module.css'
export default function SecondBar (props) {
const { topNavKey } = props
if (!hasNavSelect(props)) return null
return (
<Navbar>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<Sorts {...props} />
<NavPrice className='justify-content-end' />
</Nav>
</Navbar>
)
}

View File

@ -0,0 +1,25 @@
import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me'
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
const me = useMe()
return (
<Navbar>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<Back className='d-flex d-md-none' />
{hasNavSelect({ path, pathname })
? <NavSelect sub={sub} className='w-100' />
: (
<>
<NavPrice className='flex-shrink-1' />
{me ? <NavWalletSummary /> : <SignUpButton />}
</>)}
</Nav>
</Navbar>
)
}

19
components/nav/static.js Normal file
View File

@ -0,0 +1,19 @@
import { Container, Nav, Navbar } from 'react-bootstrap'
import styles from '../header.module.css'
import { BackOrBrand, NavPrice, SearchItem } from './common'
export default function StaticHeader () {
return (
<Container as='header' className='px-sm-0'>
<Navbar>
<Nav
className={styles.navbarNav}
>
<BackOrBrand />
<SearchItem />
<NavPrice className='justify-content-end' />
</Nav>
</Navbar>
</Container>
)
}

View File

@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
import styles from '@/components/header.module.css'
import { Container, Nav, Navbar } from 'react-bootstrap'
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
import { useMe } from '@/components/me'
import classNames from 'classnames'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef()
const sticky = useRef()
const me = useMe()
useEffect(() => {
const observer = new window.IntersectionObserver(([entry]) => {
sticky?.current?.classList.toggle(styles.hide, entry.isIntersecting)
})
ref?.current && observer.observe(ref.current)
return () => {
ref?.current && observer.unobserve(ref.current)
}
}, [ref?.current, sticky?.current])
return (
<>
<div ref={ref} style={{ position: 'relative', top: '50px' }} />
<div className={styles.hide} style={{ position: 'sticky', borderBottom: '1px solid var(--theme-toolbarActive)', top: '0', backgroundColor: 'var(--bs-body-bg)', zIndex: 1000 }} ref={sticky}>
<Container className='px-0 d-none d-md-block'>
<Navbar className='py-0'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<Back />
<Brand className='me-1' />
<SearchItem prefix={prefix} />
<NavPrice />
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
</Nav>
</Navbar>
</Container>
<Container className='px-sm-0 d-block d-md-none'>
<Navbar className='py-0'>
<Nav
className={classNames(styles.navbarNav, 'justify-content-between')}
activeKey={topNavKey}
>
<Back />
<NavPrice className='flex-shrink-1 flex-grow-0' />
{me ? <NavWalletSummary /> : <SignUpButton />}
</Nav>
</Navbar>
</Container>
</div>
</>
)
}

View File

@ -106,9 +106,9 @@ export function PostForm ({ type, sub, children }) {
</Alert>}
<SubSelect
prependSubs={['pick territory']}
className='w-auto d-flex'
className='d-flex'
noForm
large
size='medium'
sub={sub?.name}
info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'}
@ -173,8 +173,8 @@ export default function Post ({ sub }) {
sub={sub?.name}
prependSubs={sub?.name ? undefined : ['pick territory']}
filterSubs={s => s.postTypes?.includes(type.toUpperCase())}
className='w-auto d-flex'
large
className='d-flex'
size='medium'
label='territory'
info={sub && <TerritoryInfo sub={sub} />}
hint={sub?.moderated && 'this territory is moderated'}

View File

@ -15,9 +15,9 @@ export default function RecentHeader ({ type, sub }) {
type ||= router.query.type || type || 'posts'
return (
<div className='text-muted fw-bold mt-0 mb-3 d-flex justify-content-end align-items-center'>
<div className='text-muted fw-bold mt-1 mb-3 d-flex justify-content-start align-items-center'>
<Select
groupClassName='mb-0 ms-2'
groupClassName='mb-2'
className='w-auto'
name='type'
size='sm'

View File

@ -54,12 +54,12 @@ export default function Search ({ sub }) {
return (
<>
<div className={styles.searchSection}>
<Container className={`px-md-0 ${styles.searchContainer}`}>
<Container className={`px-0 ${styles.searchContainer}`}>
<Form
initial={{ q, what, sort, when, from: '', to: '' }}
onSubmit={values => search({ ...values })}
>
<div className={`${styles.active} my-3`}>
<div className={`${styles.active} mb-3`}>
<Input
name='q'
required
@ -79,7 +79,7 @@ export default function Search ({ sub }) {
</div>
{filter && router.query.q &&
<div className='text-muted fw-bold d-flex align-items-center flex-wrap pb-2'>
<div className='text-muted fw-bold d-flex align-items-center pb-2'>
<div className='text-muted fw-bold d-flex align-items-center'>
<Select
groupClassName='me-2 mb-0'
onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}

View File

@ -1,13 +1,11 @@
.searchSection {
position: sticky;
top: 0;
box-shadow: 0 4px 12px -4px hsl(0deg 0% 59% / 10%);
background-color: var(--bs-body-bg);
z-index: 1;
}
.searchContainer {
position: relative;
padding-bottom: 0.5rem;
}
.search {

View File

@ -42,7 +42,7 @@ export function useSubs ({ prependSubs = [], sub, filterSubs = () => true, appen
return subs
}
export default function SubSelect ({ prependSubs, sub, onChange, large, appendSubs, filterSubs, className, ...props }) {
export default function SubSelect ({ prependSubs, sub, onChange, size, appendSubs, filterSubs, className, ...props }) {
const router = useRouter()
const subs = useSubs({ prependSubs, sub, filterSubs, appendSubs })
const valueProps = props.noForm
@ -105,7 +105,7 @@ export default function SubSelect ({ prependSubs, sub, onChange, large, appendSu
size='sm'
{...valueProps}
{...props}
className={`${className} ${styles.subSelect} ${large ? 'me-2' : styles.subSelectSmall}`}
className={`${className} ${styles.subSelect} ${size === 'large' ? styles.subSelectLarge : size === 'medium' ? styles.subSelectMedium : ''}`}
items={subItems}
/>
)

View File

@ -5,5 +5,13 @@
}
.subSelectSmall {
max-width: 90px !important;
width: 90px !important;
}
.subSelectLarge {
width: 300px !important;
}
.subSelectMedium {
width: 200px !important;
}

View File

@ -13,18 +13,19 @@ import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
export function TerritoryDetails ({ sub }) {
export function TerritoryDetails ({ sub, children }) {
return (
<AccordianCard
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.name}
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount > 0 && ` ${sub.moderatedCount}`}</Badge>}
{(sub.nsfw) && <Badge className='ms-2' bg='secondary'>nsfw</Badge>}
</small>
}
>
{children}
<TerritoryInfo sub={sub} />
</AccordianCard>
)
@ -80,43 +81,45 @@ export default function TerritoryHeader ({ sub }) {
<TerritoryPaymentDue sub={sub} />
<div className='mb-3'>
<div>
<TerritoryDetails sub={sub} />
</div>
<div className='d-flex my-2 justify-content-end'>
<Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-1' />
{me &&
<>
{(isMine
? (
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center'>
<Button variant='outline-grey border-2 rounded py-0' size='sm'>edit territory</Button>
</Link>)
: (
<Button
variant='outline-grey border-2 py-0 rounded'
size='sm'
onClick={async () => {
try {
await toggleMuteSub({ variables: { name: sub.name } })
} catch {
toaster.danger(`failed to ${sub.meMuteSub ? 'join' : 'mute'} territory`)
return
}
toaster.success(`${sub.meMuteSub ? 'joined' : 'muted'} territory`)
}}
>{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>)
<TerritoryDetails sub={sub}>
<div className='d-flex my-2 justify-content-end'>
{sub.name}
<Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-1' />
{me &&
<>
{(isMine
? (
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center'>
<Button variant='outline-grey border-2 rounded py-0' size='sm'>edit territory</Button>
</Link>)
: (
<Button
variant='outline-grey border-2 py-0 rounded'
size='sm'
onClick={async () => {
try {
await toggleMuteSub({ variables: { name: sub.name } })
} catch {
toaster.danger(`failed to ${sub.meMuteSub ? 'join' : 'mute'} territory`)
return
}
toaster.success(`${sub.meMuteSub ? 'joined' : 'muted'} territory`)
}}
>{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>)
)}
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
{isMine && (
<>
<Dropdown.Divider />
<TerritoryTransferDropdownItem sub={sub} />
</>
)}
</ActionDropdown>
</>}
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
{isMine && (
<>
<Dropdown.Divider />
<TerritoryTransferDropdownItem sub={sub} />
</>
)}
</ActionDropdown>
</>}
</div>
</TerritoryDetails>
</div>
</div>
</>

View File

@ -46,11 +46,10 @@ export default function TopHeader ({ sub, cat }) {
initial={{ what, by, when, from: '', to: '' }}
onSubmit={top}
>
<div className='text-muted fw-bold my-3 d-flex align-items-center flex-wrap'>
<div className='text-muted fw-bold my-2 d-flex align-items-center'>
top
<div className='text-muted fw-bold mt-1 mb-3 d-flex align-items-center flex-wrap'>
<div className='text-muted fw-bold mb-2 d-flex align-items-center'>
<Select
groupClassName='mx-2 mb-0'
groupClassName='me-2 mb-0'
onChange={(formik, e) => top({ ...formik?.values, what: e.target.value })}
name='what'
size='sm'

View File

@ -0,0 +1,32 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { clearNotifications } from '@/lib/badge'
import { SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import React, { useContext } from 'react'
export const HasNewNotesContext = React.createContext(false)
export function HasNewNotesProvider ({ me, children }) {
const { data } = useQuery(HAS_NOTIFICATIONS,
SSR
? {}
: {
pollInterval: 30000,
nextFetchPolicy: 'cache-and-network',
onCompleted: ({ hasNewNotes }) => {
if (!hasNewNotes) {
clearNotifications()
}
}
})
return (
<HasNewNotesContext.Provider value={!!data?.hasNewNotes}>
{children}
</HasNewNotesContext.Provider>
)
}
export function useHasNewNotes () {
return useContext(HasNewNotesContext)
}

View File

@ -9,6 +9,7 @@ export const ME = gql`
id
name
bioId
photoId
privates {
autoDropBolt11s
diagnostics

View File

@ -69,7 +69,7 @@ export const config = {
// NextJS recommends to not add the CSP header to prefetches and static assets
// See https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
{
source: '/((?!api|_next/static|_next/image|_next/webpack-hmr|favicon.ico).*)',
source: '/((?!api|_next/static|_error|404|500|offline|_next/image|_next/webpack-hmr|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' }

View File

@ -177,7 +177,7 @@ module.exports = withPlausibleProxy()({
source: '/~:sub/:slug*',
destination: '/~/:slug*?sub=:sub'
},
...['/', '/post', '/search', '/rss', '/recent/:slug*', '/top/:slug*'].map(source => ({ source, destination: '/~' + source }))
...['/', '/post', '/rss', '/recent/:slug*', '/top/:slug*'].map(source => ({ source, destination: '/~' + source }))
]
},
async redirects () {

7
package-lock.json generated
View File

@ -27,6 +27,7 @@
"bolt11": "^1.4.1",
"bootstrap": "^5.3.2",
"canonical-json": "0.0.4",
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
@ -7011,9 +7012,9 @@
"dev": true
},
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clean-stack": {
"version": "2.2.0",

View File

@ -32,6 +32,7 @@
"bolt11": "^1.4.1",
"bootstrap": "^5.3.2",
"canonical-json": "0.0.4",
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",

View File

@ -75,9 +75,9 @@ export function BioForm ({ handleDone, bio }) {
)
}
export function UserLayout ({ user, children }) {
export function UserLayout ({ user, children, containClassName }) {
return (
<Layout user={user} containClassName={styles.contain}>
<Layout user={user} footer={false} containClassName={containClassName}>
<UserHeader user={user} />
{children}
</Layout>
@ -97,7 +97,7 @@ export default function User ({ ssrData }) {
const mine = me?.name === user.name
return (
<UserLayout user={user}>
<UserLayout user={user} containClassName={!user.bio && mine && styles.contain}>
{user.bio
? (edit
? (

View File

@ -20,6 +20,7 @@ import { LoggerProvider } from '@/components/logger'
import { ChainFeeProvider } from '@/components/chain-fee.js'
import { WebLNProvider } from '@/components/webln'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -102,28 +103,30 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<ApolloProvider client={client}>
<MeProvider me={me}>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<WebLNProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</WebLNProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
<HasNewNotesProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<WebLNProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</WebLNProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</HasNewNotesProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>

View File

@ -30,9 +30,9 @@ class MyDocument extends Document {
<Html lang='en'>
<Head nonce={nonce}>
<link rel='manifest' href='/api/site.webmanifest' />
<link rel='preload' href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.woff2`} as='font' type='font/woff2' crossOrigin='' />
<link rel='preload' href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.woff`} as='font' type='font/woff' crossOrigin='' />
<link rel='preload' href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.ttf`} as='font' type='font/ttf' crossOrigin='' />
<link href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.woff2`} as='font' type='font/woff2' crossOrigin='' />
<link href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.woff`} as='font' type='font/woff' crossOrigin='' />
<link href={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/Lightningvolt-xoqm.ttf`} as='font' type='font/ttf' crossOrigin='' />
<style
nonce={nonce}
dangerouslySetInnerHTML={{

View File

@ -26,9 +26,9 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
})
if (!data?.invite) {
return {
notFound: true
}
res.writeHead(301, {
Location: '/404'
}).end()
}
if (session && res) {

View File

@ -61,7 +61,8 @@ export default function PostEdit ({ ssrData }) {
<FormType item={item} editThreshold={editThreshold}>
{!item.isJob &&
<SubSelect
className='w-auto d-flex'
className='d-flex'
size='medium'
label='territory'
filterSubs={s => s.name !== 'jobs' && s.postTypes?.includes(itemType)}
onChange={(_, e) => setSub(e.target.value)}

View File

@ -50,7 +50,18 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
function LoginFooter ({ callbackUrl }) {
return (
<small className='fw-bold text-muted pt-4'>Don't have an account? <Link href={{ pathname: '/signup', query: { callbackUrl } }}>sign up</Link></small>
<small className='fw-bold text-muted pt-4'>New to town? <Link href={{ pathname: '/signup', query: { callbackUrl } }}>sign up</Link></small>
)
}
function LoginHeader () {
return (
<>
<h3 className='w-100 pb-2'>
Login
</h3>
<div className='fw-bold text-muted w-100 text-start pb-4'>Ain't you a sight for sore eyes.</div>
</>
)
}
@ -59,6 +70,7 @@ export default function LoginPage (props) {
<StaticLayout footerLinks={false}>
<Login
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
Header={() => <LoginHeader />}
{...props}
/>
</StaticLayout>

View File

@ -3,7 +3,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
import { useRouter } from 'next/router'
import { SUB_SEARCH } from '@/fragments/subs'
import Items from '@/components/items'
import styles from './search.module.css'
import styles from '@/styles/search.module.css'
export const getServerSideProps = getGetServerSideProps({
query: SUB_SEARCH,
@ -31,16 +31,16 @@ export default function Index ({ ssrData }) {
<div className={styles.box}>
<div className={styles.header}>
<div className='text-muted text-center' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>
more filters
filters
</div>
</div>
<div className={styles.body}>
<div className={styles.inner}>
<div><b>nym:</b>&#8203;<em>sn</em> - limit results by stacker nym</div>
<div><b>url:</b>&#8203;<em>stacker&#8203;.news</em> - limit to specific site</div>
<div><b>"</b>exact phrase<b>"</b> - demand results contain exact phrase</div>
<div>you are searching <em>{variables.sub || 'home'}</em><br /><em>home</em> searches show results from all</div>
</div>
<ul className={styles.inner}>
<li><b>@</b>&#8203;<em>nym</em> - limit to results authored by nym</li>
<li><b>~</b>&#8203;<em>territory</em> - limit to results from territory</li>
<li><b>url:</b>&#8203;<em>stacker&#8203;.news</em> - limit to link posts from a specific url</li>
<li><b>"</b><em>exact phrase</em><b>"</b> - limit to results that contain an exact phrase</li>
</ul>
</div>
</div>
</div>

View File

@ -9,14 +9,14 @@ function SignUpHeader () {
<h3 className='w-100 pb-2'>
Sign up
</h3>
<div className='fw-bold text-muted pb-4'>Join 21,000+ bitcoiners and start stacking sats today</div>
<div className='fw-bold text-muted w-100 text-start pb-4'>You sure you want to stack sats, pardner?</div>
</>
)
}
function SignUpFooter ({ callbackUrl }) {
return (
<small className='fw-bold text-muted pt-4'>Already have an account? <Link href={{ pathname: '/login', query: { callbackUrl } }}>login</Link></small>
<small className='fw-bold text-muted pt-4'>Been here before? <Link href={{ pathname: '/login', query: { callbackUrl } }}>login</Link></small>
)
}

View File

@ -4,7 +4,6 @@ import Items from '@/components/items'
import Layout from '@/components/layout'
import { SUB_FULL, SUB_ITEMS } from '@/fragments/subs'
import Snl from '@/components/snl'
import { MadnessBanner, WelcomeBanner } from '@/components/banners'
import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading'
import TerritoryHeader from '@/components/territory-header'
@ -29,7 +28,6 @@ export default function Sub ({ ssrData }) {
: (
<>
<Snl />
<WelcomeBanner Banner={MadnessBanner} />
</>)}
<Items ssrData={ssrData} variables={variables} />
</Layout>

View File

@ -189,6 +189,10 @@ $accordion-button-active-icon-dark: $accordion-button-icon;
}
}
.squircle {
clip-path: url(#squircleClip);
}
.btn-outline-grey {
--bs-btn-color: var(--theme-grey);
--bs-btn-border-color: var(--theme-grey);
@ -619,14 +623,22 @@ div[contenteditable] {
font-weight: normal;
}
.alert-dismissible .btn-close {
.alert-dismissible .btn-close, .offcanvas-header .btn-close {
font-weight: 300;
font-family: "lightning";
font-size: 150%;
line-height: 1;
}
.popover-header.alert-dismissible .btn-close {
header .navbar:not(:last-child) {
padding-bottom: 0;
}
header .navbar:not(:first-child) {
padding-top: 0;
}
.popover-header.alert-dismissible .btn-close, .offcanvas-header .btn-close {
padding: .5rem !important;
}
@ -645,7 +657,7 @@ div[contenteditable] {
}
.alert-dismissible .btn-close::after {
.alert-dismissible .btn-close::after, .offcanvas-header .btn-close::after {
content: 'X';
}

View File

@ -7,7 +7,6 @@
.box {
border: 1px solid var(--theme-borderColor);
border-radius: 10px;
box-shadow: 2px 2px 10px var(--theme-borderColor);
min-width: 50%;
display: flex;
flex-direction: column;
@ -20,10 +19,13 @@
align-self: center;
}
.inner {
padding: 0.5rem 0;
padding-right: 2rem;
padding-bottom: 1rem;
padding-top: 1rem;
padding-inline-start: 3rem;
display: flex;
flex-direction: column;
}
.inner > div {
padding: 5px 2rem;
.inner > li {
padding: 0.25rem 0;
}

1
svgs/sn.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill-rule="evenodd" d="m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z"/></svg>

After

Width:  |  Height:  |  Size: 263 B