210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import styled from 'styled-components';
|
|
import CodeEditor from '@/components/admin/CodeEditor';
|
|
import { adminFetch } from '@/lib/adminFetch';
|
|
|
|
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
|
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
|
|
|
const DISPLAY_NAMES: Record<string, string> = {
|
|
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
|
};
|
|
|
|
const Layout = styled.div`
|
|
display: grid;
|
|
grid-template-columns: 180px 1fr;
|
|
gap: 20px;
|
|
`;
|
|
|
|
const FileTree = styled.div`
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const TreeSection = styled.div`
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 6px;
|
|
&:last-child { border-bottom: none; }
|
|
`;
|
|
|
|
const TreeLabel = styled.div`
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
padding: 6px 8px 4px;
|
|
`;
|
|
|
|
const TreeItem = styled.button<{ $active: boolean }>`
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
|
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
|
|
transition: background 0.15s;
|
|
|
|
&:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
|
|
`;
|
|
|
|
const EditorPane = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const Toolbar = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
`;
|
|
|
|
const PathLabel = styled.span`
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
font-family: monospace;
|
|
flex: 1;
|
|
`;
|
|
|
|
const Btn = styled.button<{ $primary?: boolean; $danger?: boolean }>`
|
|
padding: 7px 16px;
|
|
border-radius: 7px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
border: 1px solid;
|
|
transition: all 0.15s;
|
|
disabled: ${({ disabled }) => disabled ? 'not-allowed' : 'pointer'};
|
|
|
|
${({ $primary }) =>
|
|
$primary &&
|
|
`background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.4); color: #58A6FF;
|
|
&:hover { background: rgba(88,166,255,0.22); }`}
|
|
${({ $danger }) =>
|
|
$danger &&
|
|
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
|
|
&:hover { color: #E6EDF3; }`}
|
|
${({ $primary, $danger }) =>
|
|
!$primary && !$danger &&
|
|
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
|
|
&:hover { color: #E6EDF3; }`}
|
|
`;
|
|
|
|
const ResultMsg = styled.div<{ $success: boolean }>`
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-family: monospace;
|
|
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
|
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
|
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
|
`;
|
|
|
|
export default function HarnessPage() {
|
|
const [selected, setSelected] = useState({ sister: 'narang', file: 'SOUL.md' });
|
|
const [content, setContent] = useState('');
|
|
const [saved, setSaved] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [result, setResult] = useState<{ success: boolean; msg: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
setContent('');
|
|
setSaved('');
|
|
setResult(null);
|
|
setLoading(true);
|
|
adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`)
|
|
.then((r) => r.json())
|
|
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
|
|
.catch(() => setContent('(로드 실패)'))
|
|
.finally(() => setLoading(false));
|
|
}, [selected.sister, selected.file]);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
setResult(null);
|
|
try {
|
|
const res = await adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
if (res.status === 401) {
|
|
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
|
|
return;
|
|
}
|
|
const d = await res.json();
|
|
if (d.success) {
|
|
setSaved(content);
|
|
setResult({ success: true, msg: '저장 완료' });
|
|
} else {
|
|
setResult({ success: false, msg: d.error ?? '저장 실패' });
|
|
}
|
|
} catch (e) {
|
|
setResult({ success: false, msg: (e as Error).message });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const isDirty = content !== saved;
|
|
|
|
return (
|
|
<Layout>
|
|
<FileTree>
|
|
{SISTERS.map((s) => (
|
|
<TreeSection key={s}>
|
|
<TreeLabel>{DISPLAY_NAMES[s] ?? s}</TreeLabel>
|
|
{FILES.map((f) => (
|
|
<TreeItem
|
|
key={f}
|
|
$active={selected.sister === s && selected.file === f}
|
|
onClick={() => setSelected({ sister: s, file: f })}
|
|
>
|
|
{f}
|
|
</TreeItem>
|
|
))}
|
|
</TreeSection>
|
|
))}
|
|
</FileTree>
|
|
|
|
<EditorPane>
|
|
<Toolbar>
|
|
<PathLabel>
|
|
~/.openclaw/workspace/{selected.file} ({DISPLAY_NAMES[selected.sister]})
|
|
</PathLabel>
|
|
{isDirty && (
|
|
<span style={{ fontSize: '11px', color: '#FF9800' }}>● 변경됨</span>
|
|
)}
|
|
<Btn onClick={() => { setContent(saved); setResult(null); }} $danger>되돌리기</Btn>
|
|
<Btn $primary onClick={handleSave} disabled={!isDirty || saving}>
|
|
{saving ? '저장 중...' : '💾 저장'}
|
|
</Btn>
|
|
</Toolbar>
|
|
|
|
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
|
|
|
|
{loading ? (
|
|
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
|
로딩 중...
|
|
</div>
|
|
) : (
|
|
<CodeEditor value={content} onChange={setContent} />
|
|
)}
|
|
</EditorPane>
|
|
</Layout>
|
|
);
|
|
}
|