feat: add single column layout toggle option

This commit is contained in:
codytseng
2025-10-19 14:50:49 +08:00
parent 56729e09c3
commit dbee10361b
23 changed files with 298 additions and 112 deletions

View File

@@ -24,43 +24,43 @@ import { PageManager } from './PageManager'
export default function App(): JSX.Element {
return (
<ThemeProvider>
<ContentPolicyProvider>
<ScreenSizeProvider>
<DeletedEventProvider>
<NostrProvider>
<ZapProvider>
<TranslationServiceProvider>
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<PinListProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<UserPreferencesProvider>
<ThemeProvider>
<ContentPolicyProvider>
<ScreenSizeProvider>
<DeletedEventProvider>
<NostrProvider>
<ZapProvider>
<TranslationServiceProvider>
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<PinListProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinListProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>
</TranslationServiceProvider>
</ZapProvider>
</NostrProvider>
</DeletedEventProvider>
</ScreenSizeProvider>
</ContentPolicyProvider>
</ThemeProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinListProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>
</TranslationServiceProvider>
</ZapProvider>
</NostrProvider>
</DeletedEventProvider>
</ScreenSizeProvider>
</ContentPolicyProvider>
</ThemeProvider>
</UserPreferencesProvider>
)
}

View File

@@ -29,6 +29,7 @@ import SearchPage from './pages/primary/SearchPage'
import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { useTheme } from './providers/ThemeProvider'
import { useUserPreferences } from './providers/UserPreferencesProvider'
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
@@ -106,6 +107,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const { enableSingleColumnLayout } = useUserPreferences()
const ignorePopStateRef = useRef(false)
useEffect(() => {
@@ -243,7 +245,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (needScrollToTop) {
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
}
if (isSmallScreen) {
if (isSmallScreen || enableSingleColumnLayout) {
clearSecondaryPages()
}
}
@@ -333,6 +335,79 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
)
}
if (enableSingleColumnLayout) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex flex-col items-center bg-surface-background">
<div
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<div className="grid grid-cols-10 w-full">
<div className="col-span-3 flex justify-end">
<Sidebar />
</div>
<div className="col-span-4 bg-background overflow-hidden border-x">
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
className="flex flex-col h-full w-full"
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.component}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name
? 'block'
: 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div className="col-span-3" />
</div>
</div>
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
return (
<PrimaryPageContext.Provider
value={{

View File

@@ -17,7 +17,7 @@ import SettingsButton from './SettingsButton'
export default function PrimaryPageSidebar() {
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const { sidebarCollapse, updateSidebarCollapse } = useUserPreferences()
const { sidebarCollapse, updateSidebarCollapse, enableSingleColumnLayout } = useUserPreferences()
if (isSmallScreen) return null
@@ -25,11 +25,11 @@ export default function PrimaryPageSidebar() {
<div
className={cn(
'relative flex flex-col pb-2 pt-3 justify-between h-full shrink-0',
sidebarCollapse ? 'px-2 w-16' : 'px-4 w-52'
sidebarCollapse && !enableSingleColumnLayout ? 'px-2 w-16' : 'px-4 w-52'
)}
>
<div className="space-y-2">
{sidebarCollapse ? (
{sidebarCollapse && !enableSingleColumnLayout ? (
<div className="px-3 py-1 mb-6 w-full">
<Icon />
</div>
@@ -38,27 +38,29 @@ export default function PrimaryPageSidebar() {
<Logo />
</div>
)}
<HomeButton collapse={sidebarCollapse} />
<RelaysButton collapse={sidebarCollapse} />
<NotificationsButton collapse={sidebarCollapse} />
<SearchButton collapse={sidebarCollapse} />
<ProfileButton collapse={sidebarCollapse} />
<SettingsButton collapse={sidebarCollapse} />
<PostButton collapse={sidebarCollapse} />
<HomeButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<RelaysButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<NotificationsButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<SearchButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<ProfileButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<SettingsButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
<PostButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
</div>
<AccountButton collapse={sidebarCollapse} />
<button
className={cn(
'absolute flex flex-col justify-center items-center right-0 w-5 h-6 p-0 rounded-l-md hover:shadow-md text-muted-foreground hover:text-foreground hover:bg-background transition-colors [&_svg]:size-4',
themeSetting === 'pure-black' ? 'top-3' : 'top-5'
)}
onClick={(e) => {
e.stopPropagation()
updateSidebarCollapse(!sidebarCollapse)
}}
>
{sidebarCollapse ? <ChevronsRight /> : <ChevronsLeft />}
</button>
<AccountButton collapse={sidebarCollapse && !enableSingleColumnLayout} />
{!enableSingleColumnLayout && (
<button
className={cn(
'absolute flex flex-col justify-center items-center right-0 w-5 h-6 p-0 rounded-l-md hover:shadow-md text-muted-foreground hover:text-foreground hover:bg-background transition-colors [&_svg]:size-4',
themeSetting === 'pure-black' ? 'top-3' : 'top-5'
)}
onClick={(e) => {
e.stopPropagation()
updateSidebarCollapse(!sidebarCollapse)
}}
>
{sidebarCollapse ? <ChevronsRight /> : <ChevronsLeft />}
</button>
)}
</div>
)
}

View File

@@ -50,6 +50,7 @@ export const StorageKey = {
SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS: 'shownCreateWalletGuideToastPubkeys',
SIDEBAR_COLLAPSE: 'sidebarCollapse',
PRIMARY_COLOR: 'primaryColor',
ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated

View File

@@ -476,6 +476,9 @@ export default {
Fuchsia: 'فوشيا',
Pink: 'وردي',
Rose: 'وردة',
'Primary color': 'اللون الأساسي'
'Primary color': 'اللون الأساسي',
Layout: 'التخطيط',
'Double column': 'عمودين',
'Single column': 'عمود واحد'
}
}

View File

@@ -490,6 +490,9 @@ export default {
Fuchsia: 'Fuchsia',
Pink: 'Rosa',
Rose: 'Rose',
'Primary color': 'Primärfarbe'
'Primary color': 'Primärfarbe',
Layout: 'Layout',
'Double column': 'Zweispaltig',
'Single column': 'Einspaltig'
}
}

View File

@@ -475,6 +475,9 @@ export default {
Fuchsia: 'Fuchsia',
Pink: 'Pink',
Rose: 'Rose',
'Primary color': 'Primary color'
'Primary color': 'Primary color',
Layout: 'Layout',
'Double column': 'Double column',
'Single column': 'Single column'
}
}

View File

@@ -484,6 +484,9 @@ export default {
Fuchsia: 'Fucsia',
Pink: 'Rosa',
Rose: 'Rosa',
'Primary color': 'Color primario'
'Primary color': 'Color primario',
Layout: 'Diseño',
'Double column': 'Doble columna',
'Single column': 'Columna única'
}
}

View File

@@ -479,6 +479,9 @@ export default {
Fuchsia: 'فوشیا',
Pink: 'صورتی',
Rose: 'گلی',
'Primary color': 'رنگ اصلی'
'Primary color': 'رنگ اصلی',
Layout: 'چیدمان',
'Double column': 'دو ستونی',
'Single column': 'تک ستونی'
}
}

View File

@@ -489,6 +489,9 @@ export default {
Fuchsia: 'Fuchsia',
Pink: 'Rose',
Rose: 'Rose',
'Primary color': 'Couleur principale'
'Primary color': 'Couleur principale',
Layout: 'Disposition',
'Double column': 'Double colonne',
'Single column': 'Colonne unique'
}
}

View File

@@ -481,6 +481,9 @@ export default {
Fuchsia: 'फुशिया',
Pink: 'गुलाबी',
Rose: 'गुलाब',
'Primary color': 'प्राथमिक रंग'
'Primary color': 'प्राथमिक रंग',
Layout: 'लेआउट',
'Double column': 'दोहरा स्तंभ',
'Single column': 'एकल स्तंभ'
}
}

View File

@@ -484,6 +484,9 @@ export default {
Fuchsia: 'Fucsia',
Pink: 'Rosa',
Rose: 'Rosa',
'Primary color': 'Colore primario'
'Primary color': 'Colore primario',
Layout: 'Layout',
'Double column': 'Doppia colonna',
'Single column': 'Colonna singola'
}
}

View File

@@ -480,6 +480,9 @@ export default {
Fuchsia: 'フクシア',
Pink: 'ピンク',
Rose: 'ローズ',
'Primary color': '主要な色'
'Primary color': '主要な色',
Layout: 'レイアウト',
'Double column': '2列',
'Single column': '1列'
}
}

View File

@@ -480,6 +480,9 @@ export default {
Fuchsia: '자홍',
Pink: '분홍',
Rose: '장미',
'Primary color': '기본 색상'
'Primary color': '기본 색상',
Layout: '레이아웃',
'Double column': '두 열',
'Single column': '한 열'
}
}

View File

@@ -484,6 +484,9 @@ export default {
Fuchsia: 'Fuksja',
Pink: 'Różowy',
Rose: 'Różany',
'Primary color': 'Kolor podstawowy'
'Primary color': 'Kolor podstawowy',
Layout: 'Układ',
'Double column': 'Dwie kolumny',
'Single column': 'Jedna kolumna'
}
}

View File

@@ -481,6 +481,9 @@ export default {
Fuchsia: 'Fúcsia',
Pink: 'Rosa',
Rose: 'Rosa',
'Primary color': 'Cor primária'
'Primary color': 'Cor primária',
Layout: 'Layout',
'Double column': 'Coluna dupla',
'Single column': 'Coluna única'
}
}

View File

@@ -484,6 +484,9 @@ export default {
Fuchsia: 'Fúcsia',
Pink: 'Rosa',
Rose: 'Rosa',
'Primary color': 'Cor primária'
'Primary color': 'Cor primária',
Layout: 'Layout',
'Double column': 'Coluna dupla',
'Single column': 'Coluna única'
}
}

View File

@@ -486,6 +486,9 @@ export default {
Fuchsia: 'Фуксия',
Pink: 'Розовый',
Rose: 'Роза',
'Primary color': 'Основной цвет'
'Primary color': 'Основной цвет',
Layout: 'Макет',
'Double column': 'Две колонки',
'Single column': 'Одна колонка'
}
}

View File

@@ -474,6 +474,9 @@ export default {
Fuchsia: 'บานเย็น',
Pink: 'ชมพู',
Rose: 'กุหลาบ',
'Primary color': 'สีหลัก'
'Primary color': 'สีหลัก',
Layout: 'เค้าโครง',
'Double column': 'สองคอลัมน์',
'Single column': 'คอลัมน์เดียว'
}
}

View File

@@ -472,6 +472,9 @@ export default {
Fuchsia: '紫红色',
Pink: '粉色',
Rose: '玫瑰色',
'Primary color': '主色调'
'Primary color': '主色调',
Layout: '布局',
'Double column': '双栏',
'Single column': '单栏'
}
}

View File

@@ -3,43 +3,56 @@ import { PRIMARY_COLORS, TPrimaryColor } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn } from '@/lib/utils'
import { useTheme } from '@/providers/ThemeProvider'
import { Monitor, Moon, Sun } from 'lucide-react'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { Monitor, Moon, Sun, Columns2, PanelLeft } from 'lucide-react'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const THEMES = [
{ key: 'system', label: 'System', icon: <Monitor className="w-5 h-5" /> },
{ key: 'light', label: 'Light', icon: <Sun className="w-5 h-5" /> },
{ key: 'dark', label: 'Dark', icon: <Moon className="w-5 h-5" /> },
{ key: 'pure-black', label: 'Pure Black', icon: <Moon className="w-5 h-5 fill-current" /> }
{ key: 'system', label: 'System', icon: <Monitor className="size-5" /> },
{ key: 'light', label: 'Light', icon: <Sun className="size-5" /> },
{ key: 'dark', label: 'Dark', icon: <Moon className="size-5" /> },
{ key: 'pure-black', label: 'Pure Black', icon: <Moon className="size-5 fill-current" /> }
] as const
const LAYOUTS = [
{ key: false, label: 'Double column', icon: <Columns2 className="size-5" /> },
{ key: true, label: 'Single column', icon: <PanelLeft className="size-5" /> }
] as const
const AppearanceSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
const { enableSingleColumnLayout, updateEnableSingleColumnLayout } = useUserPreferences()
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Appearance')}>
<div className="space-y-4 my-3">
<div className="flex flex-col gap-2 px-4">
<Label className="text-base">{t('Layout')}</Label>
<div className="grid grid-cols-2 gap-4 w-full">
{LAYOUTS.map(({ key, label, icon }) => (
<OptionButton
key={key.toString()}
isSelected={enableSingleColumnLayout === key}
icon={icon}
label={t(label)}
onClick={() => updateEnableSingleColumnLayout(key)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2 px-4">
<Label className="text-base">{t('Theme')}</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
{THEMES.map(({ key, label, icon }) => (
<button
<OptionButton
key={key}
onClick={() => {
setThemeSetting(key)
}}
className={cn(
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
themeSetting === key
? 'border-primary'
: 'border-border hover:border-muted-foreground/40'
)}
>
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
<span className="text-xs font-medium">{t(label)}</span>
</button>
isSelected={themeSetting === key}
icon={icon}
label={t(label)}
onClick={() => setThemeSetting(key)}
/>
))}
</div>
</div>
@@ -47,24 +60,20 @@ const AppearanceSettingsPage = forwardRef(({ index }: { index?: number }, ref) =
<Label className="text-base">{t('Primary color')}</Label>
<div className="grid grid-cols-4 gap-4 w-full">
{Object.entries(PRIMARY_COLORS).map(([key, config]) => (
<button
<OptionButton
key={key}
isSelected={primaryColor === key}
icon={
<div
className="size-8 rounded-full shadow-md"
style={{
backgroundColor: `hsl(${config.light.primary})`
}}
/>
}
label={t(config.name)}
onClick={() => setPrimaryColor(key as TPrimaryColor)}
className={cn(
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
primaryColor === key
? 'border-primary'
: 'border-border hover:border-muted-foreground/40'
)}
>
<div
className="size-8 rounded-full shadow-md"
style={{
backgroundColor: `hsl(${config.light.primary})`
}}
/>
<span className="text-xs font-medium">{t(config.name)}</span>
</button>
/>
))}
</div>
</div>
@@ -74,3 +83,28 @@ const AppearanceSettingsPage = forwardRef(({ index }: { index?: number }, ref) =
})
AppearanceSettingsPage.displayName = 'AppearanceSettingsPage'
export default AppearanceSettingsPage
const OptionButton = ({
isSelected,
onClick,
icon,
label
}: {
isSelected: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) => {
return (
<button
onClick={onClick}
className={cn(
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
)}
>
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
<span className="text-xs font-medium">{label}</span>
</button>
)
}

View File

@@ -11,6 +11,9 @@ type TUserPreferencesContext = {
sidebarCollapse: boolean
updateSidebarCollapse: (collapse: boolean) => void
enableSingleColumnLayout: boolean
updateEnableSingleColumnLayout: (enable: boolean) => void
}
const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined)
@@ -29,6 +32,9 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
)
const [muteMedia, setMuteMedia] = useState(true)
const [sidebarCollapse, setSidebarCollapse] = useState(storage.getSidebarCollapse())
const [enableSingleColumnLayout, setEnableSingleColumnLayout] = useState(
storage.getEnableSingleColumnLayout()
)
const updateNotificationListStyle = (style: TNotificationStyle) => {
setNotificationListStyle(style)
@@ -40,6 +46,11 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
storage.setSidebarCollapse(collapse)
}
const updateEnableSingleColumnLayout = (enable: boolean) => {
setEnableSingleColumnLayout(enable)
storage.setEnableSingleColumnLayout(enable)
}
return (
<UserPreferencesContext.Provider
value={{
@@ -48,7 +59,9 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
muteMedia,
updateMuteMedia: setMuteMedia,
sidebarCollapse,
updateSidebarCollapse: updateSidebarCollapse
updateSidebarCollapse,
enableSingleColumnLayout,
updateEnableSingleColumnLayout
}}
>
{children}

View File

@@ -51,6 +51,7 @@ class LocalStorageService {
private shownCreateWalletGuideToastPubkeys: Set<string> = new Set()
private sidebarCollapse: boolean = false
private primaryColor: TPrimaryColor = 'DEFAULT'
private enableSingleColumnLayout: boolean = false
constructor() {
if (!LocalStorageService.instance) {
@@ -201,6 +202,9 @@ class LocalStorageService {
this.primaryColor =
(window.localStorage.getItem(StorageKey.PRIMARY_COLOR) as TPrimaryColor) ?? 'DEFAULT'
this.enableSingleColumnLayout =
window.localStorage.getItem(StorageKey.ENABLE_SINGLE_COLUMN_LAYOUT) === 'true'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -502,6 +506,15 @@ class LocalStorageService {
this.primaryColor = color
window.localStorage.setItem(StorageKey.PRIMARY_COLOR, color)
}
getEnableSingleColumnLayout() {
return this.enableSingleColumnLayout
}
setEnableSingleColumnLayout(enable: boolean) {
this.enableSingleColumnLayout = enable
window.localStorage.setItem(StorageKey.ENABLE_SINGLE_COLUMN_LAYOUT, enable.toString())
}
}
const instance = new LocalStorageService()