このコミットが含まれているのは:
@@ -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] = ['値の型が不正です.']
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする