設定画面 (#34) #397

マージ済み
みてるぞ が 31 個のコミットを feature/034 から main へマージ 2026-07-07 00:48:41 +09:00
10個のファイルの変更402行の追加203行の削除
コミット 61ab4744bd の変更だけを表示してゐます - すべてのコミットを表示
+25 -11
ファイルの表示
@@ -204,7 +204,7 @@ const value =
## Backend rules ## Backend rules
- Inspect existing routes, controllers, models, services, and specs before - 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 - 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 recreates the development database. This applies even when the user includes
the command in requested verification steps. the command in requested verification steps.
@@ -215,7 +215,7 @@ const value =
- Repeated destructive instructions are not enough confirmation because they - Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`. 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. `backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under - Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage. `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 - Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication. explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency, - Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior. wiki revisions, and restore/diff behaviour.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior. - Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix - Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited. existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema. - Keep migration files and `backend/db/schema.rb` consistent when changing schema.
@@ -245,9 +245,9 @@ const value =
- Internal links must use `PrefetchLink`. - Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`. - External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an - 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. `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 important for chips, cards, buttons, and panels that may inherit white text in
dark mode. dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content, - 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 - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. explicitly, for example with `void`.
- Spell British English identifiers correctly when the feature name uses - Use correct British English spelling for new identifiers, filenames,
British English. Use `Behaviour`, not `Behavior`, for new component and file component names, helper names, comments, and developer-facing prose unless
names unless editing an already established American-English API. 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 ### Frontend delimiter decision table
@@ -740,7 +754,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- Do not scan or summarize dependency/generated/runtime directories such as - Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication - 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 - 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` apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests. only when the user explicitly asks for tests.
@@ -757,4 +771,4 @@ A task is complete only when:
explained, explained,
- unrelated files are not changed, - unrelated files are not changed,
- migrations and schema are consistent when schema changes are made, - 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. short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including - Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it. 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. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - 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. single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans - Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement. two or more physical lines, even if it is one statement.
- Spell British English identifiers correctly when the feature name uses - Use correct British English spelling for new identifiers, filenames,
British English. Use `Behaviour`, not `Behavior`, for new component and file component names, helper names, comments, and developer-facing prose unless
names unless editing an already established American-English API. 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. - Avoid reformatting unrelated JSX.
### Delimiter decision table ### Delimiter decision table
+52 -17
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
Navigate, Navigate,
Route, Route,
@@ -12,12 +12,14 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { import { applyClientAppearance,
applyClientAppearance, CLIENT_SETTINGS_EVENT,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientAnimationMode,
seedClientThemeMode, getClientThemeMode,
} from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import type { ClientAnimationMode } from '@/lib/settings'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -110,6 +112,19 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => { const App: FC = () => {
const [user, setUser] = useState<User | null> (null) const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200) 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 (() => { useEffect (() => {
applyClientAppearance () applyClientAppearance ()
@@ -124,6 +139,16 @@ const App: FC = () => {
return () => mediaQuery.removeEventListener ('change', handleThemeChange) return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, []) }, [])
useEffect (() => {
const handleClientSettingsChange = () => {
setAnimationMode (getClientAnimationMode ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
useEffect (() => { useEffect (() => {
const createUser = async () => { const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users') const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -170,6 +195,7 @@ const App: FC = () => {
} }
catch catch
{ {
return
} }
}) () }) ()
}, [user]) }, [user])
@@ -188,15 +214,24 @@ const App: FC = () => {
<BrowserRouter> <BrowserRouter>
<KeyboardShortcutsProvider> <KeyboardShortcutsProvider>
<DialogueProvider> <DialogueProvider>
<LayoutGroup> <MotionConfig
<motion.div reducedMotion={
layout="position" animationMode === 'normal'
transition={{ layout: { duration: .2, ease: 'easeOut' } }} ? 'never'
className="relative flex flex-col h-dvh w-full overflow-y-hidden"> : animationMode === 'reduced'
<TopNav user={user}/> ? 'user'
<RouteTransitionWrapper user={user} setUser={setUser}/> : 'always'
</motion.div> }>
</LayoutGroup> <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/> <Toaster/>
</DialogueProvider> </DialogueProvider>
+60 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { getClientEmbedAutoLoadMode } from '@/lib/settings'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -24,16 +26,18 @@ type Props = {
onError?: (data: unknown) => void } onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ const PostEmbed: FC<Props> = (
ref, { ref,
post, post,
onLoadComplete, onLoadComplete,
onMetadataChange, onMetadataChange,
onVideoReady, onVideoReady,
onPlaybackChange, onPlaybackChange,
onError, onError },
}) => { ) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const embedAutoLoadMode = getClientEmbedAutoLoadMode ()
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null) const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false) const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +109,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false niconicoVideoReadyRef.current = false
}, [post.url]) }, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => { useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange)) if (!(youtubePlayer) || !(onPlaybackChange))
return return
@@ -117,6 +125,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url) 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 ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
{ {
@@ -128,6 +158,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return ( return (
<NicoViewer <NicoViewer
ref={ref} ref={ref}
@@ -150,6 +185,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId const [userId] = mUserId
const [statusId] = mStatusId const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/> return <TwitterEmbed userId={userId} statusId={statusId}/>
} }
@@ -159,6 +199,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId)) if (!(videoId))
break break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return ( return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: { <YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1, playsinline: 1,
@@ -184,6 +229,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>) height={360}/>)
: ( : (
<div> <div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => { <a href="#" onClick={async e => {
e.preventDefault () e.preventDefault ()
@@ -197,7 +247,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' })) confirmText: '表示' }))
}}> }}>
</a> </a>)}
</div>)} </div>)}
</>) </>)
} }
+23 -7
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { getClientThumbnailMode } from '@/lib/settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => { const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation () const location = useLocation ()
const thumbnailMode = getClientThumbnailMode ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -54,18 +56,32 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}} }}
onLayoutAnimationComplete={() => { onLayoutAnimationComplete={() => {
if (!(cardRef.current)) if (!(cardRef.current))
return return
cardRef.current.style.zIndex = '' cardRef.current.style.zIndex = ''
cardRef.current.style.position = '' cardRef.current.style.position = ''
}} }}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}> transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined} {thumbnailMode === 'off'
alt={post.title || post.url} ? (
title={post.title || post.url || undefined} <div className="flex h-full w-full items-center justify-center
loading={i < 12 ? 'eager' : 'lazy'} bg-muted text-center text-xs text-muted-foreground">
decoding="async"
className="object-cover w-full h-full"/> </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> </motion.div>
</PrefetchLink>) </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 } thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' 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 THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [ export const POST_LIST_ORDER_OPTIONS = [
@@ -297,6 +298,13 @@ export const loadClientSettings = (): ClientSettings =>
export const saveClientSettings = (settings: ClientSettings): void => { export const saveClientSettings = (settings: ClientSettings): void => {
localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings)) 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 { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts' import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { getClientThumbnailMode } from '@/lib/settings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
@@ -29,6 +30,7 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => { const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const thumbnailMode = getClientThumbnailMode ()
const { id } = useParams () const { id } = useParams ()
const postId = String (id ?? '') const postId = String (id ?? '')
const postKey = postsKeys.show (postId) const postKey = postsKeys.show (postId)
@@ -134,7 +136,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>) </div>)
})} })}
{(post.thumbnail || post.thumbnailBase) && ( {thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
<motion.div <motion.div
layoutId={`page-${ id }`} layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px] 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} <img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url} alt={post.title || post.url}
title={post.title || post.url || undefined} title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/> className="object-cover w-full h-full"/>
</motion.div>)} </motion.div>)}
+18 -10
ファイルの表示
@@ -8,7 +8,7 @@ import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import BehaviorSettingsSection from '@/components/users/BehaviorSettingsSection' import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue' import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue'
@@ -259,7 +259,7 @@ const ThemeSection: FC<SharedSectionProps> = (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h3 className="font-medium"></h3> <h3 className="font-medium"></h3>
</div> </div>
<Button <Button
type="button" type="button"
@@ -270,7 +270,7 @@ const ThemeSection: FC<SharedSectionProps> = (
</Button> </Button>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="space-y-3">
{CATEGORIES.map (category => { {CATEGORIES.map (category => {
const resolvedDefaults = const resolvedDefaults =
defaultThemeTagColoursFor (selectedTheme.baseTheme) defaultThemeTagColoursFor (selectedTheme.baseTheme)
@@ -280,9 +280,19 @@ const ThemeSection: FC<SharedSectionProps> = (
return ( return (
<div <div
key={category} key={category}
className="space-y-2 rounded-lg border border-border/70 px-3 py-2"> className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
<div className="flex items-center justify-between gap-3"> sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm">{CATEGORY_NAMES[category]}</span> <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 <input
type="color" type="color"
value={draftTagColours[category]} value={draftTagColours[category]}
@@ -291,8 +301,6 @@ const ThemeSection: FC<SharedSectionProps> = (
onChange={ev => onTagColourChange (category, ev.target.value)} onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/> bg-transparent p-1"/>
</div>
<div className="flex justify-end">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -693,7 +701,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && ( {activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)} <BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</div>)} </div>)}
</div> </div>
@@ -742,7 +750,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && ( {activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)} <BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</>)} </>)}
</div>)} </div>)}
</div> </div>