feat(feed): mods feed
This commit is contained in:
parent
2053e22753
commit
31d9a2b258
83
src/components/Filters/FeedFilter.tsx
Normal file
83
src/components/Filters/FeedFilter.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import { Filter } from '.'
|
||||||
|
import { FilterOptions, SortBy } from 'types'
|
||||||
|
import { Dropdown } from './Dropdown'
|
||||||
|
import { Option } from './Option'
|
||||||
|
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||||
|
import { useLocalStorage } from 'hooks'
|
||||||
|
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||||
|
|
||||||
|
interface FeedFilterProps {
|
||||||
|
tab: number
|
||||||
|
}
|
||||||
|
export const FeedFilter = React.memo(
|
||||||
|
({ tab, children }: PropsWithChildren<FeedFilterProps>) => {
|
||||||
|
const filterKey = `filter-feed-${tab}`
|
||||||
|
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||||
|
filterKey,
|
||||||
|
DEFAULT_FILTER_OPTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Filter>
|
||||||
|
{/* sort filter options */}
|
||||||
|
{/* show only if not posts tabs */}
|
||||||
|
{tab !== 2 && (
|
||||||
|
<Dropdown label={filterOptions.sort}>
|
||||||
|
{Object.values(SortBy).map((item, index) => (
|
||||||
|
<Option
|
||||||
|
key={`sortByItem-${index}`}
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sort: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* nsfw filter options */}
|
||||||
|
<Dropdown label={filterOptions.nsfw}>
|
||||||
|
<NsfwFilterOptions filterKey={filterKey} />
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{/* source filter options */}
|
||||||
|
<Dropdown
|
||||||
|
label={
|
||||||
|
filterOptions.source === window.location.host
|
||||||
|
? `Show From: ${filterOptions.source}`
|
||||||
|
: 'Show All'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Option
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
source: window.location.host
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show From: {window.location.host}
|
||||||
|
</Option>
|
||||||
|
<Option
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
source: 'Show All'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show All
|
||||||
|
</Option>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Filter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
16
src/pages/feed/FeedTabBlogs.tsx
Normal file
16
src/pages/feed/FeedTabBlogs.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||||
|
import { FilterOptions } from 'types'
|
||||||
|
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||||
|
|
||||||
|
export const FeedTabBlogs = () => {
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
const userPubkey = userState.user?.pubkey as string | undefined
|
||||||
|
|
||||||
|
const filterKey = 'filter-feed-1'
|
||||||
|
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
|
||||||
|
...DEFAULT_FILTER_OPTIONS
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userPubkey) return null
|
||||||
|
return <></>
|
||||||
|
}
|
226
src/pages/feed/FeedTabMods.tsx
Normal file
226
src/pages/feed/FeedTabMods.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useLoaderData } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
FilterOptions,
|
||||||
|
ModDetails,
|
||||||
|
NSFWFilter,
|
||||||
|
RepostFilter,
|
||||||
|
SortBy
|
||||||
|
} from 'types'
|
||||||
|
import {
|
||||||
|
constructModListFromEvents,
|
||||||
|
DEFAULT_FILTER_OPTIONS,
|
||||||
|
orderEventsChronologically
|
||||||
|
} from 'utils'
|
||||||
|
import { FeedPageLoaderResult } from './loader'
|
||||||
|
import {
|
||||||
|
NDKFilter,
|
||||||
|
NDKKind,
|
||||||
|
NDKSubscriptionCacheUsage
|
||||||
|
} from '@nostr-dev-kit/ndk'
|
||||||
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
|
import { ModCard } from 'components/ModCard'
|
||||||
|
|
||||||
|
export const FeedTabMods = () => {
|
||||||
|
const { muteLists, nsfwList, repostList, followList } =
|
||||||
|
useLoaderData() as FeedPageLoaderResult
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
const userPubkey = userState.user?.pubkey as string | undefined
|
||||||
|
|
||||||
|
const filterKey = 'filter-feed-0'
|
||||||
|
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
|
||||||
|
...DEFAULT_FILTER_OPTIONS
|
||||||
|
})
|
||||||
|
const { ndk } = useNDKContext()
|
||||||
|
const [mods, setMods] = useState<ModDetails[]>([])
|
||||||
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
|
const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true)
|
||||||
|
const [showing, setShowing] = useState(10)
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
setShowing((prev) => prev + 10)
|
||||||
|
const lastMod = mods[mods.length - 1]
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
authors: [...followList],
|
||||||
|
kinds: [NDKKind.Classified],
|
||||||
|
limit: 20
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.source === window.location.host) {
|
||||||
|
filter['#r'] = [window.location.host]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.sort === SortBy.Latest) {
|
||||||
|
filter.until = lastMod.published_at
|
||||||
|
} else if (filterOptions.sort === SortBy.Oldest) {
|
||||||
|
filter.since = lastMod.published_at
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetching(true)
|
||||||
|
ndk
|
||||||
|
.fetchEvents(filter, {
|
||||||
|
closeOnEose: true,
|
||||||
|
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||||
|
})
|
||||||
|
.then((ndkEventSet) => {
|
||||||
|
const ndkEvents = Array.from(ndkEventSet)
|
||||||
|
orderEventsChronologically(
|
||||||
|
ndkEvents,
|
||||||
|
filterOptions.sort === SortBy.Latest
|
||||||
|
)
|
||||||
|
setMods((prevMods) => {
|
||||||
|
const newMods = constructModListFromEvents(ndkEvents)
|
||||||
|
const combinedMods = [...prevMods, ...newMods]
|
||||||
|
const uniqueMods = Array.from(
|
||||||
|
new Set(combinedMods.map((mod) => mod.id))
|
||||||
|
)
|
||||||
|
.map((id) => combinedMods.find((mod) => mod.id === id))
|
||||||
|
.filter((mod): mod is ModDetails => mod !== undefined)
|
||||||
|
|
||||||
|
if (newMods.length < 20) {
|
||||||
|
setIsLoadMoreVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMods
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsFetching(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsFetching(true)
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
authors: [...followList],
|
||||||
|
kinds: [NDKKind.Classified],
|
||||||
|
limit: 50
|
||||||
|
}
|
||||||
|
// If the source matches the current window location, add a filter condition
|
||||||
|
if (filterOptions.source === window.location.host) {
|
||||||
|
filter['#r'] = [window.location.host]
|
||||||
|
}
|
||||||
|
|
||||||
|
ndk
|
||||||
|
.fetchEvents(filter, {
|
||||||
|
closeOnEose: true,
|
||||||
|
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||||
|
})
|
||||||
|
.then((ndkEventSet) => {
|
||||||
|
const ndkEvents = Array.from(ndkEventSet)
|
||||||
|
setMods(constructModListFromEvents(ndkEvents))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsFetching(false)
|
||||||
|
})
|
||||||
|
}, [filterOptions.source, followList, ndk])
|
||||||
|
|
||||||
|
const filteredModList = useMemo(() => {
|
||||||
|
const nsfwFilter = (mods: ModDetails[]) => {
|
||||||
|
// Add nsfw tag to mods included in nsfwList
|
||||||
|
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
|
||||||
|
mods = mods.map((mod) => {
|
||||||
|
return !mod.nsfw && nsfwList.includes(mod.aTag)
|
||||||
|
? { ...mod, nsfw: true }
|
||||||
|
: mod
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the filtering logic based on the NSFW filter option
|
||||||
|
switch (filterOptions.nsfw) {
|
||||||
|
case NSFWFilter.Hide_NSFW:
|
||||||
|
// If 'Hide_NSFW' is selected, filter out NSFW mods
|
||||||
|
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
|
||||||
|
case NSFWFilter.Show_NSFW:
|
||||||
|
// If 'Show_NSFW' is selected, return all mods (no filtering)
|
||||||
|
return mods
|
||||||
|
case NSFWFilter.Only_NSFW:
|
||||||
|
// If 'Only_NSFW' is selected, filter to show only NSFW mods
|
||||||
|
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repostFilter = (mods: ModDetails[]) => {
|
||||||
|
if (filterOptions.repost !== RepostFilter.Hide_Repost) {
|
||||||
|
// Add repost tag to mods included in repostList
|
||||||
|
mods = mods.map((mod) => {
|
||||||
|
return !mod.repost && repostList.includes(mod.aTag)
|
||||||
|
? { ...mod, repost: true }
|
||||||
|
: mod
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Determine the filtering logic based on the Repost filter option
|
||||||
|
switch (filterOptions.repost) {
|
||||||
|
case RepostFilter.Hide_Repost:
|
||||||
|
return mods.filter(
|
||||||
|
(mod) => !mod.repost && !repostList.includes(mod.aTag)
|
||||||
|
)
|
||||||
|
case RepostFilter.Show_Repost:
|
||||||
|
return mods
|
||||||
|
case RepostFilter.Only_Repost:
|
||||||
|
return mods.filter(
|
||||||
|
(mod) => mod.repost || repostList.includes(mod.aTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = nsfwFilter(mods)
|
||||||
|
filtered = repostFilter(filtered)
|
||||||
|
|
||||||
|
// Filter out mods from muted authors and replaceable events
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(mod) =>
|
||||||
|
!muteLists.admin.authors.includes(mod.author) &&
|
||||||
|
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filterOptions.sort === SortBy.Latest) {
|
||||||
|
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||||
|
} else if (filterOptions.sort === SortBy.Oldest) {
|
||||||
|
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||||
|
}
|
||||||
|
showing > 0 && filtered.splice(showing)
|
||||||
|
return filtered
|
||||||
|
}, [
|
||||||
|
filterOptions.nsfw,
|
||||||
|
filterOptions.repost,
|
||||||
|
filterOptions.sort,
|
||||||
|
mods,
|
||||||
|
muteLists.admin.authors,
|
||||||
|
muteLists.admin.replaceableEvents,
|
||||||
|
nsfwList,
|
||||||
|
repostList,
|
||||||
|
showing
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!userPubkey) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFetching && <LoadingSpinner desc='Fetching mod details from relays' />}
|
||||||
|
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSContent'>
|
||||||
|
<div className='IBMSMList IBMSMListFeed'>
|
||||||
|
{filteredModList.map((mod) => (
|
||||||
|
<ModCard key={mod.id} {...mod} />
|
||||||
|
))}
|
||||||
|
{filteredModList.length === 0 && !isFetching && (
|
||||||
|
<div className='IBMSMListFeedNoPosts'>
|
||||||
|
<p>You aren't following people (or there are no posts to show)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isFetching && isLoadMoreVisible && (
|
||||||
|
<div className='IBMSMListFeedLoadMore'>
|
||||||
|
<button
|
||||||
|
className='btn btnMain IBMSMListFeedLoadMoreBtn'
|
||||||
|
type='button'
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
3
src/pages/feed/FeedTabPosts.tsx
Normal file
3
src/pages/feed/FeedTabPosts.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const FeedTabPosts = () => {
|
||||||
|
return <>WIP: Posts</>
|
||||||
|
}
|
@ -1,3 +1,22 @@
|
|||||||
|
import { Tabs } from 'components/Tabs'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { FeedTabBlogs } from './FeedTabBlogs'
|
||||||
|
import { FeedTabMods } from './FeedTabMods'
|
||||||
|
import { FeedTabPosts } from './FeedTabPosts'
|
||||||
|
import { FeedFilter } from 'components/Filters/FeedFilter'
|
||||||
|
|
||||||
export const FeedPage = () => {
|
export const FeedPage = () => {
|
||||||
return <h2>Feed</h2>
|
const [tab, setTab] = useState(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs tabs={['Mods', 'Blogs', 'Posts']} tab={tab} setTab={setTab} />
|
||||||
|
|
||||||
|
<FeedFilter tab={tab} />
|
||||||
|
|
||||||
|
{tab === 0 && <FeedTabMods />}
|
||||||
|
{tab === 1 && <FeedTabBlogs />}
|
||||||
|
{tab === 2 && <FeedTabPosts />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
101
src/pages/feed/loader.ts
Normal file
101
src/pages/feed/loader.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { NDKUser } from '@nostr-dev-kit/ndk'
|
||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
|
import { redirect } from 'react-router-dom'
|
||||||
|
import { appRoutes } from 'routes'
|
||||||
|
import { store } from 'store'
|
||||||
|
import { MuteLists } from 'types'
|
||||||
|
import {
|
||||||
|
CurationSetIdentifiers,
|
||||||
|
getFallbackPubkey,
|
||||||
|
getReportingSet,
|
||||||
|
log,
|
||||||
|
LogType
|
||||||
|
} from 'utils'
|
||||||
|
|
||||||
|
export interface FeedPageLoaderResult {
|
||||||
|
muteLists: {
|
||||||
|
admin: MuteLists
|
||||||
|
user: MuteLists
|
||||||
|
}
|
||||||
|
nsfwList: string[]
|
||||||
|
repostList: string[]
|
||||||
|
followList: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feedPageLoader = (ndkContext: NDKContextType) => async () => {
|
||||||
|
// Empty result
|
||||||
|
const result: FeedPageLoaderResult = {
|
||||||
|
muteLists: {
|
||||||
|
admin: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nsfwList: [],
|
||||||
|
repostList: [],
|
||||||
|
followList: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current state
|
||||||
|
const userState = store.getState().user
|
||||||
|
const loggedInUserPubkey =
|
||||||
|
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
|
||||||
|
if (!loggedInUserPubkey) return redirect(appRoutes.home)
|
||||||
|
const ndkUser = new NDKUser({ pubkey: loggedInUserPubkey })
|
||||||
|
ndkUser.ndk = ndkContext.ndk
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
ndkContext.getMuteLists(loggedInUserPubkey),
|
||||||
|
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
|
||||||
|
getReportingSet(CurationSetIdentifiers.Repost, ndkContext),
|
||||||
|
ndkUser.followSet()
|
||||||
|
])
|
||||||
|
|
||||||
|
// Check the mutelist event result
|
||||||
|
const muteListResult = settled[0]
|
||||||
|
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
|
||||||
|
result.muteLists = muteListResult.value
|
||||||
|
} else if (muteListResult.status === 'rejected') {
|
||||||
|
log(true, LogType.Error, 'Failed to fetch mutelist.', muteListResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the nsfwlist event result
|
||||||
|
const nsfwListResult = settled[1]
|
||||||
|
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
|
||||||
|
result.nsfwList = nsfwListResult.value
|
||||||
|
} else if (nsfwListResult.status === 'rejected') {
|
||||||
|
log(true, LogType.Error, 'Failed to fetch nsfwlist.', nsfwListResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the repostlist event result
|
||||||
|
const repostListResult = settled[2]
|
||||||
|
if (repostListResult.status === 'fulfilled' && repostListResult.value) {
|
||||||
|
result.repostList = repostListResult.value
|
||||||
|
} else if (repostListResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Failed to fetch repost list.',
|
||||||
|
repostListResult.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the followSet result
|
||||||
|
const followSetResult = settled[3]
|
||||||
|
if (followSetResult.status === 'fulfilled') {
|
||||||
|
result.followList = Array.from(followSetResult.value)
|
||||||
|
} else if (followSetResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Failed to fetch follow set.',
|
||||||
|
followSetResult.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
@ -19,6 +19,7 @@ import { NotFoundPage } from '../pages/404'
|
|||||||
import { submitModRouteAction } from 'pages/submitMod/action'
|
import { submitModRouteAction } from 'pages/submitMod/action'
|
||||||
import { FeedLayout } from '../layout/feed'
|
import { FeedLayout } from '../layout/feed'
|
||||||
import { FeedPage } from '../pages/feed'
|
import { FeedPage } from '../pages/feed'
|
||||||
|
import { feedPageLoader } from 'pages/feed/loader'
|
||||||
import { NotificationsPage } from '../pages/notifications'
|
import { NotificationsPage } from '../pages/notifications'
|
||||||
import { WritePage } from '../pages/write'
|
import { WritePage } from '../pages/write'
|
||||||
import { writeRouteAction } from '../pages/write/action'
|
import { writeRouteAction } from '../pages/write/action'
|
||||||
@ -197,7 +198,8 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: appRoutes.feed,
|
path: appRoutes.feed,
|
||||||
element: <FeedPage />
|
element: <FeedPage />,
|
||||||
|
loader: feedPageLoader(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.notifications,
|
path: appRoutes.notifications,
|
||||||
|
@ -119,3 +119,27 @@
|
|||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.IBMSMListFeedLoadMore {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnMain.IBMSMListFeedLoadMoreBtn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.IBMSMListFeedNoPosts {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: solid 1px rgba(255,255,255,0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(255,255,255,0.25);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user