このコミットが含まれているのは:
@@ -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
|
||||
|
||||
|
||||
+52
-19
@@ -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<User | null> (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 (
|
||||
<>
|
||||
<UserSettingsProvider user={user}>
|
||||
<RouteBlockerOverlay/>
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
<RouteBlockerOverlay/>
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
|
||||
<BrowserRouter>
|
||||
<DialogueProvider>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
<BrowserRouter>
|
||||
<DialogueProvider>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
|
||||
<Toaster/>
|
||||
</DialogueProvider>
|
||||
</BrowserRouter>
|
||||
</UserSettingsProvider>
|
||||
<Toaster/>
|
||||
</DialogueProvider>
|
||||
</BrowserRouter>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ tag,
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
className={linkClass}
|
||||
style={colourStyle}>
|
||||
?
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
@@ -71,13 +72,15 @@ const TagLink: FC<Props> = ({ tag,
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/materials/${ tag.materialId }`}
|
||||
className={linkClass}>
|
||||
className={linkClass}
|
||||
style={colourStyle}>
|
||||
?
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
<PrefetchLink
|
||||
to={`/tags/${ tag.id }/deerjikists`}
|
||||
className={linkClass}>
|
||||
className={linkClass}
|
||||
style={colourStyle}>
|
||||
?
|
||||
</PrefetchLink>)))
|
||||
: (
|
||||
@@ -120,6 +123,7 @@ const TagLink: FC<Props> = ({ tag,
|
||||
<span
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
style={colourStyle}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.matchedAlias}
|
||||
@@ -134,6 +138,7 @@ const TagLink: FC<Props> = ({ tag,
|
||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||
title={textTitle}
|
||||
className={cn (linkClass, textClass, className)}
|
||||
style={colourStyle}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.name}
|
||||
@@ -144,6 +149,7 @@ const TagLink: FC<Props> = ({ tag,
|
||||
<span
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
style={colourStyle}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.name}
|
||||
|
||||
@@ -7,12 +7,15 @@ type TabProps = { name: string
|
||||
init?: boolean
|
||||
children: React.ReactNode }
|
||||
|
||||
type Props = { children: React.ReactNode }
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
rightAddon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Tab = ({ children }: TabProps) => <>{children}</>
|
||||
|
||||
|
||||
const TabGroup: FC<Props> = ({ children }) => {
|
||||
const TabGroup: FC<Props> = ({ children, rightAddon }) => {
|
||||
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
|
||||
|
||||
const [current, setCurrent] = useState<number> (() => {
|
||||
@@ -22,17 +25,23 @@ const TabGroup: FC<Props> = ({ children }) => {
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex gap-4">
|
||||
{tabs.map ((tab, i) => (
|
||||
<a key={i}
|
||||
href="#"
|
||||
className={cn (i === current && 'font-bold')}
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setCurrent (i)
|
||||
}}>
|
||||
{tab.props.name}
|
||||
</a>))}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{tabs.map ((tab, i) => (
|
||||
<a key={i}
|
||||
href="#"
|
||||
className={cn (i === current && 'font-bold')}
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
setCurrent (i)
|
||||
}}>
|
||||
{tab.props.name}
|
||||
</a>))}
|
||||
</div>
|
||||
{rightAddon && (
|
||||
<div className="shrink-0">
|
||||
{rightAddon}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{tabs[current]}
|
||||
|
||||
@@ -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<ContextValue | null> (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<ContextValue> (() => ({
|
||||
error,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+467
-32
@@ -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<Category, string>
|
||||
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<string, CustomClientTheme>
|
||||
// Legacy fields kept for migration from the earlier appearance model.
|
||||
theme?: UserSettings['theme']
|
||||
tagColours?: Partial<ThemeTagColours> }
|
||||
|
||||
// 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<string, ClientPaneSettings>
|
||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||
keyboard?: ClientKeyboardSettings
|
||||
theatre?: {
|
||||
appearance?: ClientAppearanceSettings
|
||||
panes?: Record<string, ClientPaneSettings>
|
||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||
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<BuiltinThemeId, ClientTheme> = {
|
||||
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<UserSettings> =>
|
||||
await apiGet<UserSettings> ('/users/settings')
|
||||
export const fetchUserSettings = async (): Promise<UserSettings> => {
|
||||
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<UserSettings> => await apiPatch<UserSettings> ('/users/settings', settings)
|
||||
): Promise<UserSettings> => {
|
||||
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<ThemeTagColours> | null | undefined,
|
||||
fallback: ThemeTagColours,
|
||||
): ThemeTagColours =>
|
||||
CATEGORIES.reduce<ThemeTagColours> (
|
||||
(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<string, unknown>
|
||||
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<ThemeTagColours> | undefined,
|
||||
defaultThemeTagColoursFor (baseTheme),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
|
||||
const appearance = appearanceFromSettings ()
|
||||
const customThemes = appearance.customThemes
|
||||
|
||||
if (customThemes && typeof customThemes === 'object')
|
||||
{
|
||||
return Object.entries (customThemes).reduce<Record<string, CustomClientTheme>> (
|
||||
(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<string, CustomClientTheme>,
|
||||
): ClientTheme =>
|
||||
isBuiltinThemeId (themeId)
|
||||
? BUILTIN_THEMES[themeId]
|
||||
: (customThemes[themeId] ?? BUILTIN_THEMES.light)
|
||||
|
||||
|
||||
export const getResolvedThemeFromSelection = (
|
||||
activeThemeId: ActiveThemeId,
|
||||
customThemes: Record<string, CustomClientTheme>,
|
||||
): ClientTheme =>
|
||||
activeThemeId === 'system'
|
||||
? BUILTIN_THEMES[resolveThemeMode ('system')]
|
||||
: getThemeById (activeThemeId, customThemes)
|
||||
|
||||
|
||||
export const deriveUserThemeModeFromSelection = (
|
||||
activeThemeId: ActiveThemeId,
|
||||
customThemes: Record<string, CustomClientTheme>,
|
||||
): 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<string, CustomClientTheme> = getClientCustomThemes (),
|
||||
): ThemeTagColours => {
|
||||
return {
|
||||
...getResolvedThemeFromSelection (activeThemeId, customThemes).tagColours,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const setClientAppearanceThemes = (
|
||||
activeThemeId: ActiveThemeId,
|
||||
customThemes: Record<string, CustomClientTheme>,
|
||||
): void => {
|
||||
updateClientSettings (settings => ({
|
||||
...settings,
|
||||
appearance: {
|
||||
...(settings.appearance ?? { }),
|
||||
activeThemeId,
|
||||
customThemes,
|
||||
theme: undefined,
|
||||
tagColours: undefined,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
export const setClientThemeTagColours = (
|
||||
tagColours: ThemeTagColours,
|
||||
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
|
||||
customThemes: Record<string, CustomClientTheme> = 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<string, CustomClientTheme>,
|
||||
): 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 (
|
||||
|
||||
@@ -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<FetchPostsOrder, string> = {
|
||||
'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<HTMLDivElement | null> (null)
|
||||
@@ -73,11 +50,7 @@ const PostListPage: FC = () => {
|
||||
?? getClientListLimit ('postList')
|
||||
?? DEFAULT_POST_LIST_LIMIT
|
||||
)
|
||||
const order = (
|
||||
parseOrder (query.get ('order'))
|
||||
?? getClientListOrder<FetchPostsOrder> ('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 = () => {
|
||||
}}/>
|
||||
|
||||
<MainArea>
|
||||
<div className="mb-4 flex flex-wrap gap-4 rounded-xl border border-border bg-background/80 p-4">
|
||||
<FormField label="表示件数">
|
||||
{() => (
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}>
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value} 件
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="並び順">
|
||||
{() => (
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
value={order}
|
||||
onChange={ev => updateListQuery ({
|
||||
order: ev.target.value as FetchPostsOrder,
|
||||
})}>
|
||||
{POST_LIST_ORDER_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{postOrderLabel[value]}
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
<TabGroup
|
||||
rightAddon={
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}>
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>))}
|
||||
</select>
|
||||
</label>
|
||||
}>
|
||||
<Tab name="広場">
|
||||
{posts.length > 0
|
||||
? (
|
||||
|
||||
@@ -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<FetchPostsOrder, string> = {
|
||||
'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 = () => {
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>広場検索</PageTitle>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>広場検索</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}>
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
|
||||
<FormField label="表示件数">
|
||||
{() => (
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}>
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value} 件
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="並び順">
|
||||
{() => (
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
value={order}
|
||||
onChange={ev => updateListQuery ({
|
||||
order: ev.target.value as FetchPostsOrder,
|
||||
})}>
|
||||
{POST_LIST_ORDER_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{postOrderLabel[value]}
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル">
|
||||
{({ invalid }) => (
|
||||
|
||||
@@ -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<FetchTagsOrder, string> = {
|
||||
'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 = () => {
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>タグ</PageTitle>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>タグ</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground">
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
|
||||
<FormField label="表示件数">
|
||||
{() => (
|
||||
<select
|
||||
value={limit}
|
||||
onChange={ev => updateListQuery ({
|
||||
limit: Number (ev.target.value) as 20 | 50 | 100,
|
||||
})}
|
||||
className={inputClass (false)}>
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{value} 件
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="並び順">
|
||||
{() => (
|
||||
<select
|
||||
value={order}
|
||||
onChange={ev => updateListQuery ({
|
||||
order: ev.target.value as FetchTagsOrder,
|
||||
})}
|
||||
className={inputClass (false)}>
|
||||
{TAG_LIST_ORDER_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>
|
||||
{tagOrderLabel[value]}
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* 名前 */}
|
||||
<FormField label="名前">
|
||||
{({ invalid }) => (
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -8,7 +8,6 @@ import { useParams, 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 { apiGet, apiPut } from '@/lib/api'
|
||||
@@ -25,27 +24,11 @@ import type { FC } from 'react'
|
||||
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 }
|
||||
|
||||
@@ -54,7 +37,6 @@ type WikiFormField = 'title' | 'body'
|
||||
|
||||
const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const { settings } = useUserSettings ()
|
||||
|
||||
const { id } = useParams ()
|
||||
|
||||
@@ -137,7 +119,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
{() => (
|
||||
<MdEditor value={body}
|
||||
style={{ height: '500px' }}
|
||||
config={{ view: editorView (settings.wikiEditorMode) }}
|
||||
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
|
||||
renderHTML={text => mdParser.render (text)}
|
||||
onChange={({ text }) => setBody (text)}/>)}
|
||||
</FormField>
|
||||
|
||||
@@ -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<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const { settings } = useUserSettings ()
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
@@ -115,7 +97,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
{() => (
|
||||
<MdEditor value={body}
|
||||
style={{ height: '500px' }}
|
||||
config={{ view: editorView (settings.wikiEditorMode) }}
|
||||
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
|
||||
renderHTML={text => mdParser.render (text)}
|
||||
onChange={({ text }) => setBody (text)}/>)}
|
||||
</FormField>
|
||||
|
||||
@@ -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 }`),
|
||||
|
||||
新しい課題から参照
ユーザをブロックする