上映会のし組み作り(#295) (#296)
#295 #295 #295 #295 #295 #295 #295 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #296
This commit was merged in pull request #296.
This commit is contained in:
@@ -19,6 +19,7 @@ import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
import SettingPage from '@/pages/users/SettingPage'
|
||||
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
|
||||
import WikiEditPage from '@/pages/wiki/WikiEditPage'
|
||||
@@ -47,6 +48,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
|
||||
@@ -1,78 +1,204 @@
|
||||
import { useRef, useLayoutEffect, useEffect, useState } from 'react'
|
||||
type Props = { id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
style?: CSSProperties }
|
||||
import { forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import type { CSSProperties, ForwardedRef } from 'react'
|
||||
|
||||
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '@/types'
|
||||
|
||||
type NiconicoPlayerMessage =
|
||||
| { eventName: 'enterProgrammaticFullScreen' }
|
||||
| { eventName: 'exitProgrammaticFullScreen' }
|
||||
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
|
||||
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
|
||||
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
|
||||
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
|
||||
|
||||
type NiconicoCommand =
|
||||
| { eventName: 'play'; sourceConnectorType: 1; playerId: string }
|
||||
| { eventName: 'pause'; sourceConnectorType: 1; playerId: string }
|
||||
| { eventName: 'seek'; sourceConnectorType: 1; playerId: string; data: { time: number } }
|
||||
| { eventName: 'mute'; sourceConnectorType: 1; playerId: string; data: { mute: boolean } }
|
||||
| { eventName: 'volumeChange'; sourceConnectorType: 1; playerId: string;
|
||||
data: { volume: number } }
|
||||
| { eventName: 'commentVisibilityChange'; sourceConnectorType: 1; playerId: string;
|
||||
data: { commentVisibility: boolean } }
|
||||
|
||||
const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
style?: CSSProperties
|
||||
onLoadComplete?: (info: NiconicoVideoInfo) => void
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void }
|
||||
|
||||
|
||||
export default ((props: Props) => {
|
||||
const { id, width, height, style = { } } = props
|
||||
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
|
||||
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement> (null)
|
||||
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
|
||||
|
||||
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
|
||||
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
|
||||
const [landscape, setLandscape] = useState<boolean> (false)
|
||||
const [fullScreen, setFullScreen] = useState<boolean> (false)
|
||||
|
||||
const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`;
|
||||
const src =
|
||||
`${ EMBED_ORIGIN }/watch/${ id }`
|
||||
+ '?jsapi=1'
|
||||
+ `&playerId=${ encodeURIComponent (playerId) }`
|
||||
+ '&persistence=1'
|
||||
+ '&oldScript=1'
|
||||
+ '&referer='
|
||||
+ '&from=0'
|
||||
+ '&allowProgrammaticFullScreen=1'
|
||||
|
||||
const styleFullScreen: CSSProperties = fullScreen ? {
|
||||
top: 0,
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2147483647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' } : {};
|
||||
const styleFullScreen: CSSProperties =
|
||||
fullScreen
|
||||
? { top: 0,
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2_147_483_647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
|
||||
: { }
|
||||
|
||||
const margedStyle = {
|
||||
border: 'none',
|
||||
maxWidth: '100%',
|
||||
...style,
|
||||
...styleFullScreen }
|
||||
const margedStyle: CSSProperties =
|
||||
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
|
||||
|
||||
const postToPlayer = useCallback ((message: NiconicoCommand) => {
|
||||
const win = iframeRef.current?.contentWindow
|
||||
if (!(win))
|
||||
return
|
||||
|
||||
win.postMessage (message, EMBED_ORIGIN)
|
||||
}, [])
|
||||
|
||||
const play = useCallback (() => {
|
||||
postToPlayer ({ eventName: 'play', sourceConnectorType: 1, playerId })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const pause = useCallback (() => {
|
||||
postToPlayer ({ eventName: 'pause', sourceConnectorType: 1, playerId })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const seek = useCallback ((time: number) => {
|
||||
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const mute = useCallback (() => {
|
||||
postToPlayer ({ eventName: 'mute', sourceConnectorType: 1, playerId, data: { mute: true } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const unmute = useCallback (() => {
|
||||
postToPlayer ({ eventName: 'mute', sourceConnectorType: 1, playerId, data: { mute: false } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const setVolume = useCallback ((volume: number) => {
|
||||
postToPlayer (
|
||||
{ eventName: 'volumeChange', sourceConnectorType: 1, playerId, data: { volume } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const showComments = useCallback (() => {
|
||||
postToPlayer (
|
||||
{ eventName: 'commentVisibilityChange', sourceConnectorType: 1, playerId,
|
||||
data: { commentVisibility: true } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const hideComments = useCallback (() => {
|
||||
postToPlayer (
|
||||
{ eventName: 'commentVisibilityChange', sourceConnectorType: 1, playerId,
|
||||
data: { commentVisibility: false } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
useImperativeHandle (
|
||||
ref,
|
||||
() => ({ play, pause, seek, mute, unmute, setVolume, showComments, hideComments }),
|
||||
[play, pause, seek, mute, unmute, setVolume, showComments, hideComments])
|
||||
|
||||
useEffect (() => {
|
||||
const onMessage = (event: MessageEvent<any>) => {
|
||||
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
|
||||
if (!(iframeRef.current)
|
||||
|| (event.source !== iframeRef.current.contentWindow))
|
||||
return
|
||||
|| (event.source !== iframeRef.current.contentWindow)
|
||||
|| (event.origin !== EMBED_ORIGIN))
|
||||
return
|
||||
|
||||
if (event.data.eventName === 'enterProgrammaticFullScreen')
|
||||
setFullScreen (true)
|
||||
else if (event.data.eventName === 'exitProgrammaticFullScreen')
|
||||
setFullScreen (false)
|
||||
const data = event.data
|
||||
|
||||
if (!(data)
|
||||
|| typeof data !== 'object'
|
||||
|| !('eventName' in data))
|
||||
return
|
||||
|
||||
if (('playerId' in data)
|
||||
&& data.playerId
|
||||
&& data.playerId !== playerId)
|
||||
return
|
||||
|
||||
if (data.eventName === 'enterProgrammaticFullScreen')
|
||||
{
|
||||
setFullScreen (true)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.eventName === 'exitProgrammaticFullScreen')
|
||||
{
|
||||
setFullScreen (false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.eventName === 'loadComplete')
|
||||
{
|
||||
onLoadComplete?.(data.data.videoInfo)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.eventName === 'playerMetadataChange')
|
||||
{
|
||||
onMetadataChange?.(data.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.eventName === 'error')
|
||||
console.error ('niconico player error:', data)
|
||||
}
|
||||
|
||||
addEventListener ('message', onMessage)
|
||||
|
||||
return () => removeEventListener ('message', onMessage)
|
||||
}, [])
|
||||
}, [onLoadComplete, onMetadataChange, playerId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useLayoutEffect (() => {
|
||||
if (!(fullScreen))
|
||||
return
|
||||
|
||||
const initialScrollX = scrollX
|
||||
const initialScrollY = scrollY
|
||||
let timer: NodeJS.Timeout
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
let ended = false
|
||||
|
||||
const pollingResize = () => {
|
||||
if (ended)
|
||||
return
|
||||
return
|
||||
|
||||
const landscape = innerWidth >= innerHeight
|
||||
const windowWidth = `${landscape ? innerWidth : innerHeight}px`
|
||||
const windowHeight = `${landscape ? innerHeight : innerWidth}px`
|
||||
const isLandscape = innerWidth >= innerHeight
|
||||
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
|
||||
const windowHeight = `${ isLandscape ? innerHeight : innerWidth }px`
|
||||
|
||||
setLandscape (landscape)
|
||||
setLandscape (isLandscape)
|
||||
setScreenWidth (windowWidth)
|
||||
setScreenHeight (windowHeight)
|
||||
timer = setTimeout (startPollingResize, 200)
|
||||
@@ -80,9 +206,9 @@ export default ((props: Props) => {
|
||||
|
||||
const startPollingResize = () => {
|
||||
if (requestAnimationFrame)
|
||||
requestAnimationFrame (pollingResize)
|
||||
requestAnimationFrame (pollingResize)
|
||||
else
|
||||
pollingResize ()
|
||||
pollingResize ()
|
||||
}
|
||||
|
||||
startPollingResize ()
|
||||
@@ -97,15 +223,17 @@ export default ((props: Props) => {
|
||||
useEffect (() => {
|
||||
if (!(fullScreen))
|
||||
return
|
||||
|
||||
scrollTo (0, 0)
|
||||
}, [screenWidth, screenHeight, fullScreen])
|
||||
|
||||
return (
|
||||
<iframe ref={iframeRef}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
}) satisfies FC<Props>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
})
|
||||
|
||||
@@ -4,14 +4,18 @@ import YoutubeEmbed from 'react-youtube'
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
import TwitterEmbed from '@/components/TwitterEmbed'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { FC, RefObject } from 'react'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
|
||||
|
||||
type Props = { post: Post }
|
||||
type Props = {
|
||||
ref?: RefObject<NiconicoViewerHandle | null>
|
||||
post: Post
|
||||
onLoadComplete?: (info: NiconicoVideoInfo) => void
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void }
|
||||
|
||||
|
||||
export default (({ post }: Props) => {
|
||||
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
|
||||
const url = new URL (post.url)
|
||||
|
||||
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
||||
@@ -24,7 +28,14 @@ export default (({ post }: Props) => {
|
||||
|
||||
const [videoId] = mVideoId
|
||||
|
||||
return <NicoViewer id={videoId} width={640} height={360}/>
|
||||
return (
|
||||
<NicoViewer
|
||||
ref={ref}
|
||||
id={videoId}
|
||||
width={640}
|
||||
height={360}
|
||||
onLoadComplete={onLoadComplete}
|
||||
onMetadataChange={onMetadataChange}/>)
|
||||
}
|
||||
|
||||
case 'twitter.com':
|
||||
|
||||
@@ -79,6 +79,9 @@ export default (({ user }: Props) => {
|
||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||
// TODO: 本実装時に消す.
|
||||
// { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||
// { name: '一覧', to: '/theatres' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
@@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
import type { NiconicoViewerHandle, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
@@ -38,6 +38,8 @@ export default (({ user }: Props) => {
|
||||
|
||||
const qc = useQueryClient ()
|
||||
|
||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||
|
||||
const [status, setStatus] = useState (200)
|
||||
|
||||
const changeViewedFlg = useMutation ({
|
||||
@@ -120,7 +122,10 @@ export default (({ user }: Props) => {
|
||||
className="object-cover w-full h-full"/>
|
||||
</motion.div>)}
|
||||
|
||||
<PostEmbed post={post}/>
|
||||
<PostEmbed
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={() => embedRef.current?.play ()}/>
|
||||
<Button onClick={() => changeViewedFlg.mutate ()}
|
||||
disabled={changeViewedFlg.isPending}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import PostEmbed from '@/components/PostEmbed'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
||||
import { fetchPost } from '@/lib/posts'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types'
|
||||
|
||||
type TheatreInfo = {
|
||||
hostFlg: boolean
|
||||
postId: number | null
|
||||
postStartedAt: string | null }
|
||||
|
||||
|
||||
export default (() => {
|
||||
const { id } = useParams ()
|
||||
|
||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
||||
const [theatreInfo, setTheatreInfo] =
|
||||
useState<TheatreInfo> ({ hostFlg: false, postId: null, postStartedAt: null })
|
||||
const [post, setPost] = useState<Post | null> (null)
|
||||
const [videoLength, setVideoLength] = useState (9_999_999_999)
|
||||
|
||||
useEffect (() => {
|
||||
if (!(id))
|
||||
return
|
||||
|
||||
void (async () => {
|
||||
setTheatre (await apiGet<Theatre> (`/theatres/${ id }`))
|
||||
}) ()
|
||||
|
||||
const interval = setInterval (async () => {
|
||||
if (theatreInfo.hostFlg
|
||||
&& theatreInfo.postStartedAt
|
||||
&& ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()
|
||||
> videoLength))
|
||||
setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null })
|
||||
else
|
||||
setTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
|
||||
}, 1_000)
|
||||
|
||||
return () => clearInterval (interval)
|
||||
}, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(theatreInfo.hostFlg) || loading)
|
||||
return
|
||||
|
||||
if (theatreInfo.postId == null)
|
||||
{
|
||||
void (async () => {
|
||||
setLoading (true)
|
||||
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
||||
setLoading (false)
|
||||
}) ()
|
||||
return
|
||||
}
|
||||
}, [id, loading, theatreInfo.hostFlg, theatreInfo.postId])
|
||||
|
||||
useEffect (() => {
|
||||
if (theatreInfo.postId == null)
|
||||
return
|
||||
|
||||
void (async () => {
|
||||
setPost (await fetchPost (String (theatreInfo.postId)))
|
||||
}) ()
|
||||
}, [theatreInfo.postId, theatreInfo.postStartedAt])
|
||||
|
||||
const syncPlayback = (meta: NiconicoMetadata) => {
|
||||
if (!(theatreInfo.postStartedAt))
|
||||
return
|
||||
|
||||
const targetTime =
|
||||
((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ())
|
||||
|
||||
const drift = Math.abs (meta.currentTime - targetTime)
|
||||
|
||||
if (drift > 5_000)
|
||||
embedRef.current?.seek (targetTime)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
{theatre && (
|
||||
<title>
|
||||
{'上映会場'
|
||||
+ (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`)
|
||||
+ ` | ${ SITE_TITLE }`}
|
||||
</title>)}
|
||||
</Helmet>
|
||||
|
||||
{post && (
|
||||
<PostEmbed
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={meta => {
|
||||
syncPlayback (meta)
|
||||
}}/>)}
|
||||
</MainArea>)
|
||||
}) satisfies FC
|
||||
@@ -38,6 +38,37 @@ export type NicoTag = Tag & {
|
||||
category: 'nico'
|
||||
linkedTags: Tag[] }
|
||||
|
||||
export type NiconicoMetadata = {
|
||||
currentTime: number
|
||||
duration: number
|
||||
isVideoMetaDataLoaded: boolean
|
||||
maximumBuffered: number
|
||||
muted: boolean
|
||||
showComment: boolean
|
||||
volume: number }
|
||||
|
||||
export type NiconicoVideoInfo = {
|
||||
title: string
|
||||
videoId: string
|
||||
lengthInSeconds: number
|
||||
thumbnailUrl: string
|
||||
description: string
|
||||
viewCount: number
|
||||
commentCount: number
|
||||
mylistCount: number
|
||||
postedAt: string
|
||||
watchId: number }
|
||||
|
||||
export type NiconicoViewerHandle = {
|
||||
play: () => void
|
||||
pause: () => void
|
||||
seek: (time: number) => void
|
||||
mute: () => void
|
||||
unmute: () => void
|
||||
setVolume: (volume: number) => void
|
||||
showComments: () => void
|
||||
hideComments: () => void }
|
||||
|
||||
export type Post = {
|
||||
id: number
|
||||
url: string
|
||||
@@ -75,6 +106,15 @@ export type Tag = {
|
||||
children?: Tag[]
|
||||
matchedAlias?: string | null }
|
||||
|
||||
export type Theatre = {
|
||||
id: number
|
||||
name: string | null
|
||||
opensAt: string
|
||||
closesAt: string | null
|
||||
createdByUser: { id: number; name: string }
|
||||
createdAt: string
|
||||
updatedAt: string }
|
||||
|
||||
export type User = {
|
||||
id: number
|
||||
name: string | null
|
||||
|
||||
Reference in New Issue
Block a user