diff --git a/AGENTS.md b/AGENTS.md index 96c9d79..b97a943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 2c2727e..4d0178d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14b1bb2..75b76da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (null) const [status, setStatus] = useState (200) + const [animationMode, setAnimationMode] = + useState (() => 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 = () => { - - - - - - + + + + + + + + diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 3cbbd00..7ff3e14 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -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 = ({ - ref, - post, - onLoadComplete, - onMetadataChange, - onVideoReady, - onPlaybackChange, - onError, -}) => { +const PostEmbed: FC = ( + { 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 (null) const niconicoVideoReadyRef = useRef (false) @@ -105,6 +109,10 @@ const PostEmbed: FC = ({ niconicoVideoReadyRef.current = false }, [post.url]) + useEffect (() => { + setManualLoadRequested (false) + }, [embedAutoLoadMode, post.url]) + useEffect (() => { if (!(youtubePlayer) || !(onPlaybackChange)) return @@ -117,6 +125,28 @@ const PostEmbed: FC = ({ }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) const url = new URL (post.url) + const shouldLoadEmbed = + embedAutoLoadMode === 'auto' + || ( + embedAutoLoadMode === 'manual' + && manualLoadRequested + ) + + const externalLink = ( + + 外部ページを開く + ) + + const manualLoadControl = ( +
+ + {externalLink} +
) switch (url.hostname.split ('.').slice (-2).join ('.')) { @@ -128,6 +158,11 @@ const PostEmbed: FC = ({ const [videoId] = mVideoId + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return ( = ({ const [userId] = mUserId const [statusId] = mStatusId + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return } @@ -159,6 +199,11 @@ const PostEmbed: FC = ({ if (!(videoId)) break + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return ( = ({ height={360}/>) : (
+ {embedAutoLoadMode === 'off' + ? externalLink + : embedAutoLoadMode === 'manual' && shouldLoadEmbed === false + ? manualLoadControl + : ( { e.preventDefault () @@ -197,7 +247,7 @@ const PostEmbed: FC = ({ confirmText: '表示' })) }}> 外部ページを表示 - + )}
)} ) } diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 69ef69a..e7cced5 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -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 = ({ posts, onClick }) => { const location = useLocation () + const thumbnailMode = getClientThumbnailMode () const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) @@ -54,18 +56,32 @@ const PostList: FC = ({ posts, onClick }) => { }} onLayoutAnimationComplete={() => { if (!(cardRef.current)) - return + return cardRef.current.style.zIndex = '' cardRef.current.style.position = '' }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}> - {post.title + {thumbnailMode === 'off' + ? ( +
+ サムネイルなし +
) + : ( + {post.title)} ) })} diff --git a/frontend/src/components/users/BehaviorSettingsSection.tsx b/frontend/src/components/users/BehaviorSettingsSection.tsx deleted file mode 100644 index 9aa0eee..0000000 --- a/frontend/src/components/users/BehaviorSettingsSection.tsx +++ /dev/null @@ -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 = ({ sectionClassName }) => { - const [savedSettings, setSavedSettings] = - useState (() => getClientBehaviorSettings ()) - const [draftSettings, setDraftSettings] = - useState (() => getClientBehaviorSettings ()) - - useEffect (() => { - const current = getClientBehaviorSettings () - setSavedSettings (current) - setDraftSettings (current) - }, []) - - const hasUnsavedChanges = useMemo ( - () => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings), - [draftSettings, savedSettings], - ) - - const updateDraft = ( - 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 ( -
-
-
-

動作

-

- この設定は現在のブラウザに保存されます。 -

-

- 重い端末や通信量を抑えたい場合に調整します。 -

- {hasUnsavedChanges && ( -

- 未保存の変更があります -

)} -
- -
- - -
-
- -
- - {() => ( - )} - - - - {() => ( - )} - - - - {() => ( - )} - -
-
) -} - -export default BehaviorSettingsSection diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx new file mode 100644 index 0000000..7e5ca1d --- /dev/null +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -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 = { + value: T + label: string } + +type SegmentedControlProps = { + value: T + options: SegmentedOption[] + onChange: (value: T) => void } + +type SettingBlockProps = { + title: string + description: string + children: ReactNode } + +const animationOptions: SegmentedOption[] = [ + { value: 'normal', label: '通常' }, + { value: 'reduced', label: '控えめ' }, + { value: 'off', label: 'なし' }, +] + +const embedAutoLoadOptions: SegmentedOption[] = [ + { value: 'auto', label: '自動' }, + { value: 'manual', label: 'クリック時' }, + { value: 'off', label: '読み込まない' }, +] + +const thumbnailModeOptions: SegmentedOption[] = [ + { value: 'normal', label: '通常' }, + { value: 'light', label: '軽量' }, + { value: 'off', label: '非表示' }, +] + + +const SegmentedControl = ( + { value, options, onChange }: SegmentedControlProps, +) => { + return ( +
+ {options.map (option => ( + ))} +
) +} + + +const SettingBlock: FC = ( + { title, description, children }, +) => ( +
+
+

{title}

+

{description}

+
+ {children} +
) + + +const BehaviourSettingsSection: FC = ({ sectionClassName }) => { + const [savedSettings, setSavedSettings] = + useState (() => getClientBehaviorSettings ()) + const [draftSettings, setDraftSettings] = + useState (() => getClientBehaviorSettings ()) + + useEffect (() => { + const current = getClientBehaviorSettings () + setSavedSettings (current) + setDraftSettings (current) + }, []) + + const hasUnsavedChanges = useMemo ( + () => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings), + [draftSettings, savedSettings], + ) + + const updateDraft = ( + 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 ( +
+
+
+

動作

+

+ この設定は現在のブラウザに保存されます。 +

+

+ 重い端末や通信量を抑えたい場合に調整します。 +

+ {hasUnsavedChanges && ( +

+ 未保存の変更があります +

)} +
+ +
+ + +
+
+ +
+ + updateDraft ('animation', value)}/> + + + + updateDraft ('embedAutoLoad', value)}/> + + + + updateDraft ('thumbnailMode', value)}/> + +
+
) +} + +export default BehaviourSettingsSection diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index b747fa3..a81eb8a 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -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, + })) + } } diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index e5e823d..54b9def 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -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 = ({ 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 = ({ user }) => { ) })} - {(post.thumbnail || post.thumbnailBase) && ( + {thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && ( )} diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index aca8f40..c3bb9a6 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -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 = (
-

タグカテゴリ色

+

タグカテゴリ色

-
+
{CATEGORIES.map (category => { const resolvedDefaults = defaultThemeTagColoursFor (selectedTheme.baseTheme) @@ -280,9 +280,19 @@ const ThemeSection: FC = ( return (
-
- {CATEGORY_NAMES[category]} + 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"> +
+

{CATEGORY_NAMES[category]}

+
+ +
+
+
= ( onChange={ev => onTagColourChange (category, ev.target.value)} className="h-10 w-16 cursor-pointer rounded border border-border bg-transparent p-1"/> -
-
)}
@@ -742,7 +750,7 @@ const SettingPage: FC = ({ user, setUser }) => { {activeTab === 'keyboard' && ( )} {activeTab === 'behavior' && ( - )} + )} )}
)}