diff --git a/backend/app/controllers/user_settings_controller.rb b/backend/app/controllers/user_settings_controller.rb index 5b41c6a..4980b01 100644 --- a/backend/app/controllers/user_settings_controller.rb +++ b/backend/app/controllers/user_settings_controller.rb @@ -1,4 +1,6 @@ class UserSettingsController < ApplicationController + wrap_parameters false + def show return head :unauthorized unless current_user @@ -8,12 +10,12 @@ class UserSettingsController < ApplicationController def update return head :unauthorized unless current_user - raw_attributes = request.request_parameters + raw_attributes = editable_raw_attributes field_errors = validate_raw_attributes(raw_attributes) return render_validation_error fields: field_errors if field_errors.present? setting = current_setting - setting.assign_attributes(raw_attributes.slice(*Setting::EDITABLE_ATTRIBUTES)) + setting.assign_attributes(raw_attributes) if setting.save render json: setting.serializable_hash, status: :ok @@ -32,13 +34,12 @@ class UserSettingsController < ApplicationController Setting.find_by!(user: current_user) end + def editable_raw_attributes + request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES) + end + def validate_raw_attributes raw_attributes raw_attributes.each_with_object({ }) do |(key, value), errors| - unless Setting::EDITABLE_ATTRIBUTES.include?(key) - errors[key.to_sym] = ['不明な設定です.'] - next - end - next if value_matches_type?(key, value) errors[key.to_sym] = ['値の型が不正です.'] diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index 6b048c5..425e152 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -1,46 +1,20 @@ class Setting < ApplicationRecord THEMES = ['system', 'light', 'dark'].freeze - DISPLAY_DENSITIES = ['comfortable', 'compact'].freeze - FONT_SIZES = ['small', 'normal', 'large'].freeze - POST_LIST_LIMITS = [20, 50, 100].freeze - POST_LIST_ORDERS = [ - 'title_asc', - 'title_desc', - 'url_asc', - 'url_desc', - 'original_created_at_asc', - 'original_created_at_desc', - 'created_at_asc', - 'created_at_desc', - 'updated_at_asc', - 'updated_at_desc', - ].freeze - VIEWED_POST_DISPLAYS = ['show', 'dim'].freeze AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze STRING_ATTRIBUTES = [ 'theme', - 'display_density', - 'font_size', - 'post_list_order', - 'viewed_post_display', 'auto_fetch_title', 'auto_fetch_thumbnail', 'wiki_editor_mode', ].freeze - INTEGER_ATTRIBUTES = ['post_list_limit'].freeze - BOOLEAN_ATTRIBUTES = ['tag_autocomplete_nico'].freeze + INTEGER_ATTRIBUTES = [].freeze + BOOLEAN_ATTRIBUTES = [].freeze EDITABLE_ATTRIBUTES = (STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze TYPE_BY_ATTRIBUTE = { 'theme' => :string, - 'display_density' => :string, - 'font_size' => :string, - 'post_list_limit' => :integer, - 'post_list_order' => :string, - 'viewed_post_display' => :string, - 'tag_autocomplete_nico' => :boolean, 'auto_fetch_title' => :string, 'auto_fetch_thumbnail' => :string, 'wiki_editor_mode' => :string, @@ -52,12 +26,6 @@ class Setting < ApplicationRecord validates :user_id, uniqueness: true validates :theme, inclusion: { in: THEMES } - validates :display_density, inclusion: { in: DISPLAY_DENSITIES } - validates :font_size, inclusion: { in: FONT_SIZES } - validates :post_list_limit, inclusion: { in: POST_LIST_LIMITS } - validates :post_list_order, inclusion: { in: POST_LIST_ORDERS } - validates :viewed_post_display, inclusion: { in: VIEWED_POST_DISPLAYS } - validates :tag_autocomplete_nico, inclusion: { in: [true, false] } validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES } validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES } validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES } @@ -65,12 +33,6 @@ class Setting < ApplicationRecord def self.defaults { theme: 'system', - display_density: 'comfortable', - font_size: 'normal', - post_list_limit: 50, - post_list_order: 'created_at_desc', - viewed_post_display: 'show', - tag_autocomplete_nico: true, auto_fetch_title: 'manual', auto_fetch_thumbnail: 'manual', wiki_editor_mode: 'split', diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 41b5a66..09619e1 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -81,6 +81,9 @@ Rails.application.routes.draw do end end + get 'users/settings', to: 'user_settings#show' + patch 'users/settings', to: 'user_settings#update' + resources :users, only: [:create, :update] do collection do post :verify @@ -89,11 +92,6 @@ Rails.application.routes.draw do end end - scope 'users/settings', controller: :user_settings do - get '', action: :show - patch '', action: :update - end - resources :deerjikists, only: [] do collection do scope ':platform/:code' do diff --git a/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb b/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb index 36c7eb7..f69cc24 100644 --- a/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb +++ b/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb @@ -9,28 +9,6 @@ class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0] change_column_null :settings, :user_id, false add_column :settings, :theme, :string, null: false, default: 'system' - add_column :settings, - :display_density, - :string, - null: false, - default: 'comfortable' - add_column :settings, :font_size, :string, null: false, default: 'normal' - add_column :settings, :post_list_limit, :integer, null: false, default: 50 - add_column :settings, - :post_list_order, - :string, - null: false, - default: 'created_at_desc' - add_column :settings, - :viewed_post_display, - :string, - null: false, - default: 'show' - add_column :settings, - :tag_autocomplete_nico, - :boolean, - null: false, - default: true add_column :settings, :auto_fetch_title, :string, diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 6463278..311935c 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -377,12 +377,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "theme", default: "system", null: false - t.string "display_density", default: "comfortable", null: false - t.string "font_size", default: "normal", null: false - t.integer "post_list_limit", default: 50, null: false - t.string "post_list_order", default: "created_at_desc", null: false - t.string "viewed_post_display", default: "show", null: false - t.boolean "tag_autocomplete_nico", default: true, null: false t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "wiki_editor_mode", default: "split", null: false diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index ca973f7..69ef69a 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -2,7 +2,6 @@ import { motion } from 'framer-motion' import { useRef } from 'react' import { useLocation } from 'react-router-dom' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import PrefetchLink from '@/components/PrefetchLink' import { cn } from '@/lib/utils' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' @@ -17,7 +16,6 @@ type Props = { posts: Post[] const PostList: FC = ({ posts, onClick }) => { const location = useLocation () - const { settings } = useUserSettings () const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) @@ -44,9 +42,6 @@ const PostList: FC = ({ posts, onClick }) => { layoutId={layoutId} className={cn ('w-full h-full overflow-hidden rounded-xl shadow', 'transform-gpu will-change-transform', - settings.viewedPostDisplay === 'dim' - && post.viewed - && 'opacity-40 saturate-50', (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} whileHover={{ scale: 1.02 }} diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx index dffc950..96eabf8 100644 --- a/frontend/src/components/common/TagInput.test.tsx +++ b/frontend/src/components/common/TagInput.test.tsx @@ -26,7 +26,7 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/autocomplete', - { params: { q: '虹夏' } }, + { params: { q: '虹夏', nico: '0' } }, ) }) expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') @@ -41,4 +41,20 @@ describe ('TagInput', () => { expect (api.apiGet).not.toHaveBeenCalled () expect (setValue).toHaveBeenCalledWith (' ') }) + + it ('sends nico=1 only when includeNico is true', async () => { + const setValue = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) + + render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/autocomplete', + { params: { q: '虹夏', nico: '1' } }, + ) + }) + }) }) diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index a01f35d..8eec0b6 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import TagSearchBox from '@/components/TagSearchBox' import { apiGet } from '@/lib/api' import { inputClass } from '@/lib/utils' @@ -11,17 +10,23 @@ import type { Tag } from '@/types' type Props = { + includeNico?: boolean describedBy?: string invalid?: boolean value: string setValue: (value: string) => void } -const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { +const TagInput: FC = ({ + includeNico = false, + describedBy, + invalid, + value, + setValue, +}) => { const [activeIndex, setActiveIndex] = useState (-1) const [suggestions, setSuggestions] = useState ([]) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - const { settings } = useUserSettings () // TODO: TagSearch からのコピペのため,共通化を考へる. const whenChanged = async (ev: ChangeEvent) => { @@ -36,7 +41,7 @@ const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { const data = await apiGet ('/tags/autocomplete', { params: { q, - nico: settings.tagAutocompleteNico ? '1' : '0' } }) + nico: includeNico ? '1' : '0' } }) const nextSuggestions = data.filter (t => t.postCount > 0) setSuggestions (nextSuggestions) setSuggestionsVsbl (nextSuggestions.length > 0) diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx index ab15e19..2d059bc 100644 --- a/frontend/src/components/users/UserSettingsProvider.tsx +++ b/frontend/src/components/users/UserSettingsProvider.tsx @@ -89,11 +89,6 @@ export const UserSettingsProvider: FC<{ useEffect (() => applyTheme (settings.theme), [settings.theme]) - useEffect (() => { - document.documentElement.dataset.displayDensity = settings.displayDensity - document.documentElement.dataset.fontSize = settings.fontSize - }, [settings.displayDensity, settings.fontSize]) - const value = useMemo (() => ({ error, loaded, diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index bce5ca9..f32db7b 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -1,49 +1,38 @@ 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' - | 'url_asc' - | 'url_desc' - | 'original_created_at_asc' - | 'original_created_at_desc' - | 'created_at_asc' - | 'created_at_desc' - | 'updated_at_asc' - | 'updated_at_desc' +import type { FetchPostsOrder, FetchTagsOrder } from '@/types' +// DB-backed user settings. These are worth sharing across browsers for one user. export type UserSettings = { - theme: 'system' | 'light' | 'dark' - displayDensity: 'comfortable' | 'compact' - fontSize: 'small' | 'normal' | 'large' - postListLimit: 20 | 50 | 100 - postListOrder: UserPostListOrder - viewedPostDisplay: 'show' | 'dim' - tagAutocompleteNico: boolean - autoFetchTitle: 'auto' | 'manual' | 'off' - autoFetchThumbnail: 'auto' | 'manual' | 'off' - wikiEditorMode: 'split' | 'write' | 'preview' } + theme: 'system' | 'light' | 'dark' + autoFetchTitle: 'auto' | 'manual' | 'off' + autoFetchThumbnail: 'auto' | 'manual' | 'off' + wikiEditorMode: 'split' | 'write' | 'preview' } export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' export type ClientPaneBreakpoint = 'desktop' | 'tablet' +export type ClientListKey = 'postList' | 'postSearch' | 'tagList' +export type ClientListLimit = 20 | 50 | 100 -// Browser-local settings. These stay in localStorage and are never synced to DB. type ClientPaneSettings = { widthPxByBreakpoint?: Partial> collapsed?: boolean } +type ClientListSettings = { + limit?: ClientListLimit + order?: string } + +// Browser-local settings. These depend on device or screen context. export type ClientSettings = { - panes?: Record - theatre?: { + panes?: Record + lists?: Partial> + theatre?: { layoutMode?: TheatreLayoutMode tagFlow?: TheatreTagFlow } - gekanator?: { + gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode } reducedMotion?: string @@ -52,36 +41,42 @@ export type ClientSettings = { export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' export const DEFAULT_USER_SETTINGS: UserSettings = { - theme: 'system', - displayDensity: 'comfortable', - fontSize: 'normal', - postListLimit: 50, - postListOrder: 'created_at_desc', - viewedPostDisplay: 'show', - tagAutocompleteNico: true, - autoFetchTitle: 'manual', - autoFetchThumbnail: 'manual', - wikiEditorMode: 'split' } + theme: 'system', + autoFetchTitle: 'manual', + autoFetchThumbnail: 'manual', + wikiEditorMode: 'split' } export const THEME_OPTIONS = ['system', 'light', 'dark'] as const -export const DISPLAY_DENSITY_OPTIONS = ['comfortable', 'compact'] as const -export const FONT_SIZE_OPTIONS = ['small', 'normal', 'large'] as const -export const POST_LIST_LIMIT_OPTIONS = [20, 50, 100] as const -export const POST_LIST_ORDER_OPTIONS = [ - 'title_asc', - 'title_desc', - 'url_asc', - 'url_desc', - 'original_created_at_asc', - 'original_created_at_desc', - 'created_at_asc', - 'created_at_desc', - 'updated_at_asc', - 'updated_at_desc', -] 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 +export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const +export const POST_LIST_ORDER_OPTIONS = [ + 'title:asc', + 'title:desc', + 'url:asc', + 'url:desc', + 'original_created_at:asc', + 'original_created_at:desc', + 'created_at:asc', + 'created_at:desc', + 'updated_at:asc', + 'updated_at:desc', +] as const satisfies readonly FetchPostsOrder[] +export const TAG_LIST_ORDER_OPTIONS = [ + 'name:asc', + 'name:desc', + 'category:asc', + 'category:desc', + 'post_count:asc', + 'post_count:desc', + 'created_at:asc', + 'created_at:desc', + 'updated_at:asc', + 'updated_at:desc', +] as const satisfies readonly FetchTagsOrder[] +export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 +export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' +export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'name:asc' const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' @@ -95,26 +90,14 @@ export const fetchUserSettings = async (): Promise => export const updateUserSettings = async ( settings: Partial<{ - theme: UserSettings['theme'] - display_density: UserSettings['displayDensity'] - font_size: UserSettings['fontSize'] - post_list_limit: UserSettings['postListLimit'] - post_list_order: UserSettings['postListOrder'] - viewed_post_display: UserSettings['viewedPostDisplay'] - tag_autocomplete_nico: UserSettings['tagAutocompleteNico'] - auto_fetch_title: UserSettings['autoFetchTitle'] - auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] - wiki_editor_mode: UserSettings['wikiEditorMode'] + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] }>, ): Promise => await apiPatch ('/users/settings', settings) -export const toFetchPostsOrder = ( - value: UserSettings['postListOrder'], -): FetchPostsOrder => - value.replace (/_(asc|desc)$/, ':$1') as FetchPostsOrder - - const safeParseClientSettings = (raw: string | null): ClientSettings => { if (!(raw)) return { } @@ -149,6 +132,45 @@ export const updateClientSettings = ( } +const validListLimit = (value: unknown): ClientListLimit | null => + value === 20 || value === 50 || value === 100 ? value : null + + +export const getClientListLimit = ( + listKey: ClientListKey, +): ClientListLimit | null => { + const value = loadClientSettings ().lists?.[listKey]?.limit + return validListLimit (value) +} + + +export const getClientListOrder = ( + listKey: ClientListKey, +): T | null => { + const value = loadClientSettings ().lists?.[listKey]?.order + return typeof value === 'string' ? value as T : null +} + + +export const setClientListSettings = ( + listKey: ClientListKey, + { limit, order }: { limit?: ClientListLimit + order?: string }, +): void => { + updateClientSettings (settings => ({ + ...settings, + lists: { + ...(settings.lists ?? { }), + [listKey]: { + ...(settings.lists?.[listKey] ?? { }), + ...(limit == null ? { } : { limit }), + ...(order == null ? { } : { order }), + }, + }, + })) +} + + const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) return ( diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 40f233f..ef8c6a9 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => { {/* タグ */} - + {({ describedBy, invalid }) => ( = { + 'title:asc': 'タイトル昇順', + 'title:desc': 'タイトル降順', + 'url:asc': 'URL 昇順', + 'url:desc': 'URL 降順', + 'original_created_at:asc': 'オリジナル投稿日時 昇順', + 'original_created_at:desc': 'オリジナル投稿日時 降順', + 'created_at:asc': '投稿日 昇順', + 'created_at:desc': '投稿日 降順', + 'updated_at:asc': '更新日 昇順', + 'updated_at:desc': '更新日 降順', +} + +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + +const parseOrder = (value: string | null): FetchPostsOrder | null => + POST_LIST_ORDER_OPTIONS.some (option => option === value) + ? value as FetchPostsOrder + : null const PostListPage: FC = () => { const containerRef = useRef (null) - const { settings } = useUserSettings () const [wikiPage, setWikiPage] = useState (null) const location = useLocation () + const navigate = useNavigate () const query = new URLSearchParams (location.search) const tagsQuery = query.get ('tags') ?? '' const anyFlg = query.get ('match') === 'any' @@ -36,8 +68,16 @@ const PostListPage: FC = () => { const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) const tagsKey = tags.join (' ') const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? settings.postListLimit) - const order = toFetchPostsOrder (settings.postListOrder) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('postList') + ?? DEFAULT_POST_LIST_LIMIT + ) + const order = ( + parseOrder (query.get ('order')) + ?? getClientListOrder ('postList') + ?? DEFAULT_POST_LIST_ORDER + ) const keys = { tags: tagsKey, match, page, limit, @@ -51,6 +91,19 @@ const PostListPage: FC = () => { const cursor = '' const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('postList', { limit, order }) + }, [limit, order]) + + const updateListQuery = (next: { limit?: 20 | 50 | 100 + order?: FetchPostsOrder }) => { + const params = new URLSearchParams (location.search) + params.set ('limit', String (next.limit ?? limit)) + params.set ('order', next.order ?? order) + params.set ('page', '1') + navigate (`${ location.pathname }?${ params.toString () }`) + } + useLayoutEffect (() => { scroll (0, 0) @@ -93,6 +146,38 @@ const PostListPage: FC = () => { }}/> +
+ + {() => ( + )} + + + + {() => ( + )} + +
+ {posts.length > 0 diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index 413ed9c..dca39c4 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -49,13 +49,14 @@ const PostNewPage: FC = ({ user }) => { const [title, setTitle] = useState ('') const [titleLoading, setTitleLoading] = useState (false) const [url, setURL] = useState ('') + const [titleAutoFlg, setTitleAutoFlg] = useState (settings.autoFetchTitle === 'auto') + const [thumbnailAutoFlg, setThumbnailAutoFlg] = + useState (settings.autoFetchThumbnail === 'auto') 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 = @@ -143,6 +144,14 @@ const PostNewPage: FC = ({ user }) => { thumbnailPreviewRef.current = thumbnailPreview }, [thumbnailPreview]) + useEffect (() => { + setTitleAutoFlg (settings.autoFetchTitle === 'auto') + }, [settings.autoFetchTitle]) + + useEffect (() => { + setThumbnailAutoFlg (settings.autoFetchThumbnail === 'auto') + }, [settings.autoFetchThumbnail]) + useEffect (() => { if (titleAutoFlg && url) fetchTitle () @@ -189,23 +198,33 @@ const PostNewPage: FC = ({ user }) => { value={title} placeholder={titleLoading ? 'Loading...' : ''} onChange={ev => setTitle (ev.target.value)} - disabled={titleAutoFlg}/> + disabled={titleLoading}/>
{titleAutoFlg ? 'URL 入力時に自動取得します.' : titleFetchMode === 'manual' ? '自動取得しません.必要なら手動で取得できます.' - : '取得機能は無効です.'} + : '取得機能は無効です.'} - {titleFetchVisible && !(titleAutoFlg) && ( - )} + {titleFetchVisible && ( + <> + + {!(titleAutoFlg) && ( + )} + )}
)}
@@ -220,16 +239,26 @@ const PostNewPage: FC = ({ user }) => { ? 'URL 入力時に自動取得します.' : thumbnailFetchMode === 'manual' ? '自動取得しません.必要なら手動で取得できます.' - : '取得機能は無効です.'} + : '取得機能は無効です.'} - {thumbnailFetchVisible && !(thumbnailAutoFlg) && ( - )} + {thumbnailFetchVisible && ( + <> + + {!(thumbnailAutoFlg) && ( + )} + )} {thumbnailAutoFlg ? (thumbnailLoading diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 531abaa..0372361 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -13,11 +13,18 @@ import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import { SITE_TITLE } from '@/config' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' -import { toFetchPostsOrder } from '@/lib/settings' +import { + DEFAULT_POST_LIST_LIMIT, + DEFAULT_POST_LIST_ORDER, + getClientListLimit, + getClientListOrder, + LIST_LIMIT_OPTIONS, + POST_LIST_ORDER_OPTIONS, + setClientListSettings, +} from '@/lib/settings' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -33,19 +40,44 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { qs.set (k, t) } +const postOrderLabel: Record = { + 'title:asc': 'タイトル昇順', + 'title:desc': 'タイトル降順', + 'url:asc': 'URL 昇順', + 'url:desc': 'URL 降順', + 'original_created_at:asc': 'オリジナル投稿日時 昇順', + 'original_created_at:desc': 'オリジナル投稿日時 降順', + 'created_at:asc': '投稿日 昇順', + 'created_at:desc': '投稿日 降順', + 'updated_at:asc': '更新日 昇順', + 'updated_at:desc': '更新日 降順', +} + +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + +const parseOrder = (value: string | null): FetchPostsOrder | null => + POST_LIST_ORDER_OPTIONS.some (option => option === value) + ? value as FetchPostsOrder + : null + const PostSearchPage: FC = () => { const location = useLocation () - const { settings } = useUserSettings () const navigate = useNavigate () const query = useMemo (() => new URLSearchParams (location.search), [location.search]) - const defaultOrder = toFetchPostsOrder (settings.postListOrder) const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? settings.postListLimit) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('postSearch') + ?? DEFAULT_POST_LIST_LIMIT + ) const qURL = query.get ('url') ?? '' const qTitle = query.get ('title') ?? '' @@ -57,7 +89,11 @@ const PostSearchPage: FC = () => { const qCreatedTo = query.get ('created_to') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? '' const qUpdatedTo = query.get ('updated_to') ?? '' - const order = (query.get ('order') || defaultOrder) as FetchPostsOrder + const order = ( + parseOrder (query.get ('order')) + ?? getClientListOrder ('postSearch') + ?? DEFAULT_POST_LIST_ORDER + ) const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) @@ -87,6 +123,10 @@ const PostSearchPage: FC = () => { const results = data?.posts ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('postSearch', { limit, order }) + }, [limit, order]) + useEffect (() => { setURL (qURL ?? '') setTitle (qTitle ?? '') @@ -117,6 +157,16 @@ const PostSearchPage: FC = () => { qs.set ('match', matchType) qs.set ('page', '1') qs.set ('order', order) + qs.set ('limit', String (limit)) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + + const updateListQuery = (next: { limit?: 20 | 50 | 100 + order?: FetchPostsOrder }) => { + const qs = new URLSearchParams (location.search) + qs.set ('limit', String (next.limit ?? limit)) + qs.set ('order', next.order ?? order) + qs.set ('page', '1') navigate (`${ location.pathname }?${ qs.toString () }`) } @@ -141,6 +191,38 @@ const PostSearchPage: FC = () => { 広場検索
+
+ + {() => ( + )} + + + + {() => ( + )} + +
+ {/* タイトル */} {({ invalid }) => ( @@ -165,6 +247,7 @@ const PostSearchPage: FC = () => { {() => ( )} @@ -302,11 +385,7 @@ const PostSearchPage: FC = () => { {results.map (row => ( + className="even:bg-gray-100 dark:even:bg-gray-700"> const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' +const tagOrderLabel: Record = { + 'name:asc': '名前昇順', + 'name:desc': '名前降順', + 'category:asc': 'カテゴリ昇順', + 'category:desc': 'カテゴリ降順', + 'post_count:asc': '件数昇順', + 'post_count:desc': '件数降順', + 'created_at:asc': '最初の記載日時 昇順', + 'created_at:desc': '最初の記載日時 降順', + 'updated_at:asc': '更新日時 昇順', + 'updated_at:desc': '更新日時 降順', +} + +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + +const parseOrder = (value: string | null): FetchTagsOrder | null => + TAG_LIST_ORDER_OPTIONS.some (option => option === value) + ? value as FetchTagsOrder + : null + const TagListPage: FC = () => { const location = useLocation () @@ -44,7 +76,11 @@ const TagListPage: FC = () => { const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? 20) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('tagList') + ?? DEFAULT_POST_LIST_LIMIT + ) const qName = query.get ('name') ?? '' const qCategory = (query.get ('category') || null) as Category | null @@ -58,7 +94,11 @@ const TagListPage: FC = () => { const qDeprecated = query.has ('deprecated') ? boolFromQuery (query.get ('deprecated')) : null - const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder + const order = ( + parseOrder (query.get ('order')) + ?? getClientListOrder ('tagList') + ?? DEFAULT_TAG_LIST_ORDER + ) const [name, setName] = useState ('') const [category, setCategory] = useState (null) @@ -88,6 +128,10 @@ const TagListPage: FC = () => { const results = data?.tags ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('tagList', { limit, order }) + }, [limit, order]) + useEffect (() => { setName (qName) setCategory (qCategory) @@ -121,10 +165,20 @@ const TagListPage: FC = () => { qs.set ('deprecated', deprecated ? '1' : '0') qs.set ('page', '1') qs.set ('order', order) + qs.set ('limit', String (limit)) navigate (`${ location.pathname }?${ qs.toString () }`) } + const updateListQuery = (next: { limit?: 20 | 50 | 100 + order?: FetchTagsOrder }) => { + const qs = new URLSearchParams (location.search) + qs.set ('limit', String (next.limit ?? limit)) + qs.set ('order', next.order ?? order) + qs.set ('page', '1') + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const defaultDirection = { name: 'asc', category: 'asc', post_count: 'desc', @@ -141,6 +195,38 @@ const TagListPage: FC = () => { タグ +
+ + {() => ( + )} + + + + {() => ( + )} + +
+ {/* 名前 */} {({ invalid }) => ( diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index b40385d..36ec88b 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,11 +1,15 @@ -import type { Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' +import type { + Dispatch, + FC, + KeyboardEvent, + SetStateAction, +} from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' -import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' @@ -18,16 +22,11 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' import { - AUTO_FETCH_OPTIONS, - DEFAULT_USER_SETTINGS, - DISPLAY_DENSITY_OPTIONS, - FONT_SIZE_OPTIONS, - POST_LIST_LIMIT_OPTIONS, - POST_LIST_ORDER_OPTIONS, - THEME_OPTIONS, - VIEWED_POST_DISPLAY_OPTIONS, - WIKI_EDITOR_MODE_OPTIONS, - updateUserSettings, + AUTO_FETCH_OPTIONS, + DEFAULT_USER_SETTINGS, + THEME_OPTIONS, + WIKI_EDITOR_MODE_OPTIONS, + updateUserSettings, } from '@/lib/settings' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -41,41 +40,55 @@ type Props = { user: User | null type UserFormField = 'name' type SettingsFormField = | 'theme' - | 'displayDensity' - | 'fontSize' - | 'postListLimit' - | 'postListOrder' - | 'viewedPostDisplay' - | 'tagAutocompleteNico' | 'autoFetchTitle' | 'autoFetchThumbnail' | 'wikiEditorMode' -type SettingsTab = 'account' | 'display' | 'posts' | 'editing' | 'wiki' +type SettingsTab = 'account' | 'theme' | 'keyboard' | 'editing' | 'wiki' type TabSpec = { id: SettingsTab label: string } +type NameFieldErrors = Partial> +type SettingsFieldErrors = Partial> + +type SharedSectionProps = { + draftSettings: UserSettings + name: string + nameBaseErrors: string[] + nameFieldErrors: NameFieldErrors + onDraftSettingsChange: Dispatch> + onInheritOpen: () => void + onNameChange: (value: string) => void + onSettingsSubmit: () => void + onUserCodeOpen: () => void + onUserSubmit: () => void + settingsBaseErrors: string[] + settingsFieldErrors: SettingsFieldErrors + user: User } + const sectionClassName = 'space-y-4 rounded-xl border border-border bg-background/80 p-4' +const desktopTabRailClassName = [ + 'hidden md:flex md:justify-center', +].join (' ') + +const desktopTabRailInnerClassName = [ + 'grid w-fit grid-cols-[12rem_minmax(0,36rem)] items-start gap-6', +].join (' ') + const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, - { id: 'display', label: '表示' }, - { id: 'posts', label: '投稿' }, + { id: 'theme', label: 'テーマ' }, + { id: 'keyboard', label: 'キーボード' }, { id: 'editing', label: '編輯支援' }, { id: 'wiki', label: 'Wiki' }, ] const settingsTabByField: Record = { - theme: 'display', - displayDensity: 'display', - fontSize: 'display', - postListLimit: 'posts', - postListOrder: 'posts', - viewedPostDisplay: 'posts', - tagAutocompleteNico: 'editing', + theme: 'theme', autoFetchTitle: 'editing', autoFetchThumbnail: 'editing', wikiEditorMode: 'wiki', @@ -83,12 +96,6 @@ const settingsTabByField: Record = { const settingsFieldOrder: SettingsFormField[] = [ 'theme', - 'displayDensity', - 'fontSize', - 'postListLimit', - 'postListOrder', - 'viewedPostDisplay', - 'tagAutocompleteNico', 'autoFetchTitle', 'autoFetchThumbnail', 'wikiEditorMode', @@ -96,43 +103,44 @@ const settingsFieldOrder: SettingsFormField[] = [ const themeLabel: Record = { system: 'システム設定に従う', - light: '明', - dark: '暗' } - -const densityLabel: Record = { - comfortable: '標準', - compact: '詰める' } - -const fontSizeLabel: Record = { - small: '小', - normal: '標準', - large: '大' } - -const postListOrderLabel: Record = { - title_asc: 'タイトル昇順', - title_desc: 'タイトル降順', - url_asc: 'URL 昇順', - url_desc: 'URL 降順', - original_created_at_asc: 'オリジナル投稿日時 昇順', - original_created_at_desc: 'オリジナル投稿日時 降順', - created_at_asc: '投稿日 昇順', - created_at_desc: '投稿日 降順', - updated_at_asc: '更新日 昇順', - updated_at_desc: '更新日 降順' } - -const viewedPostDisplayLabel: Record = { - show: '通常表示', - dim: '薄く表示' } + light: 'ライト・モード', + dark: 'ダーク・モード', +} const autoFetchLabel: Record = { auto: '自動', manual: '手動', - off: '無効' } + off: '無効', +} const wikiEditorModeLabel: Record = { split: '左右表示', write: '本文のみ', - preview: 'プレビューのみ' } + preview: 'プレビューのみ', +} + +const keyboardShortcutRows = [ + { action: '投稿詳細を開く', key: 'Enter', scope: '一覧・検索結果', conflict: 'なし' }, + { action: '候補タグを選ぶ', key: '↑ / ↓ / Enter', scope: 'タグ入力', conflict: 'なし' }, + { action: '候補タグを閉ぢる', key: 'Escape', scope: 'タグ入力', conflict: 'なし' }, +] + +const themeColorTargets = [ + '背景色', + '文字色', + '枠線色', + 'リンク色', + '強調色', + '通常タグ', + 'キャラタグ', + '素材タグ', + 'Nico タグ', + '廃止タグ', + 'Wiki ありタグ', + 'Wiki 未作成タグ', + '選択中タグ', + 'ホバー中タグ', +] const parseTab = (search: string): SettingsTab => { @@ -147,6 +155,243 @@ const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` +const AccountSection: FC = ({ + name, + nameBaseErrors, + nameFieldErrors, + onInheritOpen, + onNameChange, + onUserCodeOpen, + onUserSubmit, + user, +}) => ( +
+

アカウント

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

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

)} + )} +
+ +
+ +
+ +
+ +
+ + +
+
+
) + + +const ThemeSection: FC = ({ + draftSettings, + onDraftSettingsChange, + onSettingsSubmit, + settingsBaseErrors, + settingsFieldErrors, +}) => ( +
+

テーマ

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

今後の拡張予定

+

+ 将来は `themes` または `user_themes` を導入し,ユーザごとの + カスタム・テーマを持てる構造にする前提です. +

+

その際は次の色を個別に設定できるようにします.

+
+ {themeColorTargets.map (target => ( + + {target} + ))} +
+
+ + +
) + + +const KeyboardSection: FC = () => ( +
+

キーボード

+ +
+

+ 初期実装では固定ショートカット一覧のみを表示します. + 将来は `action / key / scope / conflict` を持つ + key bind 設定へ拡張する前提です. +

+
+ +
+ + + + + + + + + + + {keyboardShortcutRows.map (row => ( + + + + + + ))} + +
操作キー適用範囲競合
{row.action}{row.key}{row.scope}{row.conflict}
+
+
) + + +const EditingSection: FC = ({ + draftSettings, + onDraftSettingsChange, + onSettingsSubmit, + settingsBaseErrors, + settingsFieldErrors, +}) => ( +
+

編輯支援

+ + + + {() => ( + )} + + + + {() => ( + )} + + + +
) + + +const WikiSection: FC = ({ + draftSettings, + onDraftSettingsChange, + onSettingsSubmit, + settingsBaseErrors, + settingsFieldErrors, +}) => ( +
+

Wiki

+ + + + {() => ( + )} + + + +
) + + const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () const navigate = useNavigate () @@ -182,19 +427,10 @@ const SettingPage: FC = ({ user, setUser }) => { 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) - ), + theme: Boolean (settingsFieldErrors.theme?.length), + keyboard: false, editing: ( - Boolean (settingsFieldErrors.tagAutocompleteNico?.length) - || Boolean (settingsFieldErrors.autoFetchTitle?.length) + Boolean (settingsFieldErrors.autoFetchTitle?.length) || Boolean (settingsFieldErrors.autoFetchThumbnail?.length) ), wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length), @@ -260,12 +496,6 @@ const SettingPage: FC = ({ user, setUser }) => { { const data = await updateUserSettings ({ theme: draftSettings.theme, - display_density: draftSettings.displayDensity, - font_size: draftSettings.fontSize, - post_list_limit: draftSettings.postListLimit, - post_list_order: draftSettings.postListOrder, - viewed_post_display: draftSettings.viewedPostDisplay, - tag_autocomplete_nico: draftSettings.tagAutocompleteNico, auto_fetch_title: draftSettings.autoFetchTitle, auto_fetch_thumbnail: draftSettings.autoFetchThumbnail, wiki_editor_mode: draftSettings.wikiEditorMode, @@ -305,6 +535,22 @@ const SettingPage: FC = ({ user, setUser }) => { setActiveTab (settingsTabByField[fieldWithError]) }, [setActiveTab, settingsFieldErrors]) + const sectionProps: SharedSectionProps | null = user ? { + draftSettings, + name, + nameBaseErrors, + nameFieldErrors, + onDraftSettingsChange: setDraftSettings, + onInheritOpen: () => setInheritVsbl (true), + onNameChange: setName, + onSettingsSubmit: handleSettingsSubmit, + onUserCodeOpen: () => setUserCodeVsbl (true), + onUserSubmit: handleUserSubmit, + settingsBaseErrors, + settingsFieldErrors, + user, + } : null + return ( @@ -312,322 +558,73 @@ const SettingPage: FC = ({ user, setUser }) => { 設定 | {SITE_TITLE} - - 設定 - +
+
+ 設定 + +
{user && loaded ? ( <> -
- {tabs.map (tab => ( - ))} +
+ {sectionProps && ( + <> + + + + + + )}
-
- {activeTab === 'account' && ( - <> -

アカウント

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

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

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

表示

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

投稿

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

編輯支援

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

Wiki

- - - - {() => ( - )} - - - - )} -
+
+ {sectionProps && ( +
+ {activeTab === 'account' && } + {activeTab === 'theme' && } + {activeTab === 'keyboard' && } + {activeTab === 'editing' && } + {activeTab === 'wiki' && } +
)} +
+
+
) : 'Loading...'} - +