このコミットが含まれているのは:
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする