- backend: /api/rails/file-content 프록시 (Gitea host allowlist) - frontend: FileViewerModal — 마크다운/코드 렌더링, front matter 파싱 - drawer: FileRow 클릭 핸들러 + 호버 효과 - .md 로그는 resultJson에서 직접 표시 - 추출된 코드 파일은 rawUrlBase로 백엔드 프록시 페치
294 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|