このコミットが含まれているのは:
2026-07-05 11:57:29 +09:00
コミット 61ab4744bd
10個のファイルの変更402行の追加203行の削除
+25 -11
ファイルの表示
@@ -204,7 +204,7 @@ const value =
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
editing backend behaviour.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
recreates the development database. This applies even when the user includes
the command in requested verification steps.
@@ -215,7 +215,7 @@ const value =
- Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behavior changes, add or update request specs under
- For API behaviour changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
@@ -223,8 +223,8 @@ const value =
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
wiki revisions, and restore/diff behaviour.
- Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
@@ -245,9 +245,9 @@ const value =
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` color and dark-mode counterparts such as
explicit readable `text-*` colour and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text color for light backgrounds. This is especially
- Do not rely on inherited text colour for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in
dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
@@ -355,9 +355,23 @@ const value =
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
- Spell British English identifiers correctly when the feature name uses
British English. Use `Behaviour`, not `Behavior`, for new component and file
names unless editing an already established American-English API.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
codebase. For example, prefer `import { color: colour } from '@external-lib'`
over spreading `color` through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
### Frontend delimiter decision table
@@ -740,7 +754,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
behaviour, inspect the related request specs and service objects.
- If frontend code changes, run only non-test verification commands that
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests.
@@ -757,4 +771,4 @@ A task is complete only when:
explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
- user-facing behaviour is documented when needed.
+19 -4
ファイルの表示
@@ -118,7 +118,7 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
@@ -192,9 +192,24 @@ pass or the remaining failure is clearly blocked.
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Spell British English identifiers correctly when the feature name uses
British English. Use `Behaviour`, not `Behavior`, for new component and file
names unless editing an already established American-English API.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
frontend codebase. For example, prefer
`import { color: colour } from '@external-lib'` over spreading `color`
through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- In this frontend, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
- Avoid reformatting unrelated JSX.
### Delimiter decision table
+52 -17
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
@@ -12,12 +12,14 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import {
applyClientAppearance,
fetchUserSettings,
getClientThemeMode,
seedClientThemeMode,
} from '@/lib/settings'
import { applyClientAppearance,
CLIENT_SETTINGS_EVENT,
fetchUserSettings,
getClientAnimationMode,
getClientThemeMode,
seedClientThemeMode } from '@/lib/settings'
import type { ClientAnimationMode } from '@/lib/settings'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -110,6 +112,19 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const [animationMode, setAnimationMode] =
useState<ClientAnimationMode> (() => getClientAnimationMode ())
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
@@ -124,6 +139,16 @@ const App: FC = () => {
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
const handleClientSettingsChange = () => {
setAnimationMode (getClientAnimationMode ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -170,6 +195,7 @@ const App: FC = () => {
}
catch
{
return
}
}) ()
}, [user])
@@ -188,15 +214,24 @@ const App: FC = () => {
<BrowserRouter>
<KeyboardShortcutsProvider>
<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>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
</MotionConfig>
<Toaster/>
</DialogueProvider>
+60 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { getClientEmbedAutoLoadMode } from '@/lib/settings'
import type { FC, RefObject } from 'react'
@@ -24,16 +26,18 @@ type Props = {
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const PostEmbed: FC<Props> = (
{ ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError },
) => {
const dialogue = useDialogue ()
const embedAutoLoadMode = getClientEmbedAutoLoadMode ()
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +109,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
@@ -117,6 +125,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
const shouldLoadEmbed =
embedAutoLoadMode === 'auto'
|| (
embedAutoLoadMode === 'manual'
&& manualLoadRequested
)
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
</a>)
const manualLoadControl = (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
</Button>
{externalLink}
</div>)
switch (url.hostname.split ('.').slice (-2).join ('.'))
{
@@ -128,6 +158,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<NicoViewer
ref={ref}
@@ -150,6 +185,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId
const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
@@ -159,6 +199,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId))
break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
@@ -184,6 +229,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>)
: (
<div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => {
e.preventDefault ()
@@ -197,7 +247,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' }))
}}>
</a>
</a>)}
</div>)}
</>)
}
+23 -7
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { getClientThumbnailMode } from '@/lib/settings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const thumbnailMode = getClientThumbnailMode ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -54,18 +56,32 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return
return
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
{thumbnailMode === 'off'
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
</div>)
: (
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async"
className="object-cover w-full h-full"/>)}
</motion.div>
</PrefetchLink>)
})}
-143
ファイルの表示
@@ -1,143 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getClientBehaviorSettings,
setClientBehaviorSettings,
} from '@/lib/settings'
import { inputClass } from '@/lib/utils'
import type {
ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode,
} from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
}
const BehaviorSettingsSection: FC<Props> = ({ sectionClassName }) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings],
)
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
調
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="アニメーション">
{() => (
<select
className={inputClass (false)}
value={draftSettings.animation ?? 'normal'}
onChange={ev => updateDraft (
'animation',
ev.target.value as ClientAnimationMode,
)}>
<option value="normal"></option>
<option value="reduced"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="埋め込み自動読込">
{() => (
<select
className={inputClass (false)}
value={draftSettings.embedAutoLoad ?? 'auto'}
onChange={ev => updateDraft (
'embedAutoLoad',
ev.target.value as ClientEmbedAutoLoadMode,
)}>
<option value="auto"></option>
<option value="manual"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="サムネイル表示">
{() => (
<select
className={inputClass (false)}
value={draftSettings.thumbnailMode ?? 'normal'}
onChange={ev => updateDraft (
'thumbnailMode',
ev.target.value as ClientThumbnailMode,
)}>
<option value="normal"></option>
<option value="light"></option>
<option value="off"></option>
</select>)}
</FormField>
</div>
</section>)
}
export default BehaviorSettingsSection
+192
ファイルの表示
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings,
setClientBehaviorSettings } from '@/lib/settings'
import { cn } from '@/lib/utils'
import type {
ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode } from '@/lib/settings'
import type { FC, ReactNode } from 'react'
type Props = {
sectionClassName: string }
type SegmentedOption<T extends string> = {
value: T
label: string }
type SegmentedControlProps<T extends string> = {
value: T
options: SegmentedOption<T>[]
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'normal', label: '通常' },
{ value: 'reduced', label: '控えめ' },
{ value: 'off', label: 'なし' },
]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'auto', label: '自動' },
{ value: 'manual', label: 'クリック時' },
{ value: 'off', label: '読み込まない' },
]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'normal', label: '通常' },
{ value: 'light', label: '軽量' },
{ value: 'off', label: '非表示' },
]
const SegmentedControl = <T extends string,>(
{ value, options, onChange }: SegmentedControlProps<T>,
) => {
return (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 flex-1 rounded-lg px-3 py-2 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
</button>))}
</div>)
}
const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children },
) => (
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>)
const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings],
)
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
調
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock
title="アニメーション"
description="画面遷移やレイアウト移動の動きを調整します。">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
<SettingBlock
title="サムネイル表示"
description="投稿一覧や詳細の画像表示を軽量化します。">
<SegmentedControl
value={draftSettings.thumbnailMode ?? 'normal'}
options={thumbnailModeOptions}
onChange={value => updateDraft ('thumbnailMode', value)}/>
</SettingBlock>
</div>
</section>)
}
export default BehaviourSettingsSection
+8
ファイルの表示
@@ -112,6 +112,7 @@ export type ClientSettings = {
thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change'
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 = [
@@ -297,6 +298,13 @@ export const loadClientSettings = (): ClientSettings =>
export const saveClientSettings = (settings: ClientSettings): void => {
localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings))
if (typeof window !== 'undefined')
{
window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, {
detail: settings,
}))
}
}
+5 -1
ファイルの表示
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { getClientThumbnailMode } from '@/lib/settings'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
@@ -29,6 +30,7 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const thumbnailMode = getClientThumbnailMode ()
const { id } = useParams ()
const postId = String (id ?? '')
const postKey = postsKeys.show (postId)
@@ -134,7 +136,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>)
})}
{(post.thumbnail || post.thumbnailBase) && (
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
@@ -145,6 +147,8 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>)}
+18 -10
ファイルの表示
@@ -8,7 +8,7 @@ import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import BehaviorSettingsSection from '@/components/users/BehaviorSettingsSection'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
@@ -259,7 +259,7 @@ const ThemeSection: FC<SharedSectionProps> = (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
<h3 className="font-medium"></h3>
</div>
<Button
type="button"
@@ -270,7 +270,7 @@ const ThemeSection: FC<SharedSectionProps> = (
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-3">
{CATEGORIES.map (category => {
const resolvedDefaults =
defaultThemeTagColoursFor (selectedTheme.baseTheme)
@@ -280,9 +280,19 @@ const ThemeSection: FC<SharedSectionProps> = (
return (
<div
key={category}
className="space-y-2 rounded-lg border border-border/70 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">{CATEGORY_NAMES[category]}</span>
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (draftTagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={draftTagColours[category]}
@@ -291,8 +301,6 @@ const ThemeSection: FC<SharedSectionProps> = (
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
@@ -693,7 +701,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div>
@@ -742,7 +750,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</>)}
</div>)}
</div>