Merge pull request #982 from stackernews/nav
Improved navigation with dedicated mobile navigation
This commit is contained in:
commit
4c2fcec69b
@ -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({
|
||||
|
@ -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 = {
|
||||
|
@ -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 =>
|
||||
|
@ -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'>
|
||||
|
@ -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'>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -1,5 +1,4 @@
|
||||
.title {
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
367
components/nav/common.js
Normal 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>
|
||||
)
|
||||
}
|
14
components/nav/desktop/header.js
Normal file
14
components/nav/desktop/header.js
Normal 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>
|
||||
)
|
||||
}
|
20
components/nav/desktop/second-bar.js
Normal file
20
components/nav/desktop/second-bar.js
Normal 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>
|
||||
)
|
||||
}
|
24
components/nav/desktop/top-bar.js
Normal file
24
components/nav/desktop/top-bar.js
Normal 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
25
components/nav/index.js
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
34
components/nav/mobile/footer.js
Normal file
34
components/nav/mobile/footer.js
Normal 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>
|
||||
)
|
||||
}
|
17
components/nav/mobile/footer.module.css
Normal file
17
components/nav/mobile/footer.module.css
Normal 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%;
|
||||
}
|
14
components/nav/mobile/header.js
Normal file
14
components/nav/mobile/header.js
Normal 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>
|
||||
)
|
||||
}
|
112
components/nav/mobile/offcanvas.js
Normal file
112
components/nav/mobile/offcanvas.js
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
19
components/nav/mobile/second-bar.js
Normal file
19
components/nav/mobile/second-bar.js
Normal 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>
|
||||
)
|
||||
}
|
25
components/nav/mobile/top-bar.js
Normal file
25
components/nav/mobile/top-bar.js
Normal 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
19
components/nav/static.js
Normal 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>
|
||||
)
|
||||
}
|
57
components/nav/sticky-bar.js
Normal file
57
components/nav/sticky-bar.js
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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'}
|
||||
|
@ -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'
|
||||
|
@ -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 })}
|
||||
|
@ -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 {
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
@ -5,5 +5,13 @@
|
||||
}
|
||||
|
||||
.subSelectSmall {
|
||||
max-width: 90px !important;
|
||||
width: 90px !important;
|
||||
}
|
||||
|
||||
.subSelectLarge {
|
||||
width: 300px !important;
|
||||
}
|
||||
|
||||
.subSelectMedium {
|
||||
width: 200px !important;
|
||||
}
|
@ -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>
|
||||
</>
|
||||
|
@ -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'
|
||||
|
32
components/use-has-new-notes.js
Normal file
32
components/use-has-new-notes.js
Normal 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)
|
||||
}
|
@ -9,6 +9,7 @@ export const ME = gql`
|
||||
id
|
||||
name
|
||||
bioId
|
||||
photoId
|
||||
privates {
|
||||
autoDropBolt11s
|
||||
diagnostics
|
||||
|
@ -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' }
|
||||
|
@ -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
7
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
? (
|
||||
|
@ -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>
|
||||
|
@ -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={{
|
||||
|
@ -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) {
|
||||
|
@ -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)}
|
||||
|
@ -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>
|
||||
|
@ -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>​<em>sn</em> - limit results by stacker nym</div>
|
||||
<div><b>url:</b>​<em>stacker​.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>​<em>nym</em> - limit to results authored by nym</li>
|
||||
<li><b>~</b>​<em>territory</em> - limit to results from territory</li>
|
||||
<li><b>url:</b>​<em>stacker​.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>
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
|
@ -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
1
svgs/sn.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user