diff --git a/backend/app/controllers/user_settings_controller.rb b/backend/app/controllers/user_settings_controller.rb index 2c40034..5b41c6a 100644 --- a/backend/app/controllers/user_settings_controller.rb +++ b/backend/app/controllers/user_settings_controller.rb @@ -25,7 +25,11 @@ class UserSettingsController < ApplicationController private def current_setting - current_user.setting || current_user.create_setting!(Setting.defaults) + Setting.find_or_create_by!(user: current_user) do |setting| + setting.assign_attributes(Setting.defaults) + end + rescue ActiveRecord::RecordNotUnique + Setting.find_by!(user: current_user) end def validate_raw_attributes raw_attributes diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index dc3d689..6b048c5 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -15,7 +15,7 @@ class Setting < ApplicationRecord 'updated_at_asc', 'updated_at_desc', ].freeze - VIEWED_POST_DISPLAYS = ['show', 'dim', 'hide'].freeze + VIEWED_POST_DISPLAYS = ['show', 'dim'].freeze AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index a8e9f29..68bcdaa 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -2,7 +2,6 @@ import { useRef, useState } from 'react' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import TagSearchBox from '@/components/TagSearchBox' import FormField from '@/components/common/FormField' import TextArea from '@/components/common/TextArea' @@ -40,7 +39,6 @@ type Props = Omit, 'value' | 'onChange' | ' const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { const ref = useRef (null) - const { settings } = useUserSettings () const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [focused, setFocused] = useState (false) @@ -72,9 +70,10 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { const data = await apiGet ('/tags/autocomplete', { params: { q: token, - nico: settings.tagAutocompleteNico ? '1' : '0' } }) - setSuggestions (data.filter (t => t.postCount > 0)) - setSuggestionsVsbl (suggestions.length > 0) + nico: '0' } }) + const nextSuggestions = data.filter (t => t.postCount > 0) + setSuggestions (nextSuggestions) + setSuggestionsVsbl (nextSuggestions.length > 0) } return ( diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 352f316..ca973f7 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -22,14 +22,10 @@ const PostList: FC = ({ posts, onClick }) => { const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) const cardRef = useRef (null) - const visiblePosts = - settings.viewedPostDisplay === 'hide' - ? posts.filter (post => !(post.viewed)) - : posts return (
- {visiblePosts.map ((post, i) => { + {posts.map ((post, i) => { const sharedId = `page-${ post.id }` const layoutId = sharedId diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index 878ba3e..a01f35d 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -37,9 +37,9 @@ const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { const data = await apiGet ('/tags/autocomplete', { params: { q, nico: settings.tagAutocompleteNico ? '1' : '0' } }) - setSuggestions (data.filter (t => t.postCount > 0)) - if (suggestions.length > 0) - setSuggestionsVsbl (true) + const nextSuggestions = data.filter (t => t.postCount > 0) + setSuggestions (nextSuggestions) + setSuggestionsVsbl (nextSuggestions.length > 0) } // TODO: TagSearch からのコピペのため,共通化を考へる. diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx index 7a73f51..ab15e19 100644 --- a/frontend/src/components/users/UserSettingsProvider.tsx +++ b/frontend/src/components/users/UserSettingsProvider.tsx @@ -9,6 +9,7 @@ import type { UserSettings } from '@/lib/settings' type ContextValue = { loaded: boolean + error: string | null settings: UserSettings setSettings: Dispatch> } @@ -41,6 +42,7 @@ const applyTheme = (theme: UserSettings['theme']) => { export const UserSettingsProvider: FC<{ children: ReactNode user: User | null }> = ({ children, user }) => { + const [error, setError] = useState (null) const [loaded, setLoaded] = useState (false) const [settings, setSettings] = useState (DEFAULT_USER_SETTINGS) @@ -49,12 +51,14 @@ export const UserSettingsProvider: FC<{ if (!(user)) { + setError (null) setSettings (DEFAULT_USER_SETTINGS) setLoaded (true) return } setLoaded (false) + setError (null) void (async () => { try @@ -62,6 +66,7 @@ export const UserSettingsProvider: FC<{ const next = await fetchUserSettings () if (!(cancelled)) { + setError (null) setSettings (next) setLoaded (true) } @@ -70,6 +75,7 @@ export const UserSettingsProvider: FC<{ { if (!(cancelled)) { + setError ('設定を読み込めませんでした.既定値で表示しています.') setSettings (DEFAULT_USER_SETTINGS) setLoaded (true) } @@ -89,10 +95,11 @@ export const UserSettingsProvider: FC<{ }, [settings.displayDensity, settings.fontSize]) const value = useMemo (() => ({ + error, loaded, settings, setSettings, - }), [loaded, settings]) + }), [error, loaded, settings]) return ( diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 97d04a0..bce5ca9 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -2,6 +2,7 @@ import { apiGet, apiPatch } from '@/lib/api' import type { FetchPostsOrder } from '@/types' +// DB-backed user settings. These are shared across browsers for the same user. export type UserPostListOrder = | 'title_asc' | 'title_desc' @@ -20,7 +21,7 @@ export type UserSettings = { fontSize: 'small' | 'normal' | 'large' postListLimit: 20 | 50 | 100 postListOrder: UserPostListOrder - viewedPostDisplay: 'show' | 'dim' | 'hide' + viewedPostDisplay: 'show' | 'dim' tagAutocompleteNico: boolean autoFetchTitle: 'auto' | 'manual' | 'off' autoFetchThumbnail: 'auto' | 'manual' | 'off' @@ -31,6 +32,7 @@ export type TheatreTagFlow = 'vertical' | 'horizontal' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' export type ClientPaneBreakpoint = 'desktop' | 'tablet' +// Browser-local settings. These stay in localStorage and are never synced to DB. type ClientPaneSettings = { widthPxByBreakpoint?: Partial> collapsed?: boolean } @@ -77,7 +79,7 @@ export const POST_LIST_ORDER_OPTIONS = [ 'updated_at_asc', 'updated_at_desc', ] as const -export const VIEWED_POST_DISPLAY_OPTIONS = ['show', 'dim', 'hide'] as const +export const VIEWED_POST_DISPLAY_OPTIONS = ['show', 'dim'] as const export const AUTO_FETCH_OPTIONS = ['auto', 'manual', 'off'] as const export const WIKI_EDITOR_MODE_OPTIONS = ['split', 'write', 'preview'] as const diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index a6bda52..413ed9c 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -43,18 +43,21 @@ const PostNewPage: FC = ({ user }) => { const [parentPostIds, setParentPostIds] = useState ('') const [tags, setTags] = useState ('') const [duration, setDuration] = useState ('') - const [thumbnailAutoFlg, setThumbnailAutoFlg] = - useState (settings.autoFetchThumbnail === 'auto') const [thumbnailFile, setThumbnailFile] = useState (null) const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailPreview, setThumbnailPreview] = useState ('') const [title, setTitle] = useState ('') - const [titleAutoFlg, setTitleAutoFlg] = useState (settings.autoFetchTitle === 'auto') const [titleLoading, setTitleLoading] = useState (false) const [url, setURL] = useState ('') const previousURLRef = useRef ('') const thumbnailPreviewRef = useRef ('') + const titleFetchMode = settings.autoFetchTitle + const thumbnailFetchMode = settings.autoFetchThumbnail + const titleAutoFlg = titleFetchMode === 'auto' + const thumbnailAutoFlg = thumbnailFetchMode === 'auto' + const titleFetchVisible = titleFetchMode !== 'off' + const thumbnailFetchVisible = thumbnailFetchMode !== 'off' const videoFlg = useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), [tags]) @@ -103,9 +106,15 @@ const PostNewPage: FC = ({ user }) => { const fetchTitle = useCallback (async () => { setTitle ('') setTitleLoading (true) - const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) - setTitle (data.title || '') - setTitleLoading (false) + try + { + const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) + setTitle (data.title || '') + } + finally + { + setTitleLoading (false) + } }, [url]) const fetchThumbnail = useCallback (async () => { @@ -114,28 +123,26 @@ const PostNewPage: FC = ({ user }) => { setThumbnailLoading (true) if (thumbnailPreviewRef.current) URL.revokeObjectURL (thumbnailPreviewRef.current) - const data = await apiGet ('/preview/thumbnail', - { params: { url }, responseType: 'blob' }) - const imageURL = URL.createObjectURL (data) - setThumbnailPreview (imageURL) - setThumbnailFile (new File ([data], - 'thumbnail.png', - { type: data.type || 'image/png' })) - setThumbnailLoading (false) + try + { + const data = await apiGet ('/preview/thumbnail', + { params: { url }, responseType: 'blob' }) + const imageURL = URL.createObjectURL (data) + setThumbnailPreview (imageURL) + setThumbnailFile (new File ([data], + 'thumbnail.png', + { type: data.type || 'image/png' })) + } + finally + { + setThumbnailLoading (false) + } }, [url]) useEffect (() => { thumbnailPreviewRef.current = thumbnailPreview }, [thumbnailPreview]) - useEffect (() => { - setTitleAutoFlg (settings.autoFetchTitle === 'auto') - }, [settings.autoFetchTitle]) - - useEffect (() => { - setThumbnailAutoFlg (settings.autoFetchThumbnail === 'auto') - }, [settings.autoFetchThumbnail]) - useEffect (() => { if (titleAutoFlg && url) fetchTitle () @@ -172,34 +179,58 @@ const PostNewPage: FC = ({ user }) => { {/* タイトル */} - setTitleAutoFlg (ev.target.checked)}} - label="タイトル" - messages={fieldErrors.title}> + {({ describedBy, invalid }) => ( - setTitle (ev.target.value)} - disabled={titleAutoFlg}/>)} +
+ setTitle (ev.target.value)} + disabled={titleAutoFlg}/> +
+ + {titleAutoFlg + ? 'URL 入力時に自動取得します.' + : titleFetchMode === 'manual' + ? '自動取得しません.必要なら手動で取得できます.' + : '取得機能は無効です.'} + + {titleFetchVisible && !(titleAutoFlg) && ( + )} +
+
)}
{/* サムネール */} - setThumbnailAutoFlg (ev.target.checked)}} - label="サムネール" - messages={fieldErrors.thumbnail}> + {({ describedBy, invalid }) => ( <> +
+ + {thumbnailAutoFlg + ? 'URL 入力時に自動取得します.' + : thumbnailFetchMode === 'manual' + ? '自動取得しません.必要なら手動で取得できます.' + : '取得機能は無効です.'} + + {thumbnailFetchVisible && !(thumbnailAutoFlg) && ( + )} +
{thumbnailAutoFlg ? (thumbnailLoading ?

Loading...

diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 3d4a66c..531abaa 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -85,10 +85,6 @@ const PostSearchPage: FC = () => { queryKey: postsKeys.index (keys), queryFn: () => fetchPosts (keys) }) const results = data?.posts ?? [] - const visibleResults = - settings.viewedPostDisplay === 'hide' - ? results.filter (row => !(row.viewed)) - : results const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { @@ -247,7 +243,7 @@ const PostSearchPage: FC = () => {
- {loading ? 'Loading...' : (visibleResults.length > 0 ? ( + {loading ? 'Loading...' : (results.length > 0 ? (
@@ -303,7 +299,7 @@ const PostSearchPage: FC = () => { - {visibleResults.map (row => ( + {results.map (row => ( = { + theme: 'display', + displayDensity: 'display', + fontSize: 'display', + postListLimit: 'posts', + postListOrder: 'posts', + viewedPostDisplay: 'posts', + tagAutocompleteNico: 'editing', + autoFetchTitle: 'editing', + autoFetchThumbnail: 'editing', + wikiEditorMode: 'wiki', +} + +const settingsFieldOrder: SettingsFormField[] = [ + 'theme', + 'displayDensity', + 'fontSize', + 'postListLimit', + 'postListOrder', + 'viewedPostDisplay', + 'tagAutocompleteNico', + 'autoFetchTitle', + 'autoFetchThumbnail', + 'wikiEditorMode', +] const themeLabel: Record = { system: 'システム設定に従う', @@ -80,8 +122,7 @@ const postListOrderLabel: Record = { const viewedPostDisplayLabel: Record = { show: '通常表示', - dim: '薄く表示', - hide: '隠す' } + dim: '薄く表示' } const autoFetchLabel: Record = { auto: '自動', @@ -94,12 +135,27 @@ const wikiEditorModeLabel: Record = { preview: 'プレビューのみ' } +const parseTab = (search: string): SettingsTab => { + const tab = new URLSearchParams (search).get ('tab') + return tabs.some (candidate => candidate.id === tab) + ? tab as SettingsTab + : 'account' +} + + +const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` +const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` + + const SettingPage: FC = ({ user, setUser }) => { + const location = useLocation () + const navigate = useNavigate () + const [draftSettings, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) const [name, setName] = useState ('') const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false) - const { loaded, settings, setSettings } = useUserSettings () + const { error, loaded, settings, setSettings } = useUserSettings () const { baseErrors: nameBaseErrors, fieldErrors: nameFieldErrors, @@ -113,6 +169,66 @@ const SettingPage: FC = ({ user, setUser }) => { applyValidationError: applySettingsValidationError, } = useValidationErrors () + const activeTab = useMemo (() => parseTab (location.search), [location.search]) + + const setActiveTab = useCallback ((tab: SettingsTab) => { + const params = new URLSearchParams (location.search) + params.set ('tab', tab) + navigate ( + `${ location.pathname }${ params.toString () ? `?${ params.toString () }` : '' }`, + { replace: true }, + ) + }, [location.pathname, location.search, navigate]) + + const settingsTabErrors = useMemo> (() => ({ + account: Boolean (nameFieldErrors.name?.length), + display: ( + Boolean (settingsFieldErrors.theme?.length) + || Boolean (settingsFieldErrors.displayDensity?.length) + || Boolean (settingsFieldErrors.fontSize?.length) + ), + posts: ( + Boolean (settingsFieldErrors.postListLimit?.length) + || Boolean (settingsFieldErrors.postListOrder?.length) + || Boolean (settingsFieldErrors.viewedPostDisplay?.length) + ), + editing: ( + Boolean (settingsFieldErrors.tagAutocompleteNico?.length) + || Boolean (settingsFieldErrors.autoFetchTitle?.length) + || Boolean (settingsFieldErrors.autoFetchThumbnail?.length) + ), + wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length), + }), [nameFieldErrors.name, settingsFieldErrors]) + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = tabs.findIndex (tab => tab.id === activeTab) + + switch (event.key) + { + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault () + setActiveTab (tabs[(currentIndex + 1) % tabs.length].id) + break + + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault () + setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) + break + + case 'Home': + event.preventDefault () + setActiveTab (tabs[0].id) + break + + case 'End': + event.preventDefault () + setActiveTab (tabs[tabs.length - 1].id) + break + } + } + const handleUserSubmit = async () => { if (!(user)) return @@ -130,9 +246,9 @@ const SettingPage: FC = ({ user, setUser }) => { setUser (currentUser => ({ ...currentUser, ...data })) toast ({ title: '表示名を更新しました.' }) } - catch (error) + catch (submitError) { - applyNameValidationError (error) + applyNameValidationError (submitError) toast ({ title: '表示名を更新できませんでした.' }) } } @@ -158,9 +274,9 @@ const SettingPage: FC = ({ user, setUser }) => { setSettings (data) toast ({ title: '設定を保存しました.' }) } - catch (error) + catch (submitError) { - applySettingsValidationError (error) + applySettingsValidationError (submitError) toast ({ title: '設定を保存できませんでした.' }) } } @@ -176,6 +292,19 @@ const SettingPage: FC = ({ user, setUser }) => { setDraftSettings (settings) }, [settings]) + useEffect (() => { + if (nameFieldErrors.name?.length) + setActiveTab ('account') + }, [nameFieldErrors.name, setActiveTab]) + + useEffect (() => { + const fieldWithError = settingsFieldOrder.find ( + field => (settingsFieldErrors[field]?.length ?? 0) > 0, + ) + if (fieldWithError) + setActiveTab (settingsTabByField[fieldWithError]) + }, [setActiveTab, settingsFieldErrors]) + return ( @@ -185,246 +314,331 @@ const SettingPage: FC = ({ user, setUser }) => {
設定 + {user && loaded ? ( <> -
-

アカウント

- +
+ {tabs.map (tab => ( + ))} +
- - {({ describedBy, invalid }) => ( - <> - setName (ev.target.value)}/> - {!(user.name) && ( -

- 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! -

)} - )} -
+
+ {activeTab === 'account' && ( + <> +

アカウント

+ -
- -
+ + {({ describedBy, invalid }) => ( + <> + setName (ev.target.value)}/> + {!(user.name) && ( +

+ 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! +

)} + )} +
-
- -
- - -
-
-
+
+ +
-
-

表示

+
+ +
+ + +
+
+ )} - - {() => ( - )} - + {activeTab === 'display' && ( + <> +

表示

+ - - {() => ( - )} - + + {() => ( + )} + - - {() => ( - )} - -
+ + {() => ( + )} + -
-

投稿一覧

+ + {() => ( + )} + - - {() => ( - )} - + + )} - - {() => ( - )} - + {activeTab === 'posts' && ( + <> +

投稿

+ - - {() => ( - )} - -
+ + {() => ( + )} + -
-

編輯支援

+ + {() => ( + )} + - - {() => ( - )} - + + {() => ( + )} + - - {() => ( - )} - + + )} - - {() => ( - )} - -
+ {activeTab === 'editing' && ( + <> +

編輯支援

+ -
-

Wiki

+ + {() => ( + )} + - - {() => ( - )} - -
+ + {() => ( + )} + -
- -
- -
+ + {() => ( + )} + + + + )} + + {activeTab === 'wiki' && ( + <> +

Wiki

+ + + + {() => ( + )} + + + + )}
) : 'Loading...'} - + - + ) }