feat: pure black

This commit is contained in:
codytseng
2025-10-18 17:54:28 +08:00
parent 057de9595b
commit b17846f264
27 changed files with 156 additions and 63 deletions

View File

@@ -28,6 +28,7 @@ import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { useTheme } from './providers/ThemeProvider'
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
@@ -104,6 +105,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const ignorePopStateRef = useRef(false)
useEffect(() => {
@@ -356,8 +358,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}}
>
<Sidebar />
<div className="grid grid-cols-2 gap-2 w-full pr-2 py-2">
<div className="rounded-lg shadow-lg bg-background overflow-hidden">
<div
className={cn(
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
>
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-lg shadow-lg'
)}
>
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
@@ -370,7 +382,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div>
))}
</div>
<div className="rounded-lg shadow-lg bg-background overflow-hidden">
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-lg shadow-lg'
)}
>
{secondaryStack.map((item, index) => (
<div
key={item.index}

View File

@@ -80,7 +80,7 @@ function AccountManagerNav({
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting,
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return

View File

@@ -1,16 +1,14 @@
// import { useTheme } from "next-themes"
import { useTheme } from '@/providers/ThemeProvider'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
// const { theme = "system" } = useTheme()
const { themeSetting } = useTheme()
return (
<Sonner
theme={themeSetting}
theme={themeSetting === 'pure-black' ? 'dark' : themeSetting}
className="toaster group"
richColors
mobileOffset={64}

View File

@@ -455,6 +455,8 @@ export default {
'Unpinned!': 'تم إلغاء التثبيت!',
'Failed to unpin: {{error}}': 'فشل في إلغاء التثبيت: {{error}}',
'Unpin from profile': 'إلغاء التثبيت من الملف الشخصي',
'Pin to profile': 'تثبيت في الملف الشخصي'
'Pin to profile': 'تثبيت في الملف الشخصي',
Appearance: 'المظهر',
'Pure Black': 'أسود نقي'
}
}

View File

@@ -469,6 +469,8 @@ export default {
'Unpinned!': 'Anheften aufgehoben!',
'Failed to unpin: {{error}}': 'Fehler beim Anheften aufheben: {{error}}',
'Unpin from profile': 'Vom Profil lösen',
'Pin to profile': 'An Profil anheften'
'Pin to profile': 'An Profil anheften',
Appearance: 'Aussehen',
'Pure Black': 'Reines Schwarz'
}
}

View File

@@ -454,6 +454,8 @@ export default {
'Unpinned!': 'Unpinned!',
'Failed to unpin: {{error}}': 'Failed to unpin: {{error}}',
'Unpin from profile': 'Unpin from profile',
'Pin to profile': 'Pin to profile'
'Pin to profile': 'Pin to profile',
Appearance: 'Appearance',
'Pure Black': 'Pure Black'
}
}

View File

@@ -463,6 +463,8 @@ export default {
'Unpinned!': '¡Desfijado!',
'Failed to unpin: {{error}}': 'Error al desfijar: {{error}}',
'Unpin from profile': 'Desfijar del perfil',
'Pin to profile': 'Fijar al perfil'
'Pin to profile': 'Fijar al perfil',
Appearance: 'Apariencia',
'Pure Black': 'Negro Puro'
}
}

View File

@@ -458,6 +458,8 @@ export default {
'Unpinned!': 'لغو پین شد!',
'Failed to unpin: {{error}}': 'لغو پین ناموفق بود: {{error}}',
'Unpin from profile': 'لغو پین از پروفایل',
'Pin to profile': 'پین به پروفایل'
'Pin to profile': 'پین به پروفایل',
Appearance: 'ظاهر',
'Pure Black': 'سیاه خالص'
}
}

View File

@@ -468,6 +468,8 @@ export default {
'Unpinned!': 'Retrait de lépingle effectué !',
'Failed to unpin: {{error}}': 'Échec du retrait de lépingle : {{error}}',
'Unpin from profile': 'Retirer lépingle du profil',
'Pin to profile': 'Épingler au profil'
'Pin to profile': 'Épingler au profil',
Appearance: 'Apparence',
'Pure Black': 'Noir pur'
}
}

View File

@@ -460,6 +460,8 @@ export default {
'Unpinned!': 'पिन हटा दिया गया!',
'Failed to unpin: {{error}}': 'पिन हटाने में असफल: {{error}}',
'Unpin from profile': 'प्रोफ़ाइल से पिन हटाएं',
'Pin to profile': 'प्रोफ़ाइल पर पिन करें'
'Pin to profile': 'प्रोफ़ाइल पर पिन करें',
Appearance: 'दिखावट',
'Pure Black': 'शुद्ध काला'
}
}

View File

@@ -463,6 +463,8 @@ export default {
'Unpinned!': 'Rimosso fissaggio!',
'Failed to unpin: {{error}}': 'Impossibile rimuovere il fissaggio: {{error}}',
'Unpin from profile': 'Rimuovi fissaggio dal profilo',
'Pin to profile': 'Fissa al profilo'
'Pin to profile': 'Fissa al profilo',
Appearance: 'Aspetto',
'Pure Black': 'Nero Puro'
}
}

View File

@@ -459,6 +459,8 @@ export default {
'Unpinned!': '固定が解除されました!',
'Failed to unpin: {{error}}': '固定解除に失敗しました: {{error}}',
'Unpin from profile': 'プロフィールから固定解除',
'Pin to profile': 'プロフィールに固定'
'Pin to profile': 'プロフィールに固定',
Appearance: '外観',
'Pure Black': '純黒'
}
}

View File

@@ -459,6 +459,8 @@ export default {
'Unpinned!': '고정 해제됨!',
'Failed to unpin: {{error}}': '고정 해제 실패: {{error}}',
'Unpin from profile': '프로필에서 고정 해제',
'Pin to profile': '프로필에 고정'
'Pin to profile': '프로필에 고정',
Appearance: '외관',
'Pure Black': '순수한 검은색'
}
}

View File

@@ -463,6 +463,8 @@ export default {
'Unpinned!': 'Odpięte!',
'Failed to unpin: {{error}}': 'Nie udało się przypiąć: {{error}}',
'Unpin from profile': 'Odpiń z profilu',
'Pin to profile': 'Przypnij do profilu'
'Pin to profile': 'Przypnij do profilu',
Appearance: 'Wygląd',
'Pure Black': 'Czysta Czerń'
}
}

View File

@@ -460,6 +460,8 @@ export default {
'Unpinned!': 'Desafixado!',
'Failed to unpin: {{error}}': 'Falha ao desafixar: {{error}}',
'Unpin from profile': 'Desafixar do perfil',
'Pin to profile': 'Fixar no perfil'
'Pin to profile': 'Fixar no perfil',
Appearance: 'Aparência',
'Pure Black': 'Preto Puro'
}
}

View File

@@ -463,6 +463,8 @@ export default {
'Unpinned!': 'Desafixado!',
'Failed to unpin: {{error}}': 'Falha ao desafixar: {{error}}',
'Unpin from profile': 'Desafixar do perfil',
'Pin to profile': 'Fixar no perfil'
'Pin to profile': 'Fixar no perfil',
Appearance: 'Aparência',
'Pure Black': 'Preto Puro'
}
}

View File

@@ -465,6 +465,8 @@ export default {
'Unpinned!': 'Откреплено!',
'Failed to unpin: {{error}}': 'Не удалось открепить: {{error}}',
'Unpin from profile': 'Открепить из профиля',
'Pin to profile': 'Закрепить в профиле'
'Pin to profile': 'Закрепить в профиле',
Appearance: 'Внешний вид',
'Pure Black': 'Чистый Черный'
}
}

View File

@@ -453,6 +453,8 @@ export default {
'Unpinned!': 'ยกเลิกปักหมุดแล้ว!',
'Failed to unpin: {{error}}': 'ไม่สามารถยกเลิกปักหมุดได้: {{error}}',
'Unpin from profile': 'ยกเลิกปักหมุดจากโปรไฟล์',
'Pin to profile': 'ปักหมุดไปที่โปรไฟล์'
'Pin to profile': 'ปักหมุดไปที่โปรไฟล์',
Appearance: 'รูปลักษณ์',
'Pure Black': 'สีดำล้วน'
}
}

View File

@@ -451,6 +451,8 @@ export default {
'Unpinned!': '已取消置顶!',
'Failed to unpin: {{error}}': '取消置顶失败: {{error}}',
'Unpin from profile': '从个人资料取消置顶',
'Pin to profile': '置顶到个人资料'
'Pin to profile': '置顶到个人资料',
Appearance: '外观',
'Pure Black': '纯黑'
}
}

View File

@@ -134,6 +134,12 @@
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
.dark.pure-black {
--surface-background: 0 0% 0%;
--background: 0 0% 0%;
--card: 0 0% 0%;
--popover: 0 0% 0%;
}
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.5);

View File

@@ -69,6 +69,7 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
export const toWallet = () => '/settings/wallet'
export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toAppearanceSettings = () => '/settings/appearance'
export const toTranslation = () => '/settings/translation'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`

View File

@@ -0,0 +1,48 @@
import { Label } from '@/components/ui/label'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn } from '@/lib/utils'
import { useTheme } from '@/providers/ThemeProvider'
import { Monitor, Moon, Sun } 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" /> }
] as const
const AppearanceSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { themeSetting, setThemeSetting } = useTheme()
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Appearance')}>
<div className="space-y-4 mt-3">
<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
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-primary/60'
)}
>
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
<span className="text-xs font-medium">{t(label)}</span>
</button>
))}
</div>
</div>
</div>
</SecondaryPageLayout>
)
})
AppearanceSettingsPage.displayName = 'AppearanceSettingsPage'
export default AppearanceSettingsPage

View File

@@ -6,7 +6,6 @@ import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { TMediaAutoLoadPolicy } from '@/types'
@@ -18,7 +17,6 @@ import { useTranslation } from 'react-i18next'
const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t, i18n } = useTranslation()
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme()
const {
autoplay,
setAutoplay,
@@ -57,21 +55,6 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</SelectContent>
</Select>
</SettingItem>
<SettingItem>
<Label htmlFor="theme" className="text-base font-normal">
{t('Theme')}
</Label>
<Select defaultValue="system" value={themeSetting} onValueChange={setThemeSetting}>
<SelectTrigger id="theme" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('System')}</SelectItem>
<SelectItem value="light">{t('Light')}</SelectItem>
<SelectItem value="dark">{t('Dark')}</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem>
<Label htmlFor="notification-list-style" className="text-base font-normal">
<div>{t('Notification list style')}</div>

View File

@@ -2,6 +2,7 @@ import AboutInfoDialog from '@/components/AboutInfoDialog'
import Donation from '@/components/Donation'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import {
toAppearanceSettings,
toGeneralSettings,
toPostSettings,
toRelaySettings,
@@ -18,6 +19,7 @@ import {
Info,
KeyRound,
Languages,
Palette,
PencilLine,
Server,
Settings2,
@@ -42,6 +44,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
<SettingItem className="clickable" onClick={() => push(toAppearanceSettings())}>
<div className="flex items-center gap-4">
<Palette />
<div>{t('Appearance')}</div>
</div>
<ChevronRight />
</SettingItem>
<SettingItem className="clickable" onClick={() => push(toRelaySettings())}>
<div className="flex items-center gap-4">
<Server />

View File

@@ -2,14 +2,8 @@ import storage from '@/services/local-storage.service'
import { TTheme, TThemeSetting } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react'
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: TTheme
}
type ThemeProviderState = {
themeSetting: TThemeSetting
theme: TTheme
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
}
@@ -19,7 +13,7 @@ function getSystemTheme() {
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
(localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system'
)
@@ -39,7 +33,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
}, [])
useEffect(() => {
if (themeSetting !== 'system') return
if (themeSetting !== 'system') {
setTheme(themeSetting)
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
@@ -57,27 +54,27 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const updateTheme = async () => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
root.classList.add(theme === 'pure-black' ? 'dark' : theme)
if (theme === 'pure-black') {
root.classList.add('pure-black')
} else {
root.classList.remove('pure-black')
}
}
updateTheme()
}, [theme])
const updateThemeSetting = async (themeSetting: TThemeSetting) => {
storage.setThemeSetting(themeSetting)
setThemeSetting(themeSetting)
}
return (
<ThemeProviderContext.Provider
{...props}
value={{
themeSetting: themeSetting,
theme: theme,
setThemeSetting: async (themeSetting: TThemeSetting) => {
storage.setThemeSetting(themeSetting)
setThemeSetting(themeSetting)
if (themeSetting === 'system') {
setTheme(getSystemTheme())
return
}
setTheme(themeSetting)
}
setThemeSetting: updateThemeSetting
}}
>
{children}

View File

@@ -1,5 +1,6 @@
import { match } from 'path-to-regexp'
import { isValidElement } from 'react'
import AppearanceSettingsPage from './pages/secondary/AppearanceSettingsPage'
import FollowingListPage from './pages/secondary/FollowingListPage'
import GeneralSettingsPage from './pages/secondary/GeneralSettingsPage'
import MuteListPage from './pages/secondary/MuteListPage'
@@ -34,6 +35,7 @@ const ROUTES = [
{ path: '/settings/wallet', element: <WalletPage /> },
{ path: '/settings/posts', element: <PostSettingsPage /> },
{ path: '/settings/general', element: <GeneralSettingsPage /> },
{ path: '/settings/appearance', element: <AppearanceSettingsPage /> },
{ path: '/settings/translation', element: <TranslationPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> },

View File

@@ -71,8 +71,8 @@ export type TConfig = {
theme: TThemeSetting
}
export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'
export type TThemeSetting = 'light' | 'dark' | 'system' | 'pure-black'
export type TTheme = 'light' | 'dark' | 'pure-black'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>