From d0ea3298876d3409860be2a841fe8b8a042146de Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 01:51:07 +0900 Subject: [PATCH] #34 --- backend/app/models/setting.rb | 3 + frontend/src/App.tsx | 71 +- frontend/src/components/TagLink.tsx | 30 +- frontend/src/components/common/TabGroup.tsx | 35 +- .../components/users/UserSettingsProvider.tsx | 34 +- frontend/src/index.css | 25 + frontend/src/lib/settings.ts | 499 ++++++++++- frontend/src/pages/posts/PostListPage.tsx | 90 +- frontend/src/pages/posts/PostSearchPage.tsx | 88 +- frontend/src/pages/tags/TagListPage.tsx | 88 +- frontend/src/pages/users/SettingPage.tsx | 833 +++++++++++------- frontend/src/pages/wiki/WikiEditPage.tsx | 28 +- frontend/src/pages/wiki/WikiNewPage.tsx | 28 +- frontend/tailwind.config.js | 1 + 14 files changed, 1208 insertions(+), 645 deletions(-) diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index 425e152..cc6618f 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -1,5 +1,8 @@ class Setting < ApplicationRecord THEMES = ['system', 'light', 'dark'].freeze + + # These remain in the typed settings schema to preserve existing backend + # work. The common `/users/settings` page currently surfaces only `theme`. AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 604cb2c..d90891c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,9 +10,14 @@ import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' -import { UserSettingsProvider } from '@/components/users/UserSettingsProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import { + applyClientAppearance, + fetchUserSettings, + getClientThemeMode, + seedClientThemeMode, +} from '@/lib/settings' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' @@ -105,6 +110,19 @@ const App: FC = () => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) + useEffect (() => { + applyClientAppearance () + + const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)') + const handleThemeChange = () => { + if (getClientThemeMode () === 'system') + applyClientAppearance () + } + + mediaQuery.addEventListener ('change', handleThemeChange) + return () => mediaQuery.removeEventListener ('change', handleThemeChange) + }, []) + useEffect (() => { const createUser = async () => { const data = await apiPost<{ code: string; user: User }> ('/users') @@ -138,6 +156,23 @@ const App: FC = () => { createUser () }, []) + useEffect (() => { + if (!(user)) + return + + void (async () => { + try + { + const settings = await fetchUserSettings () + seedClientThemeMode (settings.theme) + applyClientAppearance () + } + catch + { + } + }) () + }, [user]) + switch (status) { case 503: @@ -146,26 +181,24 @@ const App: FC = () => { return ( <> - - - {import.meta.env.DEV && } + + {import.meta.env.DEV && } - - - - - - - - + + + + + + + + - - - - + + + ) } diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 180892b..615f7d1 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -1,9 +1,8 @@ import PrefetchLink from '@/components/PrefetchLink' import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText' -import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { cn } from '@/lib/utils' -import type { ComponentProps, FC, HTMLAttributes } from 'react' +import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react' import type { Tag } from '@/types' @@ -36,15 +35,16 @@ const TagLink: FC = ({ tag, withWiki = true, withCount = true, className, + style, title, ...props }) => { - const spanClass = cn ( - `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, - `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) - const linkClass = cn ( - spanClass, - `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, - `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`) + const colourStyle = { + '--tag-link-colour': `var(--tag-colour-${ tag.category })`, + '--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`, + ...style, + } as CSSProperties + const spanClass = 'tag-link-colour' + const linkClass = 'tag-link-colour tag-link-hover-colour' const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' const rootClass = 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline' @@ -63,7 +63,8 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( @@ -71,13 +72,15 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( + className={linkClass} + style={colourStyle}> ? ))) : ( @@ -120,6 +123,7 @@ const TagLink: FC = ({ tag, = ({ tag, to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} title={textTitle} className={cn (linkClass, textClass, className)} + style={colourStyle} {...props}> = ({ tag, <>{children} -const TabGroup: FC = ({ children }) => { +const TabGroup: FC = ({ children, rightAddon }) => { const tabs = React.Children.toArray (children) as React.ReactElement[] const [current, setCurrent] = useState (() => { @@ -22,17 +25,23 @@ const TabGroup: FC = ({ children }) => { return (
-
- {tabs.map ((tab, i) => ( - { - ev.preventDefault () - setCurrent (i) - }}> - {tab.props.name} - ))} +
+ + {rightAddon && ( +
+ {rightAddon} +
)}
{tabs[current]} diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx index 2d059bc..3966f59 100644 --- a/frontend/src/components/users/UserSettingsProvider.tsx +++ b/frontend/src/components/users/UserSettingsProvider.tsx @@ -1,6 +1,10 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { DEFAULT_USER_SETTINGS, fetchUserSettings } from '@/lib/settings' +import { + applyClientAppearance, + DEFAULT_USER_SETTINGS, + fetchUserSettings, +} from '@/lib/settings' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' @@ -16,29 +20,6 @@ type ContextValue = { const UserSettingsContext = createContext (null) -const applyTheme = (theme: UserSettings['theme']) => { - const root = document.documentElement - const media = window.matchMedia ('(prefers-color-scheme: dark)') - - const sync = () => { - const dark = theme === 'dark' || (theme === 'system' && media.matches) - root.classList.toggle ('dark', dark) - root.style.colorScheme = dark ? 'dark' : 'light' - } - - sync () - - if (theme !== 'system') - return () => { } - - const onChange = () => sync () - media.addEventListener ('change', onChange) - return () => { - media.removeEventListener ('change', onChange) - } -} - - export const UserSettingsProvider: FC<{ children: ReactNode user: User | null }> = ({ children, user }) => { @@ -87,7 +68,10 @@ export const UserSettingsProvider: FC<{ } }, [user]) - useEffect (() => applyTheme (settings.theme), [settings.theme]) + useEffect (() => { + void settings.theme + applyClientAppearance () + }, [settings.theme]) const value = useMemo (() => ({ error, diff --git a/frontend/src/index.css b/frontend/src/index.css index ea2cbe8..b41fb31 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -29,6 +29,21 @@ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; + + --tag-colour-deerjikist: #9f1239; + --tag-colour-deerjikist-hover: #b62a51; + --tag-colour-meme: #6b21a8; + --tag-colour-meme-hover: #8238bf; + --tag-colour-character: #4d7c0f; + --tag-colour-character-hover: #649326; + --tag-colour-general: #155e75; + --tag-colour-general-hover: #2c758c; + --tag-colour-material: #c2410c; + --tag-colour-material-hover: #d95823; + --tag-colour-meta: #a16207; + --tag-colour-meta-hover: #b8791e; + --tag-colour-nico: #4b5563; + --tag-colour-nico-hover: #626c7a; } .dark @@ -161,6 +176,16 @@ body position: relative; } +.tag-link-colour +{ + color: var(--tag-link-colour); +} + +.tag-link-hover-colour:hover +{ + color: var(--tag-link-hover-colour) !important; +} + .tag-marquee__animated { position: absolute; diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index dce8538..0b064dd 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -1,10 +1,27 @@ +import { CATEGORIES } from '@/consts' import { apiGet, apiPatch } from '@/lib/api' -import type { FetchPostsOrder, FetchTagsOrder } from '@/types' +import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' -// DB-backed user settings. These are the cross-device preferences worth syncing. export type UserSettings = { - theme: 'system' | 'light' | 'dark' } + theme: 'system' | 'light' | 'dark' + autoFetchTitle: 'auto' | 'manual' | 'off' + autoFetchThumbnail: 'auto' | 'manual' | 'off' + wikiEditorMode: 'split' | 'write' | 'preview' } + +export type ThemeTagColours = Record +export type BuiltinThemeId = 'light' | 'dark' +export type ThemeId = BuiltinThemeId | `custom:${ string }` +export type ActiveThemeId = ThemeId | 'system' +export type ResolvedTheme = 'light' | 'dark' +export type ClientTheme = { + id: ThemeId + name: string + builtin: boolean + baseTheme: BuiltinThemeId + tagColours: ThemeTagColours } +export type CustomClientTheme = + ClientTheme & { builtin: false } export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' @@ -21,19 +38,32 @@ type ClientListSettings = { limit?: ClientListLimit order?: string } -type ClientKeyboardSettings = { - shortcutsEnabled?: boolean } +export type ClientAppearanceSettings = { + activeThemeId?: ActiveThemeId + customThemes?: Record + // Legacy fields kept for migration from the earlier appearance model. + theme?: UserSettings['theme'] + tagColours?: Partial } -// Browser-local settings. These stay device-specific and are not synced to DB. +// DB-backed settings. At present only `theme` is surfaced in `/users/settings`. +// The remaining fields are retained for existing backend work and are not wired +// to the common settings UI yet. +export const DEFAULT_USER_SETTINGS: UserSettings = { + theme: 'system', + autoFetchTitle: 'manual', + autoFetchThumbnail: 'manual', + wikiEditorMode: 'split' } + +// Browser-local settings only. These are device or screen specific. export type ClientSettings = { - panes?: Record - lists?: Partial> - keyboard?: ClientKeyboardSettings - theatre?: { + appearance?: ClientAppearanceSettings + panes?: Record + lists?: Partial> + theatre?: { layoutMode?: TheatreLayoutMode tagFlow?: TheatreTagFlow } - gekanator?: { + gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode } reducedMotion?: string @@ -41,9 +71,6 @@ export type ClientSettings = { thumbnailMode?: string } export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' -export const DEFAULT_USER_SETTINGS: UserSettings = { - theme: 'system' } - export const THEME_OPTIONS = ['system', 'light', 'dark'] as const export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const export const POST_LIST_ORDER_OPTIONS = [ @@ -72,21 +99,84 @@ export const TAG_LIST_ORDER_OPTIONS = [ ] as const satisfies readonly FetchTagsOrder[] export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' -export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'name:asc' +export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' +export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#9f1239', + meme: '#6b21a8', + character: '#4d7c0f', + general: '#155e75', + material: '#c2410c', + meta: '#a16207', + nico: '#4b5563', +} +export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#fb7185', + meme: '#d8b4fe', + character: '#bef264', + general: '#67e8f9', + material: '#fdba74', + meta: '#fde047', + nico: '#e5e7eb', +} +export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS +export const BUILTIN_THEMES: Record = { + light: { + id: 'light', + name: 'ライト・モード', + builtin: true, + baseTheme: 'light', + tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS, + }, + dark: { + id: 'dark', + name: 'ダーク・モード', + builtin: true, + baseTheme: 'dark', + tagColours: DEFAULT_DARK_THEME_TAG_COLOURS, + }, +} const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1' +const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated' -export const fetchUserSettings = async (): Promise => - await apiGet ('/users/settings') +export const fetchUserSettings = async (): Promise => { + const raw = await apiGet<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] + }> ('/users/settings') + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode, + } +} export const updateUserSettings = async ( settings: Partial<{ theme: UserSettings['theme'] }>, -): Promise => await apiPatch ('/users/settings', settings) +): Promise => { + const raw = await apiPatch<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] + }> ('/users/settings', settings) + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode, + } +} const safeParseClientSettings = (raw: string | null): ClientSettings => { @@ -127,6 +217,365 @@ const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null +const normaliseHexColour = (value: string): string | null => + /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null + + +const isBuiltinThemeId = (value: string): value is BuiltinThemeId => + value === 'light' || value === 'dark' + + +const isCustomThemeId = (value: string): value is `custom:${ string }` => + value.startsWith ('custom:') + + +const themeCopyName = (baseTheme: BuiltinThemeId): string => + `${ BUILTIN_THEMES[baseTheme].name }のコピー` + + +const appearanceFromSettings = (): ClientAppearanceSettings => + loadClientSettings ().appearance ?? { } + + +export const hasStoredClientThemeSelection = (): boolean => { + const appearance = appearanceFromSettings () + return ( + appearance.activeThemeId != null + || appearance.customThemes != null + || appearance.theme != null + || appearance.tagColours != null + ) +} + + +const mixHexColour = ( + source: string, + target: string, + ratio: number, +): string => { + const clampedRatio = Math.max (0, Math.min (1, ratio)) + const src = source.slice (1) + const dst = target.slice (1) + const channels = [0, 2, 4].map (offset => { + const srcValue = parseInt (src.slice (offset, offset + 2), 16) + const dstValue = parseInt (dst.slice (offset, offset + 2), 16) + const mixed = Math.round ( + srcValue + (dstValue - srcValue) * clampedRatio, + ) + return mixed.toString (16).padStart (2, '0') + }) + + return `#${ channels.join ('') }` +} + + +export const getClientThemeMode = (): UserSettings['theme'] => + (() => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if (appearance.activeThemeId === 'system') + return 'system' + if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark') + return appearance.activeThemeId + if ( + typeof appearance.activeThemeId === 'string' + && customThemes[appearance.activeThemeId] != null + ) + return customThemes[appearance.activeThemeId].baseTheme + + return appearance.theme ?? DEFAULT_USER_SETTINGS.theme + }) () + + +export const setClientThemeMode = (theme: UserSettings['theme']): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + activeThemeId: theme, + theme, + }, + })) +} + + +export const seedClientThemeMode = (theme: UserSettings['theme']): void => { + if (hasStoredClientThemeSelection ()) + return + + setClientThemeMode (theme) +} + + +const prefersDarkTheme = (): boolean => + window.matchMedia ('(prefers-color-scheme: dark)').matches + + +export const resolveThemeMode = ( + theme: UserSettings['theme'], +): ResolvedTheme => + theme === 'system' + ? (prefersDarkTheme () ? 'dark' : 'light') + : theme + + +export const defaultThemeTagColoursFor = ( + theme: ResolvedTheme, +): ThemeTagColours => + theme === 'dark' + ? DEFAULT_DARK_THEME_TAG_COLOURS + : DEFAULT_LIGHT_THEME_TAG_COLOURS + + +const normaliseThemeTagColours = ( + input: Partial | null | undefined, + fallback: ThemeTagColours, +): ThemeTagColours => + CATEGORIES.reduce ( + (colours, category) => { + const candidate = input?.[category] + colours[category] = + typeof candidate === 'string' && normaliseHexColour (candidate) != null + ? candidate + : fallback[category] + return colours + }, + { ...fallback }, + ) + + +const normaliseCustomTheme = ( + rawTheme: unknown, +): CustomClientTheme | null => { + if (!(rawTheme) || typeof rawTheme !== 'object') + return null + + const theme = rawTheme as Record + const id = typeof theme.id === 'string' ? theme.id : null + const name = typeof theme.name === 'string' ? theme.name : null + const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null + if ( + id == null + || !(isCustomThemeId (id)) + || name == null + || baseTheme == null + || !(isBuiltinThemeId (baseTheme)) + ) + return null + + return { + id, + name, + builtin: false, + baseTheme, + tagColours: normaliseThemeTagColours ( + theme.tagColours as Partial | undefined, + defaultThemeTagColoursFor (baseTheme), + ), + } +} + + +export const getClientCustomThemes = (): Record => { + const appearance = appearanceFromSettings () + const customThemes = appearance.customThemes + + if (customThemes && typeof customThemes === 'object') + { + return Object.entries (customThemes).reduce> ( + (themes, [key, rawTheme]) => { + const theme = normaliseCustomTheme (rawTheme) + if (theme != null) + themes[key] = theme + return themes + }, + { }, + ) + } + + const legacyTagColours = appearance.tagColours + if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0) + return { } + + const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme) + return { + [LEGACY_MIGRATED_THEME_ID]: { + id: LEGACY_MIGRATED_THEME_ID, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tagColours: normaliseThemeTagColours ( + legacyTagColours, + defaultThemeTagColoursFor (baseTheme), + ), + }, + } +} + + +export const getClientActiveThemeId = (): ActiveThemeId => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if ( + typeof appearance.activeThemeId === 'string' + && ( + appearance.activeThemeId === 'system' + || isBuiltinThemeId (appearance.activeThemeId) + || customThemes[appearance.activeThemeId] != null + ) + ) + return appearance.activeThemeId + + if ( + Object.keys (customThemes).length > 0 + && appearance.tagColours != null + && Object.keys (appearance.tagColours).length > 0 + ) + return LEGACY_MIGRATED_THEME_ID + + if (typeof appearance.theme === 'string' && ( + appearance.theme === 'system' + || appearance.theme === 'light' + || appearance.theme === 'dark' + )) + return appearance.theme + + return DEFAULT_USER_SETTINGS.theme +} + + +export const getThemeById = ( + themeId: ThemeId, + customThemes: Record, +): ClientTheme => + isBuiltinThemeId (themeId) + ? BUILTIN_THEMES[themeId] + : (customThemes[themeId] ?? BUILTIN_THEMES.light) + + +export const getResolvedThemeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): ClientTheme => + activeThemeId === 'system' + ? BUILTIN_THEMES[resolveThemeMode ('system')] + : getThemeById (activeThemeId, customThemes) + + +export const deriveUserThemeModeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): UserSettings['theme'] => { + if (activeThemeId === 'system') + return 'system' + + return getThemeById (activeThemeId, customThemes).baseTheme +} + + +export const createCustomThemeFromBase = ( + baseTheme: BuiltinThemeId, + tagColours?: ThemeTagColours, +): CustomClientTheme => { + const id = `custom:${ crypto.randomUUID () }` as ThemeId + return { + id, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tagColours: tagColours ?? { ...defaultThemeTagColoursFor (baseTheme) }, + } +} + + +export const getClientThemeTagColours = ( + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): ThemeTagColours => { + return { + ...getResolvedThemeFromSelection (activeThemeId, customThemes).tagColours, + } +} + + +export const setClientAppearanceThemes = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + activeThemeId, + customThemes, + theme: undefined, + tagColours: undefined, + }, + })) +} + + +export const setClientThemeTagColours = ( + tagColours: ThemeTagColours, + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): void => { + if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId)) + return + + setClientAppearanceThemes ( + activeThemeId, + { + ...customThemes, + [activeThemeId]: { + ...customThemes[activeThemeId], + tagColours, + }, + }, + ) +} + + +export const applyThemeMode = (theme: UserSettings['theme']): void => { + const root = document.documentElement + const resolvedTheme = resolveThemeMode (theme) + + root.classList.toggle ('dark', resolvedTheme === 'dark') + root.style.colorScheme = resolvedTheme +} + + +export const applyThemeTagColours = (tagColours: ThemeTagColours): void => { + const root = document.documentElement + + CATEGORIES.forEach (category => { + const colour = tagColours[category] + root.style.setProperty (`--tag-colour-${ category }`, colour) + root.style.setProperty ( + `--tag-colour-${ category }-hover`, + mixHexColour (colour, '#ffffff', .18), + ) + }) +} + + +export const applyThemeSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): void => { + const theme = getResolvedThemeFromSelection (activeThemeId, customThemes) + applyThemeMode (theme.baseTheme) + applyThemeTagColours (theme.tagColours) +} + + +export const applyClientAppearance = (): void => { + applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ()) +} + + export const getClientListLimit = ( listKey: ClientListKey, ): ClientListLimit | null => { @@ -162,20 +611,6 @@ export const setClientListSettings = ( } -export const getClientKeyboardShortcutsEnabled = (): boolean => - loadClientSettings ().keyboard?.shortcutsEnabled ?? true - - -export const setClientKeyboardShortcutsEnabled = ( - enabled: boolean, -): void => { - updateClientSettings (settings => ({ - ...settings, - keyboard: { ...(settings.keyboard ?? { }), shortcutsEnabled: enabled }, - })) -} - - const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) return ( diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index be7c8fa..ab5330d 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -7,7 +7,6 @@ import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' import TagSidebar from '@/components/TagSidebar' import WikiBody from '@/components/WikiBody' -import FormField from '@/components/common/FormField' import Pagination from '@/components/common/Pagination' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' @@ -16,43 +15,21 @@ import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' import { DEFAULT_POST_LIST_LIMIT, - DEFAULT_POST_LIST_ORDER, getClientListLimit, - getClientListOrder, LIST_LIMIT_OPTIONS, - POST_LIST_ORDER_OPTIONS, setClientListSettings, } from '@/lib/settings' -import { inputClass } from '@/lib/utils' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' -import type { FetchPostsOrder, WikiPage } from '@/types' - -const postOrderLabel: Record = { - 'title:asc': 'タイトル昇順', - 'title:desc': 'タイトル降順', - 'url:asc': 'URL 昇順', - 'url:desc': 'URL 降順', - 'original_created_at:asc': 'オリジナル投稿日時 昇順', - 'original_created_at:desc': 'オリジナル投稿日時 降順', - 'created_at:asc': '投稿日 昇順', - 'created_at:desc': '投稿日 降順', - 'updated_at:asc': '更新日 昇順', - 'updated_at:desc': '更新日 降順', -} +import type { WikiPage } from '@/types' const parseLimit = (value: string | null): 20 | 50 | 100 | null => { const n = Number (value) return n === 20 || n === 50 || n === 100 ? n : null } -const parseOrder = (value: string | null): FetchPostsOrder | null => - POST_LIST_ORDER_OPTIONS.some (option => option === value) - ? value as FetchPostsOrder - : null - const PostListPage: FC = () => { const containerRef = useRef (null) @@ -73,11 +50,7 @@ const PostListPage: FC = () => { ?? getClientListLimit ('postList') ?? DEFAULT_POST_LIST_LIMIT ) - const order = ( - parseOrder (query.get ('order')) - ?? getClientListOrder ('postList') - ?? DEFAULT_POST_LIST_ORDER - ) + const order = 'original_created_at:desc' as const const keys = { tags: tagsKey, match, page, limit, @@ -92,14 +65,12 @@ const PostListPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { - setClientListSettings ('postList', { limit, order }) - }, [limit, order]) + setClientListSettings ('postList', { limit }) + }, [limit]) - const updateListQuery = (next: { limit?: 20 | 50 | 100 - order?: FetchPostsOrder }) => { + const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => { const params = new URLSearchParams (location.search) params.set ('limit', String (next.limit ?? limit)) - params.set ('order', next.order ?? order) params.set ('page', '1') navigate (`${ location.pathname }?${ params.toString () }`) } @@ -146,39 +117,24 @@ const PostListPage: FC = () => { }}/> -
- - {() => ( - )} - - - - {() => ( - )} - -
- - + + 件数 + + + }> {posts.length > 0 ? ( diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 0372361..4ef0ba1 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -22,7 +22,6 @@ import { getClientListLimit, getClientListOrder, LIST_LIMIT_OPTIONS, - POST_LIST_ORDER_OPTIONS, setClientListSettings, } from '@/lib/settings' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' @@ -40,28 +39,30 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { qs.set (k, t) } -const postOrderLabel: Record = { - 'title:asc': 'タイトル昇順', - 'title:desc': 'タイトル降順', - 'url:asc': 'URL 昇順', - 'url:desc': 'URL 降順', - 'original_created_at:asc': 'オリジナル投稿日時 昇順', - 'original_created_at:desc': 'オリジナル投稿日時 降順', - 'created_at:asc': '投稿日 昇順', - 'created_at:desc': '投稿日 降順', - 'updated_at:asc': '更新日 昇順', - 'updated_at:desc': '更新日 降順', -} - const parseLimit = (value: string | null): 20 | 50 | 100 | null => { const n = Number (value) return n === 20 || n === 50 || n === 100 ? n : null } -const parseOrder = (value: string | null): FetchPostsOrder | null => - POST_LIST_ORDER_OPTIONS.some (option => option === value) - ? value as FetchPostsOrder - : null +const parseOrder = (value: string | null): FetchPostsOrder | null => { + switch (value) + { + case 'title:asc': + case 'title:desc': + case 'url:asc': + case 'url:desc': + case 'original_created_at:asc': + case 'original_created_at:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} const PostSearchPage: FC = () => { @@ -188,41 +189,26 @@ const PostSearchPage: FC = () => {
- 広場検索 +
+ 広場検索 + +
-
- - {() => ( - )} - - - - {() => ( - )} - -
- {/* タイトル */} {({ invalid }) => ( diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index a322b80..b01350e 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -21,7 +21,6 @@ import { getClientListOrder, LIST_LIMIT_OPTIONS, setClientListSettings, - TAG_LIST_ORDER_OPTIONS, } from '@/lib/settings' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -44,28 +43,30 @@ const boolFromQuery = (value: string | null): boolean => const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' -const tagOrderLabel: Record = { - 'name:asc': '名前昇順', - 'name:desc': '名前降順', - 'category:asc': 'カテゴリ昇順', - 'category:desc': 'カテゴリ降順', - 'post_count:asc': '件数昇順', - 'post_count:desc': '件数降順', - 'created_at:asc': '最初の記載日時 昇順', - 'created_at:desc': '最初の記載日時 降順', - 'updated_at:asc': '更新日時 昇順', - 'updated_at:desc': '更新日時 降順', -} - const parseLimit = (value: string | null): 20 | 50 | 100 | null => { const n = Number (value) return n === 20 || n === 50 || n === 100 ? n : null } -const parseOrder = (value: string | null): FetchTagsOrder | null => - TAG_LIST_ORDER_OPTIONS.some (option => option === value) - ? value as FetchTagsOrder - : null +const parseOrder = (value: string | null): FetchTagsOrder | null => { + switch (value) + { + case 'name:asc': + case 'name:desc': + case 'category:asc': + case 'category:desc': + case 'post_count:asc': + case 'post_count:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} const TagListPage: FC = () => { @@ -192,41 +193,26 @@ const TagListPage: FC = () => {
- タグ +
+ タグ + +
-
- - {() => ( - )} - - - - {() => ( - )} - -
- {/* 名前 */} {({ invalid }) => ( diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 36ec88b..ee94c08 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,11 +1,6 @@ -import type { - Dispatch, - FC, - KeyboardEvent, - SetStateAction, -} from 'react' +import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -14,92 +9,84 @@ import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' +import TagLink from '@/components/TagLink' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' +import { CATEGORY_NAMES, CATEGORIES } from '@/consts' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' import { - AUTO_FETCH_OPTIONS, + applyThemeSelection, + BUILTIN_THEMES, + createCustomThemeFromBase, + defaultThemeTagColoursFor, DEFAULT_USER_SETTINGS, - THEME_OPTIONS, - WIKI_EDITOR_MODE_OPTIONS, + deriveUserThemeModeFromSelection, + fetchUserSettings, + hasStoredClientThemeSelection, + getClientActiveThemeId, + getClientCustomThemes, + getResolvedThemeFromSelection, + setClientAppearanceThemes, updateUserSettings, } from '@/lib/settings' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' -import type { User } from '@/types' -import type { UserSettings } from '@/lib/settings' +import type { Category, Tag, User } from '@/types' +import type { + ActiveThemeId, + ClientTheme, + CustomClientTheme, + ThemeTagColours, + UserSettings, +} from '@/lib/settings' -type Props = { user: User | null - setUser: Dispatch> } +type Props = { + user: User | null + setUser: Dispatch> +} type UserFormField = 'name' -type SettingsFormField = - | 'theme' - | 'autoFetchTitle' - | 'autoFetchThumbnail' - | 'wikiEditorMode' - -type SettingsTab = 'account' | 'theme' | 'keyboard' | 'editing' | 'wiki' +type SettingsFormField = 'theme' +type SettingsTab = 'account' | 'theme' | 'keyboard' type TabSpec = { id: SettingsTab label: string } -type NameFieldErrors = Partial> -type SettingsFieldErrors = Partial> - type SharedSectionProps = { - draftSettings: UserSettings + user: User name: string - nameBaseErrors: string[] - nameFieldErrors: NameFieldErrors - onDraftSettingsChange: Dispatch> - onInheritOpen: () => void onNameChange: (value: string) => void - onSettingsSubmit: () => void + onInheritOpen: () => void onUserCodeOpen: () => void onUserSubmit: () => void + userBaseErrors: string[] + userFieldErrors: Partial> + draftActiveThemeId: ActiveThemeId + draftCustomThemes: Record + selectedTheme: ClientTheme + draftTagColours: ThemeTagColours + onThemeChange: (themeId: ActiveThemeId) => void + onTagColourChange: (category: Category, colour: string) => void + onResetAllTagColours: () => void + onResetTagColour: (category: Category) => void + onThemeSubmit: () => void settingsBaseErrors: string[] - settingsFieldErrors: SettingsFieldErrors - user: User } - -const sectionClassName = - 'space-y-4 rounded-xl border border-border bg-background/80 p-4' - -const desktopTabRailClassName = [ - 'hidden md:flex md:justify-center', -].join (' ') - -const desktopTabRailInnerClassName = [ - 'grid w-fit grid-cols-[12rem_minmax(0,36rem)] items-start gap-6', -].join (' ') + settingsFieldErrors: Partial> +} const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, { id: 'theme', label: 'テーマ' }, { id: 'keyboard', label: 'キーボード' }, - { id: 'editing', label: '編輯支援' }, - { id: 'wiki', label: 'Wiki' }, ] -const settingsTabByField: Record = { - theme: 'theme', - autoFetchTitle: 'editing', - autoFetchThumbnail: 'editing', - wikiEditorMode: 'wiki', -} - -const settingsFieldOrder: SettingsFormField[] = [ - 'theme', - 'autoFetchTitle', - 'autoFetchThumbnail', - 'wikiEditorMode', -] +const sectionClassName = + 'space-y-4 rounded-xl border border-border bg-background/80 p-4' const themeLabel: Record = { system: 'システム設定に従う', @@ -107,40 +94,64 @@ const themeLabel: Record = { dark: 'ダーク・モード', } -const autoFetchLabel: Record = { - auto: '自動', - manual: '手動', - off: '無効', -} - -const wikiEditorModeLabel: Record = { - split: '左右表示', - write: '本文のみ', - preview: 'プレビューのみ', +const builtinThemeLabel: Record<'light' | 'dark', string> = { + light: 'ライト・モード', + dark: 'ダーク・モード', } const keyboardShortcutRows = [ - { action: '投稿詳細を開く', key: 'Enter', scope: '一覧・検索結果', conflict: 'なし' }, - { action: '候補タグを選ぶ', key: '↑ / ↓ / Enter', scope: 'タグ入力', conflict: 'なし' }, - { action: '候補タグを閉ぢる', key: 'Escape', scope: 'タグ入力', conflict: 'なし' }, + { + action: '投稿詳細を開く', + key: 'Enter', + scope: '一覧・検索結果', + conflict: 'なし', + }, + { + action: '候補タグを選ぶ', + key: '↑ / ↓ / Enter', + scope: 'タグ入力', + conflict: 'なし', + }, + { + action: '候補タグを閉じる', + key: 'Escape', + scope: 'タグ入力', + conflict: 'なし', + }, ] -const themeColorTargets = [ - '背景色', - '文字色', - '枠線色', - 'リンク色', - '強調色', - '通常タグ', - 'キャラタグ', - '素材タグ', - 'Nico タグ', - '廃止タグ', - 'Wiki ありタグ', - 'Wiki 未作成タグ', - '選択中タグ', - 'ホバー中タグ', -] +const previewTag = ( + id: number, + name: string, + category: Category, + postCount: number, + hasWiki: boolean, + options?: { hasDeerjikists?: boolean }, +): Tag => ({ + id, + name, + category, + deprecatedAt: null, + aliases: [], + parents: [], + postCount, + createdAt: '', + updatedAt: '', + hasWiki, + materialId: null, + hasDeerjikists: options?.hasDeerjikists ?? false, + children: [], +}) + +const previewTags: Record = { + deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), + meme: previewTag (2, 'ミーム', 'meme', 34, true), + character: previewTag (3, 'キャラクター', 'character', 56, false), + general: previewTag (4, '一般', 'general', 78, true), + material: previewTag (5, '素材', 'material', 90, false), + meta: previewTag (6, 'メタ', 'meta', 21, true), + nico: previewTag (7, 'nico:sm9', 'nico', 43, false), +} const parseTab = (search: string): SettingsTab => { @@ -155,21 +166,42 @@ const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` +const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => + Object.fromEntries ( + CATEGORIES.flatMap (category => [ + [`--tag-colour-${ category }`, tagColours[category]], + [`--tag-colour-${ category }-hover`, tagColours[category]], + ]), + ) as CSSProperties + + +const themeLabelById = ( + themeId: ActiveThemeId, + customThemes: Record, +): string => { + if (themeId === 'system') + return themeLabel.system + if (themeId === 'light' || themeId === 'dark') + return builtinThemeLabel[themeId] + return (customThemes[themeId] ?? BUILTIN_THEMES.light).name +} + + const AccountSection: FC = ({ name, - nameBaseErrors, - nameFieldErrors, onInheritOpen, onNameChange, onUserCodeOpen, onUserSubmit, user, + userBaseErrors, + userFieldErrors, }) => (

アカウント

- + - + {({ describedBy, invalid }) => ( <> = ({ const ThemeSection: FC = ({ - draftSettings, - onDraftSettingsChange, - onSettingsSubmit, + draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onResetAllTagColours, + onResetTagColour, + onTagColourChange, + onThemeChange, + onThemeSubmit, settingsBaseErrors, settingsFieldErrors, }) => ( @@ -230,37 +268,119 @@ const ThemeSection: FC = ({ {() => ( )} -
-

今後の拡張予定

+

- 将来は `themes` または `user_themes` を導入し,ユーザごとの - カスタム・テーマを持てる構造にする前提です. + テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.

-

その際は次の色を個別に設定できるようにします.

-
- {themeColorTargets.map (target => ( - - {target} - ))} +

+ backend には system / light / dark の mode だけ保存します. + カスタムテーマ本体は今は browser-local です. +

+

+ 将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます. +

+
+ +
+
+
+

タグカテゴリ色

+

+ {selectedTheme.builtin || draftActiveThemeId === 'system' + ? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.' + : `現在の編輯対象: ${ selectedTheme.name }`} +

+
+ +
+ +
+ {CATEGORIES.map (category => { + const resolvedDefaults = + defaultThemeTagColoursFor (selectedTheme.baseTheme) + const isDefault = + draftTagColours[category] === resolvedDefaults[category] + + return ( +
+
+ {CATEGORY_NAMES[category]} + onTagColourChange (category, ev.target.value)} + className="h-10 w-16 cursor-pointer rounded border border-border + bg-transparent p-1"/> +
+
+ +
+
) + })}
-
) @@ -269,13 +389,10 @@ const KeyboardSection: FC = () => (

キーボード

-
-

- 初期実装では固定ショートカット一覧のみを表示します. - 将来は `action / key / scope / conflict` を持つ - key bind 設定へ拡張する前提です. -

-
+

+ 現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ + key bind 設定へ拡張できる形にしています. +

@@ -301,111 +418,29 @@ const KeyboardSection: FC = () => ( ) -const EditingSection: FC = ({ - draftSettings, - onDraftSettingsChange, - onSettingsSubmit, - settingsBaseErrors, - settingsFieldErrors, -}) => ( -
-

編輯支援

- - - - {() => ( - )} - - - - {() => ( - )} - - - -
) - - -const WikiSection: FC = ({ - draftSettings, - onDraftSettingsChange, - onSettingsSubmit, - settingsBaseErrors, - settingsFieldErrors, -}) => ( -
-

Wiki

- - - - {() => ( - )} - - - -
) - - const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () const navigate = useNavigate () - const [draftSettings, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) - const [name, setName] = useState ('') - const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false) - const { error, loaded, settings, setSettings } = useUserSettings () + const [loading, setLoading] = useState (true) + const [name, setName] = useState ('') + const [settingsError, setSettingsError] = useState (null) + const [, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) + const [savedActiveThemeId, setSavedActiveThemeId] = + useState (getClientActiveThemeId ()) + const [savedCustomThemes, setSavedCustomThemes] = + useState> (getClientCustomThemes ()) + const [draftActiveThemeId, setDraftActiveThemeId] = + useState (getClientActiveThemeId ()) + const [draftCustomThemes, setDraftCustomThemes] = + useState> (getClientCustomThemes ()) + const [userCodeVsbl, setUserCodeVsbl] = useState (false) const { - baseErrors: nameBaseErrors, - fieldErrors: nameFieldErrors, - clearValidationErrors: clearNameValidationErrors, - applyValidationError: applyNameValidationError, + baseErrors: userBaseErrors, + fieldErrors: userFieldErrors, + clearValidationErrors: clearUserValidationErrors, + applyValidationError: applyUserValidationError, } = useValidationErrors () const { baseErrors: settingsBaseErrors, @@ -415,40 +450,80 @@ const SettingPage: FC = ({ user, setUser }) => { } = useValidationErrors () const activeTab = useMemo (() => parseTab (location.search), [location.search]) + const selectedTheme = useMemo ( + () => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes), + [draftActiveThemeId, draftCustomThemes], + ) + const draftTagColours = selectedTheme.tagColours - const setActiveTab = useCallback ((tab: SettingsTab) => { + const setActiveTab = (tab: SettingsTab) => { const params = new URLSearchParams (location.search) params.set ('tab', tab) navigate ( - `${ location.pathname }${ params.toString () ? `?${ params.toString () }` : '' }`, + `${ location.pathname }?${ params.toString () }`, { replace: true }, ) - }, [location.pathname, location.search, navigate]) + } const settingsTabErrors = useMemo> (() => ({ - account: Boolean (nameFieldErrors.name?.length), + account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), keyboard: false, - editing: ( - Boolean (settingsFieldErrors.autoFetchTitle?.length) - || Boolean (settingsFieldErrors.autoFetchThumbnail?.length) - ), - wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length), - }), [nameFieldErrors.name, settingsFieldErrors]) + }), [settingsFieldErrors.theme, userFieldErrors.name]) + + const applyDraftThemeSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, + ) => { + setDraftActiveThemeId (activeThemeId) + setDraftCustomThemes (customThemes) + setDraftSettings (current => ({ + ...current, + theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes), + })) + applyThemeSelection (activeThemeId, customThemes) + } + + const ensureEditableCustomTheme = (): { + activeThemeId: ActiveThemeId + customThemes: Record + } => { + if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null) + { + return { + activeThemeId: draftActiveThemeId, + customThemes: draftCustomThemes, + } + } + + const customTheme = createCustomThemeFromBase ( + selectedTheme.baseTheme, + { ...selectedTheme.tagColours }, + ) + const nextCustomThemes = { + ...draftCustomThemes, + [customTheme.id]: customTheme, + } + + return { + activeThemeId: customTheme.id, + customThemes: nextCustomThemes, + } + } const handleTabKeyDown = (event: KeyboardEvent) => { const currentIndex = tabs.findIndex (tab => tab.id === activeTab) switch (event.key) { - case 'ArrowRight': case 'ArrowDown': + case 'ArrowRight': event.preventDefault () setActiveTab (tabs[(currentIndex + 1) % tabs.length].id) break - case 'ArrowLeft': case 'ArrowUp': + case 'ArrowLeft': event.preventDefault () setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) break @@ -469,7 +544,7 @@ const SettingPage: FC = ({ user, setUser }) => { if (!(user)) return - clearNameValidationErrors () + clearUserValidationErrors () const formData = new FormData formData.append ('name', name) @@ -484,30 +559,30 @@ const SettingPage: FC = ({ user, setUser }) => { } catch (submitError) { - applyNameValidationError (submitError) + applyUserValidationError (submitError) toast ({ title: '表示名を更新できませんでした.' }) } } - const handleSettingsSubmit = async () => { + const handleThemeSubmit = async () => { clearSettingsValidationErrors () try { - const data = await updateUserSettings ({ - theme: draftSettings.theme, - auto_fetch_title: draftSettings.autoFetchTitle, - auto_fetch_thumbnail: draftSettings.autoFetchThumbnail, - wiki_editor_mode: draftSettings.wikiEditorMode, - }) - setDraftSettings (data) - setSettings (data) - toast ({ title: '設定を保存しました.' }) + const themeMode = + deriveUserThemeModeFromSelection (draftActiveThemeId, draftCustomThemes) + const data = await updateUserSettings ({ theme: themeMode }) + setDraftSettings ({ ...data, theme: themeMode }) + setSavedActiveThemeId (draftActiveThemeId) + setSavedCustomThemes (draftCustomThemes) + setClientAppearanceThemes (draftActiveThemeId, draftCustomThemes) + applyThemeSelection (draftActiveThemeId, draftCustomThemes) + toast ({ title: 'テーマ設定を保存しました.' }) } catch (submitError) { applySettingsValidationError (submitError) - toast ({ title: '設定を保存できませんでした.' }) + toast ({ title: 'テーマ設定を保存できませんでした.' }) } } @@ -519,36 +594,140 @@ const SettingPage: FC = ({ user, setUser }) => { }, [user]) useEffect (() => { - setDraftSettings (settings) - }, [settings]) + if (!(user)) + return + + let cancelled = false + + void (async () => { + setLoading (true) + + try + { + const data = await fetchUserSettings () + if (cancelled) + return + + const customThemes = getClientCustomThemes () + const activeThemeId = hasStoredClientThemeSelection () + ? getClientActiveThemeId () + : data.theme + const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes) + + setDraftSettings ({ ...data, theme: themeMode }) + setSavedActiveThemeId (activeThemeId) + setSavedCustomThemes (customThemes) + setDraftActiveThemeId (activeThemeId) + setDraftCustomThemes (customThemes) + setSettingsError (null) + } + catch + { + if (cancelled) + return + + setSettingsError ('設定の読込みに失敗しました.') + } + finally + { + if (!(cancelled)) + setLoading (false) + } + }) () + + return () => { + cancelled = true + } + }, [user]) useEffect (() => { - if (nameFieldErrors.name?.length) + if (userFieldErrors.name?.length) setActiveTab ('account') - }, [nameFieldErrors.name, setActiveTab]) + }, [location.pathname, location.search, userFieldErrors.name]) useEffect (() => { - const fieldWithError = settingsFieldOrder.find ( - field => (settingsFieldErrors[field]?.length ?? 0) > 0, - ) - if (fieldWithError) - setActiveTab (settingsTabByField[fieldWithError]) - }, [setActiveTab, settingsFieldErrors]) + if (settingsFieldErrors.theme?.length) + setActiveTab ('theme') + }, [location.pathname, location.search, settingsFieldErrors.theme]) + + useEffect (() => { + applyThemeSelection (draftActiveThemeId, draftCustomThemes) + + return () => { + applyThemeSelection (savedActiveThemeId, savedCustomThemes) + } + }, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes]) const sectionProps: SharedSectionProps | null = user ? { - draftSettings, + user, name, - nameBaseErrors, - nameFieldErrors, - onDraftSettingsChange: setDraftSettings, - onInheritOpen: () => setInheritVsbl (true), onNameChange: setName, - onSettingsSubmit: handleSettingsSubmit, + onInheritOpen: () => setInheritVsbl (true), onUserCodeOpen: () => setUserCodeVsbl (true), onUserSubmit: handleUserSubmit, + userBaseErrors, + userFieldErrors, + draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onThemeChange: themeId => { + applyDraftThemeSelection (themeId, draftCustomThemes) + }, + onTagColourChange: (category, colour) => { + const editable = ensureEditableCustomTheme () + const editableTheme = editable.customThemes[editable.activeThemeId] + const nextCustomThemes = { + ...editable.customThemes, + [editable.activeThemeId]: { + ...editableTheme, + tagColours: { + ...editableTheme.tagColours, + [category]: colour, + }, + }, + } + applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes) + }, + onResetAllTagColours: () => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return + + applyDraftThemeSelection ( + draftActiveThemeId, + { + ...draftCustomThemes, + [draftActiveThemeId]: { + ...draftCustomThemes[draftActiveThemeId], + tagColours: { + ...defaultThemeTagColoursFor (selectedTheme.baseTheme), + }, + }, + }, + ) + }, + onResetTagColour: category => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return + + applyDraftThemeSelection ( + draftActiveThemeId, + { + ...draftCustomThemes, + [draftActiveThemeId]: { + ...draftCustomThemes[draftActiveThemeId], + tagColours: { + ...draftCustomThemes[draftActiveThemeId].tagColours, + [category]: + defaultThemeTagColoursFor (selectedTheme.baseTheme)[category], + }, + }, + }, + ) + }, + onThemeSubmit: handleThemeSubmit, settingsBaseErrors, settingsFieldErrors, - user, } : null return ( @@ -558,72 +737,68 @@ const SettingPage: FC = ({ user, setUser }) => { 設定 | {SITE_TITLE} -
-
- 設定 - -
- - {user && loaded ? ( +
+ 設定 + + {loading ? 'Loading...' : ( <> -
- {sectionProps && ( - <> - - - - - - )} -
+ {sectionProps && ( + <> + + + + )} + )} +
-
-
-
- {tabs.map (tab => ( - ))} -
+
+
+
+ {tabs.map (tab => ( + ))} +
-
- {sectionProps && ( -
- {activeTab === 'account' && } - {activeTab === 'theme' && } - {activeTab === 'keyboard' && } - {activeTab === 'editing' && } - {activeTab === 'wiki' && } -
)} -
-
-
- ) : 'Loading...'} +
+ 設定 + + {loading ? 'Loading...' : ( +
+ {sectionProps && ( + <> + {activeTab === 'account' && } + {activeTab === 'theme' && } + {activeTab === 'keyboard' && } + )} +
)} +
+
{ - switch (mode) - { - case 'write': - return { menu: true, md: true, html: false } - - case 'preview': - return { menu: true, md: false, html: true } - - default: - return { menu: true, md: true, html: true } - } -} +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -54,7 +37,6 @@ type WikiFormField = 'title' | 'body' const WikiEditPage: FC = ({ user }) => { const editable = canEditContent (user) - const { settings } = useUserSettings () const { id } = useParams () @@ -137,7 +119,7 @@ const WikiEditPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index b4d5d6a..ac07b3d 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -9,7 +9,6 @@ import { useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' @@ -23,27 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css' import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const -type EditorView = { - menu: boolean - md: boolean - html: boolean } - -const editorView = ( - mode: 'split' | 'write' | 'preview', -): EditorView => { - switch (mode) - { - case 'write': - return { menu: true, md: true, html: false } - - case 'preview': - return { menu: true, md: false, html: true } - - default: - return { menu: true, md: true, html: true } - } -} +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -52,7 +35,6 @@ type WikiFormField = 'title' | 'body' const WikiNewPage: FC = ({ user }) => { const editable = canEditContent (user) - const { settings } = useUserSettings () const location = useLocation () const navigate = useNavigate () @@ -115,7 +97,7 @@ const WikiNewPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 16795ac..3ffb5b0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -8,6 +8,7 @@ import { DARK_COLOUR_SHADE, const colours = Object.values (TAG_COLOUR) export default { + darkMode: 'class', content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'], safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),