File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { useEffect, useRef, useState } from 'react'
import { getPosts, fileDownloadUrl, type ChannelKindKey, type FileRef, type Org, type PostView } from '../api/client'
const POLL_INTERVAL_MS = 3000
const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' })
function humanizeSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`
}
const kb = bytes / 1024
if (kb < 1024) {
return `${kb.toFixed(1)} KB`
}
return `${(kb / 1024).toFixed(1)} MB`
}
function AttachmentChip({ file }: { file: FileRef }) {
return (
<a
href={fileDownloadUrl(file.id)}
className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-600 hover:bg-gray-100"
>
<span className="font-medium text-gray-700">{file.name}</span>
<span className="text-gray-400">({humanizeSize(file.size)})</span>
</a>
)
}
function PostRow({ post }: { post: PostView }) {
if (post.system) {
return <p className="py-1 text-center text-xs text-gray-400">{post.message}</p>
}
return (
<div className="py-2">
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold text-gray-800">{post.user}</span>
<span className="text-xs text-gray-400">{timeFormatter.format(new Date(post.createAt))}</span>
</div>
<p className="mt-0.5 whitespace-pre-wrap text-sm text-gray-700">{post.message}</p>
{post.files.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{post.files.map((f) => (
<AttachmentChip key={f.id} file={f} />
))}
</div>
)}
</div>
)
}
/** 기관관리 상세의 게시물 탭. 선택한 채널의 최근 게시글을 3초 간격으로 폴링해서 보여준다. */
export default function ChannelPosts({ org }: { org: Org }) {
const bothMissing = !org.channelIdMj && !org.channelIdLaw
const [channel, setChannel] = useState<ChannelKindKey>(org.channelIdMj ? 'mj' : 'law')
const [posts, setPosts] = useState<PostView[]>([])
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
useEffect(() => {
if (bothMissing) {
return
}
let cancelled = false
async function load() {
try {
const data = await getPosts(org.id, channel)
if (!cancelled) {
setPosts(data)
setError(null)
}
} catch {
if (!cancelled) {
setError('연결이 원활하지 않습니다. 재시도 중…')
}
}
}
void load()
const timer = setInterval(() => void load(), POLL_INTERVAL_MS)
return () => {
cancelled = true
clearInterval(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel, org.id])
useEffect(() => {
if (posts.length > prevCountRef.current && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
prevCountRef.current = posts.length
}, [posts.length])
return (
<div className="p-4">
<div className="flex gap-2">
<button
type="button"
disabled={!org.channelIdMj}
title={org.channelIdMj ? undefined : '채널 미생성'}
onClick={() => setChannel('mj')}
className={`rounded-full border px-3 py-1 text-xs ${
channel === 'mj'
? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
: 'border-gray-200 text-gray-500'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
문정원
</button>
<button
type="button"
disabled={!org.channelIdLaw}
title={org.channelIdLaw ? undefined : '채널 미생성'}
onClick={() => setChannel('law')}
className={`rounded-full border px-3 py-1 text-xs ${
channel === 'law'
? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
: 'border-gray-200 text-gray-500'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
법률검토
</button>
</div>
{bothMissing ? (
<p className="mt-6 text-center text-sm text-gray-400">채널이 아직 생성되지 않았습니다.</p>
) : (
<>
{error && (
<p className="mt-3 rounded-md bg-amber-50 px-3 py-1.5 text-xs text-amber-700">{error}</p>
)}
<div
ref={containerRef}
className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 px-3"
>
{posts.length === 0 ? (
<p className="py-6 text-center text-sm text-gray-300">게시글이 없습니다.</p>
) : (
posts.map((post) => <PostRow key={post.id} post={post} />)
)}
</div>
</>
)}
</div>
)
}