[website] Add sidebar menu and breadcrumbs (#408)

* feat: create side-menu component for the website

* feat: create index to export necessary components from website

* fix: changes from review

* feat: add breadcrumbs and refactor routes for insights
This commit is contained in:
marcelines 2023-05-24 12:11:52 +01:00 committed by GitHub
parent 8a47bb4b02
commit ca6490783f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 591 additions and 118 deletions

View File

@ -0,0 +1,60 @@
import { Text } from '@status-im/components'
import { ChevronRightIcon } from '@status-im/icons'
import { useRouter } from 'next/router'
import { Link } from '../link'
import { formatSegment } from './format-segment'
type Props = {
cutFirstSegment?: boolean
}
const Breadcrumbs = (props: Props) => {
const { cutFirstSegment = true } = props
const router = useRouter()
const { asPath } = router
// Splits the current path into segments
const segments = asPath.split('/').filter(segment => segment !== '')
return (
<nav className="flex items-center space-x-1">
{segments.map((segment, index) => {
// Builds the path up to the current segment
const path = `/${segments.slice(0, index + 1).join('/')}`
// Determines if the current segment is the last one
const isLastSegment = index === segments.length - 1
// If the first segment should be cut, skip it
if (cutFirstSegment && index === 0) {
return null
}
return (
<div className="flex flex-row items-center" key={segment}>
{/* Always render the first chevron unless cutFirstSegment is true and we need to avoid render the chevron before */}
{!cutFirstSegment || (cutFirstSegment && index > 1) ? (
<ChevronRightIcon size={20} color="$neutral-40" />
) : null}
{isLastSegment ? (
<Text size={15} color="$neutral-50" weight="medium">
{formatSegment(segment)}
</Text>
) : (
<Link href={path}>
<Text size={15} weight="medium">
{formatSegment(segment)}
</Text>
</Link>
)}
</div>
)
})}
</nav>
)
}
export { Breadcrumbs }

View File

@ -0,0 +1,15 @@
/**
* Util function that formats a segment for the breadcrumbs
* @param segment - string to be formatted
* formatSegment @returns a formatted string with the first letter of each word capitalized and spaces instead of "-"
**/
const formatSegment = (segment: string): string => {
// Replaces "-" with a space and capitalize the first letter of each word
const words = segment.split('-')
const formattedSegment = words
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return formattedSegment
}
export { formatSegment }

View File

@ -0,0 +1,2 @@
export { Breadcrumbs } from './breadcrumbs/breadcrumbs'
export { SideBar } from './side-bar/side-bar'

View File

@ -0,0 +1,31 @@
import { Text } from '@status-im/components'
import { useRouter } from 'next/router'
import { Link } from '../link'
import type { LinkProps } from 'next/link'
const NavLink = (
props: LinkProps & {
children: string
}
) => {
const { children, ...linkProps } = props
const { asPath } = useRouter()
const active = asPath === props.href
return (
<Link className="pl-5 transition-opacity hover:opacity-50" {...linkProps}>
<Text
size={19}
weight="medium"
color={active ? '$neutral-50' : '$neutral-100'}
>
{children}
</Text>
</Link>
)
}
export { NavLink }

View File

@ -0,0 +1,72 @@
import * as Accordion from '@radix-ui/react-accordion'
import {} from '@radix-ui/react-accordion'
import { Text } from '@status-im/components'
import { ChevronRightIcon } from '@status-im/icons'
import { useRouter } from 'next/router'
import { Link } from '../link'
import type { Url } from 'next/dist/shared/lib/router/router'
type NavLinkProps = {
label: string
links: {
label: string
href: Url
}[]
}
const NavNestedLinks = (props: NavLinkProps) => {
const { label, links } = props
const { asPath } = useRouter()
return (
<Accordion.Item value={label} className="accordion-item">
<div>
<Accordion.Trigger className="accordion-trigger">
<div className="accordion-chevron inline-flex h-5 w-5">
<ChevronRightIcon size={20} />
</div>
<Text size={19} weight="medium" color={'$neutral-100'}>
{label}
</Text>
</Accordion.Trigger>
<Accordion.Content className="accordion-content">
<div
style={{
overflow: 'hidden',
paddingLeft: 20,
}}
>
{links.map((link, index) => {
const active = asPath === link.href
const paddingClassName = index === 0 ? 'pt-5' : 'pt-2'
const paddingLastChild = index === links.length - 1 ? 'pb-5' : ''
return (
<div
key={link.label + index}
className={`transition-opacity hover:opacity-50 ${paddingClassName} ${paddingLastChild}`}
>
<Link href={link.href}>
<Text
size={15}
weight="medium"
color={active ? '$neutral-50' : '$neutral-100'}
>
{link.label}
</Text>
</Link>
</div>
)
})}
</div>
</Accordion.Content>
</div>
</Accordion.Item>
)
}
export { NavNestedLinks }

View File

@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import * as Accordion from '@radix-ui/react-accordion'
import { useRouter } from 'next/router'
import { NavLink } from './nav-link'
import { NavNestedLinks } from './nav-nested-links'
import type { Url } from 'next/dist/shared/lib/router/router'
type Props = {
data?: {
label: string
href?: Url
links?: {
label: string
href: Url
}[]
}[]
}
const SideBar = (props: Props) => {
const { data } = props
const [label, setLabel] = useState<string>('')
const { asPath } = useRouter()
const defaultLabel = data?.find(
item =>
item.href === asPath || item.links?.find(link => link.href === asPath)
)?.label
useEffect(() => {
setLabel(defaultLabel || '')
}, [defaultLabel])
return (
<div className="border-neutral-10 border-r p-5">
<aside className=" sticky top-5 min-w-[320px]">
<Accordion.Root
type="single"
collapsible
value={label}
onValueChange={value => setLabel(value)}
className="accordion-root flex flex-col gap-3"
>
{data?.map((item, index) => {
if (item.links) {
return (
<NavNestedLinks
key={index}
label={item.label}
links={item.links}
/>
)
}
return (
<Accordion.Item
key={item.label}
value={item.label}
className="accordion-item"
>
<Accordion.Trigger
className="accordion-trigger"
onClick={() => setLabel(item.label)}
>
<NavLink href={item.href || ''}>{item.label}</NavLink>
</Accordion.Trigger>
</Accordion.Item>
)
})}
</Accordion.Root>
</aside>
</div>
)
}
export { SideBar }

View File

@ -1,42 +1,91 @@
import { Text } from '@status-im/components'
import { useRouter } from 'next/router'
import { Link } from '@/components/link'
import { SideBar } from '../components'
import { AppLayout } from './app-layout'
import type { PageLayout } from 'next'
import type { LinkProps } from 'next/link'
// Eventually this will be fetched from the API, at least the nested links
const MENU_LINKS = [
{
label: 'Epics',
links: [
{
label: 'Overview',
href: '/insights/epics',
},
{
label: 'Community Protocol',
href: '/insights/epics/community-protocol',
},
{
label: 'Keycard',
href: '/insights/epics/keycard',
},
{
label: 'Notifications Settings',
href: '/insights/epics/notifications-settings',
},
{
label: 'Wallet',
href: '/insights/epics/wallet',
},
{
label: 'Communities',
href: '/insights/epics/communities',
},
{
label: 'Acitivity Center',
href: '/insights/epics/activity-center',
},
],
},
{
label: 'Workstreams',
links: [
{
label: 'Overview',
href: '/insights/workstreams',
},
{
label: 'Community Protocol 2',
href: '/insights/workstreams/community-protocol-2',
},
{
label: 'Keycard 2',
href: '/insights/workstreams/keycard-2',
},
{
label: 'Notifications Settings 2',
href: '/insights/workstreams/notifications-settings-2',
},
{
label: 'Wallet 2',
href: '/insights/workstreams/wallet-2',
},
{
label: 'Communities 2',
href: '/insights/workstreams/communities-2',
},
{
label: 'Acitivity Center 2',
href: '/insights/workstreams/activity-center-2',
},
],
},
{
label: 'Orphans',
href: '/insights/orphans',
},
{
label: 'Repos',
href: '/insights/repos',
},
]
export const InsightsLayout: PageLayout = page => {
return AppLayout(
<div className="bg-white-100 mx-1 grid min-h-[calc(100vh-56px-4px)] grid-cols-[320px_1fr] items-stretch rounded-3xl">
<aside className="border-neutral-10 flex flex-col gap-3 border-r p-5">
<NavLink href="/insights">Epics</NavLink>
<NavLink href="/insights/detail">Detail</NavLink>
<NavLink href="/insights/orphans">Orphans</NavLink>
<NavLink href="/insights/repos">Repos</NavLink>
</aside>
<main className="p-10">{page}</main>
<div className="bg-white-100 relative mx-1 flex min-h-[calc(100vh-56px-4px)] rounded-3xl">
<SideBar data={MENU_LINKS} />
<main className="flex-1">{page}</main>
</div>
)
}
const NavLink = (props: LinkProps & { children: string }) => {
const { children, ...linkProps } = props
const { asPath } = useRouter()
const active = asPath === props.href
return (
<Link className="transition-opacity hover:opacity-50" {...linkProps}>
<Text
size={19}
weight="medium"
color={active ? '$neutral-50' : '$neutral-100'}
>
{children}
</Text>
</Link>
)
}

View File

@ -1,4 +1,5 @@
import '@/styles/global.css'
import '@/styles/nav-nested-links.css'
import { Provider } from '@status-im/components'
import { Inter } from 'next/font/google'

View File

@ -1,25 +0,0 @@
import { EpicOverview } from '@/components/epic-overview'
import { TableIssues } from '@/components/table-issues'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const InsightsDetailPage: Page = () => {
return (
<>
<EpicOverview
title="Communities protocol"
description="Detecting keycard reader removal for the beginning of each flow"
fullscreen
/>
<div role="separator" className="bg-neutral-10 -mx-6 my-6 h-px" />
<TableIssues />
</>
)
}
InsightsDetailPage.getLayout = InsightsLayout
export default InsightsDetailPage

View File

@ -0,0 +1,31 @@
import { Breadcrumbs } from '@/components'
import { EpicOverview } from '@/components/epic-overview'
import { TableIssues } from '@/components/table-issues'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const EpicsDetailPage: Page = () => {
return (
<div>
<div className="border-neutral-10 border-b px-5 py-3">
<Breadcrumbs />
</div>
<div className="px-10 py-6">
<EpicOverview
title="Communities protocol"
description="Detecting keycard reader removal for the beginning of each flow"
fullscreen
/>
<div role="separator" className="bg-neutral-10 -mx-6 my-6 h-px" />
<TableIssues />
</div>
</div>
)
}
EpicsDetailPage.getLayout = InsightsLayout
export default EpicsDetailPage

View File

@ -0,0 +1,62 @@
import { IconButton, Shadow, Tag, Text } from '@status-im/components'
import {
DoneIcon,
NotStartedIcon,
OpenIcon,
SearchIcon,
SortIcon,
} from '@status-im/icons'
import { EpicOverview } from '@/components/epic-overview'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const epics = [
{
id: 1,
title: 'Communities protocol',
description: 'Support Encrypted Communities',
},
{
id: 5155,
title: 'Keycard',
description:
'Detecting keycard reader removal for the beginning of each flow',
},
]
const EpicsPage: Page = () => {
return (
<div className="space-y-4 p-10">
<Text size={27} weight="semibold">
Epics
</Text>
<div className="flex justify-between">
<div className="flex gap-2">
<Tag size={32} label="In Progress" icon={OpenIcon} selected />
<Tag size={32} label="Closed" icon={DoneIcon} />
<Tag size={32} label="Not Started" icon={NotStartedIcon} />
</div>
<div className="flex gap-2">
<IconButton variant="outline" icon={<SearchIcon size={20} />} />
<IconButton variant="outline" icon={<SortIcon size={20} />} />
</div>
</div>
<div className="grid gap-4">
{epics.map(epic => (
<Shadow key={epic.id} variant="$2" className="rounded-2xl px-4 py-3">
<EpicOverview title={epic.title} description={epic.description} />
</Shadow>
))}
</div>
</div>
)
}
EpicsPage.getLayout = InsightsLayout
export default EpicsPage

View File

@ -1,62 +1,16 @@
import { IconButton, Shadow, Tag, Text } from '@status-im/components'
import {
DoneIcon,
NotStartedIcon,
OpenIcon,
SearchIcon,
SortIcon,
} from '@status-im/icons'
import { useEffect } from 'react'
import { EpicOverview } from '@/components/epic-overview'
import { InsightsLayout } from '@/layouts/insights-layout'
import { useRouter } from 'next/router'
import type { Page } from 'next'
export default function InsightsPage() {
const router = useRouter()
const epics = [
{
id: 1,
title: 'Communities protocol',
description: 'Support Encrypted Communities',
},
{
id: 5155,
title: 'Keycard',
description:
'Detecting keycard reader removal for the beginning of each flow',
},
]
const InsightsPage: Page = () => {
return (
<div className="space-y-4">
<Text size={27} weight="semibold">
Epics
</Text>
<div className="flex justify-between">
<div className="flex gap-2">
<Tag size={32} label="In Progress" icon={OpenIcon} selected />
<Tag size={32} label="Closed" icon={DoneIcon} />
<Tag size={32} label="Not Started" icon={NotStartedIcon} />
</div>
<div className="flex gap-2">
<IconButton variant="outline" icon={<SearchIcon size={20} />} />
<IconButton variant="outline" icon={<SortIcon size={20} />} />
</div>
</div>
<div className="grid gap-4">
{epics.map(epic => (
<Shadow key={epic.id} variant="$2" className="rounded-2xl px-4 py-3">
<EpicOverview title={epic.title} description={epic.description} />
</Shadow>
))}
</div>
</div>
)
useEffect(() => {
// Redirect to the epics page if the user lands on the insights page
if (router.pathname === '/insights') {
router.replace('/insights/epics')
}
}, [router])
InsightsPage.getLayout = InsightsLayout
export default InsightsPage
return null
}

View File

@ -7,7 +7,7 @@ import type { Page } from 'next'
const OrphansPage: Page = () => {
return (
<div className="space-y-6">
<div className="space-y-6 p-10">
<Text size={27} weight="semibold">
Orphans
</Text>

View File

@ -46,7 +46,7 @@ const repos = [
const ReposPage: Page = () => {
return (
<>
<div className="p-10">
<div className="mb-6">
<Text size={27} weight="semibold">
Repos
@ -81,7 +81,7 @@ const ReposPage: Page = () => {
</Link>
))}
</div>
</>
</div>
)
}

View File

@ -0,0 +1,31 @@
import { Breadcrumbs } from '@/components'
import { EpicOverview } from '@/components/epic-overview'
import { TableIssues } from '@/components/table-issues'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const WorkstreamDetailPage: Page = () => {
return (
<div>
<div className="border-neutral-10 border-b px-5 py-3">
<Breadcrumbs />
</div>
<div className="px-10 py-6">
<EpicOverview
title="Communities protocol"
description="Detecting keycard reader removal for the beginning of each flow"
fullscreen
/>
<div role="separator" className="bg-neutral-10 -mx-6 my-6 h-px" />
<TableIssues />
</div>
</div>
)
}
WorkstreamDetailPage.getLayout = InsightsLayout
export default WorkstreamDetailPage

View File

@ -0,0 +1,69 @@
import { IconButton, Shadow, Tag, Text } from '@status-im/components'
import {
DoneIcon,
NotStartedIcon,
OpenIcon,
SearchIcon,
SortIcon,
} from '@status-im/icons'
import { EpicOverview } from '@/components/epic-overview'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const workstreams = [
{
id: 1,
title: 'Workstream protocol',
description: 'Support Encrypted Communities',
},
{
id: 5155,
title: 'Work with keys',
description:
'Detecting keycard reader removal for the beginning of each flow',
},
]
const WorkstreamsPage: Page = () => {
return (
<div className="space-y-4 p-10">
<Text size={27} weight="semibold">
Workstreams
</Text>
<div className="flex justify-between">
<div className="flex gap-2">
<Tag size={32} label="In Progress" icon={OpenIcon} selected />
<Tag size={32} label="Closed" icon={DoneIcon} />
<Tag size={32} label="Not Started" icon={NotStartedIcon} />
</div>
<div className="flex gap-2">
<IconButton variant="outline" icon={<SearchIcon size={20} />} />
<IconButton variant="outline" icon={<SortIcon size={20} />} />
</div>
</div>
<div className="grid gap-4">
{workstreams.map(workstream => (
<Shadow
key={workstream.id}
variant="$2"
className="rounded-2xl px-4 py-3"
>
<EpicOverview
title={workstream.title}
description={workstream.description}
/>
</Shadow>
))}
</div>
</div>
)
}
WorkstreamsPage.getLayout = InsightsLayout
export default WorkstreamsPage

View File

@ -0,0 +1,41 @@
.accordion-trigger {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
}
.accordion-content {
overflow: hidden;
}
.accordion-content[data-state='open'] {
animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion-content[data-state='closed'] {
animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion-chevron {
transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion-trigger[data-state='open'] > .accordion-chevron {
transform: rotate(90deg);
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}