Merge pull request #4 from acid-info/topic-implement-post-container

Implement post container & style updates for desktop / mobile
This commit is contained in:
amir houieh 2023-05-04 16:03:18 +02:00 committed by GitHub
commit aa32cfab87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 679 additions and 290 deletions

2
.gitignore vendored
View File

@ -33,3 +33,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.idea

View File

@ -0,0 +1,56 @@
import { Tag } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
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)}
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 './FilterTags'

View File

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

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

@ -0,0 +1,46 @@
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
export default function Hero() {
return (
<Container>
<Title genericFontFamily="serif" component="span" variant="h2">
LOGOS {' '}
<Title
style={{ whiteSpace: 'nowrap' }}
genericFontFamily="serif"
component="span"
variant="h2"
>
PRESS ENGINE
</Title>
</Title>
<Description component="div" variant="label2">
Blog with media written by Logos members
</Description>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 16px 8px 16px;
@media (max-width: 768px) {
align-items: flex-start;
}
`
const Title = styled(Typography)`
// temporary breakpoint
@media (min-width: 1440px) {
padding-block: 16px;
font-size: 90px;
}
`
const Description = styled(Typography)`
margin-top: 6px;
`

View File

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

View File

@ -1,18 +1,20 @@
import styled from "@emotion/styled";
import { LogosIcon } from "../icons/LogosIcon";
import { IconButton, Typography } from "@acid-info/lsd-react";
import { MoonIcon } from "../icons/MoonIcon";
import { SunIcon } from "../icons/SunIcon";
import styled from '@emotion/styled'
import { IconButton, Typography } from '@acid-info/lsd-react'
import { LogosIcon } from '../Icons/LogosIcon'
import { SunIcon } from '../Icons/SunIcon'
import { MoonIcon } from '../Icons/MoonIcon'
interface NavbarProps {
isDark: boolean;
toggle: () => void;
isDark: boolean
toggle: () => void
}
export default function Navbar({ isDark, toggle }: NavbarProps) {
return (
<Container>
<LogosIcon color="primary" />
<LogosIconContainer>
<LogosIcon color="primary" />
</LogosIconContainer>
<Icons>
<IconButton size="small" onClick={() => toggle()}>
{isDark ? <SunIcon color="primary" /> : <MoonIcon color="primary" />}
@ -22,22 +24,37 @@ export default function Navbar({ isDark, toggle }: NavbarProps) {
</Selector>
</Icons>
</Container>
);
)
}
const Container = styled.div`
const Container = styled.nav`
display: flex;
padding: 8px;
align-items: center;
justify-content: space-between;
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 LogosIconContainer = styled.div`
display: flex;
align-items: center;
margin-left: auto;
@media (max-width: 768px) {
margin-left: unset;
}
`
const Icons = styled.div`
display: flex;
align-items: center;
`;
margin-left: auto;
`
const Selector = styled(IconButton)`
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

@ -68,7 +68,7 @@ export default function Post({
{description}
</CustomTypography>
),
[classType, description, size],
[classType, description],
)
const _thumbnail = useMemo(() => {

View File

@ -1,4 +1,5 @@
import Post, { PostProps } from './Post'
import { PostContainer } from '../PostContainer'
import { PostProps } from './Post'
const postsData: PostProps[] = [
{
@ -18,7 +19,7 @@ const postsData: PostProps[] = [
tags: ['Privacy', 'Security', 'Liberty'],
},
{
aspectRatio: 'portrait', // different aspect ratio
aspectRatio: 'portrait', // different aspect ratio - portrait
imageUrl:
'https://images.pexels.com/photos/4992820/pexels-photo-4992820.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2',
date: new Date(),
@ -56,31 +57,67 @@ const postsData: PostProps[] = [
description:
'We built a pedal-powered generator and controller, which is practical to use as an energy source and exercise machine in a household -- and which you can integrate into a solar PV',
},
{
showImage: false, // without image
classType: 'article',
date: new Date(),
title: 'Satoshi breaks their silence: Inside the mind of the OG anon',
description:
"Bitcoin's creator reveals their feelings on privacy, CBDCs and their favorite NFT collection in an unprecedented interview with Acid.info",
author: 'Jason Freeman',
tags: ['Privacy', 'Security', 'Liberty'],
},
{
aspectRatio: 'square', // square
imageUrl:
'https://images.pexels.com/photos/6477673/pexels-photo-6477673.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2',
date: new Date(),
title: 'How to Build a Practical Household Bike Generator',
description:
'We built a pedal-powered generator and controller, which is practical to use as an energy source and exercise machine in a household -- and which you can integrate into a solar PV',
author: 'Jason Freeman',
tags: ['Privacy', 'Security', 'Liberty'],
},
{
// featured
imageUrl:
'https://images.pexels.com/photos/6227715/pexels-photo-6227715.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2',
date: new Date(),
title: 'How to Build a Practical Household Bike Generator',
description:
'We built a pedal-powered generator and controller, which is practical to use as an energy source and exercise machine in a household -- and which you can integrate into a solar PV',
author: 'Jason Freeman',
tags: ['Privacy', 'Security', 'Liberty'],
},
]
const PostsDemo = () => {
return (
<div style={{ marginTop: '78px' }}>
{/* For Demo purposes only. Use inline CSS and styled components temporarily */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '16px',
gap: '24px',
}}
>
{postsData.map((post, index) => (
<div
style={{
padding: '16px 0',
borderTop: '1px solid rgb(var(--lsd-theme-primary))',
}}
>
<Post key={index} {...post} />
</div>
))}
</div>
<PostContainer title="Featured" postsData={[postsData[7]]} />
<PostContainer
style={{ marginTop: '108px' }}
title="Latest Posts"
postsData={[postsData[3], postsData[0], postsData[3], postsData[2]]}
/>
<PostContainer
style={{ marginTop: '16px' }}
postsData={[postsData[3], postsData[5]]}
/>
<PostContainer
style={{ marginTop: '16px' }}
postsData={[postsData[1], postsData[5], postsData[5], postsData[6]]}
/>
<PostContainer
style={{ marginTop: '108px' }}
title="Podcasts"
postsData={[postsData[2], postsData[2]]}
/>
<PostContainer
style={{ marginTop: '16px', paddingBottom: '108px' }}
postsData={[postsData[2], postsData[2], postsData[2], postsData[2]]}
/>
</div>
)
}

View File

@ -1,2 +1,3 @@
export { default as Post } from './Post'
export { default as PostsDemo } from './PostsDemo'
export type { PostProps } from './Post'

View File

@ -0,0 +1,51 @@
import { CommonProps } from '@acid-info/lsd-react/dist/utils/useCommonProps'
import styled from '@emotion/styled'
import { Post, PostProps } from '../Post'
import { Typography } from '@acid-info/lsd-react'
export type PostContainerProps = CommonProps &
React.HTMLAttributes<HTMLDivElement> & {
title?: string
postsData: PostProps[]
}
export default function PostContainer({
title,
postsData,
...props
}: PostContainerProps) {
return (
<div {...props}>
{title && (<Title variant="body1" genericFontFamily="sans-serif">{title}</Title>)}
<Container>
{postsData.map((post, index) => (
<PostWrapper key={index}>
<Post {...post} />
</PostWrapper>
))}
</Container>
</div>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
padding: 16px;
gap: 24px;
// temporariy breakpoint
@media (max-width: 768px) {
flex-direction: column;
}
`
const PostWrapper = styled.div`
padding: 16px 0;
border-top: 1px solid rgb(var(--lsd-theme-primary));
width: 100%;
`
const Title = styled(Typography)`
padding: 0 16px;
`

View File

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

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;
}
.searchButton{
border: none !important;
}
.searchBox{
border: none !important;
}

View File

@ -0,0 +1,167 @@
import {
TextField,
Autocomplete,
IconButton,
SearchIcon,
CloseIcon,
} 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>('')
const [filterTags, setFilterTags] = useState<string[]>([])
const validateSearch = useCallback(() => {
return query.length > 0 || filterTags.length > 0
}, [query, filterTags])
const performSearch = useCallback(() => {
if (!validateSearch()) return
}, [validateSearch])
const performClear = useCallback(() => {
// TODO: clear input.value seems to be not working. When set to undefined, the input value is still there.
setQuery('')
setFilterTags([])
}, [setQuery, setFilterTags])
useEffect(() => {
performSearch()
}, [filterTags, performSearch])
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 constructCollapseText = () => {
let txt = ''
if (query !== undefined && query.length > 0) {
txt += `<span>${query}</span>`
}
if (filterTags.length > 0) {
if (txt.length > 0) txt += '<b> . </b>'
txt += `${filterTags.map((t) => `<small>[${t}]</small>`).join(' ')}`
}
return txt
}
const isCollapsed = validateSearch() && !active
const placeholder =
searchScope === ESearchScope.GLOBAL
? copyConfigs.search.searchbarPlaceholders.global()
: copyConfigs.search.searchbarPlaceholders.article()
return (
<SearchbarContainer onUnfocus={() => setActive(false)}>
<SearchBox>
<TextField
className={styles.searchBox}
placeholder={placeholder}
value={query as string}
onFocus={() => setActive(true)}
onChange={(e) => {
setQuery(e.target.value)
}}
/>
<div>
<IconButton
className={styles.searchButton}
onClick={() =>
validateSearch() ? performClear() : performSearch()
}
>
{validateSearch() ? <CloseIcon /> : <SearchIcon />}
</IconButton>
</div>
</SearchBox>
<TagsWrapper className={active ? 'active' : ''}>
<FilterTags
tags={copyConfigs.search.filterTags}
onTagClick={handleTagClick}
selectedTags={filterTags}
/>
</TagsWrapper>
<Collapsed
className={isCollapsed ? 'enabled' : ''}
onClick={() => setActive(true)}
dangerouslySetInnerHTML={{ __html: constructCollapseText() }}
/>
</SearchbarContainer>
)
}
const TagsWrapper = styled.div`
transition: height 250ms ease-in-out;
overflow: hidden;
height: 0;
&.active {
height: 45px;
}
`
const Collapsed = styled.div`
display: flex;
align-items: baseline;
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;
}
> * {
margin-right: 4px;
}
> *:not(:first-child) {
margin-left: 4px;
}
b {
transform: translateY(-2px);
}
`
const SearchBox = styled.div`
display: flex;
align-items: center;
width: 100%;
height: 100%;
`

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, onUnfocus])
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,40 @@
import useIsDarkState from "@/states/isDarkState/isDarkState";
import { defaultThemes, ThemeProvider } from "@acid-info/lsd-react";
import { css, Global } from "@emotion/react";
import type { AppProps } from "next/app";
import Head from "next/head";
import useIsDarkState from '@/states/isDarkState/isDarkState'
import { defaultThemes, ThemeProvider } from '@acid-info/lsd-react'
import { css, Global } from '@emotion/react'
import type { AppProps } from 'next/app'
import Head from 'next/head'
import { uiConfigs } from '@/configs/ui.configs'
import { DefaultLayout } from '@/layouts/DefaultLayout'
import { ReactNode } from 'react'
import { NextComponentType, NextPageContext } from 'next'
export default function App({ Component, pageProps }: AppProps) {
const isDark = useIsDarkState().get();
type NextLayoutComponentType<P = {}> = NextComponentType<
NextPageContext,
any,
P
> & {
getLayout?: (page: ReactNode) => ReactNode
}
type AppLayoutProps<P = {}> = AppProps & {
Component: NextLayoutComponentType
}
export default function App({ Component, pageProps }: AppLayoutProps) {
const isDark = useIsDarkState().get()
const getLayout =
Component.getLayout ||
((page: ReactNode) => <DefaultLayout>{page}</DefaultLayout>)
return (
<ThemeProvider theme={isDark ? defaultThemes.dark : defaultThemes.light}>
<Component {...pageProps} />
<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>
<Global
styles={css`
@ -22,8 +45,12 @@ export default function App({ Component, pageProps }: AppProps) {
width: 100%;
height: 100%;
}
:root {
--lpe-nav-rendered-height: ${uiConfigs.navbarRenderedHeight}px;
}
`}
/>
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
);
)
}

View File

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

View File

@ -1,23 +1,9 @@
import PostsDemo from '@/components/Post/PostsDemo'
import { HeaderLayout } from '@/layouts/HeaderLayout'
import Head from 'next/head'
export default function Home() {
return (
<>
<Head>
<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>
<PostsDemo />
</>
)
}

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) => {}

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

@ -0,0 +1,64 @@
import { RefObject, useEffect, useRef, useState } from 'react'
export const useSticky = <T extends HTMLElement>(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 = <T extends HTMLDivElement = HTMLDivElement>(
ref: RefObject<T>,
) => {
const [isOutside, setIsOutside] = useState(false)
useEffect(() => {
function handleClickOutside(event: any) {
const isOutside = !!(ref.current && !ref.current.contains(event.target))
setIsOutside(isOutside)
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [ref])
return { isOutside }
}
export const useIsScrolling = () => {
const [isScrolling, setIsScrolling] = useState(false)
useEffect(() => {
let timeout: NodeJS.Timeout
function handleScroll() {
setIsScrolling(true)
clearTimeout(timeout)
timeout = setTimeout(() => setIsScrolling(false), 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [setIsScrolling])
return isScrolling
}