このコミットが含まれているのは:
@@ -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>)}
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
新しい課題から参照
ユーザをブロックする