From 6a57dc53bfea1402894899c6bb5c607ed2a79930 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 4 Jul 2026 19:03:50 +0900 Subject: [PATCH 01/30] #34 --- AGENTS.md | 11 +- .../controllers/user_settings_controller.rb | 56 +++ backend/app/models/setting.rb | 84 +++- backend/app/models/user.rb | 2 +- backend/config/routes.rb | 5 + ...rebuild_settings_as_typed_user_settings.rb | 53 +++ backend/db/schema.rb | 16 +- frontend/src/App.tsx | 35 +- frontend/src/components/PostFormTagsArea.tsx | 6 +- frontend/src/components/PostList.tsx | 13 +- frontend/src/components/common/TagInput.tsx | 6 +- .../components/users/UserSettingsProvider.tsx | 111 +++++ frontend/src/index.css | 23 + frontend/src/lib/settings.ts | 258 ++++++++++++ frontend/src/pages/GekanatorPage.tsx | 16 +- frontend/src/pages/posts/PostListPage.tsx | 8 +- frontend/src/pages/posts/PostNewPage.tsx | 15 +- frontend/src/pages/posts/PostSearchPage.tsx | 24 +- .../src/pages/theatres/TheatreDetailPage.tsx | 31 +- frontend/src/pages/users/SettingPage.tsx | 392 +++++++++++++++--- frontend/src/pages/wiki/WikiEditPage.tsx | 24 ++ frontend/src/pages/wiki/WikiNewPage.tsx | 24 ++ 22 files changed, 1099 insertions(+), 114 deletions(-) create mode 100644 backend/app/controllers/user_settings_controller.rb create mode 100644 backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb create mode 100644 frontend/src/components/users/UserSettingsProvider.tsx create mode 100644 frontend/src/lib/settings.ts diff --git a/AGENTS.md b/AGENTS.md index 23c8159..fa48f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ npm run preview - Explain risks directly. - Prefer single quotes for strings unless interpolation or escaping makes double quotes better. +- For Japanese text, follow the 1986 Cabinet Notice + 《現代仮名遣い》 as the default orthography. +- For Japanese kanji spelling, do not apply + 《当用漢字による書きかえ》; prefer the original spelling as the formal one. - Ruby: never put a space before method-call parentheses. - Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: never put a line break immediately before `)`. @@ -255,8 +259,11 @@ const value = `!==` over negating a comparison like `!(a === b)`. - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for simple unit-step counter updates. -- For user-facing Japanese text, prefer modern kana usage and natural current - phrasing over historical spellings or awkward literal wording. +- For user-facing Japanese text, follow the 1986 Cabinet Notice + 《現代仮名遣い》 and avoid historical kana spellings unless the task + explicitly requires them. +- For user-facing Japanese kanji spelling, do not normalize to + 《当用漢字による書きかえ》; prefer original forms such as `編輯`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`. ### Frontend TSX style diff --git a/backend/app/controllers/user_settings_controller.rb b/backend/app/controllers/user_settings_controller.rb new file mode 100644 index 0000000..2c40034 --- /dev/null +++ b/backend/app/controllers/user_settings_controller.rb @@ -0,0 +1,56 @@ +class UserSettingsController < ApplicationController + def show + return head :unauthorized unless current_user + + render json: current_setting.serializable_hash + end + + def update + return head :unauthorized unless current_user + + raw_attributes = request.request_parameters + 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)) + + if setting.save + render json: setting.serializable_hash, status: :ok + else + render_validation_error setting + end + end + + private + + def current_setting + current_user.setting || current_user.create_setting!(Setting.defaults) + 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] = ['値の型が不正です.'] + end + end + + def value_matches_type? key, value + case Setting::TYPE_BY_ATTRIBUTE.fetch(key) + when :string + value.is_a?(String) + when :integer + value.is_a?(Integer) + when :boolean + value == true || value == false + else + false + end + end +end diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index c9e195d..dc3d689 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -1,7 +1,87 @@ 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', 'hide'].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 + 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, + }.freeze + belongs_to :user validates :user_id, presence: true - validates :key, presence: true, length: { maximum: 255 } - validates :value, presence: true + 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 } + + 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', + } + end + + def self.serializable_attributes + EDITABLE_ATTRIBUTES.map(&:to_sym) + end + + def serializable_hash(options = nil) + super({ only: self.class.serializable_attributes }.merge(options || { })) + end end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index f974adf..33de49b 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -7,7 +7,7 @@ class User < ApplicationRecord has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify - has_many :settings + has_one :setting, dependent: :destroy has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 7888289..41b5a66 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -89,6 +89,11 @@ 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 new file mode 100644 index 0000000..36c7eb7 --- /dev/null +++ b/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb @@ -0,0 +1,53 @@ +class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0] + def change + remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users) + remove_index :settings, :user_id if index_exists?(:settings, :user_id) + + remove_column :settings, :key, :string if column_exists?(:settings, :key) + remove_column :settings, :value, :json if column_exists?(:settings, :value) + + 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, + null: false, + default: 'manual' + add_column :settings, + :auto_fetch_thumbnail, + :string, + null: false, + default: 'manual' + add_column :settings, + :wiki_editor_mode, + :string, + null: false, + default: 'split' + + add_index :settings, :user_id, unique: true + add_foreign_key :settings, :users + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b9f669a..6463278 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -374,11 +374,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false - t.string "key", null: false - t.json "value", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["user_id"], name: "index_settings_on_user_id" + 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 + t.index ["user_id"], name: "index_settings_on_user_id", unique: true end create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4e9b04..604cb2c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' +import { UserSettingsProvider } from '@/components/users/UserSettingsProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' @@ -145,24 +146,26 @@ const App: FC = () => { return ( <> - - {import.meta.env.DEV && } + + + {import.meta.env.DEV && } - - - - - - - - + + + + + + + + - - - + + + + ) } diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 19c1c6a..a8e9f29 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -2,6 +2,7 @@ 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' @@ -39,6 +40,7 @@ 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) @@ -68,7 +70,9 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { setBounds ({ start, end }) - const data = await apiGet ('/tags/autocomplete', { params: { q: token, nico: '0' } }) + 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) } diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 0fe33b8..352f316 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -2,6 +2,7 @@ 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' @@ -16,14 +17,19 @@ type Props = { posts: Post[] const PostList: FC = ({ posts, onClick }) => { const location = useLocation () + const { settings } = useUserSettings () const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) const cardRef = useRef (null) + const visiblePosts = + settings.viewedPostDisplay === 'hide' + ? posts.filter (post => !(post.viewed)) + : posts return (
- {posts.map ((post, i) => { + {visiblePosts.map ((post, i) => { const sharedId = `page-${ post.id }` const layoutId = sharedId @@ -42,6 +48,9 @@ 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 }} @@ -72,4 +81,4 @@ const PostList: FC = ({ posts, onClick }) => {
) } -export default PostList \ No newline at end of file +export default PostList diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index d0d6491..878ba3e 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,5 +1,6 @@ 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' @@ -20,6 +21,7 @@ const TagInput: FC = ({ 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) => { @@ -32,7 +34,9 @@ const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { return } - const data = await apiGet ('/tags/autocomplete', { params: { q } }) + 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) diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx new file mode 100644 index 0000000..7a73f51 --- /dev/null +++ b/frontend/src/components/users/UserSettingsProvider.tsx @@ -0,0 +1,111 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react' + +import { DEFAULT_USER_SETTINGS, fetchUserSettings } from '@/lib/settings' + +import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' + +import type { User } from '@/types' +import type { UserSettings } from '@/lib/settings' + +type ContextValue = { + loaded: boolean + settings: UserSettings + setSettings: Dispatch> } + +const UserSettingsContext = createContext (null) + + +const applyTheme = (theme: UserSettings['theme']) => { + const root = document.documentElement + const media = window.matchMedia ('(prefers-color-scheme: dark)') + + const sync = () => { + const dark = theme === 'dark' || (theme === 'system' && media.matches) + root.classList.toggle ('dark', dark) + root.style.colorScheme = dark ? 'dark' : 'light' + } + + sync () + + if (theme !== 'system') + return () => { } + + const onChange = () => sync () + media.addEventListener ('change', onChange) + return () => { + media.removeEventListener ('change', onChange) + } +} + + +export const UserSettingsProvider: FC<{ + children: ReactNode + user: User | null }> = ({ children, user }) => { + const [loaded, setLoaded] = useState (false) + const [settings, setSettings] = useState (DEFAULT_USER_SETTINGS) + + useEffect (() => { + let cancelled = false + + if (!(user)) + { + setSettings (DEFAULT_USER_SETTINGS) + setLoaded (true) + return + } + + setLoaded (false) + + void (async () => { + try + { + const next = await fetchUserSettings () + if (!(cancelled)) + { + setSettings (next) + setLoaded (true) + } + } + catch + { + if (!(cancelled)) + { + setSettings (DEFAULT_USER_SETTINGS) + setLoaded (true) + } + } + }) () + + return () => { + cancelled = true + } + }, [user]) + + 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 (() => ({ + loaded, + settings, + setSettings, + }), [loaded, settings]) + + return ( + + {children} + ) +} + + +export const useUserSettings = (): ContextValue => { + const value = useContext (UserSettingsContext) + + if (value == null) + throw new Error ('UserSettingsProvider is missing') + + return value +} diff --git a/frontend/src/index.css b/frontend/src/index.css index e70403a..ea2cbe8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -87,6 +87,29 @@ -moz-osx-font-smoothing: grayscale; } +:root[data-font-size='small'] +{ + font-size: 14px; +} + +:root[data-font-size='normal'] +{ + font-size: 16px; +} + +:root[data-font-size='large'] +{ + font-size: 18px; +} + +:root[data-display-density='compact'] input, +:root[data-display-density='compact'] textarea, +:root[data-display-density='compact'] select, +:root[data-display-density='compact'] button +{ + line-height: 1.25; +} + a { font-weight: 500; diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts new file mode 100644 index 0000000..97d04a0 --- /dev/null +++ b/frontend/src/lib/settings.ts @@ -0,0 +1,258 @@ +import { apiGet, apiPatch } from '@/lib/api' + +import type { FetchPostsOrder } from '@/types' + +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' + +export type UserSettings = { + theme: 'system' | 'light' | 'dark' + displayDensity: 'comfortable' | 'compact' + fontSize: 'small' | 'normal' | 'large' + postListLimit: 20 | 50 | 100 + postListOrder: UserPostListOrder + viewedPostDisplay: 'show' | 'dim' | 'hide' + tagAutocompleteNico: boolean + 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' + +type ClientPaneSettings = { + widthPxByBreakpoint?: Partial> + collapsed?: boolean } + +export type ClientSettings = { + panes?: Record + theatre?: { + layoutMode?: TheatreLayoutMode + tagFlow?: TheatreTagFlow + } + gekanator?: { + backgroundMotion?: GekanatorBackgroundMotionMode + } + reducedMotion?: string + embedAutoLoad?: string + thumbnailMode?: string } + +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' } + +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', 'hide'] as const +export const AUTO_FETCH_OPTIONS = ['auto', 'manual', 'off'] as const +export const WIKI_EDITOR_MODE_OPTIONS = ['split', 'write', 'preview'] as const + +const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' +const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' +const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = + 'gekanator:background-motion:v1' + + +export const fetchUserSettings = async (): Promise => + await apiGet ('/users/settings') + + +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'] + }>, +): 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 { } + + try + { + const parsed = JSON.parse (raw) + return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { } + } + catch + { + return { } + } +} + + +export const loadClientSettings = (): ClientSettings => + safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY)) + + +export const saveClientSettings = (settings: ClientSettings): void => { + localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings)) +} + + +export const updateClientSettings = ( + updater: (settings: ClientSettings) => ClientSettings, +): ClientSettings => { + const next = updater (loadClientSettings ()) + saveClientSettings (next) + return next +} + + +const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { + const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) + return ( + value === 'threeColumns' + || value === 'tagsBottom' + || value === 'commentsBottom' + ) ? value : null +} + + +const legacyTheatreTagFlow = (): TheatreTagFlow | null => { + const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY) + return (value === 'vertical' || value === 'horizontal') ? value : null +} + + +const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => { + const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY) + return (value === 'on' || value === 'calm' || value === 'off') ? value : null +} + + +export const getClientTheatreLayoutMode = (): TheatreLayoutMode => + loadClientSettings ().theatre?.layoutMode + ?? legacyTheatreLayoutMode () + ?? 'threeColumns' + + +export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => { + updateClientSettings (settings => ({ + ...settings, + theatre: { ...(settings.theatre ?? { }), layoutMode } })) +} + + +export const getClientTheatreTagFlow = (): TheatreTagFlow => + loadClientSettings ().theatre?.tagFlow + ?? legacyTheatreTagFlow () + ?? 'vertical' + + +export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => { + updateClientSettings (settings => ({ + ...settings, + theatre: { ...(settings.theatre ?? { }), tagFlow } })) +} + + +export const getClientGekanatorBackgroundMotion = + (): GekanatorBackgroundMotionMode => + loadClientSettings ().gekanator?.backgroundMotion + ?? legacyGekanatorBackgroundMotion () + ?? 'on' + + +export const setClientGekanatorBackgroundMotion = ( + backgroundMotion: GekanatorBackgroundMotionMode, +): void => { + updateClientSettings (settings => ({ + ...settings, + gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) +} + + +export const getClientPaneWidthPx = ( + paneKey: string, + breakpoint: ClientPaneBreakpoint, +): number | null => { + const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint] + return typeof value === 'number' ? value : null +} + + +export const setClientPaneWidthPx = ( + paneKey: string, + breakpoint: ClientPaneBreakpoint, + widthPx: number, +): void => { + updateClientSettings (settings => ({ + ...settings, + panes: { + ...(settings.panes ?? { }), + [paneKey]: { + ...(settings.panes?.[paneKey] ?? { }), + widthPxByBreakpoint: { + ...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }), + [breakpoint]: widthPx, + }, + }, + }, + })) +} + + +export const setClientPaneCollapsed = ( + paneKey: string, + collapsed: boolean, +): void => { + updateClientSettings (settings => ({ + ...settings, + panes: { + ...(settings.panes ?? { }), + [paneKey]: { + ...(settings.panes?.[paneKey] ?? { }), + collapsed, + }, + }, + })) +} diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 6ea1f84..2e15020 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -24,6 +24,10 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' import { isQuestionHardFilteredAfterAnswers, monthForCondition } from '@/lib/gekanatorQuestionFilters' import { gekanatorKeys } from '@/lib/queryKeys' +import { + getClientGekanatorBackgroundMotion, + setClientGekanatorBackgroundMotion, +} from '@/lib/settings' import { cn } from '@/lib/utils' import type { FC } from 'react' @@ -185,7 +189,6 @@ const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1' -const backgroundMotionStorageKey = 'gekanator:background-motion:v1' const maxStoredRecentGames = 12 const specialOriginalMonthDayLabels: Record = { '1-1': '元日', @@ -429,18 +432,13 @@ const storeRecentGameSummary = ( const loadBackgroundMotionMode = (): BackgroundMotionMode => { - const fallbackMode = 'on' try { - const raw = localStorage.getItem (backgroundMotionStorageKey) - if (raw === 'off' || raw === 'calm' || raw === 'on') - return raw - - return fallbackMode + return getClientGekanatorBackgroundMotion () } catch { - return fallbackMode + return 'on' } } @@ -3916,7 +3914,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { useEffect (() => { try { - localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode) + setClientGekanatorBackgroundMotion (backgroundMotionMode) } catch { diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 9fe7d25..297c942 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom' import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' +import { useUserSettings } from '@/components/users/UserSettingsProvider' import TagSidebar from '@/components/TagSidebar' import WikiBody from '@/components/WikiBody' import Pagination from '@/components/common/Pagination' @@ -13,6 +14,7 @@ import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' +import { toFetchPostsOrder } from '@/lib/settings' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' @@ -22,6 +24,7 @@ import type { WikiPage } from '@/types' const PostListPage: FC = () => { const containerRef = useRef (null) + const { settings } = useUserSettings () const [wikiPage, setWikiPage] = useState (null) @@ -33,13 +36,14 @@ 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') ?? 20) + const limit = Number (query.get ('limit') ?? settings.postListLimit) + const order = toFetchPostsOrder (settings.postListOrder) const keys = { tags: tagsKey, match, page, limit, url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', - order: 'original_created_at:desc' } as const + order } as const const { data, isLoading: loading } = useQuery ({ queryKey: postsKeys.index (keys), queryFn: () => fetchPosts (keys) }) diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index 0a99dc5..a6bda52 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -9,6 +9,7 @@ import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' +import { useUserSettings } from '@/components/users/UserSettingsProvider' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' @@ -30,6 +31,7 @@ type PostFormField = const PostNewPage: FC = ({ user }) => { const editable = canEditContent (user) + const { settings } = useUserSettings () const navigate = useNavigate () @@ -41,12 +43,13 @@ const PostNewPage: FC = ({ user }) => { const [parentPostIds, setParentPostIds] = useState ('') const [tags, setTags] = useState ('') const [duration, setDuration] = useState ('') - const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) + 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 (true) + const [titleAutoFlg, setTitleAutoFlg] = useState (settings.autoFetchTitle === 'auto') const [titleLoading, setTitleLoading] = useState (false) const [url, setURL] = useState ('') @@ -125,6 +128,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 () diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 7237c9a..3d4a66c 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -13,9 +13,11 @@ 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 { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -34,14 +36,16 @@ const setIf = (qs: URLSearchParams, k: string, v: string | 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') ?? 20) + const limit = Number (query.get ('limit') ?? settings.postListLimit) const qURL = query.get ('url') ?? '' const qTitle = query.get ('title') ?? '' @@ -53,7 +57,7 @@ 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') || 'original_created_at:desc') as FetchPostsOrder + const order = (query.get ('order') || defaultOrder) as FetchPostsOrder const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) @@ -81,6 +85,10 @@ 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 (() => { @@ -239,7 +247,7 @@ const PostSearchPage: FC = () => { - {loading ? 'Loading...' : (results.length > 0 ? ( + {loading ? 'Loading...' : (visibleResults.length > 0 ? (
@@ -295,8 +303,14 @@ const PostSearchPage: FC = () => { - {results.map (row => ( - + {visibleResults.map (row => ( + - - + + diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index de45233..fa809e4 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -152,7 +152,7 @@ const themeSlotNoSelections: ThemeSegmentedOption[] = [ { value: 3, label: '3' } ] const sectionClassName = - 'space-y-4 rounded-xl border border-border bg-background/80 p-4' + 'space-y-4 rounded-xl p-4' const themeSelections: ThemeSelectionItem[] = [ { id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 }, @@ -517,16 +517,6 @@ const ThemeSection: FC = (

テーマ

-

- 現在表示中: {displayedThemeLabel} -

- {draftThemeMode === 'system' && ( -

- システム設定: {systemThemeLabel} -

)} -

- 保存先: {themeLabelBySelection (currentDisplayedThemeSlot)} -

{hasUnsavedChanges && (

未保存の変更があります @@ -534,6 +524,13 @@ const ThemeSection: FC = (

+ + -
- -
- -
{themeTokenGroups.map (group => (
+ className="space-y-3 rounded-xl p-4">

{group.title}

{group.fields.map (field => ( @@ -657,7 +623,7 @@ const ThemeSection: FC = (
))} -
+

タグ

-- 2.34.1 From 2d9a79fecc738a7aa15781faa8d988bf5fe93216 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 6 Jul 2026 23:08:27 +0900 Subject: [PATCH 25/30] #34 --- .../users/BehaviourSettingsSection.tsx | 15 ++++--------- .../src/pages/theatres/TheatreDetailPage.tsx | 2 -- frontend/src/pages/users/SettingPage.tsx | 21 +------------------ 3 files changed, 5 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx index d5208a1..8399a35 100644 --- a/frontend/src/components/users/BehaviourSettingsSection.tsx +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -11,8 +11,7 @@ import type { ClientAnimationMode, ClientBehaviorSettings, ClientEmbedAutoLoadMode, ClientLinkPreloadMode, - ClientTagRelationDisplayMode, - ClientThumbnailMode } from '@/lib/settings' + ClientTagRelationDisplayMode } from '@/lib/settings' type Props = { sectionClassName: string @@ -31,9 +30,9 @@ type SegmentedControlProps = { onChange: (value: T) => void } type SettingBlockProps = { - title: string - description: string - children: ReactNode } + title: string + description?: string + children: ReactNode } const animationOptions: SegmentedOption[] = [ { value: 'off', label: 'なし' }, @@ -53,12 +52,6 @@ const tagRelationDisplayOptions: SegmentedOption[] { value: 'flat', label: 'まとめない' }, { value: 'grouped', label: 'まとめる' }] -const thumbnailModeOptions: SegmentedOption[] = [ - { value: 'off', label: '非表示' }, - { value: 'light', label: '軽量' }, - { value: 'normal', label: '通常' }] - - const SegmentedControl = ( { value, options, onChange }: SegmentedControlProps, ) => { diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 2a99a9f..43a7e8b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -57,8 +57,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo = const INITIAL_WEIGHTS: TheatrePostSelectionWeights = { tagPenalties: [], lightestPosts: [], heaviestPosts: [] } -const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' -const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const MIN_MAIN_WIDTH = 360 const SIDEBAR_GAP_WIDTH = 16 diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index fa809e4..a72b6ab 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -168,24 +168,7 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [ fields: [ { key: 'background', label: '背景' }, { key: 'foreground', label: '文字' }, - { key: 'primary', label: '主色' }, - { key: 'accent', label: 'アクセント' }, - { key: 'link', label: 'リンク' }, - ] }, - { title: '面', - fields: [ - { key: 'card', label: 'カード' }, - { key: 'muted', label: '薄い背景' }, - { key: 'border', label: '境界線' }, - ] }, - { title: '上部ナビ', - fields: [ - { key: 'topNavRootBackgroundMobile', label: 'スマホ背景' }, - { key: 'topNavRootBackgroundDesktop', label: 'PC 背景' }, - { key: 'topNavActiveBackground', label: '選択中背景' }, - { key: 'topNavBrandLink', label: 'ロゴ色' }, - ] }, -] + { key: 'link', label: 'リンク' }] }] const ThemeSegmentedControl = ( @@ -498,8 +481,6 @@ const ThemeSection: FC = ( draftActiveDarkThemeSlotNo, currentDisplayedThemeSlot, selectedTheme, - displayedThemeLabel, - systemThemeLabel, hasUnsavedChanges, settingsBaseErrors, settingsFieldErrors, -- 2.34.1 From 5f5299ea396125d8956de78f435696ab57245ca8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 6 Jul 2026 23:18:52 +0900 Subject: [PATCH 26/30] #34 --- frontend/src/components/users/BehaviourSettingsSection.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx index 8399a35..4bf5e26 100644 --- a/frontend/src/components/users/BehaviourSettingsSection.tsx +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -169,7 +169,9 @@ const BehaviourSettingsSection: FC = ( onChange={value => updateDraft ('animation', value)}/> - + Date: Mon, 6 Jul 2026 23:25:14 +0900 Subject: [PATCH 27/30] #34 --- frontend/src/App.tsx | 7 +- frontend/src/components/TagDetailSidebar.tsx | 143 ++++++++++--------- frontend/src/components/TopNav.tsx | 16 ++- 3 files changed, 94 insertions(+), 72 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d95cbcf..307695e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,8 @@ 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, +import { applyClientAnimationMode, + applyClientAppearance, fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, @@ -142,6 +143,10 @@ const App: FC = () => { return () => mediaQuery.removeEventListener ('change', handleThemeChange) }, []) + useEffect (() => { + applyClientAnimationMode (animationMode) + }, [animationMode]) + useEffect (() => { const createUser = async () => { const data = await apiPost<{ code: string; user: User }> ('/users') diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 47d1daa..e90f4f5 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -30,9 +30,6 @@ import type { FC, MutableRefObject, ReactNode } from 'react' import type { Category, Post, TagWithSections } from '@/types' type TagByCategory = { [key in Category]: TagWithSections[] } -type FlatTagRow = { - tag: TagWithSections - parentTagId?: number } const renderTagTree = ( @@ -64,34 +61,6 @@ const renderTagTree = ( ?? [])] } - -const flattenTagTree = ( - tags: TagWithSections[], -): FlatTagRow[] => { - const seen = new Set () - const rows: FlatTagRow[] = [] - - const visit = ( - tag: TagWithSections, - parentTagId?: number, - ) => { - if (seen.has (tag.id)) - return - - seen.add (tag.id) - rows.push ({ tag, parentTagId }) - - for (const child of tag.children ?? []) - visit (child, tag.id) - } - - for (const tag of tags) - visit (tag) - - return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1) -} - - const isDescendant = ( root: TagWithSections, targetId: number, @@ -158,6 +127,37 @@ const buildTagByCategory = (post: Post): TagByCategory => { } +const buildFlatTagByCategory = ( + byCategory: TagByCategory, +): TagByCategory => { + const tagsTmp = { } as TagByCategory + const seen = new Set () + + for (const category of CATEGORIES) + tagsTmp[category] = [] + + const visit = (tag: TagWithSections) => { + if (seen.has (tag.id)) + return + + seen.add (tag.id) + tagsTmp[tag.category].push ({ ...tag, children: [] }) + + for (const child of tag.children ?? []) + visit (child) + } + + for (const category of CATEGORIES) + for (const tag of byCategory[category] ?? []) + visit (tag) + + for (const category of CATEGORIES) + tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) + + return tagsTmp +} + + const changeCategory = async ( tagId: number, category: Category): Promise => { @@ -210,6 +210,10 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { const [dragging, setDragging] = useState (false) const [saving, setSaving] = useState (false) const [tags, setTags] = useState (baseTags) + const flatTagsByCategory = useMemo ( + () => buildFlatTagByCategory (tags), + [tags], + ) const suppressClickRef = useRef (false) @@ -343,41 +347,50 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { document.body.style.userSelect = '' }} modifiers={[restrictToWindowEdges]}> - {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( -
- - - {CATEGORY_NAMES[cat]} - - + {CATEGORIES.map ((cat: Category) => { + const categoryTags = + tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []) + : (flatTagsByCategory[cat] ?? []) -
    - {(tagRelationDisplay === 'grouped' - ? (tags[cat] ?? []).flatMap (tag => - renderTagTree ( - tag, - 0, - `cat-${ cat }`, - suppressClickRef, - undefined, - sp, - ), - ) - : flattenTagTree (tags[cat] ?? []).map (row => ( -
  • - -
  • )))} - -
-
))} + if (!(categoryTags.length > 0 || dragging)) + return null + + return ( +
+ + + {CATEGORY_NAMES[cat]} + + + +
    + {(tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []).flatMap (tag => + renderTagTree ( + tag, + 0, + `cat-${ cat }`, + suppressClickRef, + undefined, + sp, + ), + ) + : (flatTagsByCategory[cat] ?? []).map (tag => ( +
  • + +
  • )))} + +
+
) + })} {post && ( = ({ user }) => { visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const topNavTransition = - reducedAnimations - ? { duration: .08, ease: 'linear' as const } - : { duration: .2, ease: 'easeOut' as const } + animationsOff + ? { duration: 0 } + : reducedAnimations + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } const opacityTransition = - reducedAnimations - ? { duration: .08 } - : { duration: .12 } + animationsOff + ? { duration: 0 } + : reducedAnimations + ? { duration: .08 } + : { duration: .12 } const highlightTransitionClass = animationsOff ? undefined -- 2.34.1 From f2651a987a24edc058e3ac1c66fb1c4a5e339dc5 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 6 Jul 2026 23:37:21 +0900 Subject: [PATCH 28/30] #34 --- frontend/src/App.tsx | 55 ++++++++++++++++-- .../components/DraggableDroppableTagRow.tsx | 14 ++++- .../src/components/ResponsiveMarqueeText.tsx | 6 +- frontend/src/components/TagDetailSidebar.tsx | 18 ++++-- frontend/src/components/TagSidebar.tsx | 58 +++++++++++++------ frontend/src/components/layout/MainArea.tsx | 27 ++++++--- .../components/layout/SidebarComponent.tsx | 12 +++- frontend/src/index.css | 6 +- frontend/src/lib/clientAnimation.ts | 34 +++++++++++ frontend/src/pages/GekanatorPage.tsx | 8 ++- .../pages/materials/MaterialHistoryPage.tsx | 9 ++- frontend/src/pages/posts/PostHistoryPage.tsx | 18 ++++-- frontend/src/pages/posts/PostSearchPage.tsx | 27 ++++++--- frontend/src/pages/tags/NicoTagListPage.tsx | 9 ++- frontend/src/pages/tags/TagHistoryPage.tsx | 9 ++- frontend/src/pages/tags/TagListPage.tsx | 12 +++- .../src/pages/theatres/TheatreDetailPage.tsx | 19 ++++-- 17 files changed, 273 insertions(+), 68 deletions(-) create mode 100644 frontend/src/lib/clientAnimation.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 307695e..c2aa89b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' -import { useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { BrowserRouter, Navigate, Route, @@ -14,6 +14,7 @@ import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAnimationMode, applyClientAppearance, + type ClientAnimationMode, fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, @@ -61,11 +62,51 @@ import type { Dispatch, FC, SetStateAction } from 'react' import type { User } from '@/types' -const RouteTransitionWrapper = ({ user, setUser }: { +const RouteTransitionWrapper = ({ animationMode, user, setUser }: { + animationMode: ClientAnimationMode user: User | null setUser: Dispatch> }) => { const location = useLocation () + if (animationMode === 'off') + { + return ( + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> + }/> + }/> + }/> + }/> + }/> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + ) + } + return ( @@ -118,6 +159,7 @@ const App: FC = () => { const [status, setStatus] = useState (200) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' + const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup const appLayoutTransition = useMemo ( () => ( @@ -227,15 +269,18 @@ const App: FC = () => { ? 'user' : 'always' }> - + - + - + diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 97e8248..f465bea 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -4,6 +4,8 @@ import { motion } from 'framer-motion' import { useRef } from 'react' import TagLink from '@/components/TagLink' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { CSSProperties, FC, MutableRefObject } from 'react' @@ -20,6 +22,12 @@ type Props = { const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const dndId = `tag-node:${ pathKey }` const downPosRef = useRef<{ x: number; y: number } | null> (null) @@ -94,8 +102,10 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa {...listeners}> + transition={{ layout: layoutTransition }} + layoutId={animationMode === 'off' + ? undefined + : `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
) diff --git a/frontend/src/components/ResponsiveMarqueeText.tsx b/frontend/src/components/ResponsiveMarqueeText.tsx index c859055..0960730 100644 --- a/frontend/src/components/ResponsiveMarqueeText.tsx +++ b/frontend/src/components/ResponsiveMarqueeText.tsx @@ -1,3 +1,4 @@ +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import { useEffect, useRef, useState } from 'react' @@ -20,6 +21,8 @@ const MIN_MARQUEE_OVERFLOW_PX = 1 const ResponsiveMarqueeText: FC = ( { text, className, truncateOnMobile = false, title }, ) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const outerRef = useRef (null) const staticRef = useRef (null) const animatedRef = useRef (null) @@ -98,6 +101,7 @@ const ResponsiveMarqueeText: FC = ( const animated = animatedRef.current const canMarquee = ( active + && animationMode !== 'off' && desktopMarqueeEnabled && overflowPx >= MIN_MARQUEE_OVERFLOW_PX && animated != null) @@ -207,7 +211,7 @@ const ResponsiveMarqueeText: FC = ( resetAnimated () setMarqueeVisible (false) } - }, [active, desktopMarqueeEnabled, overflowPx, text]) + }, [active, animationMode, desktopMarqueeEnabled, overflowPx, text]) return ( = ({ className, post, sp }) => { const qc = useQueryClient () const behaviourSettings = useClientBehaviourSettings () const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped' + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const baseTags = useMemo (() => { const tagsTmp = { } as TagByCategory @@ -360,8 +366,10 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => {
+ layoutId={animationMode === 'off' + ? undefined + : `tag-${ sp ? 'sp-' : '' }${ cat }`} + transition={{ layout: layoutTransition }}> {CATEGORY_NAMES[cat]} @@ -393,8 +401,10 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { })} {post && ( + layoutId={animationMode === 'off' + ? undefined + : `post-info-${ sp }`} + transition={{ layout: layoutTransition }}> 情報
  • Id.: {post.id}
  • diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index de3dc16..6ba28de 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -8,6 +8,8 @@ import SectionTitle from '@/components/common/SectionTitle' import SidebarComponent from '@/components/layout/SidebarComponent' import { CATEGORIES } from '@/consts' import { apiGet } from '@/lib/api' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import type { FC, MouseEvent } from 'react' @@ -21,6 +23,13 @@ type Props = { posts: Post[] const TagSidebar: FC = ({ posts, onClick }) => { const navigate = useNavigate () + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const animationsOff = animationMode === 'off' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const [tagsVsbl, setTagsVsbl] = useState (false) const [tags, setTags] = useState ({ }) @@ -65,12 +74,18 @@ const TagSidebar: FC = ({ posts, onClick }) => { {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => (
  • - - - + {animationsOff + ? ( +
    + +
    ) + : ( + + + )}
  • ))) : [])}
関聯 @@ -103,18 +118,25 @@ const TagSidebar: FC = ({ posts, onClick }) => { {posts.length > 0 && TagBlock}
- - {tagsVsbl && ( - - {posts.length > 0 && TagBlock} - )} - + {animationsOff + ? ( + tagsVsbl && ( +
+ {posts.length > 0 && TagBlock} +
)) + : ( + + {tagsVsbl && ( + + {posts.length > 0 && TagBlock} + )} + )} - {children} - ) +const MainArea: FC = ({ children, className }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) -export default MainArea \ No newline at end of file + return ( + + {children} + ) +} + +export default MainArea diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index ea4b015..574f924 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -1,6 +1,8 @@ import { motion } from 'framer-motion' import { Helmet } from 'react-helmet-async' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' @@ -68,6 +70,12 @@ const SidebarComponent: FC = ({ sidebarKey, side = 'left', }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const rootRef = useRef (null) const maxWidthRef = useRef (maxWidth) const getContainerWidth = useCallback (() => { @@ -305,8 +313,8 @@ const SidebarComponent: FC = ({ return ( + animationMode === 'off' + + +export const clientAnimationTransition = ( + animationMode: ClientAnimationMode, + { normal, reduced, off }: ClientAnimationTransitionOptions, +): Transition => { + if (animationMode === 'off') + return off ?? { duration: 0 } + + if (animationMode === 'reduced') + return reduced ?? { duration: .08, ease: 'linear' as const } + + return normal +} + + +export const clientScrollBehaviour = ( + animationMode: ClientAnimationMode, +): ScrollBehavior => + animationMode === 'off' ? 'auto' : 'smooth' diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 2e15020..181bb0c 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -28,6 +28,7 @@ import { getClientGekanatorBackgroundMotion, setClientGekanatorBackgroundMotion, } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { FC } from 'react' @@ -3677,6 +3678,8 @@ const expectedAnswerFor = ( const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const storedGame = useMemo (loadStoredGame, []) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () @@ -4069,9 +4072,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { return reviewGuessedPostId === reviewCorrectPostId }) () const effectiveBackgroundMotionMode = (() => { + if (animationMode === 'off') + return 'off' + if (backgroundMotionMode === 'off') return 'off' - if (prefersReducedMotion) + if (animationMode === 'reduced' || prefersReducedMotion) return 'calm' return backgroundMotionMode diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index 82876db..920fbad 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -9,14 +9,18 @@ import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchMaterialChanges } from '@/lib/materials' import { materialsKeys } from '@/lib/queryKeys' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { dateString, inputClass } from '@/lib/utils' import type { FC, FormEvent } from 'react' const MaterialHistoryPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const navigate = useNavigate () const query = new URLSearchParams (location.search) @@ -55,8 +59,9 @@ const MaterialHistoryPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) const handleSearch = (event: FormEvent) => { event.preventDefault () diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index f6f961d..71aa3c8 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -12,9 +12,12 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' +import { clientAnimationTransition, + clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPostChanges, updatePost } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import type { FC, MouseEvent } from 'react' @@ -37,6 +40,12 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( const PostHistoryPage: FC = () => { const dialogue = useDialogue () + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const location = useLocation () const query = new URLSearchParams (location.search) @@ -113,8 +122,9 @@ const PostHistoryPage: FC = () => { } useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) const layoutIds: string[] = [] @@ -198,8 +208,8 @@ const PostHistoryPage: FC = () => { rowSpan={rowsCnt}> + layoutId={animationMode === 'off' ? undefined : layoutId} + transition={{ layout: layoutTransition }}> { const PostSearchPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const thumbnailTransition = clientAnimationTransition ( + animationMode, + { normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 }, + reduced: { duration: .08, ease: 'linear' as const } }, + ) const location = useLocation () const navigate = useNavigate () @@ -141,9 +151,11 @@ const PostSearchPage: FC = () => { setUpdatedFrom (qUpdatedFrom) setUpdatedTo (qUpdatedTo) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, - qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch, + qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, + qUpdatedTo, qURL]) const search = async () => { const qs = new URLSearchParams () @@ -400,11 +412,10 @@ const PostSearchPage: FC = () => {
= { threeColumns: '3 列', tagsBottom: '2 列 A 型', @@ -228,32 +231,20 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const [post, setPost] = useState (null) const [videoLength, setVideoLength] = useState (0) const [weights, setWeights] = useState (INITIAL_WEIGHTS) - const [layoutMode, setLayoutMode] = useState (() => { - const stored = localStorage.getItem (LAYOUT_STORAGE_KEY) - return ( - ((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[]) - .includes (stored as TheatreLayoutMode)) - ? (stored as TheatreLayoutMode) - : 'threeColumns') - }) - const [tagFlow, setTagFlow] = useState (() => { - const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY) - return ( - (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow) - ? (stored as TagFlow) - : 'vertical') - }) + const [layoutMode, setLayoutMode] = + useState (() => getClientTheatreLayoutMode ()) + const [tagFlow, setTagFlow] = useState (() => getClientTheatreTagFlow ()) const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const changeLayoutMode = (mode: TheatreLayoutMode) => { setLayoutMode (mode) - localStorage.setItem (LAYOUT_STORAGE_KEY, mode) + setClientTheatreLayoutMode (mode) } const changeTagFlow = (flow: TagFlow) => { setTagFlow (flow) - localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow) + setClientTheatreTagFlow (flow) } const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index a271da9..44f0158 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react' +import type { Dispatch, FC, SetStateAction } from 'react' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' @@ -9,35 +9,115 @@ import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' +import { useUserSettings } from '@/components/users/UserSettingsProvider' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' import { Button } from '@/components/ui/button' 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, +} from '@/lib/settings' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { User } from '@/types' +import type { UserSettings } from '@/lib/settings' type Props = { user: User | null - setUser: React.Dispatch> } + setUser: Dispatch> } type UserFormField = 'name' +type SettingsFormField = + | 'theme' + | 'displayDensity' + | 'fontSize' + | 'postListLimit' + | 'postListOrder' + | 'viewedPostDisplay' + | 'tagAutocompleteNico' + | 'autoFetchTitle' + | 'autoFetchThumbnail' + | 'wikiEditorMode' + +const sectionClassName = 'space-y-4 rounded-xl border border-border bg-background/80 p-4' + +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: '薄く表示', + hide: '隠す' } + +const autoFetchLabel: Record = { + auto: '自動', + manual: '手動', + off: '無効' } + +const wikiEditorModeLabel: Record = { + split: '左右表示', + write: '本文のみ', + preview: 'プレビューのみ' } const SettingPage: FC = ({ user, setUser }) => { + const [draftSettings, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) const [name, setName] = useState ('') const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false) - const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = - useValidationErrors () + const { loaded, settings, setSettings } = useUserSettings () + const { + baseErrors: nameBaseErrors, + fieldErrors: nameFieldErrors, + clearValidationErrors: clearNameValidationErrors, + applyValidationError: applyNameValidationError, + } = useValidationErrors () + const { + baseErrors: settingsBaseErrors, + fieldErrors: settingsFieldErrors, + clearValidationErrors: clearSettingsValidationErrors, + applyValidationError: applySettingsValidationError, + } = useValidationErrors () - const handleSubmit = async () => { + const handleUserSubmit = async () => { if (!(user)) return - clearValidationErrors () + clearNameValidationErrors () const formData = new FormData formData.append ('name', name) @@ -47,13 +127,41 @@ const SettingPage: FC = ({ user, setUser }) => { const data = await apiPut ( `/users/${ user.id }`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - setUser (user => ({ ...user, ...data })) - toast ({ title: '設定を更新しました.' }) + setUser (currentUser => ({ ...currentUser, ...data })) + toast ({ title: '表示名を更新しました.' }) } - catch (e) + catch (error) { - applyValidationError (e) - toast ({ title: 'しっぱい……' }) + applyNameValidationError (error) + toast ({ title: '表示名を更新できませんでした.' }) + } + } + + const handleSettingsSubmit = async () => { + clearSettingsValidationErrors () + + try + { + 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, + }) + setDraftSettings (data) + setSettings (data) + toast ({ title: '設定を保存しました.' }) + } + catch (error) + { + applySettingsValidationError (error) + toast ({ title: '設定を保存できませんでした.' }) } } @@ -64,6 +172,10 @@ const SettingPage: FC = ({ user, setUser }) => { setName (user.name ?? '') }, [user]) + useEffect (() => { + setDraftSettings (settings) + }, [settings]) + return ( @@ -74,48 +186,234 @@ const SettingPage: FC = ({ user, setUser }) => {
設定 - {user ? ( + {user && loaded ? ( <> - +
+

アカウント

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

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

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

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

)} + )} +
- {/* 送信 */} - +
+ +
- {/* 引継ぎ */} -
- - - + -
+ ほかのブラウザから引継ぐ + + + +
+ +
+

表示

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

投稿一覧

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

編輯支援

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

Wiki

+ + + {() => ( + )} + +
+ +
+ +
+ +
+
) : 'Loading...'} diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index dcbe12c..e7bf99d 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -8,6 +8,7 @@ import { useParams, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' +import { useUserSettings } from '@/components/users/UserSettingsProvider' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiGet, apiPut } from '@/lib/api' @@ -25,6 +26,27 @@ import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +type EditorView = { + menu: boolean + md: boolean + html: boolean } + +const editorView = ( + mode: 'split' | 'write' | 'preview', +): EditorView => { + switch (mode) + { + case 'write': + return { menu: true, md: true, html: false } + + case 'preview': + return { menu: true, md: false, html: true } + + default: + return { menu: true, md: true, html: true } + } +} + type Props = { user: User | null } type WikiFormField = 'title' | 'body' @@ -32,6 +54,7 @@ type WikiFormField = 'title' | 'body' const WikiEditPage: FC = ({ user }) => { const editable = canEditContent (user) + const { settings } = useUserSettings () const { id } = useParams () @@ -114,6 +137,7 @@ const WikiEditPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index fffb690..b4d5d6a 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -9,6 +9,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' +import { useUserSettings } from '@/components/users/UserSettingsProvider' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' @@ -23,6 +24,27 @@ import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +type EditorView = { + menu: boolean + md: boolean + html: boolean } + +const editorView = ( + mode: 'split' | 'write' | 'preview', +): EditorView => { + switch (mode) + { + case 'write': + return { menu: true, md: true, html: false } + + case 'preview': + return { menu: true, md: false, html: true } + + default: + return { menu: true, md: true, html: true } + } +} + type Props = { user: User | null } type WikiFormField = 'title' | 'body' @@ -30,6 +52,7 @@ type WikiFormField = 'title' | 'body' const WikiNewPage: FC = ({ user }) => { const editable = canEditContent (user) + const { settings } = useUserSettings () const location = useLocation () const navigate = useNavigate () @@ -92,6 +115,7 @@ const WikiNewPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} -- 2.34.1 From 4cc2dc5441f001cc95a6af66df4f977f830ecf82 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 4 Jul 2026 19:50:40 +0900 Subject: [PATCH 02/30] #34 --- .../controllers/user_settings_controller.rb | 6 +- backend/app/models/setting.rb | 2 +- frontend/src/components/PostFormTagsArea.tsx | 9 +- frontend/src/components/PostList.tsx | 6 +- frontend/src/components/common/TagInput.tsx | 6 +- .../components/users/UserSettingsProvider.tsx | 9 +- frontend/src/lib/settings.ts | 6 +- frontend/src/pages/posts/PostNewPage.tsx | 119 ++-- frontend/src/pages/posts/PostSearchPage.tsx | 8 +- frontend/src/pages/users/SettingPage.tsx | 664 ++++++++++++------ 10 files changed, 542 insertions(+), 293 deletions(-) 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...'} - + - + ) } -- 2.34.1 From 518c5fa0f200202ef7e23ccb1d67d6e155570ce6 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 4 Jul 2026 21:47:16 +0900 Subject: [PATCH 03/30] #34 --- .../controllers/user_settings_controller.rb | 15 +- backend/app/models/setting.rb | 42 +- backend/config/routes.rb | 8 +- ...rebuild_settings_as_typed_user_settings.rb | 22 - backend/db/schema.rb | 6 - frontend/src/components/PostList.tsx | 5 - .../src/components/common/TagInput.test.tsx | 18 +- frontend/src/components/common/TagInput.tsx | 13 +- .../components/users/UserSettingsProvider.tsx | 5 - frontend/src/lib/settings.ts | 162 ++-- .../src/pages/materials/MaterialNewPage.tsx | 2 +- frontend/src/pages/posts/PostListPage.tsx | 101 ++- frontend/src/pages/posts/PostNewPage.tsx | 71 +- frontend/src/pages/posts/PostSearchPage.tsx | 101 ++- frontend/src/pages/tags/TagListPage.tsx | 90 +- frontend/src/pages/users/SettingPage.tsx | 777 +++++++++--------- 16 files changed, 840 insertions(+), 598 deletions(-) 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"> diff --git a/frontend/src/components/users/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx new file mode 100644 index 0000000..0a7b1b5 --- /dev/null +++ b/frontend/src/components/users/ShortcutHelpDialog.tsx @@ -0,0 +1,77 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + formatKeyBinding, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' + +import type { + KeyBinding, + ShortcutActionId, + ShortcutScope, +} from '@/lib/keyboardShortcuts' +import type { FC } from 'react' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + activeScope: ShortcutScope + effectiveBindings: Record + conflictActionIds: Set +} + + +const ShortcutHelpDialog: FC = ( + { open, onOpenChange, activeScope, effectiveBindings, conflictActionIds }, +) => { + const rows = SHORTCUT_DEFINITIONS.filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + + return ( + + + + キーボード・ショートカット + + この画面で利用できるショートカットを表示します。 + + + +
+
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...'} - + Date: Sun, 5 Jul 2026 00:03:13 +0900 Subject: [PATCH 04/30] #34 --- frontend/src/lib/settings.ts | 41 ++++--- frontend/src/pages/posts/PostNewPage.tsx | 143 +++++------------------ 2 files changed, 54 insertions(+), 130 deletions(-) diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index f32db7b..dce8538 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -2,12 +2,9 @@ import { apiGet, apiPatch } from '@/lib/api' import type { FetchPostsOrder, FetchTagsOrder } from '@/types' -// DB-backed user settings. These are worth sharing across browsers for one user. +// DB-backed user settings. These are the cross-device preferences worth syncing. export type UserSettings = { - theme: 'system' | 'light' | 'dark' - autoFetchTitle: 'auto' | 'manual' | 'off' - autoFetchThumbnail: 'auto' | 'manual' | 'off' - wikiEditorMode: 'split' | 'write' | 'preview' } + theme: 'system' | 'light' | 'dark' } export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' @@ -24,10 +21,14 @@ type ClientListSettings = { limit?: ClientListLimit order?: string } -// Browser-local settings. These depend on device or screen context. +type ClientKeyboardSettings = { + shortcutsEnabled?: boolean } + +// Browser-local settings. These stay device-specific and are not synced to DB. export type ClientSettings = { panes?: Record lists?: Partial> + keyboard?: ClientKeyboardSettings theatre?: { layoutMode?: TheatreLayoutMode tagFlow?: TheatreTagFlow @@ -41,14 +42,9 @@ export type ClientSettings = { export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' export const DEFAULT_USER_SETTINGS: UserSettings = { - theme: 'system', - autoFetchTitle: 'manual', - autoFetchThumbnail: 'manual', - wikiEditorMode: 'split' } + theme: 'system' } export const THEME_OPTIONS = ['system', 'light', 'dark'] 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', @@ -89,12 +85,7 @@ export const fetchUserSettings = async (): Promise => export const updateUserSettings = async ( - settings: Partial<{ - theme: UserSettings['theme'] - auto_fetch_title: UserSettings['autoFetchTitle'] - auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] - wiki_editor_mode: UserSettings['wikiEditorMode'] - }>, + settings: Partial<{ theme: UserSettings['theme'] }>, ): Promise => await apiPatch ('/users/settings', settings) @@ -171,6 +162,20 @@ export const setClientListSettings = ( } +export const getClientKeyboardShortcutsEnabled = (): boolean => + loadClientSettings ().keyboard?.shortcutsEnabled ?? true + + +export const setClientKeyboardShortcutsEnabled = ( + enabled: boolean, +): void => { + updateClientSettings (settings => ({ + ...settings, + keyboard: { ...(settings.keyboard ?? { }), shortcutsEnabled: enabled }, + })) +} + + const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) return ( diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index dca39c4..14c1b50 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -9,7 +9,6 @@ import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' @@ -31,7 +30,6 @@ type PostFormField = const PostNewPage: FC = ({ user }) => { const editable = canEditContent (user) - const { settings } = useUserSettings () const navigate = useNavigate () @@ -49,16 +47,8 @@ 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 titleFetchVisible = titleFetchMode !== 'off' - const thumbnailFetchVisible = thumbnailFetchMode !== 'off' const videoFlg = useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), [tags]) @@ -93,19 +83,7 @@ const PostNewPage: FC = ({ user }) => { } } - const handleURLBlur = () => { - if (!(url) || url === previousURLRef.current) - return - - if (titleAutoFlg) - fetchTitle () - if (thumbnailAutoFlg) - fetchThumbnail () - previousURLRef.current = url - } - const fetchTitle = useCallback (async () => { - setTitle ('') setTitleLoading (true) try { @@ -144,24 +122,6 @@ 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 () - }, [fetchTitle, titleAutoFlg, url]) - - useEffect (() => { - if (thumbnailAutoFlg && url) - fetchThumbnail () - }, [fetchThumbnail, thumbnailAutoFlg, url]) - if (!(editable)) return @@ -183,8 +143,7 @@ const PostNewPage: FC = ({ user }) => { onChange={e => setURL (e.target.value)} aria-describedby={describedBy} aria-invalid={invalid} - className={inputClass (invalid)} - onBlur={handleURLBlur}/>)} + className={inputClass (invalid)}/>)}
{/* タイトル */} @@ -200,31 +159,14 @@ const PostNewPage: FC = ({ user }) => { onChange={ev => setTitle (ev.target.value)} disabled={titleLoading}/>
- - {titleAutoFlg - ? 'URL 入力時に自動取得します.' - : titleFetchMode === 'manual' - ? '自動取得しません.必要なら手動で取得できます.' - : '取得機能は無効です.'} - - {titleFetchVisible && ( - <> - - {!(titleAutoFlg) && ( - )} - )} + 必要なタイミングで URL から取得できます. +
)} @@ -234,52 +176,29 @@ const PostNewPage: FC = ({ user }) => { {({ describedBy, invalid }) => ( <>
- - {thumbnailAutoFlg - ? 'URL 入力時に自動取得します.' - : thumbnailFetchMode === 'manual' - ? '自動取得しません.必要なら手動で取得できます.' - : '取得機能は無効です.'} - - {thumbnailFetchVisible && ( - <> - - {!(thumbnailAutoFlg) && ( - )} - )} + 必要なタイミングで URL から取得できます. +
- {thumbnailAutoFlg - ? (thumbnailLoading - ?

Loading...

- : !(thumbnailPreview) && ( -

- URL から自動取得されます。 -

)) - : ( - { - const file = e.target.files?.[0] - if (file) - { - setThumbnailFile (file) - setThumbnailPreview (URL.createObjectURL (file)) - } - }}/>)} + {thumbnailLoading && ( +

Loading...

)} + { + const file = e.target.files?.[0] + if (file) + { + setThumbnailFile (file) + setThumbnailPreview (URL.createObjectURL (file)) + } + }}/> {thumbnailPreview && ( preview Date: Sun, 5 Jul 2026 01:51:07 +0900 Subject: [PATCH 05/30] #34 --- backend/app/models/setting.rb | 3 + frontend/src/App.tsx | 71 +- frontend/src/components/TagLink.tsx | 30 +- frontend/src/components/common/TabGroup.tsx | 35 +- .../components/users/UserSettingsProvider.tsx | 34 +- frontend/src/index.css | 25 + frontend/src/lib/settings.ts | 499 ++++++++++- frontend/src/pages/posts/PostListPage.tsx | 90 +- frontend/src/pages/posts/PostSearchPage.tsx | 88 +- frontend/src/pages/tags/TagListPage.tsx | 88 +- frontend/src/pages/users/SettingPage.tsx | 833 +++++++++++------- frontend/src/pages/wiki/WikiEditPage.tsx | 28 +- frontend/src/pages/wiki/WikiNewPage.tsx | 28 +- frontend/tailwind.config.js | 1 + 14 files changed, 1208 insertions(+), 645 deletions(-) diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index 425e152..cc6618f 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -1,5 +1,8 @@ class Setting < ApplicationRecord THEMES = ['system', 'light', 'dark'].freeze + + # These remain in the typed settings schema to preserve existing backend + # work. The common `/users/settings` page currently surfaces only `theme`. AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 604cb2c..d90891c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,9 +10,14 @@ import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' -import { UserSettingsProvider } from '@/components/users/UserSettingsProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import { + applyClientAppearance, + fetchUserSettings, + getClientThemeMode, + seedClientThemeMode, +} from '@/lib/settings' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' @@ -105,6 +110,19 @@ const App: FC = () => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) + useEffect (() => { + applyClientAppearance () + + const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)') + const handleThemeChange = () => { + if (getClientThemeMode () === 'system') + applyClientAppearance () + } + + mediaQuery.addEventListener ('change', handleThemeChange) + return () => mediaQuery.removeEventListener ('change', handleThemeChange) + }, []) + useEffect (() => { const createUser = async () => { const data = await apiPost<{ code: string; user: User }> ('/users') @@ -138,6 +156,23 @@ const App: FC = () => { createUser () }, []) + useEffect (() => { + if (!(user)) + return + + void (async () => { + try + { + const settings = await fetchUserSettings () + seedClientThemeMode (settings.theme) + applyClientAppearance () + } + catch + { + } + }) () + }, [user]) + switch (status) { case 503: @@ -146,26 +181,24 @@ const App: FC = () => { return ( <> - - - {import.meta.env.DEV && } + + {import.meta.env.DEV && } - - - - - - - - + + + + + + + + - - - - + + + ) } diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 180892b..615f7d1 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -1,9 +1,8 @@ import PrefetchLink from '@/components/PrefetchLink' import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText' -import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { cn } from '@/lib/utils' -import type { ComponentProps, FC, HTMLAttributes } from 'react' +import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react' import type { Tag } from '@/types' @@ -36,15 +35,16 @@ const TagLink: FC = ({ tag, withWiki = true, withCount = true, className, + style, title, ...props }) => { - const spanClass = cn ( - `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, - `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) - const linkClass = cn ( - spanClass, - `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, - `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`) + const colourStyle = { + '--tag-link-colour': `var(--tag-colour-${ tag.category })`, + '--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`, + ...style, + } as CSSProperties + const spanClass = 'tag-link-colour' + const linkClass = 'tag-link-colour tag-link-hover-colour' const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' const rootClass = 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline' @@ -63,7 +63,8 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( @@ -71,13 +72,15 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( + className={linkClass} + style={colourStyle}> ? ))) : ( @@ -120,6 +123,7 @@ const TagLink: FC = ({ tag, = ({ tag, to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} title={textTitle} className={cn (linkClass, textClass, className)} + style={colourStyle} {...props}> = ({ tag, <>{children} -const TabGroup: FC = ({ children }) => { +const TabGroup: FC = ({ children, rightAddon }) => { const tabs = React.Children.toArray (children) as React.ReactElement[] const [current, setCurrent] = useState (() => { @@ -22,17 +25,23 @@ const TabGroup: FC = ({ children }) => { return (
-
- {tabs.map ((tab, i) => ( - { - ev.preventDefault () - setCurrent (i) - }}> - {tab.props.name} - ))} +
+ + {rightAddon && ( +
+ {rightAddon} +
)}
{tabs[current]} diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx index 2d059bc..3966f59 100644 --- a/frontend/src/components/users/UserSettingsProvider.tsx +++ b/frontend/src/components/users/UserSettingsProvider.tsx @@ -1,6 +1,10 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { DEFAULT_USER_SETTINGS, fetchUserSettings } from '@/lib/settings' +import { + applyClientAppearance, + DEFAULT_USER_SETTINGS, + fetchUserSettings, +} from '@/lib/settings' import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' @@ -16,29 +20,6 @@ type ContextValue = { const UserSettingsContext = createContext (null) -const applyTheme = (theme: UserSettings['theme']) => { - const root = document.documentElement - const media = window.matchMedia ('(prefers-color-scheme: dark)') - - const sync = () => { - const dark = theme === 'dark' || (theme === 'system' && media.matches) - root.classList.toggle ('dark', dark) - root.style.colorScheme = dark ? 'dark' : 'light' - } - - sync () - - if (theme !== 'system') - return () => { } - - const onChange = () => sync () - media.addEventListener ('change', onChange) - return () => { - media.removeEventListener ('change', onChange) - } -} - - export const UserSettingsProvider: FC<{ children: ReactNode user: User | null }> = ({ children, user }) => { @@ -87,7 +68,10 @@ export const UserSettingsProvider: FC<{ } }, [user]) - useEffect (() => applyTheme (settings.theme), [settings.theme]) + useEffect (() => { + void settings.theme + applyClientAppearance () + }, [settings.theme]) const value = useMemo (() => ({ error, diff --git a/frontend/src/index.css b/frontend/src/index.css index ea2cbe8..b41fb31 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -29,6 +29,21 @@ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; + + --tag-colour-deerjikist: #9f1239; + --tag-colour-deerjikist-hover: #b62a51; + --tag-colour-meme: #6b21a8; + --tag-colour-meme-hover: #8238bf; + --tag-colour-character: #4d7c0f; + --tag-colour-character-hover: #649326; + --tag-colour-general: #155e75; + --tag-colour-general-hover: #2c758c; + --tag-colour-material: #c2410c; + --tag-colour-material-hover: #d95823; + --tag-colour-meta: #a16207; + --tag-colour-meta-hover: #b8791e; + --tag-colour-nico: #4b5563; + --tag-colour-nico-hover: #626c7a; } .dark @@ -161,6 +176,16 @@ body position: relative; } +.tag-link-colour +{ + color: var(--tag-link-colour); +} + +.tag-link-hover-colour:hover +{ + color: var(--tag-link-hover-colour) !important; +} + .tag-marquee__animated { position: absolute; diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index dce8538..0b064dd 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -1,10 +1,27 @@ +import { CATEGORIES } from '@/consts' import { apiGet, apiPatch } from '@/lib/api' -import type { FetchPostsOrder, FetchTagsOrder } from '@/types' +import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' -// DB-backed user settings. These are the cross-device preferences worth syncing. export type UserSettings = { - theme: 'system' | 'light' | 'dark' } + theme: 'system' | 'light' | 'dark' + autoFetchTitle: 'auto' | 'manual' | 'off' + autoFetchThumbnail: 'auto' | 'manual' | 'off' + wikiEditorMode: 'split' | 'write' | 'preview' } + +export type ThemeTagColours = Record +export type BuiltinThemeId = 'light' | 'dark' +export type ThemeId = BuiltinThemeId | `custom:${ string }` +export type ActiveThemeId = ThemeId | 'system' +export type ResolvedTheme = 'light' | 'dark' +export type ClientTheme = { + id: ThemeId + name: string + builtin: boolean + baseTheme: BuiltinThemeId + tagColours: ThemeTagColours } +export type CustomClientTheme = + ClientTheme & { builtin: false } export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' @@ -21,19 +38,32 @@ type ClientListSettings = { limit?: ClientListLimit order?: string } -type ClientKeyboardSettings = { - shortcutsEnabled?: boolean } +export type ClientAppearanceSettings = { + activeThemeId?: ActiveThemeId + customThemes?: Record + // Legacy fields kept for migration from the earlier appearance model. + theme?: UserSettings['theme'] + tagColours?: Partial } -// Browser-local settings. These stay device-specific and are not synced to DB. +// DB-backed settings. At present only `theme` is surfaced in `/users/settings`. +// The remaining fields are retained for existing backend work and are not wired +// to the common settings UI yet. +export const DEFAULT_USER_SETTINGS: UserSettings = { + theme: 'system', + autoFetchTitle: 'manual', + autoFetchThumbnail: 'manual', + wikiEditorMode: 'split' } + +// Browser-local settings only. These are device or screen specific. export type ClientSettings = { - panes?: Record - lists?: Partial> - keyboard?: ClientKeyboardSettings - theatre?: { + appearance?: ClientAppearanceSettings + panes?: Record + lists?: Partial> + theatre?: { layoutMode?: TheatreLayoutMode tagFlow?: TheatreTagFlow } - gekanator?: { + gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode } reducedMotion?: string @@ -41,9 +71,6 @@ export type ClientSettings = { thumbnailMode?: string } export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' -export const DEFAULT_USER_SETTINGS: UserSettings = { - theme: 'system' } - 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 = [ @@ -72,21 +99,84 @@ export const TAG_LIST_ORDER_OPTIONS = [ ] 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' +export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' +export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#9f1239', + meme: '#6b21a8', + character: '#4d7c0f', + general: '#155e75', + material: '#c2410c', + meta: '#a16207', + nico: '#4b5563', +} +export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#fb7185', + meme: '#d8b4fe', + character: '#bef264', + general: '#67e8f9', + material: '#fdba74', + meta: '#fde047', + nico: '#e5e7eb', +} +export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS +export const BUILTIN_THEMES: Record = { + light: { + id: 'light', + name: 'ライト・モード', + builtin: true, + baseTheme: 'light', + tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS, + }, + dark: { + id: 'dark', + name: 'ダーク・モード', + builtin: true, + baseTheme: 'dark', + tagColours: DEFAULT_DARK_THEME_TAG_COLOURS, + }, +} const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1' +const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated' -export const fetchUserSettings = async (): Promise => - await apiGet ('/users/settings') +export const fetchUserSettings = async (): Promise => { + const raw = await apiGet<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] + }> ('/users/settings') + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode, + } +} export const updateUserSettings = async ( settings: Partial<{ theme: UserSettings['theme'] }>, -): Promise => await apiPatch ('/users/settings', settings) +): Promise => { + const raw = await apiPatch<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] + }> ('/users/settings', settings) + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode, + } +} const safeParseClientSettings = (raw: string | null): ClientSettings => { @@ -127,6 +217,365 @@ const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null +const normaliseHexColour = (value: string): string | null => + /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null + + +const isBuiltinThemeId = (value: string): value is BuiltinThemeId => + value === 'light' || value === 'dark' + + +const isCustomThemeId = (value: string): value is `custom:${ string }` => + value.startsWith ('custom:') + + +const themeCopyName = (baseTheme: BuiltinThemeId): string => + `${ BUILTIN_THEMES[baseTheme].name }のコピー` + + +const appearanceFromSettings = (): ClientAppearanceSettings => + loadClientSettings ().appearance ?? { } + + +export const hasStoredClientThemeSelection = (): boolean => { + const appearance = appearanceFromSettings () + return ( + appearance.activeThemeId != null + || appearance.customThemes != null + || appearance.theme != null + || appearance.tagColours != null + ) +} + + +const mixHexColour = ( + source: string, + target: string, + ratio: number, +): string => { + const clampedRatio = Math.max (0, Math.min (1, ratio)) + const src = source.slice (1) + const dst = target.slice (1) + const channels = [0, 2, 4].map (offset => { + const srcValue = parseInt (src.slice (offset, offset + 2), 16) + const dstValue = parseInt (dst.slice (offset, offset + 2), 16) + const mixed = Math.round ( + srcValue + (dstValue - srcValue) * clampedRatio, + ) + return mixed.toString (16).padStart (2, '0') + }) + + return `#${ channels.join ('') }` +} + + +export const getClientThemeMode = (): UserSettings['theme'] => + (() => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if (appearance.activeThemeId === 'system') + return 'system' + if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark') + return appearance.activeThemeId + if ( + typeof appearance.activeThemeId === 'string' + && customThemes[appearance.activeThemeId] != null + ) + return customThemes[appearance.activeThemeId].baseTheme + + return appearance.theme ?? DEFAULT_USER_SETTINGS.theme + }) () + + +export const setClientThemeMode = (theme: UserSettings['theme']): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + activeThemeId: theme, + theme, + }, + })) +} + + +export const seedClientThemeMode = (theme: UserSettings['theme']): void => { + if (hasStoredClientThemeSelection ()) + return + + setClientThemeMode (theme) +} + + +const prefersDarkTheme = (): boolean => + window.matchMedia ('(prefers-color-scheme: dark)').matches + + +export const resolveThemeMode = ( + theme: UserSettings['theme'], +): ResolvedTheme => + theme === 'system' + ? (prefersDarkTheme () ? 'dark' : 'light') + : theme + + +export const defaultThemeTagColoursFor = ( + theme: ResolvedTheme, +): ThemeTagColours => + theme === 'dark' + ? DEFAULT_DARK_THEME_TAG_COLOURS + : DEFAULT_LIGHT_THEME_TAG_COLOURS + + +const normaliseThemeTagColours = ( + input: Partial | null | undefined, + fallback: ThemeTagColours, +): ThemeTagColours => + CATEGORIES.reduce ( + (colours, category) => { + const candidate = input?.[category] + colours[category] = + typeof candidate === 'string' && normaliseHexColour (candidate) != null + ? candidate + : fallback[category] + return colours + }, + { ...fallback }, + ) + + +const normaliseCustomTheme = ( + rawTheme: unknown, +): CustomClientTheme | null => { + if (!(rawTheme) || typeof rawTheme !== 'object') + return null + + const theme = rawTheme as Record + const id = typeof theme.id === 'string' ? theme.id : null + const name = typeof theme.name === 'string' ? theme.name : null + const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null + if ( + id == null + || !(isCustomThemeId (id)) + || name == null + || baseTheme == null + || !(isBuiltinThemeId (baseTheme)) + ) + return null + + return { + id, + name, + builtin: false, + baseTheme, + tagColours: normaliseThemeTagColours ( + theme.tagColours as Partial | undefined, + defaultThemeTagColoursFor (baseTheme), + ), + } +} + + +export const getClientCustomThemes = (): Record => { + const appearance = appearanceFromSettings () + const customThemes = appearance.customThemes + + if (customThemes && typeof customThemes === 'object') + { + return Object.entries (customThemes).reduce> ( + (themes, [key, rawTheme]) => { + const theme = normaliseCustomTheme (rawTheme) + if (theme != null) + themes[key] = theme + return themes + }, + { }, + ) + } + + const legacyTagColours = appearance.tagColours + if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0) + return { } + + const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme) + return { + [LEGACY_MIGRATED_THEME_ID]: { + id: LEGACY_MIGRATED_THEME_ID, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tagColours: normaliseThemeTagColours ( + legacyTagColours, + defaultThemeTagColoursFor (baseTheme), + ), + }, + } +} + + +export const getClientActiveThemeId = (): ActiveThemeId => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if ( + typeof appearance.activeThemeId === 'string' + && ( + appearance.activeThemeId === 'system' + || isBuiltinThemeId (appearance.activeThemeId) + || customThemes[appearance.activeThemeId] != null + ) + ) + return appearance.activeThemeId + + if ( + Object.keys (customThemes).length > 0 + && appearance.tagColours != null + && Object.keys (appearance.tagColours).length > 0 + ) + return LEGACY_MIGRATED_THEME_ID + + if (typeof appearance.theme === 'string' && ( + appearance.theme === 'system' + || appearance.theme === 'light' + || appearance.theme === 'dark' + )) + return appearance.theme + + return DEFAULT_USER_SETTINGS.theme +} + + +export const getThemeById = ( + themeId: ThemeId, + customThemes: Record, +): ClientTheme => + isBuiltinThemeId (themeId) + ? BUILTIN_THEMES[themeId] + : (customThemes[themeId] ?? BUILTIN_THEMES.light) + + +export const getResolvedThemeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): ClientTheme => + activeThemeId === 'system' + ? BUILTIN_THEMES[resolveThemeMode ('system')] + : getThemeById (activeThemeId, customThemes) + + +export const deriveUserThemeModeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): UserSettings['theme'] => { + if (activeThemeId === 'system') + return 'system' + + return getThemeById (activeThemeId, customThemes).baseTheme +} + + +export const createCustomThemeFromBase = ( + baseTheme: BuiltinThemeId, + tagColours?: ThemeTagColours, +): CustomClientTheme => { + const id = `custom:${ crypto.randomUUID () }` as ThemeId + return { + id, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tagColours: tagColours ?? { ...defaultThemeTagColoursFor (baseTheme) }, + } +} + + +export const getClientThemeTagColours = ( + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): ThemeTagColours => { + return { + ...getResolvedThemeFromSelection (activeThemeId, customThemes).tagColours, + } +} + + +export const setClientAppearanceThemes = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + activeThemeId, + customThemes, + theme: undefined, + tagColours: undefined, + }, + })) +} + + +export const setClientThemeTagColours = ( + tagColours: ThemeTagColours, + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): void => { + if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId)) + return + + setClientAppearanceThemes ( + activeThemeId, + { + ...customThemes, + [activeThemeId]: { + ...customThemes[activeThemeId], + tagColours, + }, + }, + ) +} + + +export const applyThemeMode = (theme: UserSettings['theme']): void => { + const root = document.documentElement + const resolvedTheme = resolveThemeMode (theme) + + root.classList.toggle ('dark', resolvedTheme === 'dark') + root.style.colorScheme = resolvedTheme +} + + +export const applyThemeTagColours = (tagColours: ThemeTagColours): void => { + const root = document.documentElement + + CATEGORIES.forEach (category => { + const colour = tagColours[category] + root.style.setProperty (`--tag-colour-${ category }`, colour) + root.style.setProperty ( + `--tag-colour-${ category }-hover`, + mixHexColour (colour, '#ffffff', .18), + ) + }) +} + + +export const applyThemeSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): void => { + const theme = getResolvedThemeFromSelection (activeThemeId, customThemes) + applyThemeMode (theme.baseTheme) + applyThemeTagColours (theme.tagColours) +} + + +export const applyClientAppearance = (): void => { + applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ()) +} + + export const getClientListLimit = ( listKey: ClientListKey, ): ClientListLimit | null => { @@ -162,20 +611,6 @@ export const setClientListSettings = ( } -export const getClientKeyboardShortcutsEnabled = (): boolean => - loadClientSettings ().keyboard?.shortcutsEnabled ?? true - - -export const setClientKeyboardShortcutsEnabled = ( - enabled: boolean, -): void => { - updateClientSettings (settings => ({ - ...settings, - keyboard: { ...(settings.keyboard ?? { }), shortcutsEnabled: enabled }, - })) -} - - const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) return ( diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index be7c8fa..ab5330d 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -7,7 +7,6 @@ import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' import TagSidebar from '@/components/TagSidebar' import WikiBody from '@/components/WikiBody' -import FormField from '@/components/common/FormField' import Pagination from '@/components/common/Pagination' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' @@ -16,43 +15,21 @@ import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' import { DEFAULT_POST_LIST_LIMIT, - DEFAULT_POST_LIST_ORDER, getClientListLimit, - getClientListOrder, LIST_LIMIT_OPTIONS, - POST_LIST_ORDER_OPTIONS, setClientListSettings, } from '@/lib/settings' -import { inputClass } from '@/lib/utils' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' -import type { FetchPostsOrder, WikiPage } from '@/types' - -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': '更新日 降順', -} +import type { WikiPage } from '@/types' 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) @@ -73,11 +50,7 @@ const PostListPage: FC = () => { ?? getClientListLimit ('postList') ?? DEFAULT_POST_LIST_LIMIT ) - const order = ( - parseOrder (query.get ('order')) - ?? getClientListOrder ('postList') - ?? DEFAULT_POST_LIST_ORDER - ) + const order = 'original_created_at:desc' as const const keys = { tags: tagsKey, match, page, limit, @@ -92,14 +65,12 @@ const PostListPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { - setClientListSettings ('postList', { limit, order }) - }, [limit, order]) + setClientListSettings ('postList', { limit }) + }, [limit]) - const updateListQuery = (next: { limit?: 20 | 50 | 100 - order?: FetchPostsOrder }) => { + const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => { 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 () }`) } @@ -146,39 +117,24 @@ const PostListPage: FC = () => { }}/> -
- - {() => ( - )} - - - - {() => ( - )} - -
- - + + 件数 + + + }> {posts.length > 0 ? ( diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 0372361..4ef0ba1 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -22,7 +22,6 @@ import { getClientListLimit, getClientListOrder, LIST_LIMIT_OPTIONS, - POST_LIST_ORDER_OPTIONS, setClientListSettings, } from '@/lib/settings' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' @@ -40,28 +39,30 @@ 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 parseOrder = (value: string | null): FetchPostsOrder | null => { + switch (value) + { + case 'title:asc': + case 'title:desc': + case 'url:asc': + case 'url:desc': + case 'original_created_at:asc': + case 'original_created_at:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} const PostSearchPage: FC = () => { @@ -188,41 +189,26 @@ const PostSearchPage: FC = () => {
- 広場検索 +
+ 広場検索 + +
-
- - {() => ( - )} - - - - {() => ( - )} - -
- {/* タイトル */} {({ invalid }) => ( diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index a322b80..b01350e 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -21,7 +21,6 @@ import { getClientListOrder, LIST_LIMIT_OPTIONS, setClientListSettings, - TAG_LIST_ORDER_OPTIONS, } from '@/lib/settings' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -44,28 +43,30 @@ const boolFromQuery = (value: string | null): boolean => 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 parseOrder = (value: string | null): FetchTagsOrder | null => { + switch (value) + { + case 'name:asc': + case 'name:desc': + case 'category:asc': + case 'category:desc': + case 'post_count:asc': + case 'post_count:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} const TagListPage: FC = () => { @@ -192,41 +193,26 @@ const TagListPage: FC = () => {
- タグ +
+ タグ + +
-
- - {() => ( - )} - - - - {() => ( - )} - -
- {/* 名前 */} {({ invalid }) => ( diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 36ec88b..ee94c08 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,11 +1,6 @@ -import type { - Dispatch, - FC, - KeyboardEvent, - SetStateAction, -} from 'react' +import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -14,92 +9,84 @@ import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' +import TagLink from '@/components/TagLink' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' +import { CATEGORY_NAMES, CATEGORIES } from '@/consts' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' import { - AUTO_FETCH_OPTIONS, + applyThemeSelection, + BUILTIN_THEMES, + createCustomThemeFromBase, + defaultThemeTagColoursFor, DEFAULT_USER_SETTINGS, - THEME_OPTIONS, - WIKI_EDITOR_MODE_OPTIONS, + deriveUserThemeModeFromSelection, + fetchUserSettings, + hasStoredClientThemeSelection, + getClientActiveThemeId, + getClientCustomThemes, + getResolvedThemeFromSelection, + setClientAppearanceThemes, updateUserSettings, } from '@/lib/settings' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' -import type { User } from '@/types' -import type { UserSettings } from '@/lib/settings' +import type { Category, Tag, User } from '@/types' +import type { + ActiveThemeId, + ClientTheme, + CustomClientTheme, + ThemeTagColours, + UserSettings, +} from '@/lib/settings' -type Props = { user: User | null - setUser: Dispatch> } +type Props = { + user: User | null + setUser: Dispatch> +} type UserFormField = 'name' -type SettingsFormField = - | 'theme' - | 'autoFetchTitle' - | 'autoFetchThumbnail' - | 'wikiEditorMode' - -type SettingsTab = 'account' | 'theme' | 'keyboard' | 'editing' | 'wiki' +type SettingsFormField = 'theme' +type SettingsTab = 'account' | 'theme' | 'keyboard' type TabSpec = { id: SettingsTab label: string } -type NameFieldErrors = Partial> -type SettingsFieldErrors = Partial> - type SharedSectionProps = { - draftSettings: UserSettings + user: User name: string - nameBaseErrors: string[] - nameFieldErrors: NameFieldErrors - onDraftSettingsChange: Dispatch> - onInheritOpen: () => void onNameChange: (value: string) => void - onSettingsSubmit: () => void + onInheritOpen: () => void onUserCodeOpen: () => void onUserSubmit: () => void + userBaseErrors: string[] + userFieldErrors: Partial> + draftActiveThemeId: ActiveThemeId + draftCustomThemes: Record + selectedTheme: ClientTheme + draftTagColours: ThemeTagColours + onThemeChange: (themeId: ActiveThemeId) => void + onTagColourChange: (category: Category, colour: string) => void + onResetAllTagColours: () => void + onResetTagColour: (category: Category) => void + onThemeSubmit: () => 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 (' ') + settingsFieldErrors: Partial> +} const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, { id: 'theme', label: 'テーマ' }, { id: 'keyboard', label: 'キーボード' }, - { id: 'editing', label: '編輯支援' }, - { id: 'wiki', label: 'Wiki' }, ] -const settingsTabByField: Record = { - theme: 'theme', - autoFetchTitle: 'editing', - autoFetchThumbnail: 'editing', - wikiEditorMode: 'wiki', -} - -const settingsFieldOrder: SettingsFormField[] = [ - 'theme', - 'autoFetchTitle', - 'autoFetchThumbnail', - 'wikiEditorMode', -] +const sectionClassName = + 'space-y-4 rounded-xl border border-border bg-background/80 p-4' const themeLabel: Record = { system: 'システム設定に従う', @@ -107,40 +94,64 @@ const themeLabel: Record = { dark: 'ダーク・モード', } -const autoFetchLabel: Record = { - auto: '自動', - manual: '手動', - off: '無効', -} - -const wikiEditorModeLabel: Record = { - split: '左右表示', - write: '本文のみ', - preview: 'プレビューのみ', +const builtinThemeLabel: Record<'light' | 'dark', string> = { + light: 'ライト・モード', + dark: 'ダーク・モード', } const keyboardShortcutRows = [ - { action: '投稿詳細を開く', key: 'Enter', scope: '一覧・検索結果', conflict: 'なし' }, - { action: '候補タグを選ぶ', key: '↑ / ↓ / Enter', scope: 'タグ入力', conflict: 'なし' }, - { action: '候補タグを閉ぢる', key: 'Escape', scope: 'タグ入力', conflict: 'なし' }, + { + action: '投稿詳細を開く', + key: 'Enter', + scope: '一覧・検索結果', + conflict: 'なし', + }, + { + action: '候補タグを選ぶ', + key: '↑ / ↓ / Enter', + scope: 'タグ入力', + conflict: 'なし', + }, + { + action: '候補タグを閉じる', + key: 'Escape', + scope: 'タグ入力', + conflict: 'なし', + }, ] -const themeColorTargets = [ - '背景色', - '文字色', - '枠線色', - 'リンク色', - '強調色', - '通常タグ', - 'キャラタグ', - '素材タグ', - 'Nico タグ', - '廃止タグ', - 'Wiki ありタグ', - 'Wiki 未作成タグ', - '選択中タグ', - 'ホバー中タグ', -] +const previewTag = ( + id: number, + name: string, + category: Category, + postCount: number, + hasWiki: boolean, + options?: { hasDeerjikists?: boolean }, +): Tag => ({ + id, + name, + category, + deprecatedAt: null, + aliases: [], + parents: [], + postCount, + createdAt: '', + updatedAt: '', + hasWiki, + materialId: null, + hasDeerjikists: options?.hasDeerjikists ?? false, + children: [], +}) + +const previewTags: Record = { + deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), + meme: previewTag (2, 'ミーム', 'meme', 34, true), + character: previewTag (3, 'キャラクター', 'character', 56, false), + general: previewTag (4, '一般', 'general', 78, true), + material: previewTag (5, '素材', 'material', 90, false), + meta: previewTag (6, 'メタ', 'meta', 21, true), + nico: previewTag (7, 'nico:sm9', 'nico', 43, false), +} const parseTab = (search: string): SettingsTab => { @@ -155,21 +166,42 @@ const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` +const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => + Object.fromEntries ( + CATEGORIES.flatMap (category => [ + [`--tag-colour-${ category }`, tagColours[category]], + [`--tag-colour-${ category }-hover`, tagColours[category]], + ]), + ) as CSSProperties + + +const themeLabelById = ( + themeId: ActiveThemeId, + customThemes: Record, +): string => { + if (themeId === 'system') + return themeLabel.system + if (themeId === 'light' || themeId === 'dark') + return builtinThemeLabel[themeId] + return (customThemes[themeId] ?? BUILTIN_THEMES.light).name +} + + const AccountSection: FC = ({ name, - nameBaseErrors, - nameFieldErrors, onInheritOpen, onNameChange, onUserCodeOpen, onUserSubmit, user, + userBaseErrors, + userFieldErrors, }) => (

アカウント

- + - + {({ describedBy, invalid }) => ( <> = ({ const ThemeSection: FC = ({ - draftSettings, - onDraftSettingsChange, - onSettingsSubmit, + draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onResetAllTagColours, + onResetTagColour, + onTagColourChange, + onThemeChange, + onThemeSubmit, settingsBaseErrors, settingsFieldErrors, }) => ( @@ -230,37 +268,119 @@ const ThemeSection: FC = ({ {() => ( )} -
-

今後の拡張予定

+

- 将来は `themes` または `user_themes` を導入し,ユーザごとの - カスタム・テーマを持てる構造にする前提です. + テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.

-

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

-
- {themeColorTargets.map (target => ( - - {target} - ))} +

+ backend には system / light / dark の mode だけ保存します. + カスタムテーマ本体は今は browser-local です. +

+

+ 将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます. +

+
+ +
+
+
+

タグカテゴリ色

+

+ {selectedTheme.builtin || draftActiveThemeId === 'system' + ? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.' + : `現在の編輯対象: ${ selectedTheme.name }`} +

+
+ +
+ +
+ {CATEGORIES.map (category => { + const resolvedDefaults = + defaultThemeTagColoursFor (selectedTheme.baseTheme) + const isDefault = + draftTagColours[category] === resolvedDefaults[category] + + return ( +
+
+ {CATEGORY_NAMES[category]} + onTagColourChange (category, ev.target.value)} + className="h-10 w-16 cursor-pointer rounded border border-border + bg-transparent p-1"/> +
+
+ +
+
) + })}
-
) @@ -269,13 +389,10 @@ const KeyboardSection: FC = () => (

キーボード

-
-

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

-
+

+ 現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ + key bind 設定へ拡張できる形にしています. +

@@ -301,111 +418,29 @@ const KeyboardSection: FC = () => ( ) -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 () - const [draftSettings, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) - const [name, setName] = useState ('') - const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false) - const { error, loaded, settings, setSettings } = useUserSettings () + const [loading, setLoading] = useState (true) + const [name, setName] = useState ('') + const [settingsError, setSettingsError] = useState (null) + const [, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) + const [savedActiveThemeId, setSavedActiveThemeId] = + useState (getClientActiveThemeId ()) + const [savedCustomThemes, setSavedCustomThemes] = + useState> (getClientCustomThemes ()) + const [draftActiveThemeId, setDraftActiveThemeId] = + useState (getClientActiveThemeId ()) + const [draftCustomThemes, setDraftCustomThemes] = + useState> (getClientCustomThemes ()) + const [userCodeVsbl, setUserCodeVsbl] = useState (false) const { - baseErrors: nameBaseErrors, - fieldErrors: nameFieldErrors, - clearValidationErrors: clearNameValidationErrors, - applyValidationError: applyNameValidationError, + baseErrors: userBaseErrors, + fieldErrors: userFieldErrors, + clearValidationErrors: clearUserValidationErrors, + applyValidationError: applyUserValidationError, } = useValidationErrors () const { baseErrors: settingsBaseErrors, @@ -415,40 +450,80 @@ const SettingPage: FC = ({ user, setUser }) => { } = useValidationErrors () const activeTab = useMemo (() => parseTab (location.search), [location.search]) + const selectedTheme = useMemo ( + () => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes), + [draftActiveThemeId, draftCustomThemes], + ) + const draftTagColours = selectedTheme.tagColours - const setActiveTab = useCallback ((tab: SettingsTab) => { + const setActiveTab = (tab: SettingsTab) => { const params = new URLSearchParams (location.search) params.set ('tab', tab) navigate ( - `${ location.pathname }${ params.toString () ? `?${ params.toString () }` : '' }`, + `${ location.pathname }?${ params.toString () }`, { replace: true }, ) - }, [location.pathname, location.search, navigate]) + } const settingsTabErrors = useMemo> (() => ({ - account: Boolean (nameFieldErrors.name?.length), + account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), keyboard: false, - editing: ( - Boolean (settingsFieldErrors.autoFetchTitle?.length) - || Boolean (settingsFieldErrors.autoFetchThumbnail?.length) - ), - wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length), - }), [nameFieldErrors.name, settingsFieldErrors]) + }), [settingsFieldErrors.theme, userFieldErrors.name]) + + const applyDraftThemeSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, + ) => { + setDraftActiveThemeId (activeThemeId) + setDraftCustomThemes (customThemes) + setDraftSettings (current => ({ + ...current, + theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes), + })) + applyThemeSelection (activeThemeId, customThemes) + } + + const ensureEditableCustomTheme = (): { + activeThemeId: ActiveThemeId + customThemes: Record + } => { + if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null) + { + return { + activeThemeId: draftActiveThemeId, + customThemes: draftCustomThemes, + } + } + + const customTheme = createCustomThemeFromBase ( + selectedTheme.baseTheme, + { ...selectedTheme.tagColours }, + ) + const nextCustomThemes = { + ...draftCustomThemes, + [customTheme.id]: customTheme, + } + + return { + activeThemeId: customTheme.id, + customThemes: nextCustomThemes, + } + } const handleTabKeyDown = (event: KeyboardEvent) => { const currentIndex = tabs.findIndex (tab => tab.id === activeTab) switch (event.key) { - case 'ArrowRight': case 'ArrowDown': + case 'ArrowRight': event.preventDefault () setActiveTab (tabs[(currentIndex + 1) % tabs.length].id) break - case 'ArrowLeft': case 'ArrowUp': + case 'ArrowLeft': event.preventDefault () setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) break @@ -469,7 +544,7 @@ const SettingPage: FC = ({ user, setUser }) => { if (!(user)) return - clearNameValidationErrors () + clearUserValidationErrors () const formData = new FormData formData.append ('name', name) @@ -484,30 +559,30 @@ const SettingPage: FC = ({ user, setUser }) => { } catch (submitError) { - applyNameValidationError (submitError) + applyUserValidationError (submitError) toast ({ title: '表示名を更新できませんでした.' }) } } - const handleSettingsSubmit = async () => { + const handleThemeSubmit = async () => { clearSettingsValidationErrors () try { - const data = await updateUserSettings ({ - theme: draftSettings.theme, - auto_fetch_title: draftSettings.autoFetchTitle, - auto_fetch_thumbnail: draftSettings.autoFetchThumbnail, - wiki_editor_mode: draftSettings.wikiEditorMode, - }) - setDraftSettings (data) - setSettings (data) - toast ({ title: '設定を保存しました.' }) + const themeMode = + deriveUserThemeModeFromSelection (draftActiveThemeId, draftCustomThemes) + const data = await updateUserSettings ({ theme: themeMode }) + setDraftSettings ({ ...data, theme: themeMode }) + setSavedActiveThemeId (draftActiveThemeId) + setSavedCustomThemes (draftCustomThemes) + setClientAppearanceThemes (draftActiveThemeId, draftCustomThemes) + applyThemeSelection (draftActiveThemeId, draftCustomThemes) + toast ({ title: 'テーマ設定を保存しました.' }) } catch (submitError) { applySettingsValidationError (submitError) - toast ({ title: '設定を保存できませんでした.' }) + toast ({ title: 'テーマ設定を保存できませんでした.' }) } } @@ -519,36 +594,140 @@ const SettingPage: FC = ({ user, setUser }) => { }, [user]) useEffect (() => { - setDraftSettings (settings) - }, [settings]) + if (!(user)) + return + + let cancelled = false + + void (async () => { + setLoading (true) + + try + { + const data = await fetchUserSettings () + if (cancelled) + return + + const customThemes = getClientCustomThemes () + const activeThemeId = hasStoredClientThemeSelection () + ? getClientActiveThemeId () + : data.theme + const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes) + + setDraftSettings ({ ...data, theme: themeMode }) + setSavedActiveThemeId (activeThemeId) + setSavedCustomThemes (customThemes) + setDraftActiveThemeId (activeThemeId) + setDraftCustomThemes (customThemes) + setSettingsError (null) + } + catch + { + if (cancelled) + return + + setSettingsError ('設定の読込みに失敗しました.') + } + finally + { + if (!(cancelled)) + setLoading (false) + } + }) () + + return () => { + cancelled = true + } + }, [user]) useEffect (() => { - if (nameFieldErrors.name?.length) + if (userFieldErrors.name?.length) setActiveTab ('account') - }, [nameFieldErrors.name, setActiveTab]) + }, [location.pathname, location.search, userFieldErrors.name]) useEffect (() => { - const fieldWithError = settingsFieldOrder.find ( - field => (settingsFieldErrors[field]?.length ?? 0) > 0, - ) - if (fieldWithError) - setActiveTab (settingsTabByField[fieldWithError]) - }, [setActiveTab, settingsFieldErrors]) + if (settingsFieldErrors.theme?.length) + setActiveTab ('theme') + }, [location.pathname, location.search, settingsFieldErrors.theme]) + + useEffect (() => { + applyThemeSelection (draftActiveThemeId, draftCustomThemes) + + return () => { + applyThemeSelection (savedActiveThemeId, savedCustomThemes) + } + }, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes]) const sectionProps: SharedSectionProps | null = user ? { - draftSettings, + user, name, - nameBaseErrors, - nameFieldErrors, - onDraftSettingsChange: setDraftSettings, - onInheritOpen: () => setInheritVsbl (true), onNameChange: setName, - onSettingsSubmit: handleSettingsSubmit, + onInheritOpen: () => setInheritVsbl (true), onUserCodeOpen: () => setUserCodeVsbl (true), onUserSubmit: handleUserSubmit, + userBaseErrors, + userFieldErrors, + draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onThemeChange: themeId => { + applyDraftThemeSelection (themeId, draftCustomThemes) + }, + onTagColourChange: (category, colour) => { + const editable = ensureEditableCustomTheme () + const editableTheme = editable.customThemes[editable.activeThemeId] + const nextCustomThemes = { + ...editable.customThemes, + [editable.activeThemeId]: { + ...editableTheme, + tagColours: { + ...editableTheme.tagColours, + [category]: colour, + }, + }, + } + applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes) + }, + onResetAllTagColours: () => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return + + applyDraftThemeSelection ( + draftActiveThemeId, + { + ...draftCustomThemes, + [draftActiveThemeId]: { + ...draftCustomThemes[draftActiveThemeId], + tagColours: { + ...defaultThemeTagColoursFor (selectedTheme.baseTheme), + }, + }, + }, + ) + }, + onResetTagColour: category => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return + + applyDraftThemeSelection ( + draftActiveThemeId, + { + ...draftCustomThemes, + [draftActiveThemeId]: { + ...draftCustomThemes[draftActiveThemeId], + tagColours: { + ...draftCustomThemes[draftActiveThemeId].tagColours, + [category]: + defaultThemeTagColoursFor (selectedTheme.baseTheme)[category], + }, + }, + }, + ) + }, + onThemeSubmit: handleThemeSubmit, settingsBaseErrors, settingsFieldErrors, - user, } : null return ( @@ -558,72 +737,68 @@ const SettingPage: FC = ({ user, setUser }) => { 設定 | {SITE_TITLE} -
-
- 設定 - -
- - {user && loaded ? ( +
+ 設定 + + {loading ? 'Loading...' : ( <> -
- {sectionProps && ( - <> - - - - - - )} -
+ {sectionProps && ( + <> + + + + )} + )} +
-
-
-
- {tabs.map (tab => ( - ))} -
+
+
+
+ {tabs.map (tab => ( + ))} +
-
- {sectionProps && ( -
- {activeTab === 'account' && } - {activeTab === 'theme' && } - {activeTab === 'keyboard' && } - {activeTab === 'editing' && } - {activeTab === 'wiki' && } -
)} -
-
-
- ) : 'Loading...'} +
+ 設定 + + {loading ? 'Loading...' : ( +
+ {sectionProps && ( + <> + {activeTab === 'account' && } + {activeTab === 'theme' && } + {activeTab === 'keyboard' && } + )} +
)} +
+
{ - switch (mode) - { - case 'write': - return { menu: true, md: true, html: false } - - case 'preview': - return { menu: true, md: false, html: true } - - default: - return { menu: true, md: true, html: true } - } -} +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -54,7 +37,6 @@ type WikiFormField = 'title' | 'body' const WikiEditPage: FC = ({ user }) => { const editable = canEditContent (user) - const { settings } = useUserSettings () const { id } = useParams () @@ -137,7 +119,7 @@ const WikiEditPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index b4d5d6a..ac07b3d 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -9,7 +9,6 @@ import { useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' -import { useUserSettings } from '@/components/users/UserSettingsProvider' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' @@ -23,27 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css' import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const -type EditorView = { - menu: boolean - md: boolean - html: boolean } - -const editorView = ( - mode: 'split' | 'write' | 'preview', -): EditorView => { - switch (mode) - { - case 'write': - return { menu: true, md: true, html: false } - - case 'preview': - return { menu: true, md: false, html: true } - - default: - return { menu: true, md: true, html: true } - } -} +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -52,7 +35,6 @@ type WikiFormField = 'title' | 'body' const WikiNewPage: FC = ({ user }) => { const editable = canEditContent (user) - const { settings } = useUserSettings () const location = useLocation () const navigate = useNavigate () @@ -115,7 +97,7 @@ const WikiNewPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 16795ac..3ffb5b0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -8,6 +8,7 @@ import { DARK_COLOUR_SHADE, const colours = Object.values (TAG_COLOUR) export default { + darkMode: 'class', content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'], safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), -- 2.34.1 From fecbb4b354307805a4a9b691e239b228f3ee918d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 02:04:07 +0900 Subject: [PATCH 06/30] #34 --- frontend/src/index.css | 40 +++--- frontend/src/lib/settings.ts | 153 ++++++++++++++++++++--- frontend/src/pages/users/SettingPage.tsx | 34 +++-- 3 files changed, 178 insertions(+), 49 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index b41fb31..05d2ee0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,10 @@ { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; @@ -29,6 +33,7 @@ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; + --theme-link: 221.2 83.2% 53.3%; --tag-colour-deerjikist: #9f1239; --tag-colour-deerjikist-hover: #b62a51; @@ -50,6 +55,10 @@ { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; @@ -73,16 +82,16 @@ body { - @apply overflow-x-clip; + @apply overflow-x-clip bg-background text-foreground; } a { - @apply text-blue-700 dark:text-blue-300; + color: hsl(var(--theme-link)); } a:hover { - @apply text-blue-500 dark:text-blue-100; + opacity: .85; } } @@ -93,8 +102,8 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); font-synthesis: none; text-rendering: optimizeLegibility; @@ -135,28 +144,15 @@ body { margin: 0; min-width: 320px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); } #root { min-height: 100dvh; -} - -@media (prefers-color-scheme: light) -{ - :root - { - color: #213547; - background-color: #ffffff; - } - a:hover - { - color: #747bff; - } - button - { - background-color: #f9f9f9; - } + background: hsl(var(--background)); + color: hsl(var(--foreground)); } @keyframes wiki-blink diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 0b064dd..51a8abc 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -14,12 +14,35 @@ export type BuiltinThemeId = 'light' | 'dark' export type ThemeId = BuiltinThemeId | `custom:${ string }` export type ActiveThemeId = ThemeId | 'system' export type ResolvedTheme = 'light' | 'dark' +export type ThemeTokens = { + background: string + foreground: string + card: string + cardForeground: string + popover: string + popoverForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + border: string + input: string + ring: string + link: string + tagColours: ThemeTagColours +} export type ClientTheme = { id: ThemeId name: string builtin: boolean baseTheme: BuiltinThemeId - tagColours: ThemeTagColours } + tokens: ThemeTokens } export type CustomClientTheme = ClientTheme & { builtin: false } @@ -119,20 +142,66 @@ export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { nico: '#e5e7eb', } export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS +export const LIGHT_THEME_TOKENS: ThemeTokens = { + background: '0 0% 100%', + foreground: '222.2 84% 4.9%', + card: '0 0% 100%', + cardForeground: '222.2 84% 4.9%', + popover: '0 0% 100%', + popoverForeground: '222.2 84% 4.9%', + primary: '222.2 47.4% 11.2%', + primaryForeground: '210 40% 98%', + secondary: '210 40% 96.1%', + secondaryForeground: '222.2 47.4% 11.2%', + muted: '210 40% 96.1%', + mutedForeground: '215.4 16.3% 46.9%', + accent: '210 40% 96.1%', + accentForeground: '222.2 47.4% 11.2%', + destructive: '0 72.2% 50.6%', + destructiveForeground: '210 40% 98%', + border: '214.3 31.8% 91.4%', + input: '214.3 31.8% 91.4%', + ring: '222.2 84% 4.9%', + link: '221.2 83.2% 53.3%', + tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS, +} +export const DARK_THEME_TOKENS: ThemeTokens = { + background: '222.2 84% 4.9%', + foreground: '210 40% 98%', + card: '222.2 84% 4.9%', + cardForeground: '210 40% 98%', + popover: '222.2 84% 4.9%', + popoverForeground: '210 40% 98%', + primary: '210 40% 98%', + primaryForeground: '222.2 47.4% 11.2%', + secondary: '217.2 32.6% 17.5%', + secondaryForeground: '210 40% 98%', + muted: '217.2 32.6% 17.5%', + mutedForeground: '215 20.2% 65.1%', + accent: '217.2 32.6% 17.5%', + accentForeground: '210 40% 98%', + destructive: '0 62.8% 45%', + destructiveForeground: '210 40% 98%', + border: '217.2 32.6% 17.5%', + input: '217.2 32.6% 17.5%', + ring: '212.7 26.8% 83.9%', + link: '213.1 93.9% 67.8%', + tagColours: DEFAULT_DARK_THEME_TAG_COLOURS, +} export const BUILTIN_THEMES: Record = { light: { id: 'light', name: 'ライト・モード', builtin: true, baseTheme: 'light', - tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS, + tokens: LIGHT_THEME_TOKENS, }, dark: { id: 'dark', name: 'ダーク・モード', builtin: true, baseTheme: 'dark', - tagColours: DEFAULT_DARK_THEME_TAG_COLOURS, + tokens: DARK_THEME_TOKENS, }, } @@ -369,10 +438,26 @@ const normaliseCustomTheme = ( name, builtin: false, baseTheme, - tagColours: normaliseThemeTagColours ( - theme.tagColours as Partial | undefined, - defaultThemeTagColoursFor (baseTheme), - ), + tokens: { + ...( + baseTheme === 'dark' + ? DARK_THEME_TOKENS + : LIGHT_THEME_TOKENS + ), + ...(theme.tokens && typeof theme.tokens === 'object' + ? theme.tokens as Partial + : { }), + tagColours: normaliseThemeTagColours ( + ( + theme.tokens + && typeof theme.tokens === 'object' + && 'tagColours' in theme.tokens + ) + ? (theme.tokens as { tagColours?: Partial }).tagColours + : theme.tagColours as Partial | undefined, + defaultThemeTagColoursFor (baseTheme), + ), + }, } } @@ -405,10 +490,13 @@ export const getClientCustomThemes = (): Record => { name: themeCopyName (baseTheme), builtin: false, baseTheme, - tagColours: normaliseThemeTagColours ( - legacyTagColours, - defaultThemeTagColoursFor (baseTheme), - ), + tokens: { + ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS), + tagColours: normaliseThemeTagColours ( + legacyTagColours, + defaultThemeTagColoursFor (baseTheme), + ), + }, }, } } @@ -477,7 +565,7 @@ export const deriveUserThemeModeFromSelection = ( export const createCustomThemeFromBase = ( baseTheme: BuiltinThemeId, - tagColours?: ThemeTagColours, + tokens?: ThemeTokens, ): CustomClientTheme => { const id = `custom:${ crypto.randomUUID () }` as ThemeId return { @@ -485,7 +573,9 @@ export const createCustomThemeFromBase = ( name: themeCopyName (baseTheme), builtin: false, baseTheme, - tagColours: tagColours ?? { ...defaultThemeTagColoursFor (baseTheme) }, + tokens: tokens ?? { + ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS), + }, } } @@ -495,7 +585,7 @@ export const getClientThemeTagColours = ( customThemes: Record = getClientCustomThemes (), ): ThemeTagColours => { return { - ...getResolvedThemeFromSelection (activeThemeId, customThemes).tagColours, + ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours, } } @@ -531,7 +621,10 @@ export const setClientThemeTagColours = ( ...customThemes, [activeThemeId]: { ...customThemes[activeThemeId], - tagColours, + tokens: { + ...customThemes[activeThemeId].tokens, + tagColours, + }, }, }, ) @@ -561,13 +654,41 @@ export const applyThemeTagColours = (tagColours: ThemeTagColours): void => { } +export const applyThemeTokens = (tokens: ThemeTokens): void => { + const root = document.documentElement + + root.style.setProperty ('--background', tokens.background) + root.style.setProperty ('--foreground', tokens.foreground) + root.style.setProperty ('--card', tokens.card) + root.style.setProperty ('--card-foreground', tokens.cardForeground) + root.style.setProperty ('--popover', tokens.popover) + root.style.setProperty ('--popover-foreground', tokens.popoverForeground) + root.style.setProperty ('--primary', tokens.primary) + root.style.setProperty ('--primary-foreground', tokens.primaryForeground) + root.style.setProperty ('--secondary', tokens.secondary) + root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground) + root.style.setProperty ('--muted', tokens.muted) + root.style.setProperty ('--muted-foreground', tokens.mutedForeground) + root.style.setProperty ('--accent', tokens.accent) + root.style.setProperty ('--accent-foreground', tokens.accentForeground) + root.style.setProperty ('--destructive', tokens.destructive) + root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground) + root.style.setProperty ('--border', tokens.border) + root.style.setProperty ('--input', tokens.input) + root.style.setProperty ('--ring', tokens.ring) + root.style.setProperty ('--theme-link', tokens.link) + + applyThemeTagColours (tokens.tagColours) +} + + export const applyThemeSelection = ( activeThemeId: ActiveThemeId, customThemes: Record, ): void => { const theme = getResolvedThemeFromSelection (activeThemeId, customThemes) applyThemeMode (theme.baseTheme) - applyThemeTagColours (theme.tagColours) + applyThemeTokens (theme.tokens) } diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index ee94c08..9d1af16 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -454,7 +454,7 @@ const SettingPage: FC = ({ user, setUser }) => { () => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes), [draftActiveThemeId, draftCustomThemes], ) - const draftTagColours = selectedTheme.tagColours + const draftTagColours = selectedTheme.tokens.tagColours const setActiveTab = (tab: SettingsTab) => { const params = new URLSearchParams (location.search) @@ -498,7 +498,10 @@ const SettingPage: FC = ({ user, setUser }) => { const customTheme = createCustomThemeFromBase ( selectedTheme.baseTheme, - { ...selectedTheme.tagColours }, + { + ...selectedTheme.tokens, + tagColours: { ...selectedTheme.tokens.tagColours }, + }, ) const nextCustomThemes = { ...draftCustomThemes, @@ -681,9 +684,12 @@ const SettingPage: FC = ({ user, setUser }) => { ...editable.customThemes, [editable.activeThemeId]: { ...editableTheme, - tagColours: { - ...editableTheme.tagColours, - [category]: colour, + tokens: { + ...editableTheme.tokens, + tagColours: { + ...editableTheme.tokens.tagColours, + [category]: colour, + }, }, }, } @@ -699,8 +705,11 @@ const SettingPage: FC = ({ user, setUser }) => { ...draftCustomThemes, [draftActiveThemeId]: { ...draftCustomThemes[draftActiveThemeId], - tagColours: { - ...defaultThemeTagColoursFor (selectedTheme.baseTheme), + tokens: { + ...draftCustomThemes[draftActiveThemeId].tokens, + tagColours: { + ...defaultThemeTagColoursFor (selectedTheme.baseTheme), + }, }, }, }, @@ -716,10 +725,13 @@ const SettingPage: FC = ({ user, setUser }) => { ...draftCustomThemes, [draftActiveThemeId]: { ...draftCustomThemes[draftActiveThemeId], - tagColours: { - ...draftCustomThemes[draftActiveThemeId].tagColours, - [category]: - defaultThemeTagColoursFor (selectedTheme.baseTheme)[category], + tokens: { + ...draftCustomThemes[draftActiveThemeId].tokens, + tagColours: { + ...draftCustomThemes[draftActiveThemeId].tokens.tagColours, + [category]: + defaultThemeTagColoursFor (selectedTheme.baseTheme)[category], + }, }, }, }, -- 2.34.1 From 4117bdd31e99b5b0b3c5abf89b08f5eb77c13a95 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 02:40:19 +0900 Subject: [PATCH 07/30] #34 --- frontend/src/lib/settings.ts | 11 ++++++++++- frontend/src/pages/users/SettingPage.tsx | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 51a8abc..c33a3c4 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -212,6 +212,15 @@ const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated' +const createCustomThemeId = (): `custom:${ string }` => { + const randomPart = + globalThis.crypto?.randomUUID?.() + ?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }` + + return `custom:${ randomPart }` +} + + export const fetchUserSettings = async (): Promise => { const raw = await apiGet<{ theme: UserSettings['theme'] @@ -567,7 +576,7 @@ export const createCustomThemeFromBase = ( baseTheme: BuiltinThemeId, tokens?: ThemeTokens, ): CustomClientTheme => { - const id = `custom:${ crypto.randomUUID () }` as ThemeId + const id = createCustomThemeId () return { id, name: themeCopyName (baseTheme), diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 9d1af16..7e83cc7 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -71,6 +71,7 @@ type SharedSectionProps = { selectedTheme: ClientTheme draftTagColours: ThemeTagColours onThemeChange: (themeId: ActiveThemeId) => void + onEnsureEditableTheme: () => void onTagColourChange: (category: Category, colour: string) => void onResetAllTagColours: () => void onResetTagColour: (category: Category) => void @@ -252,6 +253,7 @@ const ThemeSection: FC = ({ draftCustomThemes, selectedTheme, draftTagColours, + onEnsureEditableTheme, onResetAllTagColours, onResetTagColour, onTagColourChange, @@ -331,6 +333,8 @@ const ThemeSection: FC = ({ onTagColourChange (category, ev.target.value)} className="h-10 w-16 cursor-pointer rounded border border-border bg-transparent p-1"/> @@ -514,6 +518,21 @@ const SettingPage: FC = ({ user, setUser }) => { } } + const handleEnsureEditableTheme = (): { + activeThemeId: ActiveThemeId + customThemes: Record + } => { + const editable = ensureEditableCustomTheme () + + if (editable.activeThemeId !== draftActiveThemeId + || editable.customThemes !== draftCustomThemes) + { + applyDraftThemeSelection (editable.activeThemeId, editable.customThemes) + } + + return editable + } + const handleTabKeyDown = (event: KeyboardEvent) => { const currentIndex = tabs.findIndex (tab => tab.id === activeTab) @@ -677,8 +696,11 @@ const SettingPage: FC = ({ user, setUser }) => { onThemeChange: themeId => { applyDraftThemeSelection (themeId, draftCustomThemes) }, + onEnsureEditableTheme: () => { + handleEnsureEditableTheme () + }, onTagColourChange: (category, colour) => { - const editable = ensureEditableCustomTheme () + const editable = handleEnsureEditableTheme () const editableTheme = editable.customThemes[editable.activeThemeId] const nextCustomThemes = { ...editable.customThemes, -- 2.34.1 From 95b4db1fc28496afb14d75ac9ada712d410b4339 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 02:59:48 +0900 Subject: [PATCH 08/30] #34 --- frontend/src/App.tsx | 27 +- frontend/src/components/PrefetchLink.tsx | 14 +- frontend/src/components/TopNav.tsx | 10 +- frontend/src/components/common/TabGroup.tsx | 12 +- .../users/KeyboardSettingsSection.tsx | 173 ++++++++++ frontend/src/lib/keyboardShortcuts.ts | 319 ++++++++++++++++++ frontend/src/lib/settings.ts | 120 +++++++ frontend/src/lib/useKeyboardShortcuts.tsx | 283 ++++++++++++++++ frontend/src/pages/posts/PostListPage.tsx | 24 ++ frontend/src/pages/posts/PostSearchPage.tsx | 25 ++ frontend/src/pages/tags/TagListPage.tsx | 25 ++ frontend/src/pages/users/SettingPage.tsx | 67 +--- 12 files changed, 1017 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/users/KeyboardSettingsSection.tsx create mode 100644 frontend/src/lib/keyboardShortcuts.ts create mode 100644 frontend/src/lib/useKeyboardShortcuts.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d90891c..14b1bb2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { getClientThemeMode, seedClientThemeMode, } from '@/lib/settings' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' @@ -185,19 +186,21 @@ const App: FC = () => { {import.meta.env.DEV && } - - - - - - - + + + + + + + + - - + + + ) } diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 18fc855..86fe9a5 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' import { cn } from '@/lib/utils' -import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' +import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react' import type { To } from 'react-router-dom' type Props = AnchorHTMLAttributes & { @@ -25,6 +25,7 @@ export default forwardRef (({ state, onMouseEnter, onTouchStart, + onKeyDown, onClick, cancelOnError = false, ...rest }, ref) => { @@ -97,11 +98,22 @@ export default forwardRef (({ } } + const handleKeyDown = (ev: KeyboardEvent) => { + onKeyDown?.(ev) + + if (ev.defaultPrevented) + return + + if (ev.key === ' ' || ev.key === 'Spacebar') + ev.preventDefault () + } + return ( ) diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 22068fc..875ee3d 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -249,16 +249,16 @@ const TopNav: FC = ({ user }) => { - { - ev.preventDefault () + onClick={() => { setMenuOpen (!(menuOpen)) }}> {menuOpen ? '×' : 'Menu'} - + diff --git a/frontend/src/components/common/TabGroup.tsx b/frontend/src/components/common/TabGroup.tsx index 0a0974a..7a5627c 100644 --- a/frontend/src/components/common/TabGroup.tsx +++ b/frontend/src/components/common/TabGroup.tsx @@ -28,15 +28,15 @@ const TabGroup: FC = ({ children, rightAddon }) => {
{rightAddon && (
diff --git a/frontend/src/components/users/KeyboardSettingsSection.tsx b/frontend/src/components/users/KeyboardSettingsSection.tsx new file mode 100644 index 0000000..580dca3 --- /dev/null +++ b/frontend/src/components/users/KeyboardSettingsSection.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { + formatKeyBinding, + isSpaceBinding, + keyBindingFromKeyboardEvent, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' +import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' + +import type { ShortcutActionId } from '@/lib/keyboardShortcuts' +import type { FC } from 'react' + +type Props = { + sectionClassName: string +} + + +const KeyboardSettingsSection: FC = ({ sectionClassName }) => { + const { + conflictActionIds, + effectiveBindings, + keyboardSettings, + resetAllBindings, + resetBinding, + setBinding, + setEnabled, + } = useKeyboardShortcutSettings () + const [capturingActionId, setCapturingActionId] = + useState (null) + + useEffect (() => { + if (capturingActionId == null) + return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.key === 'Process') + return + + event.preventDefault () + event.stopPropagation () + + if (event.key === 'Escape') + { + setCapturingActionId (null) + return + } + + if (event.key === 'Backspace' || event.key === 'Delete') + { + setBinding (capturingActionId, null) + setCapturingActionId (null) + return + } + + const binding = keyBindingFromKeyboardEvent (event) + if (binding == null) + return + + if (isSpaceBinding (binding)) + { + toast ({ title: 'Space は今は割り当てられません.' }) + return + } + + setBinding (capturingActionId, binding) + setCapturingActionId (null) + } + + document.addEventListener ('keydown', onKeyDown, true) + return () => document.removeEventListener ('keydown', onKeyDown, true) + }, [capturingActionId, setBinding]) + + const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ + ...definition, + currentBinding: effectiveBindings[definition.id], + hasConflict: conflictActionIds.has (definition.id), + hasCustomBinding: Object.prototype.hasOwnProperty.call ( + keyboardSettings.bindings ?? { }, + definition.id, + ), + })), [conflictActionIds, effectiveBindings, keyboardSettings.bindings]) + + return ( +
+
+
+

キーボード

+

+ この設定は現在のブラウザに自動保存されます.競合した割当は保存されますが, + dispatcher では発火しません. +

+
+ + +
+ + + + {capturingActionId != null && ( +

+ キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます. +

)} + +
+
+ + + + + + + + + + + + {rows.map (row => ( + + + + + + + + ))} + +
操作適用範囲現在のキー変更既定に戻す競合状態
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (row.currentBinding)} + + + + + + {row.hasConflict + ? 競合あり + : 'なし'} +
+
+
) +} + +export default KeyboardSettingsSection diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts new file mode 100644 index 0000000..7948a26 --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -0,0 +1,319 @@ +export type ShortcutScope = + | 'global' + | 'posts' + | 'postSearch' + | 'tags' + | 'settings' + | 'wiki' + | 'materials' + +export type ShortcutActionId = + | 'global.openShortcutHelp' + | 'global.focusSearch' + | 'navigation.posts' + | 'navigation.tags' + | 'navigation.materials' + | 'navigation.wiki' + | 'pagination.next' + | 'pagination.previous' + | 'settings.account' + | 'settings.theme' + | 'settings.keyboard' + +export type KeyBinding = { + key: string + ctrl?: boolean + shift?: boolean + alt?: boolean + meta?: boolean +} + +export type ShortcutDefinition = { + id: ShortcutActionId + label: string + scope: ShortcutScope + defaultBinding: KeyBinding | null +} + +export const SHORTCUT_SCOPE_LABELS: Record = { + global: '全画面', + posts: '広場', + postSearch: '広場検索', + tags: 'タグ', + settings: '設定', + wiki: 'Wiki', + materials: '素材', +} + +export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ + { + id: 'global.openShortcutHelp', + label: 'ショートカット一覧を開く', + scope: 'global', + defaultBinding: { key: '?', shift: true }, + }, + { + id: 'global.focusSearch', + label: '検索欄へ移動', + scope: 'global', + defaultBinding: { key: 's', alt: true }, + }, + { + id: 'navigation.posts', + label: '広場へ移動', + scope: 'global', + defaultBinding: { key: 'p', alt: true, shift: true }, + }, + { + id: 'navigation.tags', + label: 'タグへ移動', + scope: 'global', + defaultBinding: { key: 't', alt: true, shift: true }, + }, + { + id: 'navigation.materials', + label: '素材へ移動', + scope: 'global', + defaultBinding: { key: 'm', alt: true, shift: true }, + }, + { + id: 'navigation.wiki', + label: 'Wiki へ移動', + scope: 'global', + defaultBinding: { key: 'w', alt: true, shift: true }, + }, + { + id: 'pagination.next', + label: '次のページへ移動', + scope: 'global', + defaultBinding: { key: 'ArrowRight', shift: true }, + }, + { + id: 'pagination.previous', + label: '前のページへ移動', + scope: 'global', + defaultBinding: { key: 'ArrowLeft', shift: true }, + }, + { + id: 'settings.account', + label: '設定: アカウント', + scope: 'settings', + defaultBinding: { key: '1', alt: true }, + }, + { + id: 'settings.theme', + label: '設定: テーマ', + scope: 'settings', + defaultBinding: { key: '2', alt: true }, + }, + { + id: 'settings.keyboard', + label: '設定: キーボード', + scope: 'settings', + defaultBinding: { key: '3', alt: true }, + }, +] + +export const SHORTCUT_DEFINITIONS_BY_ID = + SHORTCUT_DEFINITIONS.reduce> ( + (definitions, definition) => { + definitions[definition.id] = definition + return definitions + }, + { + 'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0], + 'global.focusSearch': SHORTCUT_DEFINITIONS[1], + 'navigation.posts': SHORTCUT_DEFINITIONS[2], + 'navigation.tags': SHORTCUT_DEFINITIONS[3], + 'navigation.materials': SHORTCUT_DEFINITIONS[4], + 'navigation.wiki': SHORTCUT_DEFINITIONS[5], + 'pagination.next': SHORTCUT_DEFINITIONS[6], + 'pagination.previous': SHORTCUT_DEFINITIONS[7], + 'settings.account': SHORTCUT_DEFINITIONS[8], + 'settings.theme': SHORTCUT_DEFINITIONS[9], + 'settings.keyboard': SHORTCUT_DEFINITIONS[10], + }, + ) + +export const DEFAULT_KEY_BINDINGS = + SHORTCUT_DEFINITIONS.reduce> ( + (bindings, definition) => { + bindings[definition.id] = definition.defaultBinding + return bindings + }, + { + 'global.openShortcutHelp': null, + 'global.focusSearch': null, + 'navigation.posts': null, + 'navigation.tags': null, + 'navigation.materials': null, + 'navigation.wiki': null, + 'pagination.next': null, + 'pagination.previous': null, + 'settings.account': null, + 'settings.theme': null, + 'settings.keyboard': null, + }, + ) + +const normaliseKey = (key: string): string => { + if (key === 'Spacebar') + return ' ' + if (key.length === 1 && key !== ' ') + return key.toLowerCase () + return key +} + +const isModifierKey = (key: string): boolean => + key === 'Shift' + || key === 'Control' + || key === 'Alt' + || key === 'Meta' + +export const normaliseKeyBinding = (value: unknown): KeyBinding | null => { + if (!(value) || typeof value !== 'object') + return null + + const binding = value as Partial + if (typeof binding.key !== 'string') + return null + + const key = normaliseKey (binding.key) + if (key.length === 0 || isModifierKey (key)) + return null + + return { + key, + ...(binding.ctrl ? { ctrl: true } : { }), + ...(binding.shift ? { shift: true } : { }), + ...(binding.alt ? { alt: true } : { }), + ...(binding.meta ? { meta: true } : { }), + } +} + +export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => { + if (binding == null) + return null + + const parts = [ + binding.ctrl ? 'Ctrl' : null, + binding.shift ? 'Shift' : null, + binding.alt ? 'Alt' : null, + binding.meta ? 'Meta' : null, + binding.key, + ].filter ((part): part is string => part != null) + + return parts.join ('+') +} + +const keyLabel = (key: string): string => { + switch (key) + { + case ' ': + return 'Space' + + case 'ArrowLeft': + return 'ArrowLeft' + + case 'ArrowRight': + return 'ArrowRight' + + case 'ArrowUp': + return 'ArrowUp' + + case 'ArrowDown': + return 'ArrowDown' + + case 'Escape': + return 'Escape' + + case 'Backspace': + return 'Backspace' + + case 'Delete': + return 'Delete' + + default: + return key.length === 1 ? key.toUpperCase () : key + } +} + +export const formatKeyBinding = (binding: KeyBinding | null): string => { + if (binding == null) + return '未割当' + + return [ + binding.ctrl ? 'Ctrl' : null, + binding.shift ? 'Shift' : null, + binding.alt ? 'Alt' : null, + binding.meta ? 'Meta' : null, + keyLabel (binding.key), + ].filter ((part): part is string => part != null) + .join (' + ') +} + +export const keyBindingFromKeyboardEvent = ( + event: Pick, +): KeyBinding | null => { + const key = normaliseKey (event.key) + if (key.length === 0 || isModifierKey (key)) + return null + + return { + key, + ...(event.ctrlKey ? { ctrl: true } : { }), + ...(event.shiftKey ? { shift: true } : { }), + ...(event.altKey ? { alt: true } : { }), + ...(event.metaKey ? { meta: true } : { }), + } +} + +export const isEditableEventTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) + return false + + return target.closest ( + 'input, textarea, select, [contenteditable="true"], [role="textbox"]', + ) != null +} + +export const getConflictingShortcutActionIds = ( + bindings: Record, +): Set => { + const grouped = new Map () + + Object.entries (bindings).forEach (([actionId, binding]) => { + const serialised = serialiseKeyBinding (binding) + if (serialised == null) + return + + const ids = grouped.get (serialised) ?? [] + ids.push (actionId as ShortcutActionId) + grouped.set (serialised, ids) + }) + + return new Set ( + [...grouped.values ()] + .filter (actionIds => actionIds.length > 1) + .flat (), + ) +} + +export const resolveShortcutScope = (pathname: string): ShortcutScope => { + if (pathname.startsWith ('/posts/search')) + return 'postSearch' + if (pathname.startsWith ('/posts')) + return 'posts' + if (pathname.startsWith ('/tags')) + return 'tags' + if (pathname.startsWith ('/users/settings')) + return 'settings' + if (pathname.startsWith ('/wiki')) + return 'wiki' + if (pathname.startsWith ('/materials')) + return 'materials' + return 'global' +} + +export const isSpaceBinding = (binding: KeyBinding | null): boolean => + binding?.key === ' ' diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index c33a3c4..53a9907 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -1,7 +1,9 @@ import { CATEGORIES } from '@/consts' import { apiGet, apiPatch } from '@/lib/api' +import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts' import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' +import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts' export type UserSettings = { theme: 'system' | 'light' | 'dark' @@ -61,6 +63,11 @@ type ClientListSettings = { limit?: ClientListLimit order?: string } +export type ClientKeyboardSettings = { + enabled?: boolean + bindings?: Partial> +} + export type ClientAppearanceSettings = { activeThemeId?: ActiveThemeId customThemes?: Record @@ -80,6 +87,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = { // Browser-local settings only. These are device or screen specific. export type ClientSettings = { appearance?: ClientAppearanceSettings + keyboard?: ClientKeyboardSettings panes?: Record lists?: Partial> theatre?: { @@ -295,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null +const normaliseKeyboardBindings = ( + bindings: unknown, +): Partial> => { + if (!(bindings) || typeof bindings !== 'object') + return { } + + return Object.keys (DEFAULT_KEY_BINDINGS).reduce< + Partial> + > ( + (normalised, actionId) => { + const value = (bindings as Record)[actionId] + if (value === null) + { + normalised[actionId as ShortcutActionId] = null + return normalised + } + + const binding = normaliseKeyBinding (value) + if (binding != null) + normalised[actionId as ShortcutActionId] = binding + + return normalised + }, + { }, + ) +} + + +export const getClientKeyboardSettings = (): ClientKeyboardSettings => { + const keyboard = loadClientSettings ().keyboard + + return { + enabled: keyboard?.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard?.bindings), + } +} + + +export const setClientKeyboardSettings = ( + keyboard: ClientKeyboardSettings, +): ClientKeyboardSettings => { + const next: ClientKeyboardSettings = { + enabled: keyboard.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard.bindings), + } + + updateClientSettings (settings => ({ + ...settings, + keyboard: next, + })) + + return next +} + + +export const getEffectiveKeyBindings = ( + bindings: Partial> = + getClientKeyboardSettings ().bindings ?? { }, +): Record => + Object.keys (DEFAULT_KEY_BINDINGS).reduce> ( + (effectiveBindings, actionId) => { + const key = actionId as ShortcutActionId + effectiveBindings[key] = + Object.prototype.hasOwnProperty.call (bindings, key) + ? bindings[key] ?? null + : DEFAULT_KEY_BINDINGS[key] + return effectiveBindings + }, + { ...DEFAULT_KEY_BINDINGS }, + ) + + +export const setClientKeyBinding = ( + actionId: ShortcutActionId, + binding: KeyBinding | null, +): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + + return setClientKeyboardSettings ({ + ...current, + bindings: { + ...(current.bindings ?? { }), + [actionId]: binding, + }, + }) +} + + +export const resetClientKeyBinding = ( + actionId: ShortcutActionId, +): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + const nextBindings = { ...(current.bindings ?? { }) } + delete nextBindings[actionId] + + return setClientKeyboardSettings ({ + ...current, + bindings: nextBindings, + }) +} + + +export const resetAllClientKeyBindings = (): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + + return setClientKeyboardSettings ({ + ...current, + bindings: { }, + }) +} + + const normaliseHexColour = (value: string): string | null => /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx new file mode 100644 index 0000000..23509fb --- /dev/null +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -0,0 +1,283 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { toast } from '@/components/ui/use-toast' +import { + DEFAULT_KEY_BINDINGS, + formatKeyBinding, + getConflictingShortcutActionIds, + isEditableEventTarget, + keyBindingFromKeyboardEvent, + resolveShortcutScope, + serialiseKeyBinding, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' +import { + getClientKeyboardSettings, + getEffectiveKeyBindings, + resetAllClientKeyBindings, + resetClientKeyBinding, + setClientKeyBinding, + setClientKeyboardSettings, +} from '@/lib/settings' + +import type { + KeyBinding, + ShortcutActionId, + ShortcutScope, +} from '@/lib/keyboardShortcuts' +import type { ClientKeyboardSettings } from '@/lib/settings' +import type { PropsWithChildren } from 'react' + +type ShortcutHandler = () => void +type ShortcutHandlers = Partial> + +type KeyboardShortcutsContextValue = { + activeScope: ShortcutScope + keyboardSettings: ClientKeyboardSettings + effectiveBindings: Record + conflictActionIds: Set + setEnabled: (enabled: boolean) => void + setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void + resetBinding: (actionId: ShortcutActionId) => void + resetAllBindings: () => void + registerHandlers: (handlers: ShortcutHandlers) => () => void +} + +const KeyboardShortcutsContext = + createContext (null) + + +const buildShortcutHelpText = ( + activeScope: ShortcutScope, + effectiveBindings: Record, +): string => + SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => effectiveBindings[definition.id] != null) + .map (definition => + `${ definition.label }: ${ formatKeyBinding (effectiveBindings[definition.id]) }`) + .join (' / ') + + +export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { + const location = useLocation () + const navigate = useNavigate () + + const [keyboardSettings, setKeyboardSettingsState] = + useState (() => getClientKeyboardSettings ()) + const [registeredHandlers, setRegisteredHandlers] = + useState> ({ }) + const registrationIdRef = useRef (0) + + const activeScope = useMemo ( + () => resolveShortcutScope (location.pathname), + [location.pathname], + ) + const effectiveBindings = useMemo ( + () => getEffectiveKeyBindings (keyboardSettings.bindings), + [keyboardSettings.bindings], + ) + const conflictActionIds = useMemo ( + () => getConflictingShortcutActionIds (effectiveBindings), + [effectiveBindings], + ) + const mergedRegisteredHandlers = useMemo ( + () => Object.values (registeredHandlers).reduce ( + (handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }), + { }, + ), + [registeredHandlers], + ) + + const setEnabled = useCallback ((enabled: boolean) => { + const next = setClientKeyboardSettings ({ + ...keyboardSettings, + enabled, + }) + setKeyboardSettingsState (next) + }, [keyboardSettings]) + + const setBinding = useCallback (( + actionId: ShortcutActionId, + binding: KeyBinding | null, + ) => { + const next = setClientKeyBinding (actionId, binding) + setKeyboardSettingsState (next) + }, []) + + const resetBinding = useCallback ((actionId: ShortcutActionId) => { + const next = resetClientKeyBinding (actionId) + setKeyboardSettingsState (next) + }, []) + + const resetAllBindings = useCallback (() => { + const next = resetAllClientKeyBindings () + setKeyboardSettingsState (next) + }, []) + + const registerHandlers = useCallback ((handlers: ShortcutHandlers) => { + const registrationId = ++registrationIdRef.current + + setRegisteredHandlers (current => ({ + ...current, + [registrationId]: handlers, + })) + + return () => { + setRegisteredHandlers (current => { + const next = { ...current } + delete next[registrationId] + return next + }) + } + }, []) + + useEffect (() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + keyboardSettings.enabled === false + || event.defaultPrevented + || event.isComposing + || event.key === 'Process' + || event.repeat + ) + return + + if (isEditableEventTarget (event.target)) + return + + const binding = keyBindingFromKeyboardEvent (event) + if (binding == null) + return + + const serialisedBinding = serialiseKeyBinding (binding) + if (serialisedBinding == null) + return + + const candidates = SHORTCUT_DEFINITIONS.filter (definition => ( + definition.scope === 'global' + || definition.scope === activeScope + )).filter (definition => { + const currentBinding = effectiveBindings[definition.id] + return ( + currentBinding != null + && serialiseKeyBinding (currentBinding) === serialisedBinding + ) + }).filter (definition => !(conflictActionIds.has (definition.id))) + + if (candidates.length !== 1) + return + + const actionId = candidates[0].id + const builtinHandler = ( + { + 'global.openShortcutHelp': () => { + const description = + buildShortcutHelpText (activeScope, effectiveBindings) + || 'この画面で使えるショートカットはありません.' + toast ({ + title: 'キーボード・ショートカット', + description, + }) + }, + 'global.focusSearch': () => { + const target = document.querySelector ( + '[data-shortcut-focus-search="true"]', + ) + target?.focus () + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) + target.select () + }, + 'navigation.posts': () => navigate ('/posts'), + 'navigation.tags': () => navigate ('/tags'), + 'navigation.materials': () => navigate ('/materials'), + 'navigation.wiki': () => navigate ('/wiki'), + 'settings.account': () => navigate ('/users/settings?tab=account'), + 'settings.theme': () => navigate ('/users/settings?tab=theme'), + 'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'), + } as ShortcutHandlers + )[actionId] + const handler = builtinHandler ?? mergedRegisteredHandlers[actionId] + + if (handler == null) + return + + event.preventDefault () + handler () + } + + document.addEventListener ('keydown', onKeyDown) + return () => document.removeEventListener ('keydown', onKeyDown) + }, [ + activeScope, + conflictActionIds, + effectiveBindings, + keyboardSettings.enabled, + mergedRegisteredHandlers, + navigate, + ]) + + const value = useMemo (() => ({ + activeScope, + keyboardSettings, + effectiveBindings, + conflictActionIds, + setEnabled, + setBinding, + resetBinding, + resetAllBindings, + registerHandlers, + }), [ + activeScope, + conflictActionIds, + effectiveBindings, + keyboardSettings, + registerHandlers, + resetAllBindings, + resetBinding, + setBinding, + setEnabled, + ]) + + return ( + + {children} + ) +} + + +export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => { + const context = useContext (KeyboardShortcutsContext) + + if (context == null) + throw new Error ('KeyboardShortcutsProvider が必要です.') + + return context +} + + +export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { + const { registerHandlers } = useKeyboardShortcutSettings () + + useEffect ( + () => registerHandlers (handlers), + [handlers, registerHandlers], + ) +} + +export { + DEFAULT_KEY_BINDINGS, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index ab5330d..48be9ff 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -19,6 +19,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' @@ -68,6 +69,12 @@ const PostListPage: FC = () => { setClientListSettings ('postList', { limit }) }, [limit]) + const updatePage = (nextPage: number) => { + const params = new URLSearchParams (location.search) + params.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ params.toString () }`) + } + const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => { const params = new URLSearchParams (location.search) params.set ('limit', String (next.limit ?? limit)) @@ -75,6 +82,23 @@ const PostListPage: FC = () => { navigate (`${ location.pathname }?${ params.toString () }`) } + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + useLayoutEffect (() => { scroll (0, 0) diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 4ef0ba1..282f7c0 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -24,6 +24,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -171,11 +172,34 @@ const PostSearchPage: FC = () => { navigate (`${ location.pathname }?${ qs.toString () }`) } + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const handleSearch = (e: FormEvent) => { e.preventDefault () search () } + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + const defaultDirection = { title: 'asc', url: 'asc', original_created_at: 'desc', @@ -214,6 +238,7 @@ const PostSearchPage: FC = () => { {({ invalid }) => ( setTitle (e.target.value)} className={inputClass (invalid)}/>)} diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index b01350e..f6c9fc6 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -22,6 +22,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -180,12 +181,35 @@ const TagListPage: FC = () => { navigate (`${ location.pathname }?${ qs.toString () }`) } + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const defaultDirection = { name: 'asc', category: 'asc', post_count: 'desc', created_at: 'desc', updated_at: 'desc' } as const + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + return ( @@ -218,6 +242,7 @@ const TagListPage: FC = () => { {({ invalid }) => ( setName (e.target.value)} className={inputClass (invalid)}/>)} diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 7e83cc7..49294d9 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -10,6 +10,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 KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' import { CATEGORY_NAMES, CATEGORIES } from '@/consts' @@ -32,6 +33,7 @@ import { setClientAppearanceThemes, updateUserSettings, } from '@/lib/settings' +import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -100,27 +102,6 @@ const builtinThemeLabel: Record<'light' | 'dark', string> = { dark: 'ダーク・モード', } -const keyboardShortcutRows = [ - { - action: '投稿詳細を開く', - key: 'Enter', - scope: '一覧・検索結果', - conflict: 'なし', - }, - { - action: '候補タグを選ぶ', - key: '↑ / ↓ / Enter', - scope: 'タグ入力', - conflict: 'なし', - }, - { - action: '候補タグを閉じる', - key: 'Escape', - scope: 'タグ入力', - conflict: 'なし', - }, -] - const previewTag = ( id: number, name: string, @@ -389,39 +370,6 @@ const ThemeSection: FC = ({ ) -const KeyboardSection: FC = () => ( -
-

キーボード

- -

- 現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ - key bind 設定へ拡張できる形にしています. -

- -
- - - - - - - - - - - {keyboardShortcutRows.map (row => ( - - - - - - ))} - -
操作キー適用範囲競合
{row.action}{row.key}{row.scope}{row.conflict}
-
-
) - - const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () const navigate = useNavigate () @@ -440,6 +388,7 @@ const SettingPage: FC = ({ user, setUser }) => { const [draftCustomThemes, setDraftCustomThemes] = useState> (getClientCustomThemes ()) const [userCodeVsbl, setUserCodeVsbl] = useState (false) + const { conflictActionIds } = useKeyboardShortcutSettings () const { baseErrors: userBaseErrors, fieldErrors: userFieldErrors, @@ -472,8 +421,8 @@ const SettingPage: FC = ({ user, setUser }) => { const settingsTabErrors = useMemo> (() => ({ account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), - keyboard: false, - }), [settingsFieldErrors.theme, userFieldErrors.name]) + keyboard: conflictActionIds.size > 0, + }), [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( activeThemeId: ActiveThemeId, @@ -780,7 +729,7 @@ const SettingPage: FC = ({ user, setUser }) => { <> - + )} )}
@@ -828,7 +777,9 @@ const SettingPage: FC = ({ user, setUser }) => { <> {activeTab === 'account' && } {activeTab === 'theme' && } - {activeTab === 'keyboard' && } + {activeTab === 'keyboard' && ( + + )} )}
)}
-- 2.34.1 From 7bc9ee1e6889ead9aa63ad6ea890c895bb7d29c0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 03:19:58 +0900 Subject: [PATCH 09/30] #34 --- frontend/src/components/common/TabGroup.tsx | 6 +++++- frontend/src/lib/keyboardShortcuts.ts | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/common/TabGroup.tsx b/frontend/src/components/common/TabGroup.tsx index 7a5627c..128e2dc 100644 --- a/frontend/src/components/common/TabGroup.tsx +++ b/frontend/src/components/common/TabGroup.tsx @@ -31,7 +31,11 @@ const TabGroup: FC = ({ children, rightAddon }) => {
)` forms when nearby code uses them. +- Treat TSX formatting rules as hard constraints, not preferences. Before + finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` + placement and fix violations instead of relying on formatter defaults. - For long Tailwind `className` strings, wrap across lines only when needed. - Keep continuation indentation aligned with the 4-space logical indentation rule, using tabs only as leading 8-space compression. +- In TypeScript and TSX function declarations, including `const` arrow + function declarations, when the parameter list spans multiple lines, always + put the closing parenthesis at the beginning of its own line before the return + type or `=>`. Do not put that closing `)` at the end of the previous line. +- Do not write React component declarations in the common Prettier form + `const Component = ({ ... }) => (` when the destructured parameter spans + multiple lines. Use the project form with the opening `(` after `=`, the + destructured object as the argument, and the closing parameter `)` on its own + line before `=>`. +- In TypeScript and TSX, never place a closing parenthesis at the beginning of + a line except for a multi-line function declaration parameter list. +- Never place a closing square bracket at the beginning of a line. +- For object literals, type literals, import/export named bindings, destructuring + patterns, and other associative-array-style braces, do not place the closing + brace at the beginning of a line. Keep `}` on the same line as the final + property, binding, or specifier unless that would violate the line limit. + Function, lambda, callback, and block closing braces are exempt and should + stay on their own line when that fits the local style. When a function, + lambda, callback, or block body spans multiple lines, do not put its closing + `}` at the end of the previous line. +- For arrays and tuple-like lists, do not place the closing `]` at the + beginning of a line. Keep `]` on the same line as the final element unless + that would violate the line limit. +- For function and method calls, do not place the closing `)` at the beginning + of a line. The only TypeScript/TSX exception is the closing parameter `)` of a + multi-line function declaration. - Do not add braces around `if`, `else`, or `for` bodies when the body is a single physical line. - Always add braces around `if`, `else`, or `for` bodies when the body spans diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 49294d9..1650178 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,5 +1,3 @@ -import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' - import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -18,38 +16,35 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' -import { - applyThemeSelection, - BUILTIN_THEMES, - createCustomThemeFromBase, - defaultThemeTagColoursFor, - DEFAULT_USER_SETTINGS, - deriveUserThemeModeFromSelection, - fetchUserSettings, - hasStoredClientThemeSelection, - getClientActiveThemeId, - getClientCustomThemes, - getResolvedThemeFromSelection, - setClientAppearanceThemes, - updateUserSettings, -} from '@/lib/settings' +import { applyThemeSelection, + BUILTIN_THEMES, + createCustomThemeFromBase, + defaultThemeTagColoursFor, + DEFAULT_USER_SETTINGS, + deriveUserThemeModeFromSelection, + fetchUserSettings, + hasStoredClientThemeSelection, + getClientActiveThemeId, + getClientCustomThemes, + getResolvedThemeFromSelection, + setClientAppearanceThemes, + updateUserSettings } from '@/lib/settings' import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' +import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' + import type { Category, Tag, User } from '@/types' -import type { - ActiveThemeId, - ClientTheme, - CustomClientTheme, - ThemeTagColours, - UserSettings, -} from '@/lib/settings' +import type { ActiveThemeId, + ClientTheme, + CustomClientTheme, + ThemeTagColours, + UserSettings } from '@/lib/settings' type Props = { user: User | null - setUser: Dispatch> -} + setUser: Dispatch> } type UserFormField = 'name' type SettingsFormField = 'theme' @@ -79,14 +74,12 @@ type SharedSectionProps = { onResetTagColour: (category: Category) => void onThemeSubmit: () => void settingsBaseErrors: string[] - settingsFieldErrors: Partial> -} + settingsFieldErrors: Partial> } const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, { id: 'theme', label: 'テーマ' }, - { id: 'keyboard', label: 'キーボード' }, -] + { id: 'keyboard', label: 'キーボード' } ] const sectionClassName = 'space-y-4 rounded-xl border border-border bg-background/80 p-4' @@ -94,13 +87,12 @@ const sectionClassName = const themeLabel: Record = { system: 'システム設定に従う', light: 'ライト・モード', - dark: 'ダーク・モード', -} + dark: 'ダーク・モード' } const builtinThemeLabel: Record<'light' | 'dark', string> = { light: 'ライト・モード', - dark: 'ダーク・モード', -} + dark: 'ダーク・モード' } + const previewTag = ( id: number, @@ -122,8 +114,7 @@ const previewTag = ( hasWiki, materialId: null, hasDeerjikists: options?.hasDeerjikists ?? false, - children: [], -}) + children: [] }) const previewTags: Record = { deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), @@ -132,15 +123,14 @@ const previewTags: Record = { general: previewTag (4, '一般', 'general', 78, true), material: previewTag (5, '素材', 'material', 90, false), meta: previewTag (6, 'メタ', 'meta', 21, true), - nico: previewTag (7, 'nico:sm9', 'nico', 43, false), -} + nico: previewTag (7, 'nico:sm9', 'nico', 43, false) } const parseTab = (search: string): SettingsTab => { const tab = new URLSearchParams (search).get ('tab') - return tabs.some (candidate => candidate.id === tab) - ? tab as SettingsTab - : 'account' + return (tabs.some (candidate => candidate.id === tab) + ? (tab as SettingsTab) + : 'account') } @@ -152,9 +142,7 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => Object.fromEntries ( CATEGORIES.flatMap (category => [ [`--tag-colour-${ category }`, tagColours[category]], - [`--tag-colour-${ category }-hover`, tagColours[category]], - ]), - ) as CSSProperties + [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties const themeLabelById = ( @@ -169,16 +157,16 @@ const themeLabelById = ( } -const AccountSection: FC = ({ - name, - onInheritOpen, - onNameChange, - onUserCodeOpen, - onUserSubmit, - user, - userBaseErrors, - userFieldErrors, -}) => ( +const AccountSection: FC = ( + { name, + onInheritOpen, + onNameChange, + onUserCodeOpen, + onUserSubmit, + user, + userBaseErrors, + userFieldErrors }, +) => (

アカウント

@@ -229,20 +217,20 @@ const AccountSection: FC = ({
) -const ThemeSection: FC = ({ - draftActiveThemeId, - draftCustomThemes, - selectedTheme, - draftTagColours, - onEnsureEditableTheme, - onResetAllTagColours, - onResetTagColour, - onTagColourChange, - onThemeChange, - onThemeSubmit, - settingsBaseErrors, - settingsFieldErrors, -}) => ( +const ThemeSection: FC = ( + { draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onEnsureEditableTheme, + onResetAllTagColours, + onResetTagColour, + onTagColourChange, + onThemeChange, + onThemeSubmit, + settingsBaseErrors, + settingsFieldErrors }, +) => (

テーマ

@@ -389,24 +377,20 @@ const SettingPage: FC = ({ user, setUser }) => { useState> (getClientCustomThemes ()) const [userCodeVsbl, setUserCodeVsbl] = useState (false) const { conflictActionIds } = useKeyboardShortcutSettings () - const { - baseErrors: userBaseErrors, - fieldErrors: userFieldErrors, - clearValidationErrors: clearUserValidationErrors, - applyValidationError: applyUserValidationError, - } = useValidationErrors () - const { - baseErrors: settingsBaseErrors, - fieldErrors: settingsFieldErrors, - clearValidationErrors: clearSettingsValidationErrors, - applyValidationError: applySettingsValidationError, - } = useValidationErrors () + const { baseErrors: userBaseErrors, + fieldErrors: userFieldErrors, + clearValidationErrors: clearUserValidationErrors, + applyValidationError: applyUserValidationError } = useValidationErrors () + const { baseErrors: settingsBaseErrors, + fieldErrors: settingsFieldErrors, + clearValidationErrors: clearSettingsValidationErrors, + applyValidationError: applySettingsValidationError } = + useValidationErrors () const activeTab = useMemo (() => parseTab (location.search), [location.search]) const selectedTheme = useMemo ( () => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes), - [draftActiveThemeId, draftCustomThemes], - ) + [draftActiveThemeId, draftCustomThemes]) const draftTagColours = selectedTheme.tokens.tagColours const setActiveTab = (tab: SettingsTab) => { @@ -414,15 +398,15 @@ const SettingPage: FC = ({ user, setUser }) => { params.set ('tab', tab) navigate ( `${ location.pathname }?${ params.toString () }`, - { replace: true }, - ) + { replace: true }) } - const settingsTabErrors = useMemo> (() => ({ - account: Boolean (userFieldErrors.name?.length), - theme: Boolean (settingsFieldErrors.theme?.length), - keyboard: conflictActionIds.size > 0, - }), [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) + const settingsTabErrors = useMemo> ( + () => ( + { account: Boolean (userFieldErrors.name?.length), + theme: Boolean (settingsFieldErrors.theme?.length), + keyboard: conflictActionIds.size > 0 }), + [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( activeThemeId: ActiveThemeId, @@ -432,52 +416,40 @@ const SettingPage: FC = ({ user, setUser }) => { setDraftCustomThemes (customThemes) setDraftSettings (current => ({ ...current, - theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes), - })) + theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes) })) applyThemeSelection (activeThemeId, customThemes) } const ensureEditableCustomTheme = (): { activeThemeId: ActiveThemeId - customThemes: Record - } => { + customThemes: Record } => { if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null) { - return { - activeThemeId: draftActiveThemeId, - customThemes: draftCustomThemes, - } + return { activeThemeId: draftActiveThemeId, + customThemes: draftCustomThemes } } const customTheme = createCustomThemeFromBase ( selectedTheme.baseTheme, - { - ...selectedTheme.tokens, - tagColours: { ...selectedTheme.tokens.tagColours }, - }, - ) + { ...selectedTheme.tokens, + tagColours: { ...selectedTheme.tokens.tagColours } }) const nextCustomThemes = { ...draftCustomThemes, - [customTheme.id]: customTheme, - } + [customTheme.id]: customTheme } return { activeThemeId: customTheme.id, - customThemes: nextCustomThemes, - } + customThemes: nextCustomThemes } } const handleEnsureEditableTheme = (): { activeThemeId: ActiveThemeId - customThemes: Record - } => { + customThemes: Record } => { const editable = ensureEditableCustomTheme () if (editable.activeThemeId !== draftActiveThemeId || editable.customThemes !== draftCustomThemes) - { - applyDraftThemeSelection (editable.activeThemeId, editable.customThemes) - } + applyDraftThemeSelection (editable.activeThemeId, editable.customThemes) return editable } @@ -580,9 +552,9 @@ const SettingPage: FC = ({ user, setUser }) => { return const customThemes = getClientCustomThemes () - const activeThemeId = hasStoredClientThemeSelection () - ? getClientActiveThemeId () - : data.theme + const activeThemeId = (hasStoredClientThemeSelection () + ? getClientActiveThemeId () + : data.theme) const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes) setDraftSettings ({ ...data, theme: themeMode }) @@ -629,71 +601,62 @@ const SettingPage: FC = ({ user, setUser }) => { } }, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes]) - const sectionProps: SharedSectionProps | null = user ? { - user, - name, - onNameChange: setName, - onInheritOpen: () => setInheritVsbl (true), - onUserCodeOpen: () => setUserCodeVsbl (true), - onUserSubmit: handleUserSubmit, - userBaseErrors, - userFieldErrors, - draftActiveThemeId, - draftCustomThemes, - selectedTheme, - draftTagColours, - onThemeChange: themeId => { - applyDraftThemeSelection (themeId, draftCustomThemes) - }, - onEnsureEditableTheme: () => { - handleEnsureEditableTheme () - }, - onTagColourChange: (category, colour) => { - const editable = handleEnsureEditableTheme () - const editableTheme = editable.customThemes[editable.activeThemeId] - const nextCustomThemes = { - ...editable.customThemes, - [editable.activeThemeId]: { - ...editableTheme, - tokens: { - ...editableTheme.tokens, - tagColours: { - ...editableTheme.tokens.tagColours, - [category]: colour, - }, - }, - }, - } - applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes) - }, - onResetAllTagColours: () => { - if (selectedTheme.builtin || draftActiveThemeId === 'system') - return + const sectionProps: SharedSectionProps | null = + user + ? { user, + name, + onNameChange: setName, + onInheritOpen: () => setInheritVsbl (true), + onUserCodeOpen: () => setUserCodeVsbl (true), + onUserSubmit: handleUserSubmit, + userBaseErrors, + userFieldErrors, + draftActiveThemeId, + draftCustomThemes, + selectedTheme, + draftTagColours, + onThemeChange: themeId => { + applyDraftThemeSelection (themeId, draftCustomThemes) + }, + onEnsureEditableTheme: () => { + handleEnsureEditableTheme () + }, + onTagColourChange: (category, colour) => { + const editable = handleEnsureEditableTheme () + const editableTheme = editable.customThemes[editable.activeThemeId] + const nextCustomThemes = { + ...editable.customThemes, + [editable.activeThemeId]: { + ...editableTheme, + tokens: { + ...editableTheme.tokens, + tagColours: { + ...editableTheme.tokens.tagColours, + [category]: colour } } } } + applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes) + }, + onResetAllTagColours: () => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return - applyDraftThemeSelection ( - draftActiveThemeId, - { - ...draftCustomThemes, - [draftActiveThemeId]: { - ...draftCustomThemes[draftActiveThemeId], - tokens: { - ...draftCustomThemes[draftActiveThemeId].tokens, - tagColours: { - ...defaultThemeTagColoursFor (selectedTheme.baseTheme), - }, - }, - }, - }, - ) - }, - onResetTagColour: category => { - if (selectedTheme.builtin || draftActiveThemeId === 'system') - return + applyDraftThemeSelection ( + draftActiveThemeId, + { + ...draftCustomThemes, + [draftActiveThemeId]: { + ...draftCustomThemes[draftActiveThemeId], + tokens: { + ...draftCustomThemes[draftActiveThemeId].tokens, + tagColours: { + ...defaultThemeTagColoursFor (selectedTheme.baseTheme) } } } }) + }, + onResetTagColour: category => { + if (selectedTheme.builtin || draftActiveThemeId === 'system') + return - applyDraftThemeSelection ( - draftActiveThemeId, - { - ...draftCustomThemes, + applyDraftThemeSelection ( + draftActiveThemeId, + { ...draftCustomThemes, [draftActiveThemeId]: { ...draftCustomThemes[draftActiveThemeId], tokens: { @@ -701,17 +664,13 @@ const SettingPage: FC = ({ user, setUser }) => { tagColours: { ...draftCustomThemes[draftActiveThemeId].tokens.tagColours, [category]: - defaultThemeTagColoursFor (selectedTheme.baseTheme)[category], - }, - }, - }, - }, - ) - }, - onThemeSubmit: handleThemeSubmit, - settingsBaseErrors, - settingsFieldErrors, - } : null + defaultThemeTagColoursFor ( + selectedTheme.baseTheme)[category] } } } }) + }, + onThemeSubmit: handleThemeSubmit, + settingsBaseErrors, + settingsFieldErrors } + : null return ( @@ -752,12 +711,11 @@ const SettingPage: FC = ({ user, setUser }) => { tabIndex={activeTab === tab.id ? 0 : -1} className={cn ( 'rounded-xl border px-4 py-3 text-left text-sm font-medium', - activeTab === tab.id - ? ['border-slate-900 bg-slate-900 text-white', - 'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900'] - : ['border-slate-300 bg-white text-slate-700', - 'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'], - )} + (activeTab === tab.id + ? ['border-slate-900 bg-slate-900 text-white', + 'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900'] + : ['border-slate-300 bg-white text-slate-700', + 'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']))} onClick={() => setActiveTab (tab.id)} onKeyDown={handleTabKeyDown}> {tab.label} @@ -778,8 +736,7 @@ const SettingPage: FC = ({ user, setUser }) => { {activeTab === 'account' && } {activeTab === 'theme' && } {activeTab === 'keyboard' && ( - - )} + )} )}
)} @@ -799,4 +756,5 @@ const SettingPage: FC = ({ user, setUser }) => { ) } + export default SettingPage -- 2.34.1 From 4e3fb6bef7a5e42a948df111625a034c90dd9d93 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 03:43:37 +0900 Subject: [PATCH 11/30] #34 --- frontend/src/components/TagSearch.tsx | 1 + .../users/KeyboardSettingsSection.tsx | 126 ++++++++++++++---- .../components/users/ShortcutHelpDialog.tsx | 77 +++++++++++ frontend/src/lib/useKeyboardShortcuts.tsx | 89 +++++++++---- frontend/src/pages/users/SettingPage.tsx | 67 +++++----- 5 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/users/ShortcutHelpDialog.tsx diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index 9447e1f..dfa519a 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -105,6 +105,7 @@ const TagSearch: FC = () => { return (
= ({ sectionClassName }) => { const { - conflictActionIds, - effectiveBindings, - keyboardSettings, - resetAllBindings, - resetBinding, - setBinding, - setEnabled, + keyboardSettings, + saveKeyboardSettings, } = useKeyboardShortcutSettings () const [capturingActionId, setCapturingActionId] = useState (null) + const [draftKeyboardSettings, setDraftKeyboardSettings] = + useState (keyboardSettings) + + useEffect (() => { + setDraftKeyboardSettings (keyboardSettings) + }, [keyboardSettings]) + + const draftEffectiveBindings = useMemo ( + () => getEffectiveKeyBindings (draftKeyboardSettings.bindings), + [draftKeyboardSettings.bindings], + ) + const draftConflictActionIds = useMemo ( + () => getConflictingShortcutActionIds (draftEffectiveBindings), + [draftEffectiveBindings], + ) + const hasUnsavedChanges = useMemo ( + () => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings), + [draftKeyboardSettings, keyboardSettings], + ) useEffect (() => { if (capturingActionId == null) @@ -51,7 +68,13 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => { if (event.key === 'Backspace' || event.key === 'Delete') { - setBinding (capturingActionId, null) + setDraftKeyboardSettings (current => ({ + ...current, + bindings: { + ...(current.bindings ?? { }), + [capturingActionId]: null, + }, + })) setCapturingActionId (null) return } @@ -66,46 +89,92 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => { return } - setBinding (capturingActionId, binding) + setDraftKeyboardSettings (current => ({ + ...current, + bindings: { + ...(current.bindings ?? { }), + [capturingActionId]: binding, + }, + })) setCapturingActionId (null) } document.addEventListener ('keydown', onKeyDown, true) return () => document.removeEventListener ('keydown', onKeyDown, true) - }, [capturingActionId, setBinding]) + }, [capturingActionId]) const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ ...definition, - currentBinding: effectiveBindings[definition.id], - hasConflict: conflictActionIds.has (definition.id), + currentBinding: draftEffectiveBindings[definition.id], + hasConflict: draftConflictActionIds.has (definition.id), hasCustomBinding: Object.prototype.hasOwnProperty.call ( - keyboardSettings.bindings ?? { }, + draftKeyboardSettings.bindings ?? { }, definition.id, ), - })), [conflictActionIds, effectiveBindings, keyboardSettings.bindings]) + })), [ + draftConflictActionIds, + draftEffectiveBindings, + draftKeyboardSettings.bindings, + ]) + + const handleSave = () => { + saveKeyboardSettings (draftKeyboardSettings) + toast ({ title: 'キーボード設定を保存しました' }) + } + + const handleDiscard = () => { + setDraftKeyboardSettings (keyboardSettings) + setCapturingActionId (null) + } return (

キーボード

-

- この設定は現在のブラウザに自動保存されます.競合した割当は保存されますが, - dispatcher では発火しません. -

+ {hasUnsavedChanges && ( +

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

)}
- +
+ + + +
@@ -154,7 +223,16 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => { variant="ghost" className="h-8" disabled={!(row.hasCustomBinding)} - onClick={() => resetBinding (row.id)}> + onClick={() => { + setDraftKeyboardSettings (current => { + const nextBindings = { ...(current.bindings ?? { }) } + delete nextBindings[row.id] + return { + ...current, + bindings: nextBindings, + } + }) + }}> 既定に戻す
+ + + + + + + + + + {rows.map (row => ( + + + + + + ))} + +
操作適用範囲キー競合
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (effectiveBindings[row.id])} + + {conflictActionIds.has (row.id) + ? 競合あり + : 'なし'} +
+
+ + ) +} + +export default ShortcutHelpDialog diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 23509fb..47f05a2 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -9,10 +9,9 @@ import { } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { toast } from '@/components/ui/use-toast' +import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog' import { DEFAULT_KEY_BINDINGS, - formatKeyBinding, getConflictingShortcutActionIds, isEditableEventTarget, keyBindingFromKeyboardEvent, @@ -46,6 +45,7 @@ type KeyboardShortcutsContextValue = { keyboardSettings: ClientKeyboardSettings effectiveBindings: Record conflictActionIds: Set + saveKeyboardSettings: (settings: ClientKeyboardSettings) => void setEnabled: (enabled: boolean) => void setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void resetBinding: (actionId: ShortcutActionId) => void @@ -57,17 +57,47 @@ const KeyboardShortcutsContext = createContext (null) -const buildShortcutHelpText = ( - activeScope: ShortcutScope, - effectiveBindings: Record, -): string => - SHORTCUT_DEFINITIONS - .filter (definition => - definition.scope === 'global' || definition.scope === activeScope) - .filter (definition => effectiveBindings[definition.id] != null) - .map (definition => - `${ definition.label }: ${ formatKeyBinding (effectiveBindings[definition.id]) }`) - .join (' / ') +const isFocusableShortcutTarget = (element: HTMLElement): boolean => { + if (element.closest ('[hidden], [aria-hidden="true"]') != null) + return false + + if ( + (element instanceof HTMLInputElement + || element instanceof HTMLButtonElement + || element instanceof HTMLSelectElement + || element instanceof HTMLTextAreaElement) + && element.disabled + ) + return false + + const style = window.getComputedStyle (element) + if ( + style.display === 'none' + || style.visibility === 'hidden' + || style.pointerEvents === 'none' + ) + return false + + return element.getClientRects ().length > 0 +} + + +const focusSearchTarget = (): void => { + const targets = [ + ...document.querySelectorAll ( + '[data-shortcut-focus-search="true"]', + ), + ] + const target = targets.find (isFocusableShortcutTarget) + + if (target == null) + return + + target.focus () + + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) + target.select () +} export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { @@ -78,6 +108,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { useState (() => getClientKeyboardSettings ()) const [registeredHandlers, setRegisteredHandlers] = useState> ({ }) + const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false) const registrationIdRef = useRef (0) const activeScope = useMemo ( @@ -108,6 +139,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { setKeyboardSettingsState (next) }, [keyboardSettings]) + const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => { + const next = setClientKeyboardSettings (settings) + setKeyboardSettingsState (next) + }, []) + const setBinding = useCallback (( actionId: ShortcutActionId, binding: KeyBinding | null, @@ -157,6 +193,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { if (isEditableEventTarget (event.target)) return + if (shortcutHelpOpen) + return + const binding = keyBindingFromKeyboardEvent (event) if (binding == null) return @@ -183,21 +222,10 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { const builtinHandler = ( { 'global.openShortcutHelp': () => { - const description = - buildShortcutHelpText (activeScope, effectiveBindings) - || 'この画面で使えるショートカットはありません.' - toast ({ - title: 'キーボード・ショートカット', - description, - }) + setShortcutHelpOpen (true) }, 'global.focusSearch': () => { - const target = document.querySelector ( - '[data-shortcut-focus-search="true"]', - ) - target?.focus () - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) - target.select () + focusSearchTarget () }, 'navigation.posts': () => navigate ('/posts'), 'navigation.tags': () => navigate ('/tags'), @@ -226,6 +254,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { keyboardSettings.enabled, mergedRegisteredHandlers, navigate, + shortcutHelpOpen, ]) const value = useMemo (() => ({ @@ -234,6 +263,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { effectiveBindings, conflictActionIds, setEnabled, + saveKeyboardSettings, setBinding, resetBinding, resetAllBindings, @@ -246,6 +276,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { registerHandlers, resetAllBindings, resetBinding, + saveKeyboardSettings, setBinding, setEnabled, ]) @@ -253,6 +284,12 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { return ( {children} + ) } diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 1650178..c818e00 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -254,28 +254,10 @@ const ThemeSection: FC = ( )} -
-

- テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います. -

-

- backend には system / light / dark の mode だけ保存します. - カスタムテーマ本体は今は browser-local です. -

-

- 将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます. -

-
-

タグカテゴリ色

-

- {selectedTheme.builtin || draftActiveThemeId === 'system' - ? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.' - : `現在の編輯対象: ${ selectedTheme.name }`} -

-
-

既存 backend 設定について

-

- auto_fetch_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に - 保持したままです.この共通設定画面にはまだ接続していません. -

-
- @@ -682,15 +656,40 @@ const SettingPage: FC = ({ user, setUser }) => {
設定 +
+ {tabs.map (tab => ( + ))} +
{loading ? 'Loading...' : ( - <> - {sectionProps && ( - <> - - - - )} - )} +
+ {sectionProps && activeTab === 'account' && } + {sectionProps && activeTab === 'theme' && } + {activeTab === 'keyboard' && ( + )} +
)}
-- 2.34.1 From ca92c62049bb340290799d4cbae232b7959f2593 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 11:27:45 +0900 Subject: [PATCH 12/30] #34 --- AGENTS.md | 386 +++++++++++++++++- frontend/AGENTS.md | 386 +++++++++++++++++- frontend/src/components/PrefetchLink.tsx | 14 +- .../users/BehaviorSettingsSection.tsx | 143 +++++++ .../components/users/ShortcutHelpDialog.tsx | 16 +- frontend/src/index.css | 15 + frontend/src/lib/keyboardShortcuts.ts | 9 + frontend/src/lib/settings.ts | 136 +++++- frontend/src/lib/useKeyboardShortcuts.tsx | 108 ++--- frontend/src/main.tsx | 3 + frontend/src/pages/users/SettingPage.tsx | 13 +- 11 files changed, 1137 insertions(+), 92 deletions(-) create mode 100644 frontend/src/components/users/BehaviorSettingsSection.tsx diff --git a/AGENTS.md b/AGENTS.md index 94f69a9..96c9d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,30 @@ const value = - Treat TSX formatting rules as hard constraints, not preferences. Before finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` placement and fix violations instead of relying on formatter defaults. +- After every TSX edit, perform a style-only self-review of the edited hunks + before running verification or reporting completion. The task is not complete + while any edited TSX hunk violates these local formatting rules. +- The TSX self-review must classify every edited leading or trailing `)`, `]`, + and `}` by syntax role before deciding whether it is valid. Do not apply a + rule by glyph alone. A closing `)` for a function parameter list is different + from a closing `)` for a function call. A closing `}` for a block is different + from a closing `}` for an object, type literal, import list, or destructuring + pattern. +- The TSX self-review must confirm there are no common Prettier-style React + component declarations with a multi-line destructured parameter. +- The TSX self-review must confirm multi-line function declaration parameter + `)` placement follows the detailed parameter-list rules below. +- The TSX self-review must confirm call-expression `)` is never at the + beginning of a line. +- The TSX self-review must confirm object/type/import/destructuring `}` is not + at the beginning of a line. +- The TSX self-review must confirm multi-line function/lambda/callback/block + `}` is on its own line and never at the end of the previous line. +- The TSX self-review must confirm array `]` is not at the beginning of a line. +- The TSX self-review must confirm JSX closing markers and closing parentheses + keep the surrounding compact style. +- The TSX self-review must confirm leading indentation follows 4-space logical + indentation with tabs only as leading 8-space compression. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward. @@ -282,9 +306,15 @@ const value = character. - A leading tab is exactly equivalent to 8 leading spaces. - In TypeScript and TSX function declarations, including `const` arrow - function declarations, when the parameter list spans multiple lines, always - put the closing parenthesis at the beginning of its own line before the return - type or `=>`. Do not put that closing `)` at the end of the previous line. + function declarations, classify the parameter list before placing the closing + `)`. +- If the parameter list itself is given its own multi-line block after the + function's opening `(`, put the closing parameter `)` at the beginning of its + own line before the return type or `=>`. +- If the only line break is inside a parameter's inline type, object type, or + destructuring shape, and the parameter list itself is not split as a separate + block, keep the closing parameter `)` on the same line as that parameter's + final `}`. In this case, moving `)` to a new line is wrong. - Do not write React component declarations in the common Prettier form `const Component = ({ ... }) => (` when the destructured parameter spans multiple lines. Use the project form with the opening `(` after `=`, the @@ -325,6 +355,356 @@ 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. + +### Frontend delimiter decision table + +Use this table before accepting any edited TypeScript or TSX hunk. The table is +more authoritative than formatter habit. + +#### Import and export named bindings + +Bad: + +```ts +import { + Button, + Card, +} from '@/components/ui' +``` + +Good: + +```ts +import { Button, + Card } from '@/components/ui' +``` + +Also acceptable when short: + +```ts +import { Button, Card } from '@/components/ui' +``` + +Rule: named-binding `}` is associative-array-style syntax. It must not be alone +at the beginning of a line. Prefer keeping `{` with the first binding and `}` +with the final binding when this fits the line limit. + +#### Type literals + +Bad: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void +} +``` + +Good: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void } +``` + +Rule: type-literal `}` is not a block close. It must stay on the same line as +the final property unless that would break the hard line limit. + +#### Object literals + +Bad: + +```ts +const value = { + open, + activeScope, +} +``` + +Good: + +```ts +const value = { + open, + activeScope } +``` + +Bad: + +```ts +const value = useMemo (() => ({ + open, + activeScope, +}), [open, activeScope]) +``` + +Good: + +```ts +const value = useMemo (() => ({ + open, + activeScope }), [open, activeScope]) +``` + +Rule: object-literal `}` is associative-array-style syntax. It must not be on a +line by itself. Keep it with the final property, and keep call `)` off the +beginning of a line. + +#### Destructuring parameters + +Bad: + +```tsx +const Component: FC = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { open, + onOpenChange }, +) => { + return null +} +``` + +Rule: when a React component or helper takes a destructured object parameter +that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>` +shape. Put the function parameter list on its own lines. The destructuring `}` +stays with the final binding. The parameter-list `)` is then allowed and +required at the beginning of its own line. + +#### Inline typed destructuring parameter + +Good: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + return null +} +``` + +Rule: this is not a separately split parameter-list block. The line break is +inside the inline type. Keep the type-literal `}` and parameter-list `)` on the +same line as the final type property. + +#### Multi-line normal parameter list + +Bad: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key], +) => { + return null +} +``` + +Rule: when the parameter list itself is split across multiple parameter lines, +the closing parameter `)` goes at the beginning of its own line before `=>` or +the return type. + +#### Function and callback blocks + +Bad: + +```ts +const handleSave = () => { save () +} +``` + +Bad: + +```ts +const handleSave = () => { + save () } +``` + +Good: + +```ts +const handleSave = () => { + save () +} +``` + +Rule: block `}` closes executable code, not associative data. Multi-line +function, lambda, callback, `if`, `for`, `switch`, and similar block braces +belong on their own line. + +#### Function and method calls + +Bad: + +```ts +const value = compute ( + first, + second +) +``` + +Good: + +```ts +const value = compute ( + first, + second) +``` + +Rule: call-expression `)` must not be at the beginning of a line. The exception +for leading `)` applies only to function declaration parameter lists, never to +calls. + +#### JSX closing markers + +Bad: + +```tsx + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `
)`. + +#### Arrays and tuples + +Bad: + +```ts +const items = [ + first, + second, +] +``` + +Good: + +```ts +const items = [ + first, + second] +``` + +Good when short: + +```ts +const items = [first, second] +``` + +Rule: array and tuple `]` must not be at the beginning of a line. Keep it with +the final element unless that would break the hard line limit. + +#### Single-line braces + +Good: + +```ts +const next = { ...current, enabled: true } +const tag = { id, name } +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: JavaScript object braces on one line get one inner space. JSX expression +braces do not get inner spaces. + +#### Final TSX self-review checklist + +Before reporting completion after a TypeScript or TSX edit, check the edited +hunks line by line: + +1. No import/export/type/object/destructuring `}` appears alone at the beginning + of a line. +2. No call-expression `)` appears at the beginning of a line. +3. Any leading `)` is definitely closing a function declaration parameter list, + not a call. +4. Any parameter-list `)` at the end of a line is valid because the parameter + list itself was not split, only an inline type or destructuring shape was. +5. No array or tuple `]` appears at the beginning of a line. +6. Multi-line executable block `}` is on its own line. +7. JSX `>` and `/>` stay with the final prop unless nearby code proves + otherwise. +8. JSX closing parentheses keep the compact local style. +9. Leading indentation is 4-space logical indentation with tabs used only as + leading 8-space compression. +10. No line has trailing whitespace. Preferred: diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index eb9c576..2c2727e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -129,13 +129,43 @@ pass or the remaining failure is clearly blocked. - Treat TSX formatting rules as hard constraints, not preferences. Before finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` placement and fix violations instead of relying on formatter defaults. +- After every TSX edit, perform a style-only self-review of the edited hunks + before running verification or reporting completion. The task is not complete + while any edited TSX hunk violates these local formatting rules. +- The TSX self-review must classify every edited leading or trailing `)`, `]`, + and `}` by syntax role before deciding whether it is valid. Do not apply a + rule by glyph alone. A closing `)` for a function parameter list is different + from a closing `)` for a function call. A closing `}` for a block is different + from a closing `}` for an object, type literal, import list, or destructuring + pattern. +- The TSX self-review must confirm there are no common Prettier-style React + component declarations with a multi-line destructured parameter. +- The TSX self-review must confirm multi-line function declaration parameter + `)` placement follows the detailed parameter-list rules below. +- The TSX self-review must confirm call-expression `)` is never at the + beginning of a line. +- The TSX self-review must confirm object/type/import/destructuring `}` is not + at the beginning of a line. +- The TSX self-review must confirm multi-line function/lambda/callback/block + `}` is on its own line and never at the end of the previous line. +- The TSX self-review must confirm array `]` is not at the beginning of a line. +- The TSX self-review must confirm JSX closing markers and closing parentheses + keep the surrounding compact style. +- The TSX self-review must confirm leading indentation follows 4-space logical + indentation with tabs only as leading 8-space compression. - For long Tailwind `className` strings, wrap across lines only when needed. - Keep continuation indentation aligned with the 4-space logical indentation rule, using tabs only as leading 8-space compression. - In TypeScript and TSX function declarations, including `const` arrow - function declarations, when the parameter list spans multiple lines, always - put the closing parenthesis at the beginning of its own line before the return - type or `=>`. Do not put that closing `)` at the end of the previous line. + function declarations, classify the parameter list before placing the closing + `)`. +- If the parameter list itself is given its own multi-line block after the + function's opening `(`, put the closing parameter `)` at the beginning of its + own line before the return type or `=>`. +- If the only line break is inside a parameter's inline type, object type, or + destructuring shape, and the parameter list itself is not split as a separate + block, keep the closing parameter `)` on the same line as that parameter's + final `}`. In this case, moving `)` to a new line is wrong. - Do not write React component declarations in the common Prettier form `const Component = ({ ... }) => (` when the destructured parameter spans multiple lines. Use the project form with the opening `(` after `=`, the @@ -162,8 +192,358 @@ 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. - Avoid reformatting unrelated JSX. +### Delimiter decision table + +Use this table before accepting any edited TypeScript or TSX hunk. The table is +more authoritative than formatter habit. + +#### Import and export named bindings + +Bad: + +```ts +import { + Button, + Card, +} from '@/components/ui' +``` + +Good: + +```ts +import { Button, + Card } from '@/components/ui' +``` + +Also acceptable when short: + +```ts +import { Button, Card } from '@/components/ui' +``` + +Rule: named-binding `}` is associative-array-style syntax. It must not be alone +at the beginning of a line. Prefer keeping `{` with the first binding and `}` +with the final binding when this fits the line limit. + +#### Type literals + +Bad: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void +} +``` + +Good: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void } +``` + +Rule: type-literal `}` is not a block close. It must stay on the same line as +the final property unless that would break the hard line limit. + +#### Object literals + +Bad: + +```ts +const value = { + open, + activeScope, +} +``` + +Good: + +```ts +const value = { + open, + activeScope } +``` + +Bad: + +```ts +const value = useMemo (() => ({ + open, + activeScope, +}), [open, activeScope]) +``` + +Good: + +```ts +const value = useMemo (() => ({ + open, + activeScope }), [open, activeScope]) +``` + +Rule: object-literal `}` is associative-array-style syntax. It must not be on a +line by itself. Keep it with the final property, and keep call `)` off the +beginning of a line. + +#### Destructuring parameters + +Bad: + +```tsx +const Component: FC = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { open, + onOpenChange }, +) => { + return null +} +``` + +Rule: when a React component or helper takes a destructured object parameter +that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>` +shape. Put the function parameter list on its own lines. The destructuring `}` +stays with the final binding. The parameter-list `)` is then allowed and +required at the beginning of its own line. + +#### Inline typed destructuring parameter + +Good: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + return null +} +``` + +Rule: this is not a separately split parameter-list block. The line break is +inside the inline type. Keep the type-literal `}` and parameter-list `)` on the +same line as the final type property. + +#### Multi-line normal parameter list + +Bad: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key], +) => { + return null +} +``` + +Rule: when the parameter list itself is split across multiple parameter lines, +the closing parameter `)` goes at the beginning of its own line before `=>` or +the return type. + +#### Function and callback blocks + +Bad: + +```ts +const handleSave = () => { save () +} +``` + +Bad: + +```ts +const handleSave = () => { + save () } +``` + +Good: + +```ts +const handleSave = () => { + save () +} +``` + +Rule: block `}` closes executable code, not associative data. Multi-line +function, lambda, callback, `if`, `for`, `switch`, and similar block braces +belong on their own line. + +#### Function and method calls + +Bad: + +```ts +const value = compute ( + first, + second +) +``` + +Good: + +```ts +const value = compute ( + first, + second) +``` + +Rule: call-expression `)` must not be at the beginning of a line. The exception +for leading `)` applies only to function declaration parameter lists, never to +calls. + +#### JSX closing markers + +Bad: + +```tsx + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `
)`. + +#### Arrays and tuples + +Bad: + +```ts +const items = [ + first, + second, +] +``` + +Good: + +```ts +const items = [ + first, + second] +``` + +Good when short: + +```ts +const items = [first, second] +``` + +Rule: array and tuple `]` must not be at the beginning of a line. Keep it with +the final element unless that would break the hard line limit. + +#### Single-line braces + +Good: + +```ts +const next = { ...current, enabled: true } +const tag = { id, name } +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: JavaScript object braces on one line get one inner space. JSX expression +braces do not get inner spaces. + +#### Final TSX self-review checklist + +Before reporting completion after a TypeScript or TSX edit, check the edited +hunks line by line: + +1. No import/export/type/object/destructuring `}` appears alone at the beginning + of a line. +2. No call-expression `)` appears at the beginning of a line. +3. Any leading `)` is definitely closing a function declaration parameter list, + not a call. +4. Any parameter-list `)` at the end of a line is valid because the parameter + list itself was not split, only an inline type or destructuring shape was. +5. No array or tuple `]` appears at the beginning of a line. +6. Multi-line executable block `}` is on its own line. +7. JSX `>` and `/>` stay with the final prop unless nearby code proves + otherwise. +8. JSX closing parentheses keep the compact local style. +9. Leading indentation is 4-space logical indentation with tabs used only as + leading 8-space compression. +10. No line has trailing whitespace. + ## Lint and build constraints - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 86fe9a5..18fc855 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' import { cn } from '@/lib/utils' -import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react' +import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { To } from 'react-router-dom' type Props = AnchorHTMLAttributes & { @@ -25,7 +25,6 @@ export default forwardRef (({ state, onMouseEnter, onTouchStart, - onKeyDown, onClick, cancelOnError = false, ...rest }, ref) => { @@ -98,22 +97,11 @@ export default forwardRef (({ } } - const handleKeyDown = (ev: KeyboardEvent) => { - onKeyDown?.(ev) - - if (ev.defaultPrevented) - return - - if (ev.key === ' ' || ev.key === 'Spacebar') - ev.preventDefault () - } - return ( ) diff --git a/frontend/src/components/users/BehaviorSettingsSection.tsx b/frontend/src/components/users/BehaviorSettingsSection.tsx new file mode 100644 index 0000000..9aa0eee --- /dev/null +++ b/frontend/src/components/users/BehaviorSettingsSection.tsx @@ -0,0 +1,143 @@ +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/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx index 0a7b1b5..f00b072 100644 --- a/frontend/src/components/users/ShortcutHelpDialog.tsx +++ b/frontend/src/components/users/ShortcutHelpDialog.tsx @@ -24,14 +24,24 @@ type Props = { activeScope: ShortcutScope effectiveBindings: Record conflictActionIds: Set + availableActionIds: Set } const ShortcutHelpDialog: FC = ( - { open, onOpenChange, activeScope, effectiveBindings, conflictActionIds }, + { + open, + onOpenChange, + activeScope, + effectiveBindings, + conflictActionIds, + availableActionIds, + }, ) => { - const rows = SHORTCUT_DEFINITIONS.filter (definition => - definition.scope === 'global' || definition.scope === activeScope) + const rows = SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => availableActionIds.has (definition.id)) return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 05d2ee0..4aa7caa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -134,6 +134,21 @@ line-height: 1.25; } +:root[data-animation='reduced'] *, +:root[data-animation='off'] * +{ + scroll-behavior: auto !important; +} + +:root[data-animation='off'] *, +:root[data-animation='off'] *::before, +:root[data-animation='off'] *::after +{ + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; +} + a { font-weight: 500; diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts index 53990f3..1042a8c 100644 --- a/frontend/src/lib/keyboardShortcuts.ts +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -19,6 +19,7 @@ export type ShortcutActionId = | 'settings.account' | 'settings.theme' | 'settings.keyboard' + | 'settings.behavior' export type KeyBinding = { key: string @@ -112,6 +113,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: 'settings', defaultBinding: { key: '3' }, }, + { + id: 'settings.behavior', + label: '設定: 動作', + scope: 'settings', + defaultBinding: { key: '4' }, + }, ] export const SHORTCUT_DEFINITIONS_BY_ID = @@ -132,6 +139,7 @@ export const SHORTCUT_DEFINITIONS_BY_ID = 'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.theme': SHORTCUT_DEFINITIONS[9], 'settings.keyboard': SHORTCUT_DEFINITIONS[10], + 'settings.behavior': SHORTCUT_DEFINITIONS[11], }, ) @@ -153,6 +161,7 @@ export const DEFAULT_KEY_BINDINGS = 'settings.account': null, 'settings.theme': null, 'settings.keyboard': null, + 'settings.behavior': null, }, ) diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 53a9907..b747fa3 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -51,6 +51,9 @@ export type CustomClientTheme = export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' +export type ClientAnimationMode = 'normal' | 'reduced' | 'off' +export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off' +export type ClientThumbnailMode = 'normal' | 'light' | 'off' export type ClientPaneBreakpoint = 'desktop' | 'tablet' export type ClientListKey = 'postList' | 'postSearch' | 'tagList' export type ClientListLimit = 20 | 50 | 100 @@ -75,6 +78,12 @@ export type ClientAppearanceSettings = { theme?: UserSettings['theme'] tagColours?: Partial } +export type ClientBehaviorSettings = { + animation?: ClientAnimationMode + embedAutoLoad?: ClientEmbedAutoLoadMode + thumbnailMode?: ClientThumbnailMode +} + // DB-backed settings. At present only `theme` is surfaced in `/users/settings`. // The remaining fields are retained for existing backend work and are not wired // to the common settings UI yet. @@ -87,6 +96,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = { // Browser-local settings only. These are device or screen specific. export type ClientSettings = { appearance?: ClientAppearanceSettings + behavior?: ClientBehaviorSettings keyboard?: ClientKeyboardSettings panes?: Record lists?: Partial> @@ -303,6 +313,25 @@ const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null +const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => { + if (value === 'normal' || value === 'reduced' || value === 'off') + return value + if (value === 'true') + return 'reduced' + if (value === 'false') + return 'normal' + return null +} + + +const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null => + value === 'auto' || value === 'manual' || value === 'off' ? value : null + + +const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null => + value === 'normal' || value === 'light' || value === 'off' ? value : null + + const normaliseKeyboardBindings = ( bindings: unknown, ): Partial> => { @@ -341,6 +370,110 @@ export const getClientKeyboardSettings = (): ClientKeyboardSettings => { } +const legacyAnimationMode = (): ClientAnimationMode | null => + normaliseAnimationMode (loadClientSettings ().reducedMotion) + + +const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null => + normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad) + + +const legacyThumbnailMode = (): ClientThumbnailMode | null => + normaliseThumbnailMode (loadClientSettings ().thumbnailMode) + + +export const getClientAnimationMode = (): ClientAnimationMode => + loadClientSettings ().behavior?.animation + ?? legacyAnimationMode () + ?? 'normal' + + +export const applyClientAnimationMode = (mode: ClientAnimationMode): void => { + if (typeof document === 'undefined') + return + + if (mode === 'normal') + { + delete document.documentElement.dataset.animation + return + } + + document.documentElement.dataset.animation = mode +} + + +export const setClientAnimationMode = ( + animation: ClientAnimationMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ + ...getClientBehaviorSettings (), + animation, + }) + +export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode => + loadClientSettings ().behavior?.embedAutoLoad + ?? legacyEmbedAutoLoadMode () + ?? 'auto' + + +export const setClientEmbedAutoLoadMode = ( + embedAutoLoad: ClientEmbedAutoLoadMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ + ...getClientBehaviorSettings (), + embedAutoLoad, + }) + + +export const getClientThumbnailMode = (): ClientThumbnailMode => + loadClientSettings ().behavior?.thumbnailMode + ?? legacyThumbnailMode () + ?? 'normal' + + +export const setClientThumbnailMode = ( + thumbnailMode: ClientThumbnailMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ + ...getClientBehaviorSettings (), + thumbnailMode, + }) + + +export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({ + animation: getClientAnimationMode (), + embedAutoLoad: getClientEmbedAutoLoadMode (), + thumbnailMode: getClientThumbnailMode (), +}) + + +export const setClientBehaviorSettings = ( + behavior: ClientBehaviorSettings, +): ClientBehaviorSettings => { + const next: ClientBehaviorSettings = { + animation: ( + normaliseAnimationMode (behavior.animation) + ?? getClientAnimationMode () + ), + embedAutoLoad: ( + normaliseEmbedAutoLoadMode (behavior.embedAutoLoad) + ?? getClientEmbedAutoLoadMode () + ), + thumbnailMode: ( + normaliseThumbnailMode (behavior.thumbnailMode) + ?? getClientThumbnailMode () + ), + } + + updateClientSettings (settings => ({ + ...settings, + behavior: next, + })) + applyClientAnimationMode (next.animation ?? 'normal') + return next +} + + export const setClientKeyboardSettings = ( keyboard: ClientKeyboardSettings, ): ClientKeyboardSettings => { @@ -921,7 +1054,8 @@ export const setClientGekanatorBackgroundMotion = ( ): void => { updateClientSettings (settings => ({ ...settings, - gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) + gekanator: { ...(settings.gekanator ?? { }), backgroundMotion }, + })) } diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 47f05a2..2b441f6 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -23,9 +23,6 @@ import { import { getClientKeyboardSettings, getEffectiveKeyBindings, - resetAllClientKeyBindings, - resetClientKeyBinding, - setClientKeyBinding, setClientKeyboardSettings, } from '@/lib/settings' @@ -41,16 +38,12 @@ type ShortcutHandler = () => void type ShortcutHandlers = Partial> type KeyboardShortcutsContextValue = { - activeScope: ShortcutScope - keyboardSettings: ClientKeyboardSettings - effectiveBindings: Record - conflictActionIds: Set + activeScope: ShortcutScope + keyboardSettings: ClientKeyboardSettings + effectiveBindings: Record + conflictActionIds: Set saveKeyboardSettings: (settings: ClientKeyboardSettings) => void - setEnabled: (enabled: boolean) => void - setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void - resetBinding: (actionId: ShortcutActionId) => void - resetAllBindings: () => void - registerHandlers: (handlers: ShortcutHandlers) => () => void + registerHandlers: (handlers: ShortcutHandlers) => () => void } const KeyboardShortcutsContext = @@ -131,37 +124,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { [registeredHandlers], ) - const setEnabled = useCallback ((enabled: boolean) => { - const next = setClientKeyboardSettings ({ - ...keyboardSettings, - enabled, - }) - setKeyboardSettingsState (next) - }, [keyboardSettings]) - const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => { const next = setClientKeyboardSettings (settings) setKeyboardSettingsState (next) }, []) - const setBinding = useCallback (( - actionId: ShortcutActionId, - binding: KeyBinding | null, - ) => { - const next = setClientKeyBinding (actionId, binding) - setKeyboardSettingsState (next) - }, []) - - const resetBinding = useCallback ((actionId: ShortcutActionId) => { - const next = resetClientKeyBinding (actionId) - setKeyboardSettingsState (next) - }, []) - - const resetAllBindings = useCallback (() => { - const next = resetAllClientKeyBindings () - setKeyboardSettingsState (next) - }, []) - const registerHandlers = useCallback ((handlers: ShortcutHandlers) => { const registrationId = ++registrationIdRef.current @@ -179,6 +146,39 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { } }, []) + const builtinHandlers = useMemo ( + () => ({ + 'global.openShortcutHelp': () => { + setShortcutHelpOpen (true) + }, + 'global.focusSearch': () => { + focusSearchTarget () + }, + 'navigation.posts': () => navigate ('/posts'), + 'navigation.tags': () => navigate ('/tags'), + 'navigation.materials': () => navigate ('/materials'), + 'navigation.wiki': () => navigate ('/wiki'), + 'settings.account': () => navigate ('/users/settings?tab=account'), + 'settings.theme': () => navigate ('/users/settings?tab=theme'), + 'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'), + 'settings.behavior': () => navigate ('/users/settings?tab=behavior'), + }), + [navigate], + ) + + const availableActionIds = useMemo> ( + () => new Set ( + SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => + builtinHandlers[definition.id] != null + || mergedRegisteredHandlers[definition.id] != null) + .map (definition => definition.id), + ), + [activeScope, builtinHandlers, mergedRegisteredHandlers], + ) + useEffect (() => { const onKeyDown = (event: KeyboardEvent) => { if ( @@ -219,24 +219,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { return const actionId = candidates[0].id - const builtinHandler = ( - { - 'global.openShortcutHelp': () => { - setShortcutHelpOpen (true) - }, - 'global.focusSearch': () => { - focusSearchTarget () - }, - 'navigation.posts': () => navigate ('/posts'), - 'navigation.tags': () => navigate ('/tags'), - 'navigation.materials': () => navigate ('/materials'), - 'navigation.wiki': () => navigate ('/wiki'), - 'settings.account': () => navigate ('/users/settings?tab=account'), - 'settings.theme': () => navigate ('/users/settings?tab=theme'), - 'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'), - } as ShortcutHandlers - )[actionId] - const handler = builtinHandler ?? mergedRegisteredHandlers[actionId] + const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId] if (handler == null) return @@ -251,9 +234,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { activeScope, conflictActionIds, effectiveBindings, + builtinHandlers, keyboardSettings.enabled, mergedRegisteredHandlers, - navigate, shortcutHelpOpen, ]) @@ -262,11 +245,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { keyboardSettings, effectiveBindings, conflictActionIds, - setEnabled, saveKeyboardSettings, - setBinding, - resetBinding, - resetAllBindings, registerHandlers, }), [ activeScope, @@ -274,11 +253,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { effectiveBindings, keyboardSettings, registerHandlers, - resetAllBindings, - resetBinding, saveKeyboardSettings, - setBinding, - setEnabled, ]) return ( @@ -289,7 +264,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { onOpenChange={setShortcutHelpOpen} activeScope={activeScope} effectiveBindings={effectiveBindings} - conflictActionIds={conflictActionIds}/> + conflictActionIds={conflictActionIds} + availableActionIds={availableActionIds}/> ) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 64630d2..e50c55f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async' import '@/index.css' import App from '@/App' +import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' const helmetContext = { } @@ -13,6 +14,8 @@ const client = new QueryClient ({ gcTime: 30 * 60 * 1000, retry: 1 } } }) +applyClientAnimationMode (getClientAnimationMode ()) + createRoot (document.getElementById ('root')!).render ( diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index c818e00..aca8f40 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -8,6 +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 KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' @@ -48,7 +49,7 @@ type Props = { type UserFormField = 'name' type SettingsFormField = 'theme' -type SettingsTab = 'account' | 'theme' | 'keyboard' +type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior' type TabSpec = { id: SettingsTab @@ -79,7 +80,8 @@ type SharedSectionProps = { const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, { id: 'theme', label: 'テーマ' }, - { id: 'keyboard', label: 'キーボード' } ] + { id: 'keyboard', label: 'キーボード' }, + { id: 'behavior', label: '動作' } ] const sectionClassName = 'space-y-4 rounded-xl border border-border bg-background/80 p-4' @@ -379,7 +381,8 @@ const SettingPage: FC = ({ user, setUser }) => { () => ( { account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), - keyboard: conflictActionIds.size > 0 }), + keyboard: conflictActionIds.size > 0, + behavior: false }), [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( @@ -689,6 +692,8 @@ const SettingPage: FC = ({ user, setUser }) => { {sectionProps && activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} + {activeTab === 'behavior' && ( + )} )} @@ -736,6 +741,8 @@ const SettingPage: FC = ({ user, setUser }) => { {activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} + {activeTab === 'behavior' && ( + )} )} )} -- 2.34.1 From 61ab4744bd4eb7829a115d34b526798cbb2f70b1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 11:57:29 +0900 Subject: [PATCH 13/30] #34 --- AGENTS.md | 36 +++- frontend/AGENTS.md | 23 ++- frontend/src/App.tsx | 69 +++++-- frontend/src/components/PostEmbed.tsx | 70 ++++++- frontend/src/components/PostList.tsx | 30 ++- .../users/BehaviorSettingsSection.tsx | 143 ------------- .../users/BehaviourSettingsSection.tsx | 192 ++++++++++++++++++ frontend/src/lib/settings.ts | 8 + frontend/src/pages/posts/PostDetailPage.tsx | 6 +- frontend/src/pages/users/SettingPage.tsx | 28 ++- 10 files changed, 402 insertions(+), 203 deletions(-) delete mode 100644 frontend/src/components/users/BehaviorSettingsSection.tsx create mode 100644 frontend/src/components/users/BehaviourSettingsSection.tsx 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' && ( - )} + )} )}
)}
-- 2.34.1 From b0cce4b7ee39f017604152bec74fa17882994370 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 12:51:31 +0900 Subject: [PATCH 14/30] #34 --- frontend/src/App.tsx | 18 +- frontend/src/components/PostEmbed.tsx | 5 +- frontend/src/components/PostList.tsx | 17 +- frontend/src/components/TopNav.tsx | 380 ++++++++++++------ .../users/BehaviourSettingsSection.tsx | 39 +- .../src/lib/useClientBehaviourSettings.ts | 26 ++ frontend/src/pages/posts/PostDetailPage.tsx | 17 +- frontend/src/pages/users/SettingPage.tsx | 17 - 8 files changed, 319 insertions(+), 200 deletions(-) create mode 100644 frontend/src/lib/useClientBehaviourSettings.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 75b76da..2847686 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,13 +13,11 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAppearance, - CLIENT_SETTINGS_EVENT, fetchUserSettings, - getClientAnimationMode, getClientThemeMode, seedClientThemeMode } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' -import type { ClientAnimationMode } from '@/lib/settings' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' @@ -112,8 +110,8 @@ 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 behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const appLayoutTransition = useMemo ( () => ( @@ -139,16 +137,6 @@ 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') diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 7ff3e14..9933ada 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -5,7 +5,7 @@ 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 { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import type { FC, RefObject } from 'react' @@ -36,7 +36,8 @@ const PostEmbed: FC = ( onError }, ) => { const dialogue = useDialogue () - const embedAutoLoadMode = getClientEmbedAutoLoadMode () + const behaviourSettings = useClientBehaviourSettings () + const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto' const [manualLoadRequested, setManualLoadRequested] = useState (false) const [framed, setFramed] = useState (false) const [youtubePlayer, setYoutubePlayer] = useState (null) diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index e7cced5..8d50c66 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react' import { useLocation } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' -import { getClientThumbnailMode } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' @@ -17,7 +17,15 @@ type Props = { posts: Post[] const PostList: FC = ({ posts, onClick }) => { const location = useLocation () - const thumbnailMode = getClientThumbnailMode () + const behaviourSettings = useClientBehaviourSettings () + const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' + const animationMode = behaviourSettings.animation ?? 'normal' + const cardLayoutTransition = + animationMode === 'off' + ? { duration: 0 } + : animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) @@ -41,7 +49,8 @@ const PostList: FC = ({ posts, onClick }) => { }}> 0 && 'ring-4 ring-green-500', @@ -61,7 +70,7 @@ const PostList: FC = ({ posts, onClick }) => { cardRef.current.style.zIndex = '' cardRef.current.style.position = '' }} - transition={{ layout: { duration: .2, ease: 'easeOut' } }}> + transition={{ layout: cardLayoutTransition }}> {thumbnailMode === 'off' ? (
= ({ user }) => { if (moreVsbl) setMoreVsbl (false) }} - transition={{ layout: { duration: .2, ease: 'easeOut' } }} + transition={{ layout: topNavTransition }} onAnimationComplete={() => { measure (moreVsbl ? -1 : activeIdx) }}> @@ -282,12 +304,14 @@ const TopNav: FC = ({ user }) => {

{item.name}

@@ -298,25 +322,29 @@ const TopNav: FC = ({ user }) => { ? ( + exit: { y: -40, opacity: 0 } }))}> {subItem.component} ) : ( + exit: { y: -40, opacity: 0 } }))}> = ({ user }) => { : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0 && (
- - ({ y: d * 24, opacity: 0 }), - centre: { y: 0, opacity: 1 }, - exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} - className="absolute inset-0 flex items-center px-3" - initial="enter" - animate="centre" - exit="exit" - transition={{ duration: .2, ease: 'easeOut' }}> - {(visibleMenu[activeIdx]?.subMenu ?? []) - .filter (item => item.visible ?? true) - .map ((item, i) => ( - 'component' in item - ? ( - - {item.component} - ) - : ( - - - {item.name} - - )))} - - + {animationsOff + ? ( +
+ {(visibleMenu[activeIdx]?.subMenu ?? []) + .filter (item => item.visible ?? true) + .map ((item, i) => ( + 'component' in item + ? ( +
+ {item.component} +
) + : ( +
+ + {item.name} + +
)))} +
) + : ( + + ({ y: d * 24, opacity: 0 }), + centre: { y: 0, opacity: 1 }, + exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} + className="absolute inset-0 flex items-center px-3" + initial="enter" + animate="centre" + exit="exit" + transition={topNavTransition}> + {(visibleMenu[activeIdx]?.subMenu ?? []) + .filter (item => item.visible ?? true) + .map ((item, i) => ( + 'component' in item + ? ( + + {item.component} + ) + : ( + + + {item.name} + + )))} + + )}
))}
- - {menuOpen && ( - - - {visibleMenu.map ((item, i) => ( - - ) => { - if (i !== openItemIdx) - { - ev.preventDefault () - setOpenItemIdx (i) - } - }}> - {item.name} - + {animationsOff + ? ( + menuOpen && ( +
+ + {visibleMenu.map ((item, i) => ( + + ) => { + if (i !== openItemIdx) + { + ev.preventDefault () + setOpenItemIdx (i) + } + }}> + {item.name} + - - {i === openItemIdx && ( - - {item.subMenu - .filter (subItem => subItem.visible ?? true) - .map ((subItem, j) => ( - 'component' in subItem - ? ( - - {subItem.component} - ) - : ( - - {subItem.name} - )))} - )} - - ))} - { - itemsRef.current[visibleMenu.length] = el - }} - className={cn ('w-full min-h-[40px] flex items-center pl-8', - ((openItemIdx < 0) - && 'font-bold bg-yellow-50 dark:bg-red-950'))}> - その他 » - - - - )} - + {i === openItemIdx && ( +
+ {item.subMenu + .filter (subItem => subItem.visible ?? true) + .map ((subItem, j) => ( + 'component' in subItem + ? ( + + {subItem.component} + ) + : ( + + {subItem.name} + )))} +
)} + ))} + { + itemsRef.current[visibleMenu.length] = el + }} + className={cn ('w-full min-h-[40px] flex items-center pl-8', + ((openItemIdx < 0) + && 'font-bold bg-yellow-50 dark:bg-red-950'))}> + その他 » + + + +
)) + : ( + + {menuOpen && ( + + + {visibleMenu.map ((item, i) => ( + + ) => { + if (i !== openItemIdx) + { + ev.preventDefault () + setOpenItemIdx (i) + } + }}> + {item.name} + + + + {i === openItemIdx && ( + + {item.subMenu + .filter (subItem => subItem.visible ?? true) + .map ((subItem, j) => ( + 'component' in subItem + ? ( + + {subItem.component} + ) + : ( + + {subItem.name} + )))} + )} + + ))} + { + itemsRef.current[visibleMenu.length] = el + }} + className={cn ('w-full min-h-[40px] flex items-center pl-8', + ((openItemIdx < 0) + && 'font-bold bg-yellow-50 dark:bg-red-950'))}> + その他 » + + + + )} + )} ) } diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx index 7e5ca1d..7ad75cf 100644 --- a/frontend/src/components/users/BehaviourSettingsSection.tsx +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -2,17 +2,16 @@ 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 { 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' +import type { ClientAnimationMode, + ClientBehaviorSettings, + ClientEmbedAutoLoadMode, + ClientThumbnailMode } from '@/lib/settings' + type Props = { sectionClassName: string } @@ -31,25 +30,22 @@ type SettingBlockProps = { children: ReactNode } const animationOptions: SegmentedOption[] = [ - { value: 'normal', label: '通常' }, - { value: 'reduced', label: '控えめ' }, { value: 'off', label: 'なし' }, -] + { value: 'reduced', label: '控えめ' }, + { value: 'normal', label: '通常' }] const embedAutoLoadOptions: SegmentedOption[] = [ - { value: 'auto', label: '自動' }, - { value: 'manual', label: 'クリック時' }, { value: 'off', label: '読み込まない' }, -] + { value: 'manual', label: 'クリック時' }, + { value: 'auto', label: '自動' }] const thumbnailModeOptions: SegmentedOption[] = [ - { value: 'normal', label: '通常' }, - { value: 'light', label: '軽量' }, { value: 'off', label: '非表示' }, -] + { value: 'light', label: '軽量' }, + { value: 'normal', label: '通常' }] -const SegmentedControl = ( +const SegmentedControl = ( { value, options, onChange }: SegmentedControlProps, ) => { return ( @@ -103,8 +99,7 @@ const BehaviourSettingsSection: FC = ({ sectionClassName }) => { const hasUnsavedChanges = useMemo ( () => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings), - [draftSettings, savedSettings], - ) + [draftSettings, savedSettings]) const updateDraft = ( key: Key, @@ -129,12 +124,6 @@ const BehaviourSettingsSection: FC = ({ sectionClassName }) => {

動作

-

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

-

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

{hasUnsavedChanges && (

未保存の変更があります diff --git a/frontend/src/lib/useClientBehaviourSettings.ts b/frontend/src/lib/useClientBehaviourSettings.ts new file mode 100644 index 0000000..d587bd3 --- /dev/null +++ b/frontend/src/lib/useClientBehaviourSettings.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' + +import { + CLIENT_SETTINGS_EVENT, + getClientBehaviorSettings, +} from '@/lib/settings' + +import type { ClientBehaviorSettings } from '@/lib/settings' + + +export const useClientBehaviourSettings = (): ClientBehaviorSettings => { + const [settings, setSettings] = + useState (() => getClientBehaviorSettings ()) + + useEffect (() => { + const handleClientSettingsChange = () => { + setSettings (getClientBehaviorSettings ()) + } + + window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange) + return () => + window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange) + }, []) + + return settings +} diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index cf541fe..01957df 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -15,7 +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 { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { cn } from '@/lib/utils' import NotFound from '@/pages/NotFound' @@ -30,8 +30,17 @@ type Props = { user: User | null } const PostDetailPage: FC = ({ user }) => { const editable = canEditContent (user) - const thumbnailMode = getClientThumbnailMode () + const behaviourSettings = useClientBehaviourSettings () + const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' + const animationMode = behaviourSettings.animation ?? 'normal' const { id } = useParams () + const heroLayoutId = animationMode === 'off' ? undefined : `page-${ id }` + const heroTransition = + animationMode === 'off' + ? { duration: 0 } + : animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } const postId = String (id ?? '') const postKey = postsKeys.show (postId) @@ -138,12 +147,12 @@ const PostDetailPage: FC = ({ user }) => { {thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && ( + transition={heroTransition}> {post.title = (

-
-

プレビュー

-

- 現在の表示テーマ: {themeLabelById (draftActiveThemeId, draftCustomThemes)} -

-
- {CATEGORIES.map (category => ( - ))} -
-
- -- 2.34.1 From 1b6cac43c2910299706f20616014a8137fb4ed4e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 18:21:39 +0900 Subject: [PATCH 15/30] #34 --- .../user_theme_slots_controller.rb | 38 + backend/app/models/user.rb | 1 + backend/app/models/user_theme_slot.rb | 27 + backend/config/routes.rb | 2 + .../20260705000000_create_user_theme_slots.rb | 22 + backend/db/schema.rb | 65 +- frontend/src/App.tsx | 27 +- frontend/src/components/PrefetchLink.tsx | 7 + frontend/src/components/TagDetailSidebar.tsx | 56 +- frontend/src/components/TopNav.tsx | 37 +- .../users/BehaviourSettingsSection.tsx | 32 +- frontend/src/index.css | 55 + frontend/src/lib/settings.ts | 539 ++++++++- frontend/src/pages/users/SettingPage.tsx | 1029 ++++++++++++----- 14 files changed, 1540 insertions(+), 397 deletions(-) create mode 100644 backend/app/controllers/user_theme_slots_controller.rb create mode 100644 backend/app/models/user_theme_slot.rb create mode 100644 backend/db/migrate/20260705000000_create_user_theme_slots.rb diff --git a/backend/app/controllers/user_theme_slots_controller.rb b/backend/app/controllers/user_theme_slots_controller.rb new file mode 100644 index 0000000..e85a61c --- /dev/null +++ b/backend/app/controllers/user_theme_slots_controller.rb @@ -0,0 +1,38 @@ +class UserThemeSlotsController < ApplicationController + wrap_parameters false + + def index + return head :unauthorized unless current_user + + render json: current_user + .theme_slots + .order(:base_theme, :slot_no) + .map { |slot| slot.serializable_hash } + end + + def update + return head :unauthorized unless current_user + + base_theme = params[:base_theme].to_s + slot_no = params[:slot_no].to_i + tokens = params[:tokens] + + unless UserThemeSlot::BASE_THEMES.include?(base_theme) + return render_validation_error fields: { base_theme: ['値が不正です.'] } + end + unless UserThemeSlot::SLOT_NOS.include?(slot_no) + return render_validation_error fields: { slot_no: ['値が不正です.'] } + end + unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash) + return render_validation_error fields: { tokens: ['JSON object で指定してください.'] } + end + + slot = UserThemeSlot.find_or_initialize_by(user: current_user, + base_theme:, + slot_no:) + slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens + slot.save! + + render json: slot.serializable_hash, status: :ok + end +end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 33de49b..59b076c 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -8,6 +8,7 @@ class User < ApplicationRecord has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify has_one :setting, dependent: :destroy + has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy diff --git a/backend/app/models/user_theme_slot.rb b/backend/app/models/user_theme_slot.rb new file mode 100644 index 0000000..f7919a0 --- /dev/null +++ b/backend/app/models/user_theme_slot.rb @@ -0,0 +1,27 @@ +class UserThemeSlot < ApplicationRecord + BASE_THEMES = ['light', 'dark'].freeze + SLOT_NOS = [1, 2, 3].freeze + + belongs_to :user + + validates :user_id, presence: true + validates :base_theme, presence: true, inclusion: { in: BASE_THEMES } + validates :slot_no, presence: true, inclusion: { in: SLOT_NOS } + validates :tokens, presence: true + validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] } + + validate :tokens_must_be_object + + def serializable_hash(options = nil) + hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] } + super(hash.merge(options || {})) + end + + private + + def tokens_must_be_object + return if tokens.is_a?(Hash) + + errors.add(:tokens, 'は JSON object で指定してください.') + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 09619e1..223332a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -83,6 +83,8 @@ Rails.application.routes.draw do get 'users/settings', to: 'user_settings#show' patch 'users/settings', to: 'user_settings#update' + get 'users/theme_slots', to: 'user_theme_slots#index' + put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update' resources :users, only: [:create, :update] do collection do diff --git a/backend/db/migrate/20260705000000_create_user_theme_slots.rb b/backend/db/migrate/20260705000000_create_user_theme_slots.rb new file mode 100644 index 0000000..5df3d08 --- /dev/null +++ b/backend/db/migrate/20260705000000_create_user_theme_slots.rb @@ -0,0 +1,22 @@ +class CreateUserThemeSlots < ActiveRecord::Migration[8.0] + def change + create_table :user_theme_slots do |t| + t.references :user, null: false, foreign_key: true + t.string :base_theme, null: false + t.integer :slot_no, null: false + t.json :tokens, null: false + t.timestamps + end + + add_index :user_theme_slots, + [:user_id, :base_theme, :slot_no], + unique: true, + name: 'index_user_theme_slots_on_user_theme_and_slot' + add_check_constraint :user_theme_slots, + "base_theme IN ('light', 'dark')", + name: 'user_theme_slots_base_theme_valid' + add_check_constraint :user_theme_slots, + 'slot_no BETWEEN 1 AND 3', + name: 'user_theme_slots_slot_no_valid' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 311935c..04ab55e 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -72,7 +72,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id" - t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -275,10 +274,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } - t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at" + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at" t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true - t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -287,14 +285,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" - t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" end create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false t.float "cos", null: false - t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -350,8 +347,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" - t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" - t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" end create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -369,7 +364,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["url"], name: "index_posts_on_url", unique: true t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" - t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -377,6 +371,12 @@ 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 @@ -418,7 +418,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -428,32 +428,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false - t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } - t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at" + t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at" t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true - t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false - t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" - t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -568,6 +566,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["post_id"], name: "index_user_post_views_on_post_id" end + create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "base_theme", null: false + t.integer "slot_no", null: false + t.json "tokens", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true + t.index ["user_id"], name: "index_user_theme_slots_on_user_id" + t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid" + t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid" + end + create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "inheritance_code", limit: 64, null: false @@ -578,19 +589,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["banned_at"], name: "index_users_on_banned_at" end - create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "no", null: false - t.string "alt_text" - t.binary "sha256", limit: 32, null: false - t.bigint "created_by_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" - t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true - t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true - end - create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -607,13 +605,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" - t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" - t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -658,8 +654,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" - t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" - t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" @@ -740,8 +734,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" - add_foreign_key "wiki_assets", "users", column: "created_by_user_id" - add_foreign_key "wiki_assets", "wiki_pages" + add_foreign_key "user_theme_slots", "users" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2847686..a4cba2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,15 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAppearance, + fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, + getClientActiveThemeSlot, + hasStoredClientThemeSelection, + normaliseThemeSlotSelectionAgainstSlots, + normaliseUserThemeSlots, + setCachedUserThemeSlots, + setClientActiveThemeSlot, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' @@ -177,8 +184,26 @@ const App: FC = () => { void (async () => { try { - const settings = await fetchUserSettings () + const [settings, themeSlots] = await Promise.all ([ + fetchUserSettings (), + fetchUserThemeSlots (), + ]) + const storedThemeSlots = normaliseUserThemeSlots (themeSlots) + const initialSelection = ( + hasStoredClientThemeSelection () + ? getClientActiveThemeSlot () + : (settings.theme === 'system' + ? 'system' + : `builtin:${ settings.theme }`) + ) + const nextSelection = normaliseThemeSlotSelectionAgainstSlots ( + initialSelection, + storedThemeSlots, + ) + setCachedUserThemeSlots (storedThemeSlots) seedClientThemeMode (settings.theme) + if (nextSelection !== initialSelection) + setClientActiveThemeSlot (nextSelection) applyClientAppearance () } catch diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 18fc855..e9b210e 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -5,6 +5,7 @@ import { createPath, useNavigate } from 'react-router-dom' import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' @@ -33,6 +34,8 @@ export default forwardRef (({ const navigate = useNavigate () const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const url = useMemo (() => { const path = (typeof to === 'string') ? to : createPath (to) return (new URL (path, location.origin)).toString () @@ -54,11 +57,15 @@ export default forwardRef (({ const handleMouseEnter = async (ev: MouseEvent) => { onMouseEnter?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } const handleTouchStart = async (ev: TouchEvent) => { onTouchStart?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 10b7eb6..47d1daa 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -21,6 +21,7 @@ import { toast } from '@/components/ui/use-toast' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { dateString, originalCreatedAtString } from '@/lib/utils' import type { DragEndEvent } from '@dnd-kit/core' @@ -29,6 +30,9 @@ import type { FC, MutableRefObject, ReactNode } from 'react' import type { Category, Post, TagWithSections } from '@/types' type TagByCategory = { [key in Category]: TagWithSections[] } +type FlatTagRow = { + tag: TagWithSections + parentTagId?: number } const renderTagTree = ( @@ -61,6 +65,33 @@ const renderTagTree = ( } +const flattenTagTree = ( + tags: TagWithSections[], +): FlatTagRow[] => { + const seen = new Set () + const rows: FlatTagRow[] = [] + + const visit = ( + tag: TagWithSections, + parentTagId?: number, + ) => { + if (seen.has (tag.id)) + return + + seen.add (tag.id) + rows.push ({ tag, parentTagId }) + + for (const child of tag.children ?? []) + visit (child, tag.id) + } + + for (const tag of tags) + visit (tag) + + return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1) +} + + const isDescendant = ( root: TagWithSections, targetId: number, @@ -156,6 +187,8 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { sp = Boolean (sp) const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped' const baseTags = useMemo (() => { const tagsTmp = { } as TagByCategory @@ -321,8 +354,27 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => {
    - {(tags[cat] ?? []).flatMap (tag => ( - renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} + {(tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []).flatMap (tag => + renderTagTree ( + tag, + 0, + `cat-${ cat }`, + suppressClickRef, + undefined, + sp, + ), + ) + : flattenTagTree (tags[cat] ?? []).map (row => ( +
  • + +
  • )))}
))} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 334e81d..0f611d6 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -209,12 +209,11 @@ const TopNav: FC = ({ user }) => { return ( <>
)}
@@ -753,5 +1195,4 @@ const SettingPage: FC = ({ user, setUser }) => { ) } - export default SettingPage -- 2.34.1 From 021d13a2627b8cf5a3ff8215e9761b6a4052f889 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 20:00:58 +0900 Subject: [PATCH 16/30] #34 --- AGENTS.md | 68 +- frontend/AGENTS.md | 64 +- frontend/src/components/TopNav.tsx | 55 +- .../users/BehaviourSettingsSection.tsx | 19 +- .../users/KeyboardSettingsSection.tsx | 17 +- frontend/src/index.css | 75 +-- frontend/src/lib/keyboardShortcuts.ts | 22 +- frontend/src/lib/settings.ts | 188 +++--- frontend/src/lib/useKeyboardShortcuts.tsx | 2 +- frontend/src/pages/users/SettingPage.tsx | 617 +++++++++++------- 10 files changed, 647 insertions(+), 480 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b97a943..8c478cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,38 +266,45 @@ const value = 《当用漢字による書きかえ》; prefer original forms such as `編輯`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`. -### Frontend TSX style +### Frontend TypeScript and TSX style -- Preserve the local TSX formatting style. +- The delimiter-placement and line-breaking rules in this section apply to + both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says + it is JSX- or React-specific. +- Preserve the local TypeScript and TSX formatting style. - Do not normalize TSX to common Prettier-style React formatting unless explicitly asked. -- Treat TSX formatting rules as hard constraints, not preferences. Before - finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` - placement and fix violations instead of relying on formatter defaults. -- After every TSX edit, perform a style-only self-review of the edited hunks - before running verification or reporting completion. The task is not complete - while any edited TSX hunk violates these local formatting rules. -- The TSX self-review must classify every edited leading or trailing `)`, `]`, - and `}` by syntax role before deciding whether it is valid. Do not apply a - rule by glyph alone. A closing `)` for a function parameter list is different - from a closing `)` for a function call. A closing `}` for a block is different - from a closing `}` for an object, type literal, import list, or destructuring - pattern. -- The TSX self-review must confirm there are no common Prettier-style React - component declarations with a multi-line destructured parameter. -- The TSX self-review must confirm multi-line function declaration parameter - `)` placement follows the detailed parameter-list rules below. -- The TSX self-review must confirm call-expression `)` is never at the +- Treat TypeScript and TSX formatting rules as hard constraints, not + preferences. Before finishing a TypeScript or TSX edit, inspect the edited + hunks for closing `)`, `]`, and `}` placement and fix violations instead of + relying on formatter defaults. +- After every TypeScript or TSX edit, perform a style-only self-review of the + edited hunks before running verification or reporting completion. The task is + not complete while any edited TypeScript or TSX hunk violates these local + formatting rules. +- The TypeScript/TSX self-review must classify every edited leading or trailing + `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not + apply a rule by glyph alone. A closing `)` for a function parameter list is + different from a closing `)` for a function call. A closing `}` for a block + is different from a closing `}` for an object, type literal, import list, or + destructuring pattern. +- The TSX-specific self-review must confirm there are no common Prettier-style + React component declarations with a multi-line destructured parameter. +- The TypeScript/TSX self-review must confirm multi-line function declaration + parameter `)` placement follows the detailed parameter-list rules below. +- The TypeScript/TSX self-review must confirm call-expression `)` is never at + the beginning of a line. +- The TypeScript/TSX self-review must confirm + object/type/import/destructuring `}` is not at the beginning of a line. +- The TypeScript/TSX self-review must confirm multi-line + function/lambda/callback/block `}` is on its own line and never at the end + of the previous line. +- The TypeScript/TSX self-review must confirm array `]` is not at the beginning of a line. -- The TSX self-review must confirm object/type/import/destructuring `}` is not - at the beginning of a line. -- The TSX self-review must confirm multi-line function/lambda/callback/block - `}` is on its own line and never at the end of the previous line. -- The TSX self-review must confirm array `]` is not at the beginning of a line. -- The TSX self-review must confirm JSX closing markers and closing parentheses - keep the surrounding compact style. -- The TSX self-review must confirm leading indentation follows 4-space logical - indentation with tabs only as leading 8-space compression. +- The TSX-specific self-review must confirm JSX closing markers and closing + parentheses keep the surrounding compact style. +- The TypeScript/TSX self-review must confirm leading indentation follows + 4-space logical indentation with tabs only as leading 8-space compression. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward. @@ -699,10 +706,11 @@ Good: Rule: JavaScript object braces on one line get one inner space. JSX expression braces do not get inner spaces. -#### Final TSX self-review checklist +#### Final TypeScript/TSX self-review checklist Before reporting completion after a TypeScript or TSX edit, check the edited -hunks line by line: +hunks line by line. Unless a step explicitly mentions JSX, it applies equally +to `.ts` and `.tsx`: 1. No import/export/type/object/destructuring `}` appears alone at the beginning of a line. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 4d0178d..cddc89b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -122,37 +122,44 @@ pass or the remaining failure is clearly blocked. if the class cannot be statically detected. - Do not introduce new UI libraries or production dependencies without approval. -## TSX formatting +## TypeScript and TSX formatting +- The delimiter-placement and line-breaking rules in this section apply to + both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says + it is JSX- or React-specific. - Preserve compact TSX expression shapes such as inline ternary branches and closing `
)` forms when nearby code uses them. -- Treat TSX formatting rules as hard constraints, not preferences. Before - finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` - placement and fix violations instead of relying on formatter defaults. -- After every TSX edit, perform a style-only self-review of the edited hunks - before running verification or reporting completion. The task is not complete - while any edited TSX hunk violates these local formatting rules. -- The TSX self-review must classify every edited leading or trailing `)`, `]`, - and `}` by syntax role before deciding whether it is valid. Do not apply a - rule by glyph alone. A closing `)` for a function parameter list is different - from a closing `)` for a function call. A closing `}` for a block is different - from a closing `}` for an object, type literal, import list, or destructuring - pattern. -- The TSX self-review must confirm there are no common Prettier-style React - component declarations with a multi-line destructured parameter. -- The TSX self-review must confirm multi-line function declaration parameter - `)` placement follows the detailed parameter-list rules below. -- The TSX self-review must confirm call-expression `)` is never at the +- Treat TypeScript and TSX formatting rules as hard constraints, not + preferences. Before finishing a TypeScript or TSX edit, inspect the edited + hunks for closing `)`, `]`, and `}` placement and fix violations instead of + relying on formatter defaults. +- After every TypeScript or TSX edit, perform a style-only self-review of the + edited hunks before running verification or reporting completion. The task is + not complete while any edited TypeScript or TSX hunk violates these local + formatting rules. +- The TypeScript/TSX self-review must classify every edited leading or trailing + `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not + apply a rule by glyph alone. A closing `)` for a function parameter list is + different from a closing `)` for a function call. A closing `}` for a block + is different from a closing `}` for an object, type literal, import list, or + destructuring pattern. +- The TSX-specific self-review must confirm there are no common Prettier-style + React component declarations with a multi-line destructured parameter. +- The TypeScript/TSX self-review must confirm multi-line function declaration + parameter `)` placement follows the detailed parameter-list rules below. +- The TypeScript/TSX self-review must confirm call-expression `)` is never at + the beginning of a line. +- The TypeScript/TSX self-review must confirm + object/type/import/destructuring `}` is not at the beginning of a line. +- The TypeScript/TSX self-review must confirm multi-line + function/lambda/callback/block `}` is on its own line and never at the end + of the previous line. +- The TypeScript/TSX self-review must confirm array `]` is not at the beginning of a line. -- The TSX self-review must confirm object/type/import/destructuring `}` is not - at the beginning of a line. -- The TSX self-review must confirm multi-line function/lambda/callback/block - `}` is on its own line and never at the end of the previous line. -- The TSX self-review must confirm array `]` is not at the beginning of a line. -- The TSX self-review must confirm JSX closing markers and closing parentheses - keep the surrounding compact style. -- The TSX self-review must confirm leading indentation follows 4-space logical - indentation with tabs only as leading 8-space compression. +- The TSX-specific self-review must confirm JSX closing markers and closing + parentheses keep the surrounding compact style. +- The TypeScript/TSX self-review must confirm leading indentation follows + 4-space logical indentation with tabs only as leading 8-space compression. - For long Tailwind `className` strings, wrap across lines only when needed. - Keep continuation indentation aligned with the 4-space logical indentation rule, using tabs only as leading 8-space compression. @@ -215,7 +222,8 @@ pass or the remaining failure is clearly blocked. ### Delimiter decision table Use this table before accepting any edited TypeScript or TSX hunk. The table is -more authoritative than formatter habit. +more authoritative than formatter habit. Unless a subsection explicitly +mentions JSX, it applies equally to `.ts` and `.tsx`. #### Import and export named bindings diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 0f611d6..2b41409 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -208,12 +208,12 @@ const TopNav: FC = ({ user }) => { return ( <> -
操作 適用範囲 現在のキー変更既定に戻す 競合状態
+ layoutId={animationMode === 'off' + ? undefined + : `page-${ row.id }`} + transition={thumbnailTransition}> {row.title { const NicoTagListPage: FC = ({ user }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const dialogue = useDialogue () const location = useLocation () const navigate = useNavigate () @@ -152,8 +156,9 @@ const NicoTagListPage: FC = ({ user }) => { setLinkStatus (qLinkStatus) setEditingId (null) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qLinkedTag, qLinkStatus, qName]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qLinkedTag, qLinkStatus, qName]) useEffect (() => { if (!(data)) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index 8ce1d1c..f95d2ae 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -11,8 +11,10 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { CATEGORY_NAMES } from '@/consts' import { apiPut } from '@/lib/api' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTagChanges } from '@/lib/tags' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString } from '@/lib/utils' import type { FC } from 'react' @@ -43,6 +45,8 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null }) const TagHistoryPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const query = new URLSearchParams (location.search) const id = query.get ('id') @@ -58,8 +62,9 @@ const TagHistoryPage: FC = () => { const qc = useQueryClient () useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) return ( diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index f6c9fc6..568c5d6 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -13,6 +13,7 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { tagsKeys } from '@/lib/queryKeys' import { DEFAULT_POST_LIST_LIMIT, @@ -22,6 +23,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -71,6 +73,8 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => { const TagListPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const navigate = useNavigate () @@ -145,9 +149,11 @@ const TagListPage: FC = () => { setUpdatedTo (qUpdatedTo) setDeprecated (qDeprecated) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, - qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo, + qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo, + qDeprecated]) const handleSearch = (e: FormEvent) => { e.preventDefault () diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 43a7e8b..1c99361 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -15,6 +15,8 @@ import { Button } from '@/components/ui/button' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' +import { clientAnimationTransition, + clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPost } from '@/lib/posts' import { getClientTheatreLayoutMode, @@ -22,6 +24,7 @@ import { setClientTheatreLayoutMode, setClientTheatreTagFlow, } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { cn, dateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -212,6 +215,12 @@ type Props = { user: User | null } const TheatreDetailPage: FC = ({ user }: Props) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const { id } = useParams () const dialogue = useDialogue () @@ -618,7 +627,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { clearValidationErrors () await apiPost (`/theatres/${ id }/comments`, { content }) setContent ('') - commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) + commentsRef.current?.scrollTo ({ + top: 0, + behavior: clientScrollBehaviour (animationMode) }) } catch (error) { @@ -835,8 +846,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return ( @@ -860,7 +871,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { )}
-- 2.34.1 From 6d8cab98223ab508d6e47fafa3b622a91762db74 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 7 Jul 2026 00:07:12 +0900 Subject: [PATCH 29/30] #34 --- backend/db/schema.rb | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 9df940f..eb54633 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id" + t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -274,9 +275,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at" - t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -285,13 +287,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" + t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" end create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false t.float "cos", null: false - t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -347,6 +350,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" end create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -364,6 +369,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["url"], name: "index_posts_on_url", unique: true t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" + t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -377,6 +383,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["user_id"], name: "index_settings_on_user_id", unique: true end + create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.integer "no", null: false + t.string "alt_text" + t.binary "sha256", limit: 32, null: false + t.bigint "created_by_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true + t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true + end + create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "parent_tag_id", null: false @@ -412,7 +431,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -428,9 +447,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at" - t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -446,6 +466,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" + t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -599,11 +620,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" + t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -648,6 +671,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" @@ -729,6 +754,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" add_foreign_key "user_theme_slots", "users" + add_foreign_key "wiki_assets", "users", column: "created_by_user_id" + add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" -- 2.34.1 From 3898fbc156126d4e3e54ab92a66103b48c9fbcae Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 7 Jul 2026 00:38:18 +0900 Subject: [PATCH 30/30] #34 --- backend/spec/models/setting_spec.rb | 34 ++++++ backend/spec/models/user_theme_slot_spec.rb | 40 +++++++ backend/spec/requests/user_settings_spec.rb | 98 ++++++++++++++++ .../spec/requests/user_theme_slots_spec.rb | 104 +++++++++++++++++ .../src/components/common/TabGroup.test.tsx | 12 ++ frontend/src/lib/keyboardShortcuts.test.ts | 65 +++++++++++ frontend/src/lib/settings.test.ts | 108 ++++++++++++++++++ frontend/src/lib/useKeyboardShortcuts.tsx | 21 +++- .../src/pages/posts/PostListPage.test.tsx | 41 ++++++- frontend/src/pages/posts/PostNewPage.test.tsx | 10 +- .../src/pages/posts/PostSearchPage.test.tsx | 40 +++++++ .../src/pages/tags/NicoTagListPage.test.tsx | 2 + frontend/src/pages/tags/TagListPage.test.tsx | 51 ++++++++- .../pages/theatres/TheatreDetailPage.test.tsx | 1 + frontend/src/pages/users/SettingPage.test.tsx | 30 +++-- frontend/src/pages/users/SettingPage.tsx | 9 +- frontend/src/test/render.tsx | 18 ++- frontend/src/test/setup.ts | 7 ++ 18 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 backend/spec/models/setting_spec.rb create mode 100644 backend/spec/models/user_theme_slot_spec.rb create mode 100644 backend/spec/requests/user_settings_spec.rb create mode 100644 backend/spec/requests/user_theme_slots_spec.rb create mode 100644 frontend/src/lib/keyboardShortcuts.test.ts create mode 100644 frontend/src/lib/settings.test.ts diff --git a/backend/spec/models/setting_spec.rb b/backend/spec/models/setting_spec.rb new file mode 100644 index 0000000..41ae262 --- /dev/null +++ b/backend/spec/models/setting_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Setting, type: :model do + it 'accepts the default typed user settings' do + setting = described_class.new({ user: create(:user) }.merge(described_class.defaults)) + + expect(setting).to be_valid + end + + it 'requires one settings row per user' do + user = create(:user) + described_class.create!({ user: }.merge(described_class.defaults)) + + duplicate = described_class.new({ user: }.merge(described_class.defaults)) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates enum-like settings columns' do + setting = described_class.new( + { user: create(:user), + theme: 'neon', + auto_fetch_title: 'sometimes', + auto_fetch_thumbnail: 'sometimes', + wiki_editor_mode: 'sideways' }) + + expect(setting).not_to be_valid + expect(setting.errors[:theme]).to be_present + expect(setting.errors[:auto_fetch_title]).to be_present + expect(setting.errors[:auto_fetch_thumbnail]).to be_present + expect(setting.errors[:wiki_editor_mode]).to be_present + end +end diff --git a/backend/spec/models/user_theme_slot_spec.rb b/backend/spec/models/user_theme_slot_spec.rb new file mode 100644 index 0000000..9ca4a2c --- /dev/null +++ b/backend/spec/models/user_theme_slot_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe UserThemeSlot, type: :model do + it 'accepts a theme slot for one user, base theme, and slot number' do + slot = described_class.new(user: create(:user), + base_theme: 'light', + slot_no: 1, + tokens: { 'background' => '0 0% 100%' }) + + expect(slot).to be_valid + end + + it 'requires unique slot numbers per user and base theme' do + user = create(:user) + described_class.create!(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '222.2 84% 4.9%' }) + + duplicate = described_class.new(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '0 0% 100%' }) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates base theme, slot number, and token object shape' do + slot = described_class.new(user: create(:user), + base_theme: 'system', + slot_no: 4, + tokens: 'not-object') + + expect(slot).not_to be_valid + expect(slot.errors[:base_theme]).to be_present + expect(slot.errors[:slot_no]).to be_present + expect(slot.errors[:tokens]).to be_present + end +end diff --git a/backend/spec/requests/user_settings_spec.rb b/backend/spec/requests/user_settings_spec.rb new file mode 100644 index 0000000..74a7661 --- /dev/null +++ b/backend/spec/requests/user_settings_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe 'user settings', type: :request do + describe 'GET /users/settings' do + it 'requires a current user' do + sign_out + + get '/users/settings' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns defaults and creates a missing settings row' do + user = create(:user) + sign_in_as(user) + + expect { + get '/users/settings' + }.to change(Setting, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to eq( + 'theme' => 'system', + 'auto_fetch_title' => 'manual', + 'auto_fetch_thumbnail' => 'manual', + 'wiki_editor_mode' => 'split') + expect(user.reload.setting).to be_present + end + end + + describe 'PATCH /users/settings' do + it 'requires a current user' do + sign_out + + patch '/users/settings', params: { theme: 'dark' }, as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates multiple typed settings at once' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'theme' => 'dark', + 'auto_fetch_title' => 'auto', + 'auto_fetch_thumbnail' => 'off', + 'wiki_editor_mode' => 'preview') + expect(user.reload.setting).to have_attributes( + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview') + end + + it 'does not treat Rails parameter wrapping as an editable setting' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { theme: 'light', user_setting: { theme: 'dark' } }, + as: :json + + expect(response).to have_http_status(:ok) + expect(user.reload.setting.theme).to eq('light') + end + + it 'returns validation_error for type mismatches' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 1 }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors')).to include( + 'theme' => ['値の型が不正です.']) + end + + it 'returns validation_error for invalid enum values' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 'neon' }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors').fetch('theme')).to be_present + end + end +end diff --git a/backend/spec/requests/user_theme_slots_spec.rb b/backend/spec/requests/user_theme_slots_spec.rb new file mode 100644 index 0000000..404cde9 --- /dev/null +++ b/backend/spec/requests/user_theme_slots_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe 'user theme slots', type: :request do + describe 'GET /users/theme_slots' do + it 'requires a current user' do + sign_out + + get '/users/theme_slots' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns only the current user slots in stable order' do + user = create(:user) + other_user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 }) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 }) + UserThemeSlot.create!(user: other_user, + base_theme: 'dark', + slot_no: 3, + tokens: { 'c' => 3 }) + + get '/users/theme_slots' + + expect(response).to have_http_status(:ok) + expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq( + [['dark', 1], ['light', 2]]) + expect(json.map { |slot| slot.fetch('tokens') }).to eq( + [{ 'b' => 2 }, { 'a' => 1 }]) + end + end + + describe 'PUT /users/theme_slots/:base_theme/:slot_no' do + it 'requires a current user' do + sign_out + + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%' } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a theme slot' do + user = create(:user) + sign_in_as(user) + + expect { + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } }, + as: :json + }.to change(UserThemeSlot, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to include('base_theme' => 'light', 'slot_no' => 1) + expect(json.fetch('tokens')).to include( + 'background' => '0 0% 100%', + 'tagColours' => { 'nico' => '#ffffff' }) + expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include( + 'background' => '0 0% 100%') + end + + it 'updates an existing theme slot instead of creating a duplicate' do + user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true }) + + expect { + put '/users/theme_slots/dark/2', + params: { tokens: { background: '222.2 84% 4.9%' } }, + as: :json + }.not_to change(UserThemeSlot, :count) + + expect(response).to have_http_status(:ok) + expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq( + 'background' => '222.2 84% 4.9%') + end + + it 'rejects invalid path parameters and token shapes' do + sign_in_as(create(:user)) + + put '/users/theme_slots/system/4', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'base_theme' => ['値が不正です.']) + end + + it 'rejects non-object tokens for valid slots' do + sign_in_as(create(:user)) + + put '/users/theme_slots/light/1', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'tokens' => ['JSON object で指定してください.']) + end + end +end diff --git a/frontend/src/components/common/TabGroup.test.tsx b/frontend/src/components/common/TabGroup.test.tsx index 424be1c..ae5cdd6 100644 --- a/frontend/src/components/common/TabGroup.test.tsx +++ b/frontend/src/components/common/TabGroup.test.tsx @@ -20,4 +20,16 @@ describe ('TabGroup', () => { expect (screen.getByText ('Alpha')).toBeInTheDocument () expect (screen.queryByText ('Beta')).not.toBeInTheDocument () }) + + it ('renders tabs as buttons and shows the right addon', () => { + render ( + 件数 20}> + Plaza + , + ) + + expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument () + expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument () + expect (screen.getByText ('件数 20')).toBeInTheDocument () + }) }) diff --git a/frontend/src/lib/keyboardShortcuts.test.ts b/frontend/src/lib/keyboardShortcuts.test.ts new file mode 100644 index 0000000..a436fad --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_KEY_BINDINGS, + formatKeyBinding, + getConflictingShortcutActionIds, + isEditableEventTarget, + keyBindingFromKeyboardEvent, + resolveShortcutScope, + SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts' + +describe ('keyboardShortcuts', () => { + it ('defines the default key bindings used by the settings screen', () => { + expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({ + label: 'ショートカット一覧を開く', + scope: 'global', + defaultBinding: { key: '?', shift: true } }) + expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' }) + expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' }) + expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' }) + }) + + it ('formats key bindings for display', () => { + expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?') + expect (formatKeyBinding ({ key: '/' })).toBe ('/') + expect (formatKeyBinding ({ key: 'p' })).toBe ('P') + expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight') + expect (formatKeyBinding (null)).toBe ('未割当') + }) + + it ('normalises keyboard events without assigning space by default', () => { + const event = { key: 'P', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false } as KeyboardEvent + + expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' }) + }) + + it ('detects conflicting action bindings', () => { + const conflicts = getConflictingShortcutActionIds ({ + ...DEFAULT_KEY_BINDINGS, + 'navigation.posts': { key: 'p' }, + 'navigation.tags': { key: 'p' } }) + + expect (conflicts.has ('navigation.posts')).toBe (true) + expect (conflicts.has ('navigation.tags')).toBe (true) + expect (conflicts.has ('navigation.wiki')).toBe (false) + }) + + it ('resolves route scopes and editable event targets', () => { + const input = document.createElement ('input') + const div = document.createElement ('div') + div.setAttribute ('role', 'textbox') + + expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch') + expect (resolveShortcutScope ('/users/settings')).toBe ('settings') + expect (resolveShortcutScope ('/materials/1')).toBe ('materials') + expect (isEditableEventTarget (input)).toBe (true) + expect (isEditableEventTarget (div)).toBe (true) + expect (isEditableEventTarget (document.createElement ('button'))).toBe (false) + }) +}) diff --git a/frontend/src/lib/settings.test.ts b/frontend/src/lib/settings.test.ts new file mode 100644 index 0000000..9ec1d66 --- /dev/null +++ b/frontend/src/lib/settings.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { applyClientAnimationMode, + applyThemeTokens, + buildThemeTokens, + CLIENT_SETTINGS_STORAGE_KEY, + DARK_THEME_TOKENS, + DEFAULT_POST_LIST_ORDER, + getClientListLimit, + getClientListOrder, + getClientThemeMode, + LIGHT_THEME_TOKENS, + loadClientSettings, + setClientListSettings, + setClientThemeAppearance } from '@/lib/settings' + +import type { FetchPostsOrder } from '@/types' + +const installMatchMedia = (matches: boolean) => { + Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + matches, + addEventListener: vi.fn (), + removeEventListener: vi.fn () })) }) +} + +describe ('settings', () => { + beforeEach (() => { + localStorage.clear () + document.documentElement.removeAttribute ('data-animation') + document.documentElement.removeAttribute ('style') + document.documentElement.className = '' + installMatchMedia (false) + }) + + it ('stores list settings under the shared client settings key', () => { + setClientListSettings ('postSearch', { + limit: 50, + order: 'title:asc' }) + + expect (getClientListLimit ('postSearch')).toBe (50) + expect (getClientListOrder ('postSearch')).toBe ('title:asc') + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } }) + }) + + it ('ignores invalid list limits and keeps stored order strings', () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } })) + + expect (getClientListLimit ('postList')).toBeNull () + expect (getClientListOrder ('postList')).toBe ( + DEFAULT_POST_LIST_ORDER) + }) + + it ('stores theme mode and light/dark slot selections in client settings', () => { + setClientThemeAppearance ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + + expect (getClientThemeMode ()).toBe ('dark') + expect (loadClientSettings ().appearance).toMatchObject ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + }) + + it ('applies animation mode to the document dataset', () => { + applyClientAnimationMode ('off') + expect (document.documentElement.dataset.animation).toBe ('off') + + applyClientAnimationMode ('reduced') + expect (document.documentElement.dataset.animation).toBe ('reduced') + + applyClientAnimationMode ('normal') + expect (document.documentElement.dataset.animation).toBeUndefined () + }) + + it ('normalises TopNav menu link tokens to complete CSS colours', () => { + const tokens = buildThemeTokens ('light', { + link: '221.2 83.2% 53.3%', + topNavMenuLink: '221.2 83.2% 53.3%' }) + + expect (tokens.link).toBe ('221.2 83.2% 53.3%') + expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)') + }) + + it ('derives TopNav colours without mixing mobile active backgrounds', () => { + expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe ( + LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop) + expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe ( + DARK_THEME_TOKENS.topNavActiveBackground) + }) + + it ('sets shadcn tokens and TopNav tokens with their different value formats', () => { + const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' }) + + applyThemeTokens (tokens) + + expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe ( + '213.1 93.9% 67.8%') + expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe ( + 'hsl(213.1 93.9% 67.8%)') + }) +}) diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 71c1f0f..cc650bd 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -291,10 +291,27 @@ export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { const { registerHandlers } = useKeyboardShortcutSettings () + const handlersRef = useRef (handlers) + const actionIdsKey = Object.keys (handlers).sort ().join ('|') + + useEffect (() => { + handlersRef.current = handlers + }, [handlers]) + + const stableHandlers = useMemo (() => { + return actionIdsKey.split ('|').filter (actionId => actionId !== '') + .reduce ((nextHandlers, actionId) => { + const key = actionId as ShortcutActionId + nextHandlers[key] = () => { + handlersRef.current[key]?.() + } + return nextHandlers + }, { }) + }, [actionIdsKey]) useEffect ( - () => registerHandlers (handlers), - [handlers, registerHandlers], + () => registerHandlers (stableHandlers), + [registerHandlers, stableHandlers], ) } diff --git a/frontend/src/pages/posts/PostListPage.test.tsx b/frontend/src/pages/posts/PostListPage.test.tsx index fb75b8c..3a3bd0d 100644 --- a/frontend/src/pages/posts/PostListPage.test.tsx +++ b/frontend/src/pages/posts/PostListPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import PostListPage from '@/pages/posts/PostListPage' import { buildPost, buildTag, buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), @@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/wiki', () => wikiApi) describe ('PostListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads posts from the current query and renders the plaza tab', async () => { const tag = buildTag ({ name: '虹夏' }) postsApi.fetchPosts.mockResolvedValueOnce ({ @@ -36,6 +42,7 @@ describe ('PostListPage', () => { match: 'all', page: 2, limit: 20, + order: 'original_created_at:desc', }), ) }) @@ -71,4 +78,36 @@ describe ('PostListPage', () => { '/wiki/%E8%99%B9%E5%A4%8F', ) }) + + it ('uses stored list limit but keeps the fixed plaza order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts' }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ + limit: 50, + order: 'original_created_at:desc', + }), + ) + }) + }) + + it ('updates the limit from the tab right addon and resets to page 1', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts?page=3' }) + + fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index 1402ed0..066e796 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -30,16 +30,12 @@ describe ('PostNewPage', () => { expect (screen.getByText ('403')).toBeInTheDocument () }) - it ('submits a new post with manual title and thumbnail settings', async () => { + it ('submits a new post with manual title and thumbnail fetch UI', async () => { api.apiPost.mockResolvedValueOnce ({}) api.apiGet.mockResolvedValue ([]) renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) @@ -97,10 +93,6 @@ describe ('PostNewPage', () => { renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) diff --git a/frontend/src/pages/posts/PostSearchPage.test.tsx b/frontend/src/pages/posts/PostSearchPage.test.tsx index d406666..06e9127 100644 --- a/frontend/src/pages/posts/PostSearchPage.test.tsx +++ b/frontend/src/pages/posts/PostSearchPage.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import PostSearchPage from '@/pages/posts/PostSearchPage' import { buildPost, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), @@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api) describe ('PostSearchPage', () => { beforeEach (() => { + localStorage.clear () vi.clearAllMocks () api.apiGet.mockResolvedValue ([]) }) @@ -40,6 +42,7 @@ describe ('PostSearchPage', () => { tags: 'x', match: 'any', page: 2, + limit: 20, }), ) }) @@ -81,4 +84,41 @@ describe ('PostSearchPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and stores the active list settings', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders ( + , + { route: '/posts/search?limit=50&order=created_at%3Adesc' }, + ) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } }) + }) + + it ('moves the compact limit control outside the search form and resets page on change', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts/search?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/tags/NicoTagListPage.test.tsx b/frontend/src/pages/tags/NicoTagListPage.test.tsx index c5efcd3..7d0d740 100644 --- a/frontend/src/pages/tags/NicoTagListPage.test.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.test.tsx @@ -6,6 +6,7 @@ import { dateString } from '@/lib/utils' import { buildTag, buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import type { ReactNode } from 'react' import type { NicoTag } from '@/types' const api = vi.hoisted (() => ({ @@ -27,6 +28,7 @@ const scrollIntoView = vi.fn () vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index f13f3eb..3d0e9ae 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import TagListPage from '@/pages/tags/TagListPage' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const tagsApi = vi.hoisted (() => ({ fetchTags: vi.fn (), @@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({ vi.mock ('@/lib/tags', () => tagsApi) describe ('TagListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads tags from URL filters and renders the results table', async () => { tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [buildTag ({ @@ -38,6 +44,8 @@ describe ('TagListPage', () => { name: '虹', category: 'character', page: 3, + limit: 20, + order: 'post_count:desc', postCountGTE: 5, deprecated: true, }), @@ -48,7 +56,7 @@ describe ('TagListPage', () => { '/tags/7', ) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) - expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1') + expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') expect (screen.getAllByText ('廃止')).toHaveLength (2) }) @@ -58,7 +66,7 @@ describe ('TagListPage', () => { renderWithProviders (, { route: '/tags' }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) - fireEvent.change (screen.getAllByRole ('combobox')[0], { + fireEvent.change (screen.getAllByRole ('combobox')[1], { target: { value: 'character' }, }) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) @@ -82,4 +90,41 @@ describe ('TagListPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and preserves the query order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } })) + tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 }) + + renderWithProviders ( + , + { route: '/tags?limit=50&order=updated_at%3Adesc' }, + ) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } }) + }) + + it ('keeps the compact limit control outside the search form and resets page on change', async () => { + tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) + + renderWithProviders (, { route: '/tags?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 745ea4e..2023a4e 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) vi.mock ('@/components/PostEmbed', () => ({ diff --git a/frontend/src/pages/users/SettingPage.test.tsx b/frontend/src/pages/users/SettingPage.test.tsx index 0595642..d88798a 100644 --- a/frontend/src/pages/users/SettingPage.test.tsx +++ b/frontend/src/pages/users/SettingPage.test.tsx @@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ + apiGet: vi.fn (), apiPut: vi.fn (), isApiError: vi.fn (), })) @@ -27,12 +28,23 @@ describe ('SettingPage', () => { beforeEach (() => { vi.clearAllMocks () api.isApiError.mockReturnValue (false) + api.apiGet.mockImplementation ((path: string) => { + if (path === '/users/settings') + return Promise.resolve ({ + theme: 'system', + auto_fetch_title: 'manual', + auto_fetch_thumbnail: 'manual', + wiki_editor_mode: 'split' }) + if (path === '/users/theme_slots') + return Promise.resolve ([]) + return Promise.resolve ({}) + }) }) it ('shows loading when user is absent', () => { renderWithProviders () - expect (screen.getByText ('Loading...')).toBeInTheDocument () + expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0) }) it ('updates the current user name', async () => { @@ -42,8 +54,9 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: 'new' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( @@ -55,7 +68,7 @@ describe ('SettingPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('name')).toBe ('new') expect (setUser).toHaveBeenCalled () - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' }) }) it ('shows validation errors returned for the name field', async () => { @@ -75,10 +88,11 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: '' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) - expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument () - expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true') + expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0) + expect (nameInput).toHaveAttribute ('aria-invalid', 'true') }) }) diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index a72b6ab..5619a6e 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom' @@ -735,6 +735,9 @@ const SettingPage: FC = ({ user, setUser }) => { state: SettingsTabDirtyState, ) => { setDirtyStates (current => { + if (!(state.dirty) && current[tab] == null) + return current + const next = { ...current } if (!(state.dirty)) @@ -827,7 +830,7 @@ const SettingPage: FC = ({ user, setUser }) => { }, nextThemeSlots) } - const discardAllDirtyChanges = async (): Promise => { + const discardAllDirtyChanges = useCallback (async (): Promise => { const currentDirtyStates = Object.values (dirtyStates) for (const dirtyState of currentDirtyStates) @@ -837,7 +840,7 @@ const SettingPage: FC = ({ user, setUser }) => { } setDirtyStates ({ }) - } + }, [dirtyStates]) const discardCurrentTabChanges = async (): Promise => { const currentDirtyState = dirtyStates[activeTab] diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx index c768200..ce8a1d9 100644 --- a/frontend/src/test/render.tsx +++ b/frontend/src/test/render.tsx @@ -3,6 +3,10 @@ import { render } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter } from 'react-router-dom' +import DialogueProvider from '@/components/dialogues/DialogueProvider' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' + import type { ReactElement, ReactNode } from 'react' type Options = { @@ -15,21 +19,25 @@ export const renderWithProviders = ( ) => { const queryClient = new QueryClient ({ defaultOptions: { - queries: { retry: false }, - }, + queries: { retry: false } }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + + + {children} + + + ) return { queryClient, - ...render (ui, { wrapper: Wrapper }), - } + ...render (ui, { wrapper: Wrapper }) } } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 12f1e30..ef2725c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => { callback (0) return 0 } + +Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + addEventListener: vi.fn (), + removeEventListener: vi.fn (), + matches: false })) }) -- 2.34.1