このコミットが含まれているのは:
@@ -1,4 +1,6 @@
|
|||||||
class UserSettingsController < ApplicationController
|
class UserSettingsController < ApplicationController
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
def show
|
def show
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
@@ -8,12 +10,12 @@ class UserSettingsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
raw_attributes = request.request_parameters
|
raw_attributes = editable_raw_attributes
|
||||||
field_errors = validate_raw_attributes(raw_attributes)
|
field_errors = validate_raw_attributes(raw_attributes)
|
||||||
return render_validation_error fields: field_errors if field_errors.present?
|
return render_validation_error fields: field_errors if field_errors.present?
|
||||||
|
|
||||||
setting = current_setting
|
setting = current_setting
|
||||||
setting.assign_attributes(raw_attributes.slice(*Setting::EDITABLE_ATTRIBUTES))
|
setting.assign_attributes(raw_attributes)
|
||||||
|
|
||||||
if setting.save
|
if setting.save
|
||||||
render json: setting.serializable_hash, status: :ok
|
render json: setting.serializable_hash, status: :ok
|
||||||
@@ -32,13 +34,12 @@ class UserSettingsController < ApplicationController
|
|||||||
Setting.find_by!(user: current_user)
|
Setting.find_by!(user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_raw_attributes raw_attributes
|
def editable_raw_attributes
|
||||||
raw_attributes.each_with_object({ }) do |(key, value), errors|
|
request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES)
|
||||||
unless Setting::EDITABLE_ATTRIBUTES.include?(key)
|
|
||||||
errors[key.to_sym] = ['不明な設定です.']
|
|
||||||
next
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_raw_attributes raw_attributes
|
||||||
|
raw_attributes.each_with_object({ }) do |(key, value), errors|
|
||||||
next if value_matches_type?(key, value)
|
next if value_matches_type?(key, value)
|
||||||
|
|
||||||
errors[key.to_sym] = ['値の型が不正です.']
|
errors[key.to_sym] = ['値の型が不正です.']
|
||||||
|
|||||||
@@ -1,46 +1,20 @@
|
|||||||
class Setting < ApplicationRecord
|
class Setting < ApplicationRecord
|
||||||
THEMES = ['system', 'light', 'dark'].freeze
|
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
|
AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze
|
||||||
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
|
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
|
||||||
|
|
||||||
STRING_ATTRIBUTES = [
|
STRING_ATTRIBUTES = [
|
||||||
'theme',
|
'theme',
|
||||||
'display_density',
|
|
||||||
'font_size',
|
|
||||||
'post_list_order',
|
|
||||||
'viewed_post_display',
|
|
||||||
'auto_fetch_title',
|
'auto_fetch_title',
|
||||||
'auto_fetch_thumbnail',
|
'auto_fetch_thumbnail',
|
||||||
'wiki_editor_mode',
|
'wiki_editor_mode',
|
||||||
].freeze
|
].freeze
|
||||||
INTEGER_ATTRIBUTES = ['post_list_limit'].freeze
|
INTEGER_ATTRIBUTES = [].freeze
|
||||||
BOOLEAN_ATTRIBUTES = ['tag_autocomplete_nico'].freeze
|
BOOLEAN_ATTRIBUTES = [].freeze
|
||||||
EDITABLE_ATTRIBUTES =
|
EDITABLE_ATTRIBUTES =
|
||||||
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
|
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
|
||||||
TYPE_BY_ATTRIBUTE = {
|
TYPE_BY_ATTRIBUTE = {
|
||||||
'theme' => :string,
|
'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_title' => :string,
|
||||||
'auto_fetch_thumbnail' => :string,
|
'auto_fetch_thumbnail' => :string,
|
||||||
'wiki_editor_mode' => :string,
|
'wiki_editor_mode' => :string,
|
||||||
@@ -52,12 +26,6 @@ class Setting < ApplicationRecord
|
|||||||
validates :user_id, uniqueness: true
|
validates :user_id, uniqueness: true
|
||||||
|
|
||||||
validates :theme, inclusion: { in: THEMES }
|
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_title, inclusion: { in: AUTO_FETCH_MODES }
|
||||||
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
|
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
|
||||||
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
|
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
|
||||||
@@ -65,12 +33,6 @@ class Setting < ApplicationRecord
|
|||||||
def self.defaults
|
def self.defaults
|
||||||
{
|
{
|
||||||
theme: 'system',
|
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_title: 'manual',
|
||||||
auto_fetch_thumbnail: 'manual',
|
auto_fetch_thumbnail: 'manual',
|
||||||
wiki_editor_mode: 'split',
|
wiki_editor_mode: 'split',
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get 'users/settings', to: 'user_settings#show'
|
||||||
|
patch 'users/settings', to: 'user_settings#update'
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
@@ -89,11 +92,6 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
scope 'users/settings', controller: :user_settings do
|
|
||||||
get '', action: :show
|
|
||||||
patch '', action: :update
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :deerjikists, only: [] do
|
resources :deerjikists, only: [] do
|
||||||
collection do
|
collection do
|
||||||
scope ':platform/:code' do
|
scope ':platform/:code' do
|
||||||
|
|||||||
@@ -9,28 +9,6 @@ class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0]
|
|||||||
change_column_null :settings, :user_id, false
|
change_column_null :settings, :user_id, false
|
||||||
|
|
||||||
add_column :settings, :theme, :string, null: false, default: 'system'
|
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,
|
add_column :settings,
|
||||||
:auto_fetch_title,
|
:auto_fetch_title,
|
||||||
:string,
|
: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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "theme", default: "system", 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_title", default: "manual", null: false
|
||||||
t.string "auto_fetch_thumbnail", default: "manual", null: false
|
t.string "auto_fetch_thumbnail", default: "manual", null: false
|
||||||
t.string "wiki_editor_mode", default: "split", 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 { useRef } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useUserSettings } from '@/components/users/UserSettingsProvider'
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||||
@@ -17,7 +16,6 @@ type Props = { posts: Post[]
|
|||||||
|
|
||||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const { settings } = useUserSettings ()
|
|
||||||
|
|
||||||
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
||||||
|
|
||||||
@@ -44,9 +42,6 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
layoutId={layoutId}
|
layoutId={layoutId}
|
||||||
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
||||||
'transform-gpu will-change-transform',
|
'transform-gpu will-change-transform',
|
||||||
settings.viewedPostDisplay === 'dim'
|
|
||||||
&& post.viewed
|
|
||||||
&& 'opacity-40 saturate-50',
|
|
||||||
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
||||||
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
|
|||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete',
|
||||||
{ params: { q: '虹夏' } },
|
{ params: { q: '虹夏', nico: '0' } },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
||||||
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
|
|||||||
expect (api.apiGet).not.toHaveBeenCalled ()
|
expect (api.apiGet).not.toHaveBeenCalled ()
|
||||||
expect (setValue).toHaveBeenCalledWith (' ')
|
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 { useState } from 'react'
|
||||||
|
|
||||||
import { useUserSettings } from '@/components/users/UserSettingsProvider'
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
@@ -11,17 +10,23 @@ import type { Tag } from '@/types'
|
|||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
includeNico?: boolean
|
||||||
describedBy?: string
|
describedBy?: string
|
||||||
invalid?: boolean
|
invalid?: boolean
|
||||||
value: string
|
value: string
|
||||||
setValue: (value: string) => void }
|
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 [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
const { settings } = useUserSettings ()
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
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: {
|
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
||||||
q,
|
q,
|
||||||
nico: settings.tagAutocompleteNico ? '1' : '0' } })
|
nico: includeNico ? '1' : '0' } })
|
||||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||||
setSuggestions (nextSuggestions)
|
setSuggestions (nextSuggestions)
|
||||||
setSuggestionsVsbl (nextSuggestions.length > 0)
|
setSuggestionsVsbl (nextSuggestions.length > 0)
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ export const UserSettingsProvider: FC<{
|
|||||||
|
|
||||||
useEffect (() => applyTheme (settings.theme), [settings.theme])
|
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> (() => ({
|
const value = useMemo<ContextValue> (() => ({
|
||||||
error,
|
error,
|
||||||
loaded,
|
loaded,
|
||||||
|
|||||||
+77
-55
@@ -1,28 +1,10 @@
|
|||||||
import { apiGet, apiPatch } from '@/lib/api'
|
import { apiGet, apiPatch } from '@/lib/api'
|
||||||
|
|
||||||
import type { FetchPostsOrder } from '@/types'
|
import type { FetchPostsOrder, FetchTagsOrder } 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'
|
|
||||||
|
|
||||||
|
// DB-backed user settings. These are worth sharing across browsers for one user.
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
theme: 'system' | 'light' | 'dark'
|
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'
|
autoFetchTitle: 'auto' | 'manual' | 'off'
|
||||||
autoFetchThumbnail: 'auto' | 'manual' | 'off'
|
autoFetchThumbnail: 'auto' | 'manual' | 'off'
|
||||||
wikiEditorMode: 'split' | 'write' | 'preview' }
|
wikiEditorMode: 'split' | 'write' | 'preview' }
|
||||||
@@ -31,14 +13,21 @@ export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
|
|||||||
export type TheatreTagFlow = 'vertical' | 'horizontal'
|
export type TheatreTagFlow = 'vertical' | 'horizontal'
|
||||||
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
|
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
|
||||||
export type ClientPaneBreakpoint = 'desktop' | 'tablet'
|
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 = {
|
type ClientPaneSettings = {
|
||||||
widthPxByBreakpoint?: Partial<Record<ClientPaneBreakpoint, number>>
|
widthPxByBreakpoint?: Partial<Record<ClientPaneBreakpoint, number>>
|
||||||
collapsed?: boolean }
|
collapsed?: boolean }
|
||||||
|
|
||||||
|
type ClientListSettings = {
|
||||||
|
limit?: ClientListLimit
|
||||||
|
order?: string }
|
||||||
|
|
||||||
|
// Browser-local settings. These depend on device or screen context.
|
||||||
export type ClientSettings = {
|
export type ClientSettings = {
|
||||||
panes?: Record<string, ClientPaneSettings>
|
panes?: Record<string, ClientPaneSettings>
|
||||||
|
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||||
theatre?: {
|
theatre?: {
|
||||||
layoutMode?: TheatreLayoutMode
|
layoutMode?: TheatreLayoutMode
|
||||||
tagFlow?: TheatreTagFlow
|
tagFlow?: TheatreTagFlow
|
||||||
@@ -53,35 +42,41 @@ export type ClientSettings = {
|
|||||||
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
|
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
|
||||||
export const DEFAULT_USER_SETTINGS: UserSettings = {
|
export const DEFAULT_USER_SETTINGS: UserSettings = {
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
displayDensity: 'comfortable',
|
|
||||||
fontSize: 'normal',
|
|
||||||
postListLimit: 50,
|
|
||||||
postListOrder: 'created_at_desc',
|
|
||||||
viewedPostDisplay: 'show',
|
|
||||||
tagAutocompleteNico: true,
|
|
||||||
autoFetchTitle: 'manual',
|
autoFetchTitle: 'manual',
|
||||||
autoFetchThumbnail: 'manual',
|
autoFetchThumbnail: 'manual',
|
||||||
wikiEditorMode: 'split' }
|
wikiEditorMode: 'split' }
|
||||||
|
|
||||||
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
|
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 AUTO_FETCH_OPTIONS = ['auto', 'manual', 'off'] as const
|
||||||
export const WIKI_EDITOR_MODE_OPTIONS = ['split', 'write', 'preview'] 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_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
|
||||||
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
||||||
@@ -96,12 +91,6 @@ export const fetchUserSettings = async (): Promise<UserSettings> =>
|
|||||||
export const updateUserSettings = async (
|
export const updateUserSettings = async (
|
||||||
settings: Partial<{
|
settings: Partial<{
|
||||||
theme: UserSettings['theme']
|
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_title: UserSettings['autoFetchTitle']
|
||||||
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
|
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
|
||||||
wiki_editor_mode: UserSettings['wikiEditorMode']
|
wiki_editor_mode: UserSettings['wikiEditorMode']
|
||||||
@@ -109,12 +98,6 @@ export const updateUserSettings = async (
|
|||||||
): Promise<UserSettings> => await apiPatch<UserSettings> ('/users/settings', settings)
|
): 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 => {
|
const safeParseClientSettings = (raw: string | null): ClientSettings => {
|
||||||
if (!(raw))
|
if (!(raw))
|
||||||
return { }
|
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 legacyTheatreLayoutMode = (): TheatreLayoutMode | null => {
|
||||||
const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
|
const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,34 +1,66 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
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 { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import PostList from '@/components/PostList'
|
import PostList from '@/components/PostList'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { useUserSettings } from '@/components/users/UserSettingsProvider'
|
|
||||||
import TagSidebar from '@/components/TagSidebar'
|
import TagSidebar from '@/components/TagSidebar'
|
||||||
import WikiBody from '@/components/WikiBody'
|
import WikiBody from '@/components/WikiBody'
|
||||||
|
import FormField from '@/components/common/FormField'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { postsKeys } from '@/lib/queryKeys'
|
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 { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
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 PostListPage: FC = () => {
|
||||||
const containerRef = useRef<HTMLDivElement | null> (null)
|
const containerRef = useRef<HTMLDivElement | null> (null)
|
||||||
const { settings } = useUserSettings ()
|
|
||||||
|
|
||||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
const navigate = useNavigate ()
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const tagsQuery = query.get ('tags') ?? ''
|
const tagsQuery = query.get ('tags') ?? ''
|
||||||
const anyFlg = query.get ('match') === 'any'
|
const anyFlg = query.get ('match') === 'any'
|
||||||
@@ -36,8 +68,16 @@ const PostListPage: FC = () => {
|
|||||||
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
|
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
|
||||||
const tagsKey = tags.join (' ')
|
const tagsKey = tags.join (' ')
|
||||||
const page = Number (query.get ('page') ?? 1)
|
const page = Number (query.get ('page') ?? 1)
|
||||||
const limit = Number (query.get ('limit') ?? settings.postListLimit)
|
const limit = (
|
||||||
const order = toFetchPostsOrder (settings.postListOrder)
|
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 = {
|
const keys = {
|
||||||
tags: tagsKey, match, page, limit,
|
tags: tagsKey, match, page, limit,
|
||||||
@@ -51,6 +91,19 @@ const PostListPage: FC = () => {
|
|||||||
const cursor = ''
|
const cursor = ''
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
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 (() => {
|
useLayoutEffect (() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
|
|
||||||
@@ -93,6 +146,38 @@ const PostListPage: FC = () => {
|
|||||||
}}/>
|
}}/>
|
||||||
|
|
||||||
<MainArea>
|
<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>
|
<TabGroup>
|
||||||
<Tab name="広場">
|
<Tab name="広場">
|
||||||
{posts.length > 0
|
{posts.length > 0
|
||||||
|
|||||||
@@ -49,13 +49,14 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const [titleLoading, setTitleLoading] = useState (false)
|
const [titleLoading, setTitleLoading] = useState (false)
|
||||||
const [url, setURL] = useState ('')
|
const [url, setURL] = useState ('')
|
||||||
|
const [titleAutoFlg, setTitleAutoFlg] = useState (settings.autoFetchTitle === 'auto')
|
||||||
|
const [thumbnailAutoFlg, setThumbnailAutoFlg] =
|
||||||
|
useState (settings.autoFetchThumbnail === 'auto')
|
||||||
|
|
||||||
const previousURLRef = useRef ('')
|
const previousURLRef = useRef ('')
|
||||||
const thumbnailPreviewRef = useRef ('')
|
const thumbnailPreviewRef = useRef ('')
|
||||||
const titleFetchMode = settings.autoFetchTitle
|
const titleFetchMode = settings.autoFetchTitle
|
||||||
const thumbnailFetchMode = settings.autoFetchThumbnail
|
const thumbnailFetchMode = settings.autoFetchThumbnail
|
||||||
const titleAutoFlg = titleFetchMode === 'auto'
|
|
||||||
const thumbnailAutoFlg = thumbnailFetchMode === 'auto'
|
|
||||||
const titleFetchVisible = titleFetchMode !== 'off'
|
const titleFetchVisible = titleFetchMode !== 'off'
|
||||||
const thumbnailFetchVisible = thumbnailFetchMode !== 'off'
|
const thumbnailFetchVisible = thumbnailFetchMode !== 'off'
|
||||||
const videoFlg =
|
const videoFlg =
|
||||||
@@ -143,6 +144,14 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
thumbnailPreviewRef.current = thumbnailPreview
|
thumbnailPreviewRef.current = thumbnailPreview
|
||||||
}, [thumbnailPreview])
|
}, [thumbnailPreview])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setTitleAutoFlg (settings.autoFetchTitle === 'auto')
|
||||||
|
}, [settings.autoFetchTitle])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setThumbnailAutoFlg (settings.autoFetchThumbnail === 'auto')
|
||||||
|
}, [settings.autoFetchThumbnail])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (titleAutoFlg && url)
|
if (titleAutoFlg && url)
|
||||||
fetchTitle ()
|
fetchTitle ()
|
||||||
@@ -189,7 +198,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
value={title}
|
value={title}
|
||||||
placeholder={titleLoading ? 'Loading...' : ''}
|
placeholder={titleLoading ? 'Loading...' : ''}
|
||||||
onChange={ev => setTitle (ev.target.value)}
|
onChange={ev => setTitle (ev.target.value)}
|
||||||
disabled={titleAutoFlg}/>
|
disabled={titleLoading}/>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span>
|
<span>
|
||||||
{titleAutoFlg
|
{titleAutoFlg
|
||||||
@@ -198,7 +207,16 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
? '自動取得しません.必要なら手動で取得できます.'
|
? '自動取得しません.必要なら手動で取得できます.'
|
||||||
: '取得機能は無効です.'}
|
: '取得機能は無効です.'}
|
||||||
</span>
|
</span>
|
||||||
{titleFetchVisible && !(titleAutoFlg) && (
|
{titleFetchVisible && (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={titleAutoFlg}
|
||||||
|
onChange={ev => setTitleAutoFlg (ev.target.checked)}/>
|
||||||
|
<span>自動</span>
|
||||||
|
</label>
|
||||||
|
{!(titleAutoFlg) && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -206,6 +224,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
disabled={!(url) || titleLoading}>
|
disabled={!(url) || titleLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>)}
|
</Button>)}
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -222,7 +241,16 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
? '自動取得しません.必要なら手動で取得できます.'
|
? '自動取得しません.必要なら手動で取得できます.'
|
||||||
: '取得機能は無効です.'}
|
: '取得機能は無効です.'}
|
||||||
</span>
|
</span>
|
||||||
{thumbnailFetchVisible && !(thumbnailAutoFlg) && (
|
{thumbnailFetchVisible && (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={thumbnailAutoFlg}
|
||||||
|
onChange={ev => setThumbnailAutoFlg (ev.target.checked)}/>
|
||||||
|
<span>自動</span>
|
||||||
|
</label>
|
||||||
|
{!(thumbnailAutoFlg) && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -230,6 +258,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
disabled={!(url) || thumbnailLoading}>
|
disabled={!(url) || thumbnailLoading}>
|
||||||
取得
|
取得
|
||||||
</Button>)}
|
</Button>)}
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
{thumbnailAutoFlg
|
{thumbnailAutoFlg
|
||||||
? (thumbnailLoading
|
? (thumbnailLoading
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { useUserSettings } from '@/components/users/UserSettingsProvider'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { postsKeys } from '@/lib/queryKeys'
|
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 { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
@@ -33,19 +40,44 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
|||||||
qs.set (k, t)
|
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 PostSearchPage: FC = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const { settings } = useUserSettings ()
|
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
const query = useMemo (() => new URLSearchParams (location.search),
|
const query = useMemo (() => new URLSearchParams (location.search),
|
||||||
[location.search])
|
[location.search])
|
||||||
const defaultOrder = toFetchPostsOrder (settings.postListOrder)
|
|
||||||
|
|
||||||
const page = Number (query.get ('page') ?? 1)
|
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 qURL = query.get ('url') ?? ''
|
||||||
const qTitle = query.get ('title') ?? ''
|
const qTitle = query.get ('title') ?? ''
|
||||||
@@ -57,7 +89,11 @@ const PostSearchPage: FC = () => {
|
|||||||
const qCreatedTo = query.get ('created_to') ?? ''
|
const qCreatedTo = query.get ('created_to') ?? ''
|
||||||
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
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 [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||||
@@ -87,6 +123,10 @@ const PostSearchPage: FC = () => {
|
|||||||
const results = data?.posts ?? []
|
const results = data?.posts ?? []
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setClientListSettings ('postSearch', { limit, order })
|
||||||
|
}, [limit, order])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setURL (qURL ?? '')
|
setURL (qURL ?? '')
|
||||||
setTitle (qTitle ?? '')
|
setTitle (qTitle ?? '')
|
||||||
@@ -117,6 +157,16 @@ const PostSearchPage: FC = () => {
|
|||||||
qs.set ('match', matchType)
|
qs.set ('match', matchType)
|
||||||
qs.set ('page', '1')
|
qs.set ('page', '1')
|
||||||
qs.set ('order', order)
|
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 () }`)
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +191,38 @@ const PostSearchPage: FC = () => {
|
|||||||
<PageTitle>広場検索</PageTitle>
|
<PageTitle>広場検索</PageTitle>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
<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="タイトル">
|
<FormField label="タイトル">
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
@@ -165,6 +247,7 @@ const PostSearchPage: FC = () => {
|
|||||||
<FormField label="タグ">
|
<FormField label="タグ">
|
||||||
{() => (
|
{() => (
|
||||||
<TagInput
|
<TagInput
|
||||||
|
includeNico={true}
|
||||||
value={tagsStr}
|
value={tagsStr}
|
||||||
setValue={setTagsStr}/>)}
|
setValue={setTagsStr}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -302,11 +385,7 @@ const PostSearchPage: FC = () => {
|
|||||||
{results.map (row => (
|
{results.map (row => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className={
|
className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||||
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'
|
|
||||||
}>
|
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||||||
import { tagsKeys } from '@/lib/queryKeys'
|
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 { fetchTags } from '@/lib/tags'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -35,6 +44,29 @@ const boolFromQuery = (value: string | null): boolean =>
|
|||||||
|
|
||||||
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
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 TagListPage: FC = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -44,7 +76,11 @@ const TagListPage: FC = () => {
|
|||||||
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
|
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
|
||||||
|
|
||||||
const page = Number (query.get ('page') ?? 1)
|
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 qName = query.get ('name') ?? ''
|
||||||
const qCategory = (query.get ('category') || null) as Category | null
|
const qCategory = (query.get ('category') || null) as Category | null
|
||||||
@@ -58,7 +94,11 @@ const TagListPage: FC = () => {
|
|||||||
const qDeprecated = query.has ('deprecated')
|
const qDeprecated = query.has ('deprecated')
|
||||||
? boolFromQuery (query.get ('deprecated'))
|
? boolFromQuery (query.get ('deprecated'))
|
||||||
: null
|
: 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 [name, setName] = useState ('')
|
||||||
const [category, setCategory] = useState<Category | null> (null)
|
const [category, setCategory] = useState<Category | null> (null)
|
||||||
@@ -88,6 +128,10 @@ const TagListPage: FC = () => {
|
|||||||
const results = data?.tags ?? []
|
const results = data?.tags ?? []
|
||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setClientListSettings ('tagList', { limit, order })
|
||||||
|
}, [limit, order])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setName (qName)
|
setName (qName)
|
||||||
setCategory (qCategory)
|
setCategory (qCategory)
|
||||||
@@ -121,10 +165,20 @@ const TagListPage: FC = () => {
|
|||||||
qs.set ('deprecated', deprecated ? '1' : '0')
|
qs.set ('deprecated', deprecated ? '1' : '0')
|
||||||
qs.set ('page', '1')
|
qs.set ('page', '1')
|
||||||
qs.set ('order', order)
|
qs.set ('order', order)
|
||||||
|
qs.set ('limit', String (limit))
|
||||||
|
|
||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
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',
|
const defaultDirection = { name: 'asc',
|
||||||
category: 'asc',
|
category: 'asc',
|
||||||
post_count: 'desc',
|
post_count: 'desc',
|
||||||
@@ -141,6 +195,38 @@ const TagListPage: FC = () => {
|
|||||||
<PageTitle>タグ</PageTitle>
|
<PageTitle>タグ</PageTitle>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
<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="名前">
|
<FormField label="名前">
|
||||||
{({ invalid }) => (
|
{({ 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 { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
import Form from '@/components/common/Form'
|
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import Label from '@/components/common/Label'
|
import Label from '@/components/common/Label'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
@@ -20,12 +24,7 @@ import { apiPut } from '@/lib/api'
|
|||||||
import {
|
import {
|
||||||
AUTO_FETCH_OPTIONS,
|
AUTO_FETCH_OPTIONS,
|
||||||
DEFAULT_USER_SETTINGS,
|
DEFAULT_USER_SETTINGS,
|
||||||
DISPLAY_DENSITY_OPTIONS,
|
|
||||||
FONT_SIZE_OPTIONS,
|
|
||||||
POST_LIST_LIMIT_OPTIONS,
|
|
||||||
POST_LIST_ORDER_OPTIONS,
|
|
||||||
THEME_OPTIONS,
|
THEME_OPTIONS,
|
||||||
VIEWED_POST_DISPLAY_OPTIONS,
|
|
||||||
WIKI_EDITOR_MODE_OPTIONS,
|
WIKI_EDITOR_MODE_OPTIONS,
|
||||||
updateUserSettings,
|
updateUserSettings,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
@@ -41,41 +40,55 @@ type Props = { user: User | null
|
|||||||
type UserFormField = 'name'
|
type UserFormField = 'name'
|
||||||
type SettingsFormField =
|
type SettingsFormField =
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'displayDensity'
|
|
||||||
| 'fontSize'
|
|
||||||
| 'postListLimit'
|
|
||||||
| 'postListOrder'
|
|
||||||
| 'viewedPostDisplay'
|
|
||||||
| 'tagAutocompleteNico'
|
|
||||||
| 'autoFetchTitle'
|
| 'autoFetchTitle'
|
||||||
| 'autoFetchThumbnail'
|
| 'autoFetchThumbnail'
|
||||||
| 'wikiEditorMode'
|
| 'wikiEditorMode'
|
||||||
|
|
||||||
type SettingsTab = 'account' | 'display' | 'posts' | 'editing' | 'wiki'
|
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'editing' | 'wiki'
|
||||||
|
|
||||||
type TabSpec = {
|
type TabSpec = {
|
||||||
id: SettingsTab
|
id: SettingsTab
|
||||||
label: string }
|
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 =
|
const sectionClassName =
|
||||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
'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[] = [
|
const tabs: TabSpec[] = [
|
||||||
{ id: 'account', label: 'アカウント' },
|
{ id: 'account', label: 'アカウント' },
|
||||||
{ id: 'display', label: '表示' },
|
{ id: 'theme', label: 'テーマ' },
|
||||||
{ id: 'posts', label: '投稿' },
|
{ id: 'keyboard', label: 'キーボード' },
|
||||||
{ id: 'editing', label: '編輯支援' },
|
{ id: 'editing', label: '編輯支援' },
|
||||||
{ id: 'wiki', label: 'Wiki' },
|
{ id: 'wiki', label: 'Wiki' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const settingsTabByField: Record<SettingsFormField, SettingsTab> = {
|
const settingsTabByField: Record<SettingsFormField, SettingsTab> = {
|
||||||
theme: 'display',
|
theme: 'theme',
|
||||||
displayDensity: 'display',
|
|
||||||
fontSize: 'display',
|
|
||||||
postListLimit: 'posts',
|
|
||||||
postListOrder: 'posts',
|
|
||||||
viewedPostDisplay: 'posts',
|
|
||||||
tagAutocompleteNico: 'editing',
|
|
||||||
autoFetchTitle: 'editing',
|
autoFetchTitle: 'editing',
|
||||||
autoFetchThumbnail: 'editing',
|
autoFetchThumbnail: 'editing',
|
||||||
wikiEditorMode: 'wiki',
|
wikiEditorMode: 'wiki',
|
||||||
@@ -83,12 +96,6 @@ const settingsTabByField: Record<SettingsFormField, SettingsTab> = {
|
|||||||
|
|
||||||
const settingsFieldOrder: SettingsFormField[] = [
|
const settingsFieldOrder: SettingsFormField[] = [
|
||||||
'theme',
|
'theme',
|
||||||
'displayDensity',
|
|
||||||
'fontSize',
|
|
||||||
'postListLimit',
|
|
||||||
'postListOrder',
|
|
||||||
'viewedPostDisplay',
|
|
||||||
'tagAutocompleteNico',
|
|
||||||
'autoFetchTitle',
|
'autoFetchTitle',
|
||||||
'autoFetchThumbnail',
|
'autoFetchThumbnail',
|
||||||
'wikiEditorMode',
|
'wikiEditorMode',
|
||||||
@@ -96,43 +103,44 @@ const settingsFieldOrder: SettingsFormField[] = [
|
|||||||
|
|
||||||
const themeLabel: Record<UserSettings['theme'], string> = {
|
const themeLabel: Record<UserSettings['theme'], string> = {
|
||||||
system: 'システム設定に従う',
|
system: 'システム設定に従う',
|
||||||
light: '明',
|
light: 'ライト・モード',
|
||||||
dark: '暗' }
|
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: '薄く表示' }
|
|
||||||
|
|
||||||
const autoFetchLabel: Record<UserSettings['autoFetchTitle'], string> = {
|
const autoFetchLabel: Record<UserSettings['autoFetchTitle'], string> = {
|
||||||
auto: '自動',
|
auto: '自動',
|
||||||
manual: '手動',
|
manual: '手動',
|
||||||
off: '無効' }
|
off: '無効',
|
||||||
|
}
|
||||||
|
|
||||||
const wikiEditorModeLabel: Record<UserSettings['wikiEditorMode'], string> = {
|
const wikiEditorModeLabel: Record<UserSettings['wikiEditorMode'], string> = {
|
||||||
split: '左右表示',
|
split: '左右表示',
|
||||||
write: '本文のみ',
|
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 => {
|
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 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 SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
@@ -182,19 +427,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
|
|
||||||
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
|
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
|
||||||
account: Boolean (nameFieldErrors.name?.length),
|
account: Boolean (nameFieldErrors.name?.length),
|
||||||
display: (
|
theme: Boolean (settingsFieldErrors.theme?.length),
|
||||||
Boolean (settingsFieldErrors.theme?.length)
|
keyboard: false,
|
||||||
|| Boolean (settingsFieldErrors.displayDensity?.length)
|
|
||||||
|| Boolean (settingsFieldErrors.fontSize?.length)
|
|
||||||
),
|
|
||||||
posts: (
|
|
||||||
Boolean (settingsFieldErrors.postListLimit?.length)
|
|
||||||
|| Boolean (settingsFieldErrors.postListOrder?.length)
|
|
||||||
|| Boolean (settingsFieldErrors.viewedPostDisplay?.length)
|
|
||||||
),
|
|
||||||
editing: (
|
editing: (
|
||||||
Boolean (settingsFieldErrors.tagAutocompleteNico?.length)
|
Boolean (settingsFieldErrors.autoFetchTitle?.length)
|
||||||
|| Boolean (settingsFieldErrors.autoFetchTitle?.length)
|
|
||||||
|| Boolean (settingsFieldErrors.autoFetchThumbnail?.length)
|
|| Boolean (settingsFieldErrors.autoFetchThumbnail?.length)
|
||||||
),
|
),
|
||||||
wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length),
|
wiki: Boolean (settingsFieldErrors.wikiEditorMode?.length),
|
||||||
@@ -260,12 +496,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
{
|
{
|
||||||
const data = await updateUserSettings ({
|
const data = await updateUserSettings ({
|
||||||
theme: draftSettings.theme,
|
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_title: draftSettings.autoFetchTitle,
|
||||||
auto_fetch_thumbnail: draftSettings.autoFetchThumbnail,
|
auto_fetch_thumbnail: draftSettings.autoFetchThumbnail,
|
||||||
wiki_editor_mode: draftSettings.wikiEditorMode,
|
wiki_editor_mode: draftSettings.wikiEditorMode,
|
||||||
@@ -305,6 +535,22 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
setActiveTab (settingsTabByField[fieldWithError])
|
setActiveTab (settingsTabByField[fieldWithError])
|
||||||
}, [setActiveTab, settingsFieldErrors])
|
}, [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 (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -312,16 +558,32 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
<title>設定 | {SITE_TITLE}</title>
|
<title>設定 | {SITE_TITLE}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<Form>
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="mx-auto max-w-xl space-y-4">
|
||||||
<PageTitle>設定</PageTitle>
|
<PageTitle>設定</PageTitle>
|
||||||
<FieldError messages={error ? [error] : []}/>
|
<FieldError messages={error ? [error] : []}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{user && loaded ? (
|
{user && loaded ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="mx-auto max-w-xl space-y-6 md:hidden">
|
||||||
|
{sectionProps && (
|
||||||
|
<>
|
||||||
|
<AccountSection {...sectionProps}/>
|
||||||
|
<ThemeSection {...sectionProps}/>
|
||||||
|
<KeyboardSection/>
|
||||||
|
<EditingSection {...sectionProps}/>
|
||||||
|
<WikiSection {...sectionProps}/>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={desktopTabRailClassName}>
|
||||||
|
<div className={desktopTabRailInnerClassName}>
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="設定区分"
|
aria-label="設定区分"
|
||||||
className="mb-4 flex flex-wrap gap-2">
|
aria-orientation="vertical"
|
||||||
|
className="sticky top-4 flex h-fit flex-col gap-2">
|
||||||
{tabs.map (tab => (
|
{tabs.map (tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -332,7 +594,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
aria-controls={tabPanelId (tab.id)}
|
aria-controls={tabPanelId (tab.id)}
|
||||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||||
className={cn (
|
className={cn (
|
||||||
'rounded-full border px-4 py-2 text-sm font-medium',
|
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? ['border-slate-900 bg-slate-900 text-white',
|
? ['border-slate-900 bg-slate-900 text-white',
|
||||||
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
||||||
@@ -346,288 +608,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
</button>))}
|
</button>))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<div className="w-[36rem] max-w-full">
|
||||||
|
{sectionProps && (
|
||||||
|
<div
|
||||||
id={tabPanelId (activeTab)}
|
id={tabPanelId (activeTab)}
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby={tabButtonId (activeTab)}
|
aria-labelledby={tabButtonId (activeTab)}>
|
||||||
className={sectionClassName}>
|
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||||||
{activeTab === 'account' && (
|
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||||||
<>
|
{activeTab === 'keyboard' && <KeyboardSection/>}
|
||||||
<h2 className="text-xl font-bold">アカウント</h2>
|
{activeTab === 'editing' && <EditingSection {...sectionProps}/>}
|
||||||
<FieldError messages={nameBaseErrors}/>
|
{activeTab === 'wiki' && <WikiSection {...sectionProps}/>}
|
||||||
|
</div>)}
|
||||||
<FormField label="表示名" messages={nameFieldErrors.name}>
|
</div>
|
||||||
{({ 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>
|
||||||
</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>
|
|
||||||
</>) : 'Loading...'}
|
</>) : 'Loading...'}
|
||||||
</Form>
|
</div>
|
||||||
|
|
||||||
<UserCodeDialogue
|
<UserCodeDialogue
|
||||||
visible={userCodeVsbl}
|
visible={userCodeVsbl}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする