Files
sam-manage/resources/views/rd/cc-to-slack/index.blade.php
김보곤 8adc70e780 fix: [cc-to-slack] 슬랙 미리보기 편집 가능 + 번호 목록 줄바꿈 분리
- 미리보기 영역에 contenteditable 속성 추가 (편집 가능)
- 번호 목록(1. 2. 3.)이 한 줄에 이어진 경우 자동 줄바꿈 분리
- 편집 시 포커스 시각 표시(보라색 아웃라인) 추가
2026-03-18 14:22:00 +09:00

400 lines
15 KiB
PHP

@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-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.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-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: 15px; line-height: 1.7; color: #d1d2d3;
background: #1a1d21;
}
.cs-preview .slack-bold { font-weight: 700; color: #f1f1f1; }
.cs-preview .slack-italic { font-style: italic; }
.cs-preview .slack-strike { text-decoration: line-through; }
.cs-preview .slack-code {
background: #2d2f33; color: #e01e5a; padding: 2px 5px;
border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 13px;
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: 13px; line-height: 1.5; white-space: pre-wrap; 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-hr { border: none; border-top: 1px solid #3c3f44; margin: 8px 0; }
.cs-preview:focus { outline: 1px solid var(--cs-purple); outline-offset: -1px; }
.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-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; }
</style>
<div class="cs-wrap">
<div class="cs-toolbar">
<h1><i class="fas fa-exchange-alt"></i> 클코 슬랙 변환기</h1>
<div style="flex:1"></div>
<button class="cs-btn danger" onclick="clearAll()"><i class="fas fa-trash-alt"></i> 초기화</button>
</div>
<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()"><i class="fas fa-paste"></i> 붙여넣기</button>
</div>
</div>
<textarea id="inputArea" class="cs-editor"
placeholder="Claude Code CLI 출력을 여기에 붙여넣으세요..."
oninput="convert()" spellcheck="false"></textarea>
</div>
<!-- 슬랙 미리보기 -->
<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="copyPreview()"><i class="fas fa-copy"></i> 복사</button>
</div>
</div>
<div id="previewArea" class="cs-preview" contenteditable="true">
<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;">
<i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>
왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 있는 형태로 변환됩니다
</div>
</div>
</div>
</div>
<div class="cs-status">
<span>Ctrl+V 자동 변환 복사 슬랙에 붙여넣기</span>
<div style="flex:1"></div>
<span><kbd style="background:var(--cs-card2); border:1px solid var(--cs-border); padding:1px 5px; border-radius:3px; font-size:10px;">Ctrl+Enter</kbd> 빠른 복사</span>
</div>
</div>
<div id="toast" class="cs-toast"></div>
@endsection
@push('scripts')
<script>
(function() {
const inputArea = document.getElementById('inputArea');
const previewArea = document.getElementById('previewArea');
function convertToSlack(text) {
if (!text.trim()) return '';
// 번호 목록이 한 줄에 이어진 경우 줄바꿈으로 분리
// e.g., "내용 1. 항목 2. 항목" → "내용\n1. 항목\n2. 항목"
text = text.replace(/([^\d\n])\s+(\d{1,2})\.\s/g, '$1\n$2. ');
let lines = text.split('\n');
let result = [];
let inCodeBlock = false;
let codeLines = [];
let inTable = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (line.trimStart().match(/^```/)) {
if (!inCodeBlock) {
inCodeBlock = true;
inTable = false;
codeLines = [];
continue;
} else {
inCodeBlock = false;
result.push('```');
result.push(...codeLines);
result.push('```');
codeLines = [];
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
// 표 구분선(|---|---|) 스킵
if (line.trim().match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
// 표 행
if (line.trim().match(/^\|.*\|$/)) {
inTable = true;
let cells = line.split('|').filter(c => c.trim() !== '');
result.push(cells.map(c => c.trim()).join(' | '));
continue;
}
// 표 영역 종료 후 빈 줄 삽입
if (inTable) {
inTable = false;
if (result.length > 0 && result[result.length - 1] !== '') {
result.push('');
}
}
if (line.trim() === '') {
if (result.length > 0 && result[result.length - 1] !== '') {
result.push('');
}
continue;
}
if (line.trim().match(/^[-]{3,}$/)) {
result.push('———');
result.push('');
continue;
}
let headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
result.push('*' + headingMatch[2].trim() + '*');
result.push('');
continue;
}
line = convertInlineMarkdown(line);
result.push(line);
result.push('');
}
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');
}
function convertInlineMarkdown(line) {
let codeSegments = [];
line = line.replace(/`([^`]+)`/g, function(m, code) {
codeSegments.push(code);
return '\x00CODE' + (codeSegments.length - 1) + '\x00';
});
line = line.replace(/\*\*(.+?)\*\*/g, '*$1*');
line = line.replace(/__(.+?)__/g, '*$1*');
line = line.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '_$1_');
line = line.replace(/~~(.+?)~~/g, '~$1~');
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
line = line.replace(/\x00CODE(\d+)\x00/g, function(m, idx) {
return '`' + codeSegments[parseInt(idx)] + '`';
});
return line;
}
function renderSlackPreview(text) {
if (!text.trim()) return '';
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) {
line = line.replace(/`([^`]+)`/g, '<span class="slack-code">$1</span>');
line = line.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '<span class="slack-bold">$1</span>');
line = line.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<span class="slack-italic">$1</span>');
line = line.replace(/(?<!\w)~([^~]+)~(?!\w)/g, '<span class="slack-strike">$1</span>');
line = line.replace(/&lt;(https?:\/\/[^|]+)\|([^&]+)&gt;/g, '<a class="slack-link" href="#">$2</a>');
return line;
}
function escapeHtml(t) {
return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 미리보기 영역의 리치 텍스트를 클립보드에 복사
* → 슬랙에 붙여넣으면 서식이 보존됨
*/
window.copyPreview = function() {
const content = previewArea.innerHTML;
if (!content || previewArea.querySelectorAll('div, span, hr').length === 0) {
showToast('복사할 내용이 없습니다', 'warning');
return;
}
// 방법 1: Selection API로 리치 텍스트 복사
const range = document.createRange();
range.selectNodeContents(previewArea);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
document.execCommand('copy');
showToast('복사 완료! 슬랙에 Ctrl+V로 붙여넣으세요');
} catch(e) {
showToast('복사 실패 — 미리보기를 드래그해서 직접 복사해주세요', 'warning');
}
selection.removeAllRanges();
};
window.convert = function() {
const input = inputArea.value;
const output = convertToSlack(input);
previewArea.innerHTML = renderSlackPreview(output) ||
'<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;"><i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 수 있는 형태로 변환됩니다</div>';
};
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 = '';
previewArea.innerHTML = '<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;"><i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 수 있는 형태로 변환됩니다</div>';
inputArea.focus();
};
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') copyPreview();
});
})();
</script>
@endpush