設定画面 (#34) #397
@@ -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
|
||||||
|
|||||||
+43
-8
@@ -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,
|
||||||
|
getClientAnimationMode,
|
||||||
getClientThemeMode,
|
getClientThemeMode,
|
||||||
seedClientThemeMode,
|
seedClientThemeMode } from '@/lib/settings'
|
||||||
} 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>
|
||||||
|
<MotionConfig
|
||||||
|
reducedMotion={
|
||||||
|
animationMode === 'normal'
|
||||||
|
? 'never'
|
||||||
|
: animationMode === 'reduced'
|
||||||
|
? 'user'
|
||||||
|
: 'always'
|
||||||
|
}>
|
||||||
<LayoutGroup>
|
<LayoutGroup>
|
||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout={animationMode === 'off' ? false : 'position'}
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
|
</MotionConfig>
|
||||||
|
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</DialogueProvider>
|
</DialogueProvider>
|
||||||
|
|||||||
@@ -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>)}
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -60,12 +62,26 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
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'
|
||||||
|
? (
|
||||||
|
<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}
|
alt={post.title || post.url}
|
||||||
title={post.title || post.url || undefined}
|
title={post.title || post.url || undefined}
|
||||||
loading={i < 12 ? 'eager' : 'lazy'}
|
loading={
|
||||||
|
thumbnailMode === 'light'
|
||||||
|
? 'lazy'
|
||||||
|
: i < 12
|
||||||
|
? 'eager'
|
||||||
|
: 'lazy'
|
||||||
|
}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="object-cover w-full h-full"/>
|
className="object-cover w-full h-full"/>)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</PrefetchLink>)
|
</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 }
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>)}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする