このコミットが含まれているのは:
2026-07-05 11:57:29 +09:00
コミット 61ab4744bd
10個のファイルの変更402行の追加203行の削除
+52 -17
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
@@ -12,12 +12,14 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import {
applyClientAppearance,
fetchUserSettings,
getClientThemeMode,
seedClientThemeMode,
} from '@/lib/settings'
import { applyClientAppearance,
CLIENT_SETTINGS_EVENT,
fetchUserSettings,
getClientAnimationMode,
getClientThemeMode,
seedClientThemeMode } from '@/lib/settings'
import type { ClientAnimationMode } from '@/lib/settings'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -110,6 +112,19 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const [animationMode, setAnimationMode] =
useState<ClientAnimationMode> (() => getClientAnimationMode ())
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
@@ -124,6 +139,16 @@ const App: FC = () => {
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
const handleClientSettingsChange = () => {
setAnimationMode (getClientAnimationMode ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -170,6 +195,7 @@ const App: FC = () => {
}
catch
{
return
}
}) ()
}, [user])
@@ -188,15 +214,24 @@ const App: FC = () => {
<BrowserRouter>
<KeyboardShortcutsProvider>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
</MotionConfig>
<Toaster/>
</DialogueProvider>
+60 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { getClientEmbedAutoLoadMode } from '@/lib/settings'
import type { FC, RefObject } from 'react'
@@ -24,16 +26,18 @@ type Props = {
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const PostEmbed: FC<Props> = (
{ ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError },
) => {
const dialogue = useDialogue ()
const embedAutoLoadMode = getClientEmbedAutoLoadMode ()
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +109,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
@@ -117,6 +125,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
const shouldLoadEmbed =
embedAutoLoadMode === 'auto'
|| (
embedAutoLoadMode === 'manual'
&& manualLoadRequested
)
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
</a>)
const manualLoadControl = (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
</Button>
{externalLink}
</div>)
switch (url.hostname.split ('.').slice (-2).join ('.'))
{
@@ -128,6 +158,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<NicoViewer
ref={ref}
@@ -150,6 +185,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId
const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
@@ -159,6 +199,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId))
break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
@@ -184,6 +229,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>)
: (
<div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => {
e.preventDefault ()
@@ -197,7 +247,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' }))
}}>
</a>
</a>)}
</div>)}
</>)
}
+23 -7
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { getClientThumbnailMode } from '@/lib/settings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const thumbnailMode = getClientThumbnailMode ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -54,18 +56,32 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return
return
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
{thumbnailMode === 'off'
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
</div>)
: (
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async"
className="object-cover w-full h-full"/>)}
</motion.div>
</PrefetchLink>)
})}
-143
ファイルの表示
@@ -1,143 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getClientBehaviorSettings,
setClientBehaviorSettings,
} from '@/lib/settings'
import { inputClass } from '@/lib/utils'
import type {
ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode,
} from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
}
const BehaviorSettingsSection: FC<Props> = ({ sectionClassName }) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings],
)
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
調
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="アニメーション">
{() => (
<select
className={inputClass (false)}
value={draftSettings.animation ?? 'normal'}
onChange={ev => updateDraft (
'animation',
ev.target.value as ClientAnimationMode,
)}>
<option value="normal"></option>
<option value="reduced"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="埋め込み自動読込">
{() => (
<select
className={inputClass (false)}
value={draftSettings.embedAutoLoad ?? 'auto'}
onChange={ev => updateDraft (
'embedAutoLoad',
ev.target.value as ClientEmbedAutoLoadMode,
)}>
<option value="auto"></option>
<option value="manual"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="サムネイル表示">
{() => (
<select
className={inputClass (false)}
value={draftSettings.thumbnailMode ?? 'normal'}
onChange={ev => updateDraft (
'thumbnailMode',
ev.target.value as ClientThumbnailMode,
)}>
<option value="normal"></option>
<option value="light"></option>
<option value="off"></option>
</select>)}
</FormField>
</div>
</section>)
}
export default BehaviorSettingsSection
+192
ファイルの表示
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings,
setClientBehaviorSettings } from '@/lib/settings'
import { cn } from '@/lib/utils'
import type {
ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode } from '@/lib/settings'
import type { FC, ReactNode } from 'react'
type Props = {
sectionClassName: string }
type SegmentedOption<T extends string> = {
value: T
label: string }
type SegmentedControlProps<T extends string> = {
value: T
options: SegmentedOption<T>[]
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'normal', label: '通常' },
{ value: 'reduced', label: '控えめ' },
{ value: 'off', label: 'なし' },
]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'auto', label: '自動' },
{ value: 'manual', label: 'クリック時' },
{ value: 'off', label: '読み込まない' },
]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'normal', label: '通常' },
{ value: 'light', label: '軽量' },
{ value: 'off', label: '非表示' },
]
const SegmentedControl = <T extends string,>(
{ value, options, onChange }: SegmentedControlProps<T>,
) => {
return (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 flex-1 rounded-lg px-3 py-2 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
</button>))}
</div>)
}
const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children },
) => (
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>)
const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings],
)
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
調
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock
title="アニメーション"
description="画面遷移やレイアウト移動の動きを調整します。">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
<SettingBlock
title="サムネイル表示"
description="投稿一覧や詳細の画像表示を軽量化します。">
<SegmentedControl
value={draftSettings.thumbnailMode ?? 'normal'}
options={thumbnailModeOptions}
onChange={value => updateDraft ('thumbnailMode', value)}/>
</SettingBlock>
</div>
</section>)
}
export default BehaviourSettingsSection
+8
ファイルの表示
@@ -112,6 +112,7 @@ export type ClientSettings = {
thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change'
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [
@@ -297,6 +298,13 @@ export const loadClientSettings = (): ClientSettings =>
export const saveClientSettings = (settings: ClientSettings): void => {
localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings))
if (typeof window !== 'undefined')
{
window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, {
detail: settings,
}))
}
}
+5 -1
ファイルの表示
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { getClientThumbnailMode } from '@/lib/settings'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
@@ -29,6 +30,7 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const thumbnailMode = getClientThumbnailMode ()
const { id } = useParams ()
const postId = String (id ?? '')
const postKey = postsKeys.show (postId)
@@ -134,7 +136,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>)
})}
{(post.thumbnail || post.thumbnailBase) && (
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
@@ -145,6 +147,8 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>)}
+18 -10
ファイルの表示
@@ -8,7 +8,7 @@ import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import BehaviorSettingsSection from '@/components/users/BehaviorSettingsSection'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
@@ -259,7 +259,7 @@ const ThemeSection: FC<SharedSectionProps> = (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
<h3 className="font-medium"></h3>
</div>
<Button
type="button"
@@ -270,7 +270,7 @@ const ThemeSection: FC<SharedSectionProps> = (
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-3">
{CATEGORIES.map (category => {
const resolvedDefaults =
defaultThemeTagColoursFor (selectedTheme.baseTheme)
@@ -280,9 +280,19 @@ const ThemeSection: FC<SharedSectionProps> = (
return (
<div
key={category}
className="space-y-2 rounded-lg border border-border/70 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">{CATEGORY_NAMES[category]}</span>
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (draftTagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={draftTagColours[category]}
@@ -291,8 +301,6 @@ const ThemeSection: FC<SharedSectionProps> = (
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
@@ -693,7 +701,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div>
@@ -742,7 +750,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</>)}
</div>)}
</div>