このコミットが含まれているのは:
+23
-18
@@ -2,12 +2,9 @@ import { apiGet, apiPatch } from '@/lib/api'
|
|||||||
|
|
||||||
import type { FetchPostsOrder, FetchTagsOrder } from '@/types'
|
import type { FetchPostsOrder, FetchTagsOrder } from '@/types'
|
||||||
|
|
||||||
// DB-backed user settings. These are worth sharing across browsers for one user.
|
// DB-backed user settings. These are the cross-device preferences worth syncing.
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
theme: 'system' | 'light' | 'dark'
|
theme: 'system' | 'light' | 'dark' }
|
||||||
autoFetchTitle: 'auto' | 'manual' | 'off'
|
|
||||||
autoFetchThumbnail: 'auto' | 'manual' | 'off'
|
|
||||||
wikiEditorMode: 'split' | 'write' | 'preview' }
|
|
||||||
|
|
||||||
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
|
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
|
||||||
export type TheatreTagFlow = 'vertical' | 'horizontal'
|
export type TheatreTagFlow = 'vertical' | 'horizontal'
|
||||||
@@ -24,10 +21,14 @@ type ClientListSettings = {
|
|||||||
limit?: ClientListLimit
|
limit?: ClientListLimit
|
||||||
order?: string }
|
order?: string }
|
||||||
|
|
||||||
// Browser-local settings. These depend on device or screen context.
|
type ClientKeyboardSettings = {
|
||||||
|
shortcutsEnabled?: boolean }
|
||||||
|
|
||||||
|
// Browser-local settings. These stay device-specific and are not synced to DB.
|
||||||
export type ClientSettings = {
|
export type ClientSettings = {
|
||||||
panes?: Record<string, ClientPaneSettings>
|
panes?: Record<string, ClientPaneSettings>
|
||||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||||
|
keyboard?: ClientKeyboardSettings
|
||||||
theatre?: {
|
theatre?: {
|
||||||
layoutMode?: TheatreLayoutMode
|
layoutMode?: TheatreLayoutMode
|
||||||
tagFlow?: TheatreTagFlow
|
tagFlow?: TheatreTagFlow
|
||||||
@@ -41,14 +42,9 @@ 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' }
|
||||||
autoFetchTitle: 'manual',
|
|
||||||
autoFetchThumbnail: 'manual',
|
|
||||||
wikiEditorMode: 'split' }
|
|
||||||
|
|
||||||
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
|
export const THEME_OPTIONS = ['system', 'light', 'dark'] 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 LIST_LIMIT_OPTIONS = [20, 50, 100] as const
|
||||||
export const POST_LIST_ORDER_OPTIONS = [
|
export const POST_LIST_ORDER_OPTIONS = [
|
||||||
'title:asc',
|
'title:asc',
|
||||||
@@ -89,12 +85,7 @@ export const fetchUserSettings = async (): Promise<UserSettings> =>
|
|||||||
|
|
||||||
|
|
||||||
export const updateUserSettings = async (
|
export const updateUserSettings = async (
|
||||||
settings: Partial<{
|
settings: Partial<{ theme: UserSettings['theme'] }>,
|
||||||
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)
|
): Promise<UserSettings> => await apiPatch<UserSettings> ('/users/settings', settings)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +162,20 @@ export const setClientListSettings = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getClientKeyboardShortcutsEnabled = (): boolean =>
|
||||||
|
loadClientSettings ().keyboard?.shortcutsEnabled ?? true
|
||||||
|
|
||||||
|
|
||||||
|
export const setClientKeyboardShortcutsEnabled = (
|
||||||
|
enabled: boolean,
|
||||||
|
): void => {
|
||||||
|
updateClientSettings (settings => ({
|
||||||
|
...settings,
|
||||||
|
keyboard: { ...(settings.keyboard ?? { }), shortcutsEnabled: enabled },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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 (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Form from '@/components/common/Form'
|
|||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { useUserSettings } from '@/components/users/UserSettingsProvider'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
@@ -31,7 +30,6 @@ type PostFormField =
|
|||||||
|
|
||||||
const PostNewPage: FC<Props> = ({ user }) => {
|
const PostNewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const { settings } = useUserSettings ()
|
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
@@ -49,16 +47,8 @@ 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 thumbnailPreviewRef = useRef ('')
|
const thumbnailPreviewRef = useRef ('')
|
||||||
const titleFetchMode = settings.autoFetchTitle
|
|
||||||
const thumbnailFetchMode = settings.autoFetchThumbnail
|
|
||||||
const titleFetchVisible = titleFetchMode !== 'off'
|
|
||||||
const thumbnailFetchVisible = thumbnailFetchMode !== 'off'
|
|
||||||
const videoFlg =
|
const videoFlg =
|
||||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||||
[tags])
|
[tags])
|
||||||
@@ -93,19 +83,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleURLBlur = () => {
|
|
||||||
if (!(url) || url === previousURLRef.current)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (titleAutoFlg)
|
|
||||||
fetchTitle ()
|
|
||||||
if (thumbnailAutoFlg)
|
|
||||||
fetchThumbnail ()
|
|
||||||
previousURLRef.current = url
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTitle = useCallback (async () => {
|
const fetchTitle = useCallback (async () => {
|
||||||
setTitle ('')
|
|
||||||
setTitleLoading (true)
|
setTitleLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -144,24 +122,6 @@ 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 (() => {
|
|
||||||
if (titleAutoFlg && url)
|
|
||||||
fetchTitle ()
|
|
||||||
}, [fetchTitle, titleAutoFlg, url])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (thumbnailAutoFlg && url)
|
|
||||||
fetchThumbnail ()
|
|
||||||
}, [fetchThumbnail, thumbnailAutoFlg, url])
|
|
||||||
|
|
||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
@@ -183,8 +143,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
onChange={e => setURL (e.target.value)}
|
onChange={e => setURL (e.target.value)}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
className={inputClass (invalid)}
|
className={inputClass (invalid)}/>)}
|
||||||
onBlur={handleURLBlur}/>)}
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
@@ -200,31 +159,14 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
onChange={ev => setTitle (ev.target.value)}
|
onChange={ev => setTitle (ev.target.value)}
|
||||||
disabled={titleLoading}/>
|
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>必要なタイミングで URL から取得できます.</span>
|
||||||
{titleAutoFlg
|
<Button
|
||||||
? 'URL 入力時に自動取得します.'
|
type="button"
|
||||||
: titleFetchMode === 'manual'
|
variant="outline"
|
||||||
? '自動取得しません.必要なら手動で取得できます.'
|
onClick={() => void fetchTitle ()}
|
||||||
: '取得機能は無効です.'}
|
disabled={!(url) || titleLoading}>
|
||||||
</span>
|
取得
|
||||||
{titleFetchVisible && (
|
</Button>
|
||||||
<>
|
|
||||||
<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>
|
||||||
</div>)}
|
</div>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -234,52 +176,29 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span>
|
<span>必要なタイミングで URL から取得できます.</span>
|
||||||
{thumbnailAutoFlg
|
<Button
|
||||||
? 'URL 入力時に自動取得します.'
|
type="button"
|
||||||
: thumbnailFetchMode === 'manual'
|
variant="outline"
|
||||||
? '自動取得しません.必要なら手動で取得できます.'
|
onClick={() => void fetchThumbnail ()}
|
||||||
: '取得機能は無効です.'}
|
disabled={!(url) || thumbnailLoading}>
|
||||||
</span>
|
取得
|
||||||
{thumbnailFetchVisible && (
|
</Button>
|
||||||
<>
|
|
||||||
<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>
|
</div>
|
||||||
{thumbnailAutoFlg
|
{thumbnailLoading && (
|
||||||
? (thumbnailLoading
|
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||||
? <p className="text-gray-500 text-sm">Loading...</p>
|
<input type="file"
|
||||||
: !(thumbnailPreview) && (
|
accept="image/*"
|
||||||
<p className="text-gray-500 text-sm">
|
aria-describedby={describedBy}
|
||||||
URL から自動取得されます。
|
aria-invalid={invalid}
|
||||||
</p>))
|
onChange={e => {
|
||||||
: (
|
const file = e.target.files?.[0]
|
||||||
<input type="file"
|
if (file)
|
||||||
accept="image/*"
|
{
|
||||||
aria-describedby={describedBy}
|
setThumbnailFile (file)
|
||||||
aria-invalid={invalid}
|
setThumbnailPreview (URL.createObjectURL (file))
|
||||||
onChange={e => {
|
}
|
||||||
const file = e.target.files?.[0]
|
}}/>
|
||||||
if (file)
|
|
||||||
{
|
|
||||||
setThumbnailFile (file)
|
|
||||||
setThumbnailPreview (URL.createObjectURL (file))
|
|
||||||
}
|
|
||||||
}}/>)}
|
|
||||||
{thumbnailPreview && (
|
{thumbnailPreview && (
|
||||||
<img src={thumbnailPreview}
|
<img src={thumbnailPreview}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする