Files
hanarang-dashboard/frontend/components/rails/FileViewerModal.tsx
이랑이 9a99a8a33c feat(rails): MD 파일 클릭해서 뷰어 모달로 내용 보기
- backend: /api/rails/file-content 프록시 (Gitea host allowlist)
- frontend: FileViewerModal — 마크다운/코드 렌더링, front matter 파싱
- drawer: FileRow 클릭 핸들러 + 호버 효과
  - .md 로그는 resultJson에서 직접 표시
  - 추출된 코드 파일은 rawUrlBase로 백엔드 프록시 페치
2026-04-10 21:09:38 +09:00

294 lines
7.1 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { API_URL } from '@/lib/config';
const Backdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
`;
const Modal = styled.div`
width: min(920px, 96vw);
max-height: 86vh;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: 14px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
`;
const Header = styled.header`
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
gap: 16px;
`;
const TitleArea = styled.div`
flex: 1;
min-width: 0;
`;
const Label = styled.span`
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
`;
const Path = styled.h2`
font-size: 14px;
font-family: var(--font-mono);
margin: 4px 0 0;
word-break: break-all;
color: var(--text-primary);
`;
const Actions = styled.div`
display: flex;
gap: 8px;
flex-shrink: 0;
`;
const Btn = styled.button`
padding: 8px 14px;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
&:hover {
border-color: #5fafff;
}
`;
const Body = styled.div`
overflow: auto;
padding: 24px 28px;
flex: 1;
`;
const Markdown = styled.div`
font-size: 13px;
line-height: 1.75;
color: var(--text-primary);
h1, h2, h3, h4, h5, h6 {
font-size: 15px;
font-weight: 700;
margin: 18px 0 8px;
}
p { margin: 10px 0; }
ul, ol { margin: 10px 0; padding-left: 22px; }
li { margin: 4px 0; }
code {
background: var(--bg-input);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-mono);
color: #5fafff;
}
pre {
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
code { background: transparent; padding: 0; color: inherit; font-size: 12px; }
}
blockquote {
border-left: 3px solid var(--border-color);
padding-left: 12px;
margin: 10px 0;
color: var(--text-secondary);
}
hr { border: none; border-top: 1px solid var(--border-color); margin: 16px 0; }
a { color: #5fafff; text-decoration: none; &:hover { text-decoration: underline; } }
strong { font-weight: 700; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; }
th, td { border: 1px solid var(--border-color); padding: 8px 10px; text-align: left; }
th { background: var(--bg-input); }
`;
const Code = styled.pre`
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 18px 20px;
border-radius: 10px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-primary);
margin: 0;
white-space: pre;
`;
const Loading = styled.div`
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
`;
const ErrorMsg = styled.div`
padding: 20px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
border-radius: 10px;
color: #fca5a5;
font-size: 13px;
`;
function isMarkdown(path: string): boolean {
return /\.(md|markdown)$/i.test(path);
}
function stripFrontMatter(text: string): { body: string; meta: string } {
// Front matter: --- ... --- at the top
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { body: text, meta: '' };
return { body: text.slice(match[0].length), meta: match[1] ?? '' };
}
interface Props {
path: string; // display path
source:
| { type: 'llm'; text: string } // already-loaded text (from sub-task result)
| { type: 'url'; url: string }; // fetch via backend proxy
onClose: () => void;
}
export default function FileViewerModal({ path, source, onClose }: Props) {
const [content, setContent] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
async function load() {
if (source.type === 'llm') {
if (!cancelled) {
setContent(source.text);
setLoading(false);
}
return;
}
try {
const res = await fetch(
`${API_URL}/api/rails/file-content?url=${encodeURIComponent(source.url)}`,
{ credentials: 'include' },
);
if (!res.ok) {
if (!cancelled) {
setError(`HTTP ${res.status}`);
setLoading(false);
}
return;
}
const data = (await res.json()) as { content: string };
if (!cancelled) {
setContent(data.content ?? '');
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [source]);
const copyToClipboard = () => {
if (!content) return;
void navigator.clipboard?.writeText(content);
};
const renderAsMarkdown = isMarkdown(path);
const { body, meta } = renderAsMarkdown
? stripFrontMatter(content)
: { body: content, meta: '' };
return (
<Backdrop onClick={onClose}>
<Modal onClick={(e) => e.stopPropagation()}>
<Header>
<TitleArea>
<Label>FILE</Label>
<Path>{path}</Path>
</TitleArea>
<Actions>
<Btn onClick={copyToClipboard}></Btn>
<Btn onClick={onClose}> ESC</Btn>
</Actions>
</Header>
<Body>
{loading ? (
<Loading> ...</Loading>
) : error ? (
<ErrorMsg> : {error}</ErrorMsg>
) : renderAsMarkdown ? (
<>
{meta && (
<div
style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
opacity: 0.5,
marginBottom: 16,
padding: 12,
border: '1px solid var(--border-color)',
borderRadius: 8,
whiteSpace: 'pre-wrap',
}}
>
{meta}
</div>
)}
<Markdown>
<ReactMarkdown>{body}</ReactMarkdown>
</Markdown>
</>
) : (
<Code>{content}</Code>
)}
</Body>
</Modal>
</Backdrop>
);
}