feat: [rd] 클코 → 슬랙 변환기 추가

- Claude Code CLI 출력을 슬랙 mrkdwn 형식으로 자동 변환
- 마크다운 → 슬랙 문법 변환 (볼드, 코드블록, 링크 등)
- 슬랙 스타일 실시간 미리보기
- 클립보드 복사/붙여넣기 지원
This commit is contained in:
김보곤
2026-03-11 09:20:22 +09:00
parent bb267d14c1
commit 878cec7935
3 changed files with 555 additions and 3 deletions

View File

@@ -613,4 +613,16 @@ public function fireShutterDrawing(Request $request): View|\Illuminate\Http\Resp
return view('rd.fire-shutter-drawing.index');
}
/**
* 클코 → 슬랙 변환기
*/
public function ccToSlack(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.cc-to-slack'));
}
return view('rd.cc-to-slack.index');
}
}

View File

@@ -0,0 +1,537 @@
@extends('layouts.app')
@section('title', '클코 → 슬랙 변환기')
@section('content')
<style>
:root {
--cs-bg: #0f172a;
--cs-card: #1e293b;
--cs-card2: #334155;
--cs-border: #475569;
--cs-text: #f1f5f9;
--cs-text2: #94a3b8;
--cs-purple: #8b5cf6;
--cs-green: #10b981;
--cs-blue: #3b82f6;
--cs-amber: #f59e0b;
--cs-red: #ef4444;
--cs-radius: 10px;
}
.cs-wrap {
display: flex; flex-direction: column; height: calc(100vh - 64px);
background: var(--cs-bg); overflow: hidden;
font-family: 'Pretendard', -apple-system, sans-serif; color: var(--cs-text);
margin: -24px;
}
.cs-toolbar {
display: flex; align-items: center; height: 48px; padding: 0 16px;
background: var(--cs-card); border-bottom: 1px solid var(--cs-border);
gap: 12px; flex-shrink: 0;
}
.cs-toolbar h1 { font-size: 14px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 6px; }
.cs-toolbar h1 i { color: var(--cs-purple); }
.cs-btn {
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer;
border: 1px solid var(--cs-border); background: var(--cs-card2); color: var(--cs-text);
transition: all .15s;
}
.cs-btn:hover { background: #475569; }
.cs-btn.primary { background: var(--cs-purple); border-color: var(--cs-purple); color: #fff; }
.cs-btn.primary:hover { background: #7c3aed; }
.cs-btn.success { background: var(--cs-green); border-color: var(--cs-green); color: #fff; }
.cs-btn.success:hover { background: #059669; }
.cs-btn.danger { background: var(--cs-red); border-color: var(--cs-red); color: #fff; }
.cs-btn.blue { background: var(--cs-blue); border-color: var(--cs-blue); color: #fff; }
.cs-btn.blue:hover { background: #2563eb; }
.cs-body {
display: flex; flex: 1; overflow: hidden;
}
.cs-panel {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
border-right: 1px solid var(--cs-border);
}
.cs-panel:last-child { border-right: none; }
.cs-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; background: var(--cs-card);
border-bottom: 1px solid var(--cs-border); flex-shrink: 0;
}
.cs-panel-header .title {
font-size: 12px; font-weight: 600; color: var(--cs-text2);
display: flex; align-items: center; gap: 6px;
}
.cs-panel-header .actions { display: flex; gap: 6px; }
.cs-editor {
flex: 1; width: 100%; background: transparent; color: var(--cs-text);
border: none; outline: none; resize: none; padding: 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px; line-height: 1.6;
}
.cs-editor::placeholder { color: var(--cs-text2); }
.cs-preview {
flex: 1; overflow-y: auto; padding: 12px;
font-family: 'Pretendard', -apple-system, sans-serif;
font-size: 13px; line-height: 1.6;
background: #1a1d21; /* 슬랙 다크 테마 배경 */
}
/* 슬랙 스타일 미리보기 */
.cs-preview .slack-bold { font-weight: 700; }
.cs-preview .slack-italic { font-style: italic; }
.cs-preview .slack-strike { text-decoration: line-through; }
.cs-preview .slack-code {
background: #2d2f33; color: #e01e5a; padding: 1px 4px;
border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 12px;
border: 1px solid #3c3f44;
}
.cs-preview .slack-codeblock {
background: #2d2f33; border: 1px solid #3c3f44; border-radius: 4px;
padding: 8px 12px; margin: 4px 0; font-family: 'JetBrains Mono', monospace;
font-size: 12px; line-height: 1.5; white-space: pre; overflow-x: auto;
}
.cs-preview .slack-blockquote {
border-left: 4px solid #5c5e63; padding-left: 12px; margin: 4px 0;
color: var(--cs-text2);
}
.cs-preview .slack-link { color: #1d9bd1; text-decoration: none; }
.cs-preview .slack-link:hover { text-decoration: underline; }
.cs-preview .slack-hr { border: none; border-top: 1px solid #3c3f44; margin: 8px 0; }
.cs-preview .slack-heading { font-weight: 700; font-size: 15px; }
.cs-preview .slack-list-item { padding-left: 16px; }
.cs-preview .slack-list-item::before { content: '• '; color: var(--cs-text2); }
.cs-preview .slack-numbered-item { padding-left: 16px; }
.cs-status {
display: flex; align-items: center; padding: 6px 16px;
background: var(--cs-card); border-top: 1px solid var(--cs-border);
font-size: 11px; color: var(--cs-text2); gap: 16px; flex-shrink: 0;
}
.cs-status .stat { display: flex; align-items: center; gap: 4px; }
.cs-toast {
position: fixed; top: 16px; right: 16px; padding: 10px 16px;
background: var(--cs-green); color: #fff; border-radius: 8px;
font-size: 13px; font-weight: 500; z-index: 9999;
transform: translateY(-20px); opacity: 0; transition: all .3s;
pointer-events: none;
}
.cs-toast.show { transform: translateY(0); opacity: 1; }
.cs-help {
padding: 16px; color: var(--cs-text2); font-size: 12px; line-height: 1.8;
}
.cs-help kbd {
background: var(--cs-card2); border: 1px solid var(--cs-border);
padding: 1px 5px; border-radius: 3px; font-size: 11px;
}
</style>
<div class="cs-wrap">
<!-- Toolbar -->
<div class="cs-toolbar">
<h1><i class="fas fa-exchange-alt"></i> 클코 슬랙 변환기</h1>
<div style="flex:1"></div>
<button class="cs-btn" onclick="showHelp()" title="도움말"><i class="fas fa-question-circle"></i></button>
<button class="cs-btn danger" onclick="clearAll()"><i class="fas fa-trash-alt"></i> 초기화</button>
</div>
<!-- Body -->
<div class="cs-body">
<!-- 입력 패널 -->
<div class="cs-panel">
<div class="cs-panel-header">
<span class="title"><i class="fas fa-terminal"></i> Claude Code 출력 (붙여넣기)</span>
<div class="actions">
<button class="cs-btn" onclick="pasteFromClipboard()" title="클립보드에서 붙여넣기"><i class="fas fa-paste"></i> 붙여넣기</button>
</div>
</div>
<textarea id="inputArea" class="cs-editor"
placeholder="Claude Code CLI 출력을 여기에 붙여넣으세요...&#10;&#10;예시:&#10;## 분석 결과&#10;- 항목 1&#10;- 항목 2&#10;&#10;```bash&#10;echo hello&#10;```"
oninput="convert()" spellcheck="false"></textarea>
</div>
<!-- 변환 결과 (슬랙 mrkdwn) -->
<div class="cs-panel">
<div class="cs-panel-header">
<span class="title"><i class="fab fa-slack"></i> 슬랙 전송용 텍스트</span>
<div class="actions">
<button class="cs-btn success" onclick="copyResult()" title="변환된 텍스트 복사"><i class="fas fa-copy"></i> 복사</button>
</div>
</div>
<textarea id="outputArea" class="cs-editor" readonly
placeholder="변환된 슬랙용 텍스트가 여기에 표시됩니다..."
spellcheck="false"></textarea>
</div>
<!-- 슬랙 미리보기 -->
<div class="cs-panel">
<div class="cs-panel-header">
<span class="title"><i class="fas fa-eye"></i> 슬랙 미리보기</span>
<div class="actions">
<span style="font-size:11px; color:var(--cs-text2);">미리보기 전용</span>
</div>
</div>
<div id="previewArea" class="cs-preview"></div>
</div>
</div>
<!-- Status Bar -->
<div class="cs-status">
<span class="stat"><i class="fas fa-file-alt"></i> 입력: <span id="statInput">0</span></span>
<span class="stat"><i class="fab fa-slack"></i> 출력: <span id="statOutput">0</span></span>
<span class="stat"><i class="fas fa-exchange-alt"></i> 변환율: <span id="statRatio">-</span></span>
<div style="flex:1"></div>
<span style="color:var(--cs-text2)">Ctrl+V로 붙여넣기 자동 변환 복사 버튼 클릭 슬랙에 붙여넣기</span>
</div>
</div>
<!-- Toast -->
<div id="toast" class="cs-toast"></div>
<!-- Help Modal -->
<div id="helpModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9998; display:none; align-items:center; justify-content:center;">
<div style="background:var(--cs-card); border:1px solid var(--cs-border); border-radius:12px; max-width:520px; width:90%; padding:24px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h3 style="margin:0; font-size:15px; font-weight:700;"><i class="fas fa-info-circle" style="color:var(--cs-blue)"></i> 사용법</h3>
<button class="cs-btn" onclick="closeHelp()"><i class="fas fa-times"></i></button>
</div>
<div class="cs-help">
<p><strong>1단계:</strong> Claude Code CLI에서 메시지를 드래그하여 복사 <kbd>Ctrl+C</kbd></p>
<p><strong>2단계:</strong> 왼쪽 입력란에 붙여넣기 <kbd>Ctrl+V</kbd></p>
<p><strong>3단계:</strong> 자동 변환된 중앙의 텍스트를 <span style="color:var(--cs-green)">복사</span> 버튼으로 복사</p>
<p><strong>4단계:</strong> 슬랙 채팅창에 붙여넣기 <kbd>Ctrl+V</kbd></p>
<hr style="border-color:var(--cs-border); margin:12px 0;">
<p><strong>변환 규칙:</strong></p>
<p> <code>**bold**</code> <code>*bold*</code> (슬랙 볼드)</p>
<p> <code>## 제목</code> → <code>*제목*</code> (볼드 처리)</p>
<p> <code>` `` `code` `` `</code> <code>`code`</code> (인라인 코드 유지)</p>
<p> <code>` `` `` `` `bash ... ` `` `` `` `</code> <code>` `` `` `` ` ... ` `` `` `` `</code> (코드 블록 유지)</p>
<p> <code>---</code> <code>———</code> (구분선 대체)</p>
<p> 불필요한 공백/빈줄 정리</p>
<p> 줄바꿈 정상화</p>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
(function() {
const inputArea = document.getElementById('inputArea');
const outputArea = document.getElementById('outputArea');
const previewArea = document.getElementById('previewArea');
/**
* Claude Code CLI 출력 → 슬랙 mrkdwn 변환
*/
function convertToSlack(text) {
if (!text.trim()) return '';
let lines = text.split('\n');
let result = [];
let inCodeBlock = false;
let codeBlockLang = '';
let codeLines = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// 코드블록 시작/끝 감지
if (line.trimStart().match(/^```/)) {
if (!inCodeBlock) {
inCodeBlock = true;
codeBlockLang = line.trimStart().replace(/^```/, '').trim();
codeLines = [];
continue;
} else {
// 코드블록 종료
inCodeBlock = false;
result.push('```');
result.push(...codeLines);
result.push('```');
codeLines = [];
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
// 빈 줄 처리 (연속 빈줄은 1개로)
if (line.trim() === '') {
if (result.length > 0 && result[result.length - 1] !== '') {
result.push('');
}
continue;
}
// 수평선: --- → ———
if (line.trim().match(/^[-]{3,}$/)) {
result.push('———');
continue;
}
// 헤딩: # ## ### → 슬랙 *볼드*
let headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
result.push('*' + headingMatch[2].trim() + '*');
continue;
}
// 테이블 구분선 건너뛰기 (|---|---|)
if (line.trim().match(/^\|[\s\-:|]+\|$/)) {
continue;
}
// 테이블 행: | a | b | → 슬랙 형식
if (line.trim().match(/^\|.*\|$/)) {
let cells = line.split('|').filter(c => c.trim() !== '');
let formatted = cells.map(c => c.trim()).join(' | ');
result.push(formatted);
continue;
}
// 일반 줄에서 마크다운 인라인 변환
line = convertInlineMarkdown(line);
result.push(line);
}
// 코드블록이 닫히지 않은 경우
if (inCodeBlock && codeLines.length > 0) {
result.push('```');
result.push(...codeLines);
result.push('```');
}
// 마지막 빈줄 제거
while (result.length > 0 && result[result.length - 1] === '') {
result.pop();
}
return result.join('\n');
}
/**
* 인라인 마크다운 → 슬랙 mrkdwn 변환
*/
function convertInlineMarkdown(line) {
// 인라인 코드 보호 (변환 대상에서 제외)
let codeSegments = [];
line = line.replace(/`([^`]+)`/g, function(match, code) {
codeSegments.push(code);
return '\x00CODE' + (codeSegments.length - 1) + '\x00';
});
// **bold** 또는 __bold__ → *bold* (슬랙)
line = line.replace(/\*\*(.+?)\*\*/g, '*$1*');
line = line.replace(/__(.+?)__/g, '*$1*');
// *italic* (단일 *) → _italic_ (슬랙)
// 주의: 이미 *bold*로 변환된 것과 구분 필요
// 단일 *만 italic로 변환 (앞뒤로 *가 아닌 경우)
line = line.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '_$1_');
// ~~strike~~ → ~strike~ (슬랙)
line = line.replace(/~~(.+?)~~/g, '~$1~');
// [text](url) → <url|text> (슬랙 링크)
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
// > blockquote → > blockquote (슬랙도 > 지원)
// 이미 동일하므로 변환 불필요
// 인라인 코드 복원
line = line.replace(/\x00CODE(\d+)\x00/g, function(match, idx) {
return '`' + codeSegments[parseInt(idx)] + '`';
});
return line;
}
/**
* 슬랙 mrkdwn → HTML 미리보기 렌더링
*/
function renderSlackPreview(text) {
if (!text.trim()) return '<div style="color:var(--cs-text2); padding:20px; text-align:center;">변환된 텍스트가 여기에 미리보기로 표시됩니다</div>';
let lines = text.split('\n');
let html = [];
let inCodeBlock = false;
let codeLines = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (line.trim() === '```') {
if (!inCodeBlock) {
inCodeBlock = true;
codeLines = [];
continue;
} else {
inCodeBlock = false;
html.push('<div class="slack-codeblock">' + escapeHtml(codeLines.join('\n')) + '</div>');
codeLines = [];
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
if (line.trim() === '') {
html.push('<div style="height:8px"></div>');
continue;
}
if (line.trim() === '———') {
html.push('<hr class="slack-hr">');
continue;
}
// 인라인 렌더링
let rendered = renderInlineSlack(escapeHtml(line));
html.push('<div>' + rendered + '</div>');
}
if (inCodeBlock && codeLines.length > 0) {
html.push('<div class="slack-codeblock">' + escapeHtml(codeLines.join('\n')) + '</div>');
}
return html.join('\n');
}
function renderInlineSlack(line) {
// `code` → <span class="slack-code">
line = line.replace(/`([^`]+)`/g, '<span class="slack-code">$1</span>');
// *bold* → <span class="slack-bold">
line = line.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '<span class="slack-bold">$1</span>');
// _italic_ → <span class="slack-italic">
line = line.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<span class="slack-italic">$1</span>');
// ~strike~ → <span class="slack-strike">
line = line.replace(/(?<!\w)~([^~]+)~(?!\w)/g, '<span class="slack-strike">$1</span>');
// <url|text> → <a>
line = line.replace(/&lt;(https?:\/\/[^|]+)\|([^&]+)&gt;/g, '<a class="slack-link" href="#">$2</a>');
return line;
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 변환 실행 (입력 → 변환 → 미리보기)
*/
window.convert = function() {
const input = inputArea.value;
const output = convertToSlack(input);
outputArea.value = output;
previewArea.innerHTML = renderSlackPreview(output);
// 상태바 업데이트
document.getElementById('statInput').textContent = input.length.toLocaleString();
document.getElementById('statOutput').textContent = output.length.toLocaleString();
if (input.length > 0) {
let ratio = Math.round((output.length / input.length) * 100);
document.getElementById('statRatio').textContent = ratio + '%';
} else {
document.getElementById('statRatio').textContent = '-';
}
};
/**
* 결과 복사
*/
window.copyResult = function() {
const output = outputArea.value;
if (!output.trim()) {
showToast('변환할 텍스트가 없습니다', 'warning');
return;
}
navigator.clipboard.writeText(output).then(() => {
showToast('슬랙용 텍스트가 복사되었습니다!');
}).catch(() => {
// fallback
outputArea.select();
document.execCommand('copy');
showToast('복사되었습니다!');
});
};
/**
* 클립보드에서 붙여넣기
*/
window.pasteFromClipboard = function() {
navigator.clipboard.readText().then(text => {
inputArea.value = text;
convert();
showToast('붙여넣기 완료!');
}).catch(() => {
inputArea.focus();
showToast('Ctrl+V로 직접 붙여넣어주세요', 'warning');
});
};
/**
* 초기화
*/
window.clearAll = function() {
inputArea.value = '';
outputArea.value = '';
previewArea.innerHTML = '';
document.getElementById('statInput').textContent = '0';
document.getElementById('statOutput').textContent = '0';
document.getElementById('statRatio').textContent = '-';
inputArea.focus();
};
/**
* 도움말
*/
window.showHelp = function() {
document.getElementById('helpModal').style.display = 'flex';
};
window.closeHelp = function() {
document.getElementById('helpModal').style.display = 'none';
};
document.getElementById('helpModal').addEventListener('click', function(e) {
if (e.target === this) closeHelp();
});
/**
* 토스트 메시지
*/
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.style.background = type === 'warning' ? 'var(--cs-amber)' : 'var(--cs-green)';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
// 입력란에 붙여넣기 시 자동 변환
inputArea.addEventListener('paste', function() {
setTimeout(() => convert(), 50);
});
// 키보드 단축키
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
copyResult();
}
if (e.key === 'Escape') {
closeHelp();
}
});
})();
</script>
@endpush

View File

@@ -16,9 +16,6 @@
use App\Http\Controllers\CategorySyncController;
use App\Http\Controllers\ChinaTech\BigTechController;
use App\Http\Controllers\ChinaTech\ChinaAiController;
use App\Http\Controllers\Help\AccountingGuideController;
use App\Http\Controllers\Help\AttendanceGuideController;
use App\Http\Controllers\Help\BarobillGuideController;
use App\Http\Controllers\ClaudeCode\CoworkController as ClaudeCodeCoworkController;
use App\Http\Controllers\ClaudeCode\HistoryController as ClaudeCodeHistoryController;
use App\Http\Controllers\ClaudeCode\NewsController as ClaudeCodeNewsController;
@@ -41,6 +38,9 @@
use App\Http\Controllers\GoogleCloud\CloudApiPricingController as GoogleCloudCloudApiPricingController;
use App\Http\Controllers\GoogleCloud\WorkspacePolicyController as GoogleCloudWorkspacePolicyController;
use App\Http\Controllers\GoogleCloud\WorkspacePricingController as GoogleCloudWorkspacePricingController;
use App\Http\Controllers\Help\AccountingGuideController;
use App\Http\Controllers\Help\AttendanceGuideController;
use App\Http\Controllers\Help\BarobillGuideController;
use App\Http\Controllers\ItemFieldController;
use App\Http\Controllers\ItemManagementController;
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
@@ -432,6 +432,9 @@
// 방화셔터 도면생성
Route::get('/fire-shutter-drawing', [RdController::class, 'fireShutterDrawing'])->name('fire-shutter-drawing');
// 클코 → 슬랙 변환기
Route::get('/cc-to-slack', [RdController::class, 'ccToSlack'])->name('cc-to-slack');
});
// 일일 스크럼 (Blade 화면만)