このコミットが含まれているのは:
2026-07-04 21:47:16 +09:00
コミット 518c5fa0f2
16個のファイルの変更840行の追加598行の削除
+8 -7
ファイルの表示
@@ -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] = ['値の型が不正です.']
+2 -40
ファイルの表示
@@ -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',
+3 -5
ファイルの表示
@@ -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
-22
ファイルの表示
@@ -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,
生成ファイル
-6
ファイルの表示
@@ -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
-5
ファイルの表示
@@ -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<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const { settings } = useUserSettings ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -44,9 +42,6 @@ const PostList: FC<Props> = ({ 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 }}
+17 -1
ファイルの表示
@@ -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 (<TagInput value="" setValue={setValue} includeNico={true}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
)
})
})
})
+9 -4
ファイルの表示
@@ -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<Props> = ({ describedBy, invalid, value, setValue }) => {
const TagInput: FC<Props> = ({
includeNico = false,
describedBy,
invalid,
value,
setValue,
}) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const { settings } = useUserSettings ()
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
@@ -36,7 +41,7 @@ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const data = await apiGet<Tag[]> ('/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)
-5
ファイルの表示
@@ -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<ContextValue> (() => ({
error,
loaded,
+92 -70
ファイルの表示
@@ -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<Record<ClientPaneBreakpoint, number>>
collapsed?: boolean }
type ClientListSettings = {
limit?: ClientListLimit
order?: string }
// Browser-local settings. These depend on device or screen context.
export type ClientSettings = {
panes?: Record<string, ClientPaneSettings>
theatre?: {
panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
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<UserSettings> =>
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<UserSettings> => await apiPatch<UserSettings> ('/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 = <T extends string>(
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 (
+1 -1
ファイルの表示
@@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => {
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
+93 -8
ファイルの表示
@@ -1,34 +1,66 @@
import { useQuery } from '@tanstack/react-query'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } 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 FormField from '@/components/common/FormField'
import Pagination from '@/components/common/Pagination'
import TabGroup, { Tab } from '@/components/common/TabGroup'
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 {
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 { WikiPage } from '@/types'
import type { FetchPostsOrder, WikiPage } from '@/types'
const postOrderLabel: Record<FetchPostsOrder, string> = {
'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<HTMLDivElement | null> (null)
const { settings } = useUserSettings ()
const [wikiPage, setWikiPage] = useState<WikiPage | null> (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<FetchPostsOrder> ('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 = () => {
}}/>
<MainArea>
<div className="mb-4 flex flex-wrap gap-4 rounded-xl border border-border bg-background/80 p-4">
<FormField label="表示件数">
{() => (
<select
className={inputClass (false)}
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>)}
</FormField>
<FormField label="並び順">
{() => (
<select
className={inputClass (false)}
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchPostsOrder,
})}>
{POST_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{postOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
<TabGroup>
<Tab name="広場">
{posts.length > 0
+50 -21
ファイルの表示
@@ -49,13 +49,14 @@ const PostNewPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>
{titleAutoFlg
? 'URL 入力時に自動取得します.'
: titleFetchMode === 'manual'
? '自動取得しません.必要なら手動で取得できます.'
: '取得機能は無効です.'}
: '取得機能は無効です.'}
</span>
{titleFetchVisible && !(titleAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>)}
{titleFetchVisible && (
<>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={titleAutoFlg}
onChange={ev => setTitleAutoFlg (ev.target.checked)}/>
<span></span>
</label>
{!(titleAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>)}
</>)}
</div>
</div>)}
</FormField>
@@ -220,16 +239,26 @@ const PostNewPage: FC<Props> = ({ user }) => {
? 'URL 入力時に自動取得します.'
: thumbnailFetchMode === 'manual'
? '自動取得しません.必要なら手動で取得できます.'
: '取得機能は無効です.'}
: '取得機能は無効です.'}
</span>
{thumbnailFetchVisible && !(thumbnailAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>)}
{thumbnailFetchVisible && (
<>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={thumbnailAutoFlg}
onChange={ev => setThumbnailAutoFlg (ev.target.checked)}/>
<span></span>
</label>
{!(thumbnailAutoFlg) && (
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>)}
</>)}
</div>
{thumbnailAutoFlg
? (thumbnailLoading
+90 -11
ファイルの表示
@@ -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<FetchPostsOrder, string> = {
'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<FetchPostsOrder> ('postSearch')
?? DEFAULT_POST_LIST_ORDER
)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (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 = () => {
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
<FormField label="表示件数">
{() => (
<select
className={inputClass (false)}
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>)}
</FormField>
<FormField label="並び順">
{() => (
<select
className={inputClass (false)}
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchPostsOrder,
})}>
{POST_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{postOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
{/* タイトル */}
<FormField label="タイトル">
{({ invalid }) => (
@@ -165,6 +247,7 @@ const PostSearchPage: FC = () => {
<FormField label="タグ">
{() => (
<TagInput
includeNico={true}
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
@@ -302,11 +385,7 @@ const PostSearchPage: FC = () => {
{results.map (row => (
<tr
key={row.id}
className={
settings.viewedPostDisplay === 'dim' && row.viewed
? 'even:bg-gray-100 opacity-40 saturate-50 dark:even:bg-gray-700'
: 'even:bg-gray-100 dark:even:bg-gray-700'
}>
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
+88 -2
ファイルの表示
@@ -14,6 +14,15 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
TAG_LIST_ORDER_OPTIONS,
} from '@/lib/settings'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -35,6 +44,29 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const tagOrderLabel: Record<FetchTagsOrder, string> = {
'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<FetchTagsOrder> ('tagList')
?? DEFAULT_TAG_LIST_ORDER
)
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (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 = () => {
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
<div className="grid gap-3 rounded-xl border border-border bg-background/80 p-3 sm:grid-cols-2">
<FormField label="表示件数">
{() => (
<select
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}
className={inputClass (false)}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>)}
</FormField>
<FormField label="並び順">
{() => (
<select
value={order}
onChange={ev => updateListQuery ({
order: ev.target.value as FetchTagsOrder,
})}
className={inputClass (false)}>
{TAG_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{tagOrderLabel[value]}
</option>))}
</select>)}
</FormField>
</div>
{/* 名前 */}
<FormField label="名前">
{({ invalid }) => (
+387 -390
ファイルの表示
@@ -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<Record<UserFormField, string[]>>
type SettingsFieldErrors = Partial<Record<SettingsFormField, string[]>>
type SharedSectionProps = {
draftSettings: UserSettings
name: string
nameBaseErrors: string[]
nameFieldErrors: NameFieldErrors
onDraftSettingsChange: Dispatch<SetStateAction<UserSettings>>
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<SettingsFormField, SettingsTab> = {
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<SettingsFormField, SettingsTab> = {
const settingsFieldOrder: SettingsFormField[] = [
'theme',
'displayDensity',
'fontSize',
'postListLimit',
'postListOrder',
'viewedPostDisplay',
'tagAutocompleteNico',
'autoFetchTitle',
'autoFetchThumbnail',
'wikiEditorMode',
@@ -96,43 +103,44 @@ const settingsFieldOrder: SettingsFormField[] = [
const themeLabel: Record<UserSettings['theme'], string> = {
system: 'システム設定に従う',
light: '',
dark: '暗' }
const densityLabel: Record<UserSettings['displayDensity'], string> = {
comfortable: '標準',
compact: '詰める' }
const fontSizeLabel: Record<UserSettings['fontSize'], string> = {
small: '小',
normal: '標準',
large: '大' }
const postListOrderLabel: Record<UserSettings['postListOrder'], string> = {
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<UserSettings['viewedPostDisplay'], string> = {
show: '通常表示',
dim: '薄く表示' }
light: 'ライト・モード',
dark: 'ダーク・モード',
}
const autoFetchLabel: Record<UserSettings['autoFetchTitle'], string> = {
auto: '自動',
manual: '手動',
off: '無効' }
off: '無効',
}
const wikiEditorModeLabel: Record<UserSettings['wikiEditorMode'], string> = {
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<SharedSectionProps> = ({
name,
nameBaseErrors,
nameFieldErrors,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={nameBaseErrors}/>
<FormField label="表示名" messages={nameFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}>
</Button>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
</Button>
</div>
</div>
</section>)
const ThemeSection: FC<SharedSectionProps> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="基本テーマ" messages={settingsFieldErrors.theme}>
{() => (
<select
className={inputClass (Boolean (settingsFieldErrors.theme?.length))}
value={draftSettings.theme}
onChange={ev => onDraftSettingsChange (current => ({
...current,
theme: ev.target.value as UserSettings['theme'],
}))}>
{THEME_OPTIONS.map (value => (
<option key={value} value={value}>
{themeLabel[value]}
</option>))}
</select>)}
</FormField>
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">
<p className="font-medium"></p>
<p>
`themes` `user_themes`
</p>
<p></p>
<div className="flex flex-wrap gap-2">
{themeColorTargets.map (target => (
<span
key={target}
className="rounded-full border border-border/70 px-2 py-1">
{target}
</span>))}
</div>
</div>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const KeyboardSection: FC = () => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">
<p>
`action / key / scope / conflict`
key bind
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[28rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{keyboardShortcutRows.map (row => (
<tr key={row.action} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.action}</td>
<td className="p-2 font-mono">{row.key}</td>
<td className="p-2">{row.scope}</td>
<td className="p-2">{row.conflict}</td>
</tr>))}
</tbody>
</table>
</div>
</section>)
const EditingSection: FC<SharedSectionProps> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="タイトル自動取得" messages={settingsFieldErrors.autoFetchTitle}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchTitle?.length),
)}
value={draftSettings.autoFetchTitle}
onChange={ev => onDraftSettingsChange (current => ({
...current,
autoFetchTitle: ev.target.value as UserSettings['autoFetchTitle'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField
label="サムネール自動取得"
messages={settingsFieldErrors.autoFetchThumbnail}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchThumbnail?.length),
)}
value={draftSettings.autoFetchThumbnail}
onChange={ev => onDraftSettingsChange (current => ({
...current,
autoFetchThumbnail:
ev.target.value as UserSettings['autoFetchThumbnail'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const WikiSection: FC<SharedSectionProps> = ({
draftSettings,
onDraftSettingsChange,
onSettingsSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold">Wiki</h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="エディタ表示" messages={settingsFieldErrors.wikiEditorMode}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.wikiEditorMode?.length),
)}
value={draftSettings.wikiEditorMode}
onChange={ev => onDraftSettingsChange (current => ({
...current,
wikiEditorMode: ev.target.value as UserSettings['wikiEditorMode'],
}))}>
{WIKI_EDITOR_MODE_OPTIONS.map (value => (
<option key={value} value={value}>
{wikiEditorModeLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={onSettingsSubmit}>
</Button>
</section>)
const SettingPage: FC<Props> = ({ user, setUser }) => {
const location = useLocation ()
const navigate = useNavigate ()
@@ -182,19 +427,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
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<Props> = ({ 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<Props> = ({ 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 (
<MainArea>
<Helmet>
@@ -312,322 +558,73 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<title> | {SITE_TITLE}</title>
</Helmet>
<Form>
<PageTitle></PageTitle>
<FieldError messages={error ? [error] : []}/>
<div className="space-y-4 p-4">
<div className="mx-auto max-w-xl space-y-4">
<PageTitle></PageTitle>
<FieldError messages={error ? [error] : []}/>
</div>
{user && loaded ? (
<>
<div
role="tablist"
aria-label="設定区分"
className="mb-4 flex flex-wrap gap-2">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
tabIndex={activeTab === tab.id ? 0 : -1}
className={cn (
'rounded-full border px-4 py-2 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'],
)}
onClick={() => setActiveTab (tab.id)}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
<div className="mx-auto max-w-xl space-y-6 md:hidden">
{sectionProps && (
<>
<AccountSection {...sectionProps}/>
<ThemeSection {...sectionProps}/>
<KeyboardSection/>
<EditingSection {...sectionProps}/>
<WikiSection {...sectionProps}/>
</>)}
</div>
<section
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}
className={sectionClassName}>
{activeTab === 'account' && (
<>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={nameBaseErrors}/>
<div className={desktopTabRailClassName}>
<div className={desktopTabRailInnerClassName}>
<div
role="tablist"
aria-label="設定区分"
aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
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'],
)}
onClick={() => setActiveTab (tab.id)}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
</div>
<FormField label="表示名" messages={nameFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleUserSubmit}>
</Button>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => setUserCodeVsbl (true)}
className="bg-gray-600 text-white"
disabled={!(user)}>
</Button>
<Button
type="button"
onClick={() => setInheritVsbl (true)}
className="bg-red-600 text-white"
disabled={!(user)}>
</Button>
</div>
</div>
</>)}
{activeTab === 'display' && (
<>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="テーマ" messages={settingsFieldErrors.theme}>
{() => (
<select
className={inputClass (Boolean (settingsFieldErrors.theme?.length))}
value={draftSettings.theme}
onChange={ev => setDraftSettings (current => ({
...current,
theme: ev.target.value as UserSettings['theme'],
}))}>
{THEME_OPTIONS.map (value => (
<option key={value} value={value}>
{themeLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField label="表示密度" messages={settingsFieldErrors.displayDensity}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.displayDensity?.length),
)}
value={draftSettings.displayDensity}
onChange={ev => setDraftSettings (current => ({
...current,
displayDensity:
ev.target.value as UserSettings['displayDensity'],
}))}>
{DISPLAY_DENSITY_OPTIONS.map (value => (
<option key={value} value={value}>
{densityLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField label="文字サイズ" messages={settingsFieldErrors.fontSize}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.fontSize?.length),
)}
value={draftSettings.fontSize}
onChange={ev => setDraftSettings (current => ({
...current,
fontSize: ev.target.value as UserSettings['fontSize'],
}))}>
{FONT_SIZE_OPTIONS.map (value => (
<option key={value} value={value}>
{fontSizeLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={handleSettingsSubmit}>
</Button>
</>)}
{activeTab === 'posts' && (
<>
<h2 className="text-xl font-bold">稿</h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="既定の件数" messages={settingsFieldErrors.postListLimit}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.postListLimit?.length),
)}
value={draftSettings.postListLimit}
onChange={ev => setDraftSettings (current => ({
...current,
postListLimit:
Number (ev.target.value) as UserSettings['postListLimit'],
}))}>
{POST_LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>)}
</FormField>
<FormField label="既定の並び順" messages={settingsFieldErrors.postListOrder}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.postListOrder?.length),
)}
value={draftSettings.postListOrder}
onChange={ev => setDraftSettings (current => ({
...current,
postListOrder:
ev.target.value as UserSettings['postListOrder'],
}))}>
{POST_LIST_ORDER_OPTIONS.map (value => (
<option key={value} value={value}>
{postListOrderLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField label="既読投稿の表示" messages={settingsFieldErrors.viewedPostDisplay}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.viewedPostDisplay?.length),
)}
value={draftSettings.viewedPostDisplay}
onChange={ev => setDraftSettings (current => ({
...current,
viewedPostDisplay:
ev.target.value as UserSettings['viewedPostDisplay'],
}))}>
{VIEWED_POST_DISPLAY_OPTIONS.map (value => (
<option key={value} value={value}>
{viewedPostDisplayLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={handleSettingsSubmit}>
</Button>
</>)}
{activeTab === 'editing' && (
<>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField
label="ニコニコタグを補完に含める"
messages={settingsFieldErrors.tagAutocompleteNico}>
{() => (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={draftSettings.tagAutocompleteNico}
onChange={ev => setDraftSettings (current => ({
...current,
tagAutocompleteNico: ev.target.checked,
}))}/>
<span></span>
</label>)}
</FormField>
<FormField label="タイトル自動取得" messages={settingsFieldErrors.autoFetchTitle}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchTitle?.length),
)}
value={draftSettings.autoFetchTitle}
onChange={ev => setDraftSettings (current => ({
...current,
autoFetchTitle:
ev.target.value as UserSettings['autoFetchTitle'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<FormField
label="サムネール自動取得"
messages={settingsFieldErrors.autoFetchThumbnail}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.autoFetchThumbnail?.length),
)}
value={draftSettings.autoFetchThumbnail}
onChange={ev => setDraftSettings (current => ({
...current,
autoFetchThumbnail:
ev.target.value as UserSettings['autoFetchThumbnail'],
}))}>
{AUTO_FETCH_OPTIONS.map (value => (
<option key={value} value={value}>
{autoFetchLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={handleSettingsSubmit}>
</Button>
</>)}
{activeTab === 'wiki' && (
<>
<h2 className="text-xl font-bold">Wiki</h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="エディタ表示" messages={settingsFieldErrors.wikiEditorMode}>
{() => (
<select
className={inputClass (
Boolean (settingsFieldErrors.wikiEditorMode?.length),
)}
value={draftSettings.wikiEditorMode}
onChange={ev => setDraftSettings (current => ({
...current,
wikiEditorMode:
ev.target.value as UserSettings['wikiEditorMode'],
}))}>
{WIKI_EDITOR_MODE_OPTIONS.map (value => (
<option key={value} value={value}>
{wikiEditorModeLabel[value]}
</option>))}
</select>)}
</FormField>
<Button type="button" onClick={handleSettingsSubmit}>
</Button>
</>)}
</section>
<div className="w-[36rem] max-w-full">
{sectionProps && (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && <KeyboardSection/>}
{activeTab === 'editing' && <EditingSection {...sectionProps}/>}
{activeTab === 'wiki' && <WikiSection {...sectionProps}/>}
</div>)}
</div>
</div>
</div>
</>) : 'Loading...'}
</Form>
</div>
<UserCodeDialogue
visible={userCodeVsbl}