add layout and its interactions

This commit is contained in:
amirhouieh 2023-04-27 12:35:06 +02:00
parent 871936702f
commit e7308edd41
37 changed files with 496 additions and 244 deletions

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

12
.idea/lpe-frontend.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lpe-frontend.iml" filepath="$PROJECT_DIR$/.idea/lpe-frontend.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,58 @@
import { Tag } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import { useState } from 'react'
import { nope } from '@/utils/general.utils'
type FilterTagsProps = {
tags: string[]
selectedTags: string[]
onTagClick?: (tag: string) => void
}
export default function FilterTags(props: FilterTagsProps) {
const { tags = [], onTagClick = nope, selectedTags } = props
return (
<Container>
<Tags>
{tags.map((tag, index) => (
<Tag
size="small"
disabled={false}
key={index}
onClick={() => onTagClick(tag)}
selected={selectedTags.includes(tag)}
variant={selectedTags.includes(tag) ? 'filled' : 'outlined'}
>
{tag}
</Tag>
))}
</Tags>
</Container>
)
}
const Container = styled.div`
padding: 8px 0;
`
const Tags = styled.div`
display: flex;
gap: 8px;
overflow-x: auto;
padding-right: 14px;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none;
}
> *:first-child {
margin-left: 14px;
}
> * {
white-space: nowrap;
}
`

View File

@ -0,0 +1 @@
export { default as FilterTags } from './HeaderTags'

View File

@ -1 +0,0 @@
export { default as Header } from './Header';

View File

@ -1,52 +0,0 @@
import { Tag } from "@acid-info/lsd-react";
import styled from "@emotion/styled";
export default function Header() {
return (
<Container>
<Tags>
<Tag size="small" disabled={false}>
Privacy
</Tag>
<Tag size="small" disabled={false}>
Security
</Tag>
<Tag size="small" disabled={false}>
Liberty
</Tag>
<Tag size="small" disabled={false}>
Censorship
</Tag>
<Tag size="small" disabled={false}>
Decentralization
</Tag>
<Tag size="small" disabled={false} style={{ whiteSpace: "nowrap" }}>
Openness / inclusivity
</Tag>
<Tag size="small" disabled={false}>
Innovation
</Tag>
<Tag size="small" disabled={false}>
Interview
</Tag>
<Tag size="small" disabled={false}>
Podcast
</Tag>
<Tag size="small" disabled={false}>
Law
</Tag>
</Tags>
</Container>
);
}
const Container = styled.div`
padding: 16px 0 16px 16px;
`;
const Tags = styled.div`
display: flex;
gap: 8px;
overflow-x: auto;
padding-right: 16px;
`;

View File

@ -1 +0,0 @@
export { default as HeaderTags } from './HeaderTags';

View File

@ -1,11 +1,11 @@
import { Typography } from "@acid-info/lsd-react"; import { Typography } from '@acid-info/lsd-react'
import styled from "@emotion/styled"; import styled from '@emotion/styled'
export default function Header() { export default function Hero() {
return ( return (
<Container> <Container>
<Typography genericFontFamily="serif" component="span" variant="h2"> <Typography genericFontFamily="serif" component="span" variant="h2">
LOGOS {" "} LOGOS {' '}
<Title genericFontFamily="serif" component="span" variant="h2"> <Title genericFontFamily="serif" component="span" variant="h2">
PRESS ENGINE PRESS ENGINE
</Title> </Title>
@ -14,17 +14,17 @@ export default function Header() {
Blog with media written by Logos members Blog with media written by Logos members
</Description> </Description>
</Container> </Container>
); )
} }
const Container = styled.div` const Container = styled.div`
padding: 16px 16px 8px 16px; padding: 16px 16px 8px 16px;
`; `
const Title = styled(Typography)` const Title = styled(Typography)`
white-space: nowrap; white-space: nowrap;
`; `
const Description = styled(Typography)` const Description = styled(Typography)`
margin-top: 6px; margin-top: 6px;
`; `

View File

@ -0,0 +1 @@
export { default as Hero } from './Hero'

View File

@ -1,12 +1,12 @@
import styled from "@emotion/styled"; import styled from '@emotion/styled'
import { LogosIcon } from "../icons/LogosIcon"; import { LogosIcon } from '../icons/LogosIcon'
import { IconButton, Typography } from "@acid-info/lsd-react"; import { IconButton, Typography } from '@acid-info/lsd-react'
import { MoonIcon } from "../icons/MoonIcon"; import { MoonIcon } from '../icons/MoonIcon'
import { SunIcon } from "../icons/SunIcon"; import { SunIcon } from '../icons/SunIcon'
interface NavbarProps { interface NavbarProps {
isDark: boolean; isDark: boolean
toggle: () => void; toggle: () => void
} }
export default function Navbar({ isDark, toggle }: NavbarProps) { export default function Navbar({ isDark, toggle }: NavbarProps) {
@ -22,22 +22,27 @@ export default function Navbar({ isDark, toggle }: NavbarProps) {
</Selector> </Selector>
</Icons> </Icons>
</Container> </Container>
); )
} }
const Container = styled.div` const Container = styled.nav`
display: flex; display: flex;
padding: 8px; padding: 8px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid rgb(var(--lsd-theme-primary)); border-bottom: 1px solid rgb(var(--lsd-theme-primary));
`; position: fixed;
top: 0;
width: calc(100% - 16px);
background: rgb(var(--lsd-surface-primary));
z-index: 100;
`
const Icons = styled.div` const Icons = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
`; `
const Selector = styled(IconButton)` const Selector = styled(IconButton)`
border-left: none; border-left: none;
`; `

View File

@ -0,0 +1,5 @@
import styled from '@emotion/styled'
export const NavbarFiller = styled.div`
height: var(--lpe-nav-rendered-height);
`

View File

@ -1,8 +0,0 @@
.searchBox {
width: 100% !important;
}
.searchBox > div {
border-left: none !important;
border-right: none !important;
}

View File

@ -1,12 +0,0 @@
import { Autocomplete } from "@acid-info/lsd-react";
import styles from "./Search.module.css";
export default function Search() {
return (
<Autocomplete
className={styles.searchBox}
placeholder="Search through the LPE posts.."
withIcon
/>
);
}

View File

@ -1 +0,0 @@
export { default as Search } from './Search';

View File

@ -0,0 +1,11 @@
.searchBox {
width: 100% !important;
}
.active::placeholder{
/*opacity: 1 !important;*/
}
.searchBox > div {
border: none !important;
}

View File

@ -0,0 +1,130 @@
import { Autocomplete } from '@acid-info/lsd-react'
import styles from './Search.module.css'
import { SearchbarContainer } from '@/components/Searchbar/SearchbarContainer'
import { copyConfigs } from '@/configs/copy.configs'
import { ESearchScope } from '@/types/ui.types'
import React, { useCallback, useEffect, useState } from 'react'
import FilterTags from '@/components/FilterTags/FilterTags'
import styled from '@emotion/styled'
export type SearchbarProps = {
searchScope?: ESearchScope
}
export default function Searchbar(props: SearchbarProps) {
const { searchScope = ESearchScope.GLOBAL } = props
const [active, setActive] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [query, setQuery] = useState<string | undefined>(undefined)
const [filterTags, setFilterTags] = useState<string[]>([])
const performSearch = () => {
console.log('performing search', query, filterTags)
if (!validateSearch()) return
}
useEffect(() => {
performSearch()
}, [filterTags])
const handleTagClick = (tag: string) => {
let newSelectedTags = [...filterTags]
if (newSelectedTags.includes(tag)) {
newSelectedTags = newSelectedTags.filter((t) => t !== tag)
} else {
newSelectedTags.push(tag)
}
setFilterTags(newSelectedTags)
}
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
performSearch()
}
}
const validateSearch = () => {
return (query !== undefined && query.length > 0) || filterTags.length > 0
}
const isCollapsed = validateSearch() && !active
const constructCollapseText = () => {
let txt = ''
if (query !== undefined && query.length > 0) {
txt += query
}
if (filterTags.length > 0) {
if (txt.length > 0) txt += ' . '
txt += `${filterTags.map((t) => `[${t}]`).join(',')}`
}
return txt
}
return (
<SearchbarContainer onUnfocus={() => setActive(false)}>
<Autocomplete
className={styles.searchBox}
placeholder={
searchScope === ESearchScope.GLOBAL
? copyConfigs.search.searchbarPlaceholders.global()
: copyConfigs.search.searchbarPlaceholders.article()
}
withIcon
value={query as string}
onFocus={() => setActive(true)}
inputProps={{
onChange: (e) => setQuery(e.target.value),
onKeyUp: (e) => handleEnter(e),
className: isCollapsed ? styles.active : '',
}}
/>
<TagsWrapper className={active ? 'active' : ''}>
<FilterTags
tags={copyConfigs.search.filterTags}
onTagClick={handleTagClick}
selectedTags={filterTags}
/>
</TagsWrapper>
<Collapsed
className={isCollapsed ? 'enabled' : ''}
onClick={() => setActive(true)}
>
{constructCollapseText()}
</Collapsed>
</SearchbarContainer>
)
}
const TagsWrapper = styled.div`
transition: height 250ms ease-in-out;
overflow: hidden;
height: 0;
&.active {
height: 45px;
}
`
const Collapsed = styled.div`
background: rgb(var(--lsd-surface-primary));
padding: 8px 14px;
width: calc(90% - 28px);
position: absolute;
z-index: auto;
top: -100%;
left: 0;
font-size: 14px;
transition: top 250ms ease-in-out;
&.enabled {
top: 0;
}
`

View File

@ -0,0 +1,58 @@
import styled from '@emotion/styled'
import { uiConfigs } from '@/configs/ui.configs'
import { useIsScrolling, useOutsideClick, useSticky } from '@/utils/ui.utils'
import { PropsWithChildren, useEffect } from 'react'
import { nope } from '@/utils/general.utils'
type Props = PropsWithChildren<{
onUnfocus?: () => void
}>
export function SearchbarContainer({ children, onUnfocus = nope }: Props) {
const { sticky, stickyRef, height } = useSticky<HTMLDivElement>(
uiConfigs.navbarRenderedHeight,
)
const { isOutside } = useOutsideClick<HTMLDivElement>(stickyRef)
const isScrolling = useIsScrolling()
useEffect(() => {
if (isOutside && onUnfocus) {
onUnfocus()
}
if (isScrolling && onUnfocus) {
console.log('scrolling', isScrolling)
onUnfocus()
}
}, [isOutside, stickyRef, isScrolling])
return (
<>
<SearchBarWrapper ref={stickyRef} className={sticky ? 'sticky' : ''}>
{children}
</SearchBarWrapper>
<div
style={{
height: `${height}px`,
}}
/>
</>
)
}
const SearchBarWrapper = styled.div<Props>`
display: block;
width: 100%;
background: rgb(var(--lsd-surface-primary));
border-bottom: 1px solid rgb(var(--lsd-border-primary));
border-top: 1px solid rgb(var(--lsd-border-primary));
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
&.sticky {
position: fixed;
top: ${uiConfigs.navbarRenderedHeight - 1}px;
z-index: 100;
}
`

View File

@ -0,0 +1 @@
export { default as Searchbar } from './Searchbar'

View File

@ -0,0 +1,20 @@
export const copyConfigs = {
search: {
searchbarPlaceholders: {
global: () => 'Search through the LPE posts...',
article: () => `Search through the article`,
},
filterTags: [
'Privacy',
'Security',
'Liberty',
'Censorship',
'Decentralization',
'Openness / inclusivity',
'Innovation',
'Interview',
'Podcast',
'Law',
],
},
}

View File

@ -0,0 +1,3 @@
export const uiConfigs = {
navbarRenderedHeight: 45,
}

View File

@ -0,0 +1,20 @@
import { Navbar } from '@/components/Navbar'
import useIsDarkState from '@/states/isDarkState/isDarkState'
import { PropsWithChildren } from 'react'
import { NavbarFiller } from '@/components/Navbar/NavbarFiller'
import { Searchbar } from '@/components/Searchbar'
import { ESearchScope } from '@/types/ui.types'
export default function ArticleLayout(props: PropsWithChildren<any>) {
const isDarkState = useIsDarkState()
return (
<>
<header>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<NavbarFiller />
<Searchbar searchScope={ESearchScope.ARTICLE} />
</header>
<main>{props.children}</main>
</>
)
}

View File

@ -0,0 +1 @@
export { default as ArticleLayout } from './Article.layout'

View File

@ -0,0 +1,22 @@
import { Navbar } from '@/components/Navbar'
import useIsDarkState from '@/states/isDarkState/isDarkState'
import { PropsWithChildren } from 'react'
import { Hero } from '@/components/Hero'
import { NavbarFiller } from '@/components/Navbar/NavbarFiller'
import { Searchbar } from '@/components/Searchbar'
export default function DefaultLayout(props: PropsWithChildren<any>) {
const isDarkState = useIsDarkState()
return (
<>
<header>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<NavbarFiller />
<Hero />
<Searchbar />
</header>
<main>{props.children}</main>
</>
)
}

View File

@ -0,0 +1 @@
export { default as DefaultLayout } from './Default.layout'

View File

@ -1,17 +0,0 @@
import { Header } from "@/components/Header";
import { HeaderTags } from "@/components/HeaderTags";
import { Navbar } from "@/components/Navbar";
import { Search } from "@/components/Search";
import useIsDarkState from "@/states/isDarkState/isDarkState";
export default function HeaderLayout() {
const isDarkState = useIsDarkState();
return (
<>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<Header />
<HeaderTags />
<Search />
</>
);
}

View File

@ -1 +0,0 @@
export { default as HeaderLayout } from './Header.layout';

View File

@ -1,17 +1,27 @@
import useIsDarkState from "@/states/isDarkState/isDarkState"; import useIsDarkState from '@/states/isDarkState/isDarkState'
import { defaultThemes, ThemeProvider } from "@acid-info/lsd-react"; import { defaultThemes, ThemeProvider } from '@acid-info/lsd-react'
import { css, Global } from "@emotion/react"; import { css, Global } from '@emotion/react'
import type { AppProps } from "next/app"; import type { AppProps } from 'next/app'
import Head from "next/head"; import Head from 'next/head'
import { uiConfigs } from '@/configs/ui.configs'
import { DefaultLayout } from '@/layouts/DefaultLayout'
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
const isDark = useIsDarkState().get(); const isDark = useIsDarkState().get()
//TODO: fix this
//@ts-ignore
const getLayout =
Component.getLayout || ((page) => <DefaultLayout>{page}</DefaultLayout>)
return ( return (
<ThemeProvider theme={isDark ? defaultThemes.dark : defaultThemes.light}> <ThemeProvider theme={isDark ? defaultThemes.dark : defaultThemes.light}>
<Component {...pageProps} />
<Head> <Head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> <title>Acid</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
</Head> </Head>
<Global <Global
styles={css` styles={css`
@ -22,8 +32,12 @@ export default function App({ Component, pageProps }: AppProps) {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
:root {
--lpe-nav-rendered-height: ${uiConfigs.navbarRenderedHeight}px;
}
`} `}
/> />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider> </ThemeProvider>
); )
} }

View File

@ -0,0 +1,14 @@
import { NextPage } from 'next'
import { ArticleLayout } from '@/layouts/ArticleLayout'
type Props = NextPage<{}>
const ArticlePage = (props: Props) => {
return <article>article</article>
}
ArticlePage.getLayout = function getLayout(page) {
return <ArticleLayout>{page}</ArticleLayout>
}
export default ArticlePage

View File

@ -1,23 +1,9 @@
import PostsDemo from '@/components/Post/PostsDemo' import PostsDemo from '@/components/Post/PostsDemo'
import { HeaderLayout } from '@/layouts/HeaderLayout'
import Head from 'next/head'
export default function Home() { export default function Home() {
return ( return (
<> <>
<Head> <PostsDemo />
<title>Logos Press Engine</title>
<meta
name="description"
content="Blog with media written by Logos members"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<HeaderLayout />
<PostsDemo />
</main>
</> </>
) )
} }

View File

@ -1,107 +0,0 @@
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--primary-glow: conic-gradient(
from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(
to bottom right,
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0.3)
);
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(
#ffffff80,
#ffffff40,
#ffffff30,
#ffffff20,
#ffffff10,
#ffffff10,
#ffffff80
);
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

4
src/types/ui.types.ts Normal file
View File

@ -0,0 +1,4 @@
export enum ESearchScope {
GLOBAL = 'global',
ARTICLE = 'article',
}

View File

View File

@ -0,0 +1 @@
export const nope = (...args: any) => {}

59
src/utils/ui.utils.ts Normal file
View File

@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react'
export const useSticky = <T>(dy: number = 0) => {
const stickyRef = useRef<T>(null)
const [sticky, setSticky] = useState(false)
const [offset, setOffset] = useState(0)
const [height, setHeight] = useState(0)
useEffect(() => {
if (!stickyRef.current) {
return
}
setOffset(stickyRef.current.offsetTop)
setHeight(stickyRef.current.clientHeight)
}, [stickyRef, setOffset])
useEffect(() => {
const handleScroll = () => {
if (!stickyRef.current) {
return
}
setSticky(window.scrollY > offset - dy)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [dy, setSticky, stickyRef, offset])
return { stickyRef, sticky, height: sticky ? height : 0 }
}
export const useOutsideClick = (ref) => {
const [isOutside, setIsOutside] = useState(false)
useEffect(() => {
function handleClickOutside(event) {
setIsOutside(ref.current && !ref.current.contains(event.target))
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [ref])
return { isOutside }
}
export const useIsScrolling = () => {
const [isScrolling, setIsScrolling] = useState(false)
useEffect(() => {
let timeout
function handleScroll() {
setIsScrolling(true)
clearTimeout(timeout)
timeout = setTimeout(() => setIsScrolling(false), 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [setIsScrolling])
return isScrolling
}