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 => ( +
= { 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)}/>)}