このコミットが含まれているのは:
2026-07-04 21:47:16 +09:00
コミット 518c5fa0f2
16個のファイルの変更840行の追加598行の削除
+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}