93037411fb
- product-swiper.js는 상품이 아니라 카페24 "디자인 보관함" 스킨 파일이라
Admin API(OAuth)로는 접근 불가 — 실물 확인(스크린샷) 결과 디자인 보관함
FTP 계정(호스트/포트/ID/PW, OAuth와 별개)으로만 읽기/쓰기 가능.
app/integrations/cafe24/design_ftp.py 를 표준 ftplib 로 새로 추가.
- 상단 탭에 "모바일 스와이프" 버튼 추가. 편집 화면(swiper.html)은 상세페이지
편집기(_editor.html)와 완전히 같은 문법강조·색상·단축키 JS를 그대로 옮겨
씀(요청사항) — 상품 전용 UI(목록·진열/판매·예약)는 제외.
- 적용 순서도 상세페이지와 동일한 원칙: FTP에서 현재값 재조회 → BACKUP →
지문 대조(충돌 거부) → FTP 쓰기 → MANUAL 버전 + 감사로그(apply_swiper).
textarea의 CRLF는 적용 전 LF로 정규화(안 하면 매번 "변경됨"으로 오판).
- 버전 이력은 cafe24_product_revisions(product_no NOT NULL)에 넣을 수 없어
새 테이블 cafe24_swiper_revisions 추가(scripts/sql/cafe24_db_003_*.sql,
서버에서 별도 실행 필요).
- 신규 env: CAFE24_FTP_HOST(미설정 시 {mall_id}.ftp.cafe24.com)/PORT/USER/
PASSWORD, CAFE24_SWIPER_FTP_PATH. .env.example·문서 갱신.
- 유닛테스트 4건 추가(FTP config 기본값, 가짜 FTP로 읽기/쓰기 왕복·오류 처리).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
350 lines
13 KiB
HTML
350 lines
13 KiB
HTML
{% extends "erp_base.html" %}
|
|
|
|
{% block head_extra %}
|
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{% include "cafe24/_nav.html" %}
|
|
|
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
|
|
|
{% if error %}
|
|
<div class="cf24-flash cf24-flash-err">
|
|
파일을 불러오지 못했습니다: {{ error }}<br />
|
|
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# 상세페이지 편집기(_editor.html)와 완전히 같은 문법강조·단축키·버튼을 쓴다.
|
|
목록/진열·판매/예약처럼 "상품" 전용인 것만 뺐다 — 이건 상품이 아니라
|
|
디자인 보관함(FTP)의 파일 1개다. #}
|
|
<section class="erp-card cf24-pane-editor" id="cf24-swiper-pane">
|
|
<p class="cf24-editor-sub"><code>{{ swiper_path }}</code></p>
|
|
|
|
{% if not error %}
|
|
<form class="cf24-editor-form" id="cf24-swiper-form" method="post"
|
|
action="/cafe24/swiper/apply"
|
|
data-confirm="카페24 디자인 보관함(FTP)에 바로 반영됩니다. 적용할까요? 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
|
|
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
|
|
|
<div class="cf24-editor-bar">
|
|
<span class="cf24-shortcuts">단축키: 주석토글[Ctrl+/] · 줄 복사[Alt+Shift+↑↓] · 줄 이동[Alt+↑↓] · 줄 삭제[Shift+Del]</span>
|
|
<span class="cf24-editor-bar-right">
|
|
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
|
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-swiper-code">복사</button>
|
|
<button class="erp-btn erp-btn-outline" type="button" id="cf24-swiper-reload"
|
|
title="카페24 디자인 보관함(FTP)에서 현재 파일을 다시 읽어옵니다.">다시 읽기</button>
|
|
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
|
</span>
|
|
</div>
|
|
|
|
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다 —
|
|
상세페이지 편집기와 동일한 방식(store.md 2-2 절 참고). #}
|
|
<div class="cf24-code" id="cf24-swiper-code-box">
|
|
<div class="cf24-gutter" aria-hidden="true"><div class="cf24-gutter-inner" id="cf24-swiper-gutter"></div></div>
|
|
<div class="cf24-code-body">
|
|
<pre class="cf24-code-hl" id="cf24-swiper-hl" aria-hidden="true"></pre>
|
|
<textarea id="cf24-swiper-code" class="cf24-code-input" name="content" wrap="off"
|
|
spellcheck="false" autocapitalize="off" autocorrect="off">{{ content }}</textarea>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
|
|
<details class="cf24-details">
|
|
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
|
|
{% if revisions %}
|
|
<div class="cf24-scroll">
|
|
<table class="erp-table cf24-compact">
|
|
<thead>
|
|
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for rev in revisions %}
|
|
<tr>
|
|
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
|
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
|
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
|
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
|
<td>{{ rev.memo or '' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="cf24-muted">아직 변경 이력이 없습니다.</p>
|
|
{% endif %}
|
|
</details>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function () {
|
|
var dirty = false;
|
|
|
|
/* ── 문법 강조 — 상세페이지 편집기와 완전히 같은 함수/색상(cafe24.css 의
|
|
cf24-t-* 클래스를 그대로 쓴다). 외부 라이브러리를 쓰지 않는다. */
|
|
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
|
|
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
|
|
var HL_LIMIT = 200000;
|
|
|
|
function esc(text) {
|
|
return text.replace(/[&<>]/g, function (c) {
|
|
return c === "&" ? "&" : c === "<" ? "<" : ">";
|
|
});
|
|
}
|
|
|
|
function paintAttrs(text) {
|
|
return text.replace(ATTR_RE, function (whole, name, eq, val) {
|
|
if (!name) return esc(whole);
|
|
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
|
|
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
|
|
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
|
|
return out;
|
|
});
|
|
}
|
|
|
|
function paintHtml(src) {
|
|
var out = "", last = 0, m;
|
|
TOKEN_RE.lastIndex = 0;
|
|
while ((m = TOKEN_RE.exec(src)) !== null) {
|
|
out += esc(src.slice(last, m.index));
|
|
last = TOKEN_RE.lastIndex;
|
|
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
|
|
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
|
|
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
|
|
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
|
|
paintAttrs(m[5]) +
|
|
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
|
|
}
|
|
return out + esc(src.slice(last));
|
|
}
|
|
|
|
function setupCodeEditor() {
|
|
var ta = document.getElementById("cf24-swiper-code");
|
|
var hl = document.getElementById("cf24-swiper-hl");
|
|
var gutter = document.getElementById("cf24-swiper-gutter");
|
|
if (!ta || !hl) return;
|
|
|
|
var timer = null;
|
|
|
|
function renderGutter(count) {
|
|
if (!gutter || gutter.dataset.lines === String(count)) return;
|
|
var out = "";
|
|
for (var i = 1; i <= count; i++) out += i + "\n";
|
|
gutter.textContent = out;
|
|
gutter.dataset.lines = String(count);
|
|
}
|
|
|
|
function sync() {
|
|
var x = ta.scrollLeft, y = ta.scrollTop;
|
|
hl.style.transform = "translate(" + -x + "px," + -y + "px)";
|
|
if (gutter) gutter.style.transform = "translateY(" + -y + "px)";
|
|
}
|
|
|
|
function repaint() {
|
|
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
|
|
else hl.innerHTML = paintHtml(ta.value) + "\n";
|
|
renderGutter(ta.value.split("\n").length);
|
|
sync();
|
|
}
|
|
|
|
function refresh() {
|
|
if (ta.value.length < 50000) { repaint(); return; }
|
|
clearTimeout(timer);
|
|
timer = setTimeout(repaint, 80);
|
|
}
|
|
|
|
/* ── 편집 단축키 공용 헬퍼(상세페이지 편집기와 동일) ── */
|
|
|
|
function lineRange() {
|
|
var value = ta.value;
|
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
|
if (end > start && value.charAt(end - 1) === "\n") end -= 1;
|
|
var from = value.lastIndexOf("\n", start - 1) + 1;
|
|
var to = value.indexOf("\n", end);
|
|
if (to === -1) to = value.length;
|
|
return { value: value, start: start, end: end, from: from, to: to };
|
|
}
|
|
|
|
function applyEdit(from, to, text, selStart, selEnd) {
|
|
var value = ta.value;
|
|
ta.selectionStart = from;
|
|
ta.selectionEnd = to;
|
|
var inserted = false;
|
|
try {
|
|
inserted = text === ""
|
|
? document.execCommand("delete")
|
|
: document.execCommand("insertText", false, text);
|
|
} catch (err) { inserted = false; }
|
|
if (!inserted) ta.value = value.slice(0, from) + text + value.slice(to);
|
|
ta.selectionStart = selStart;
|
|
ta.selectionEnd = selEnd;
|
|
dirty = true;
|
|
refresh();
|
|
}
|
|
|
|
var COMMENT_MARK = /<!--[ \t]?|[ \t]?-->/g;
|
|
|
|
function toggleComment() {
|
|
var r = lineRange();
|
|
var block = r.value.slice(r.from, r.to);
|
|
if (!block.trim()) return;
|
|
|
|
var caretIn = r.start - r.from;
|
|
var shift = 0, out;
|
|
|
|
COMMENT_MARK.lastIndex = 0;
|
|
if (COMMENT_MARK.test(block)) {
|
|
COMMENT_MARK.lastIndex = 0;
|
|
out = block.replace(COMMENT_MARK, function (mark, offset) {
|
|
if (offset < caretIn) shift -= mark.length;
|
|
return "";
|
|
});
|
|
} else {
|
|
var indent = block.match(/^[ \t]*/)[0];
|
|
out = indent + "<!-- " + block.slice(indent.length) + " -->";
|
|
shift = caretIn >= indent.length ? 5 : 0;
|
|
}
|
|
|
|
if (r.start === r.end) {
|
|
var pos = r.from + Math.min(out.length, Math.max(0, caretIn + shift));
|
|
applyEdit(r.from, r.to, out, pos, pos);
|
|
} else {
|
|
applyEdit(r.from, r.to, out, r.from, r.from + out.length);
|
|
}
|
|
}
|
|
|
|
function moveLines(down) {
|
|
var r = lineRange();
|
|
var block = r.value.slice(r.from, r.to);
|
|
if (!down) {
|
|
if (r.from === 0) return;
|
|
var prevFrom = r.value.lastIndexOf("\n", r.from - 2) + 1;
|
|
var prev = r.value.slice(prevFrom, r.from - 1);
|
|
var up = -(prev.length + 1);
|
|
applyEdit(prevFrom, r.to, block + "\n" + prev, r.start + up, r.end + up);
|
|
} else {
|
|
if (r.to >= r.value.length) return;
|
|
var nextTo = r.value.indexOf("\n", r.to + 1);
|
|
if (nextTo === -1) nextTo = r.value.length;
|
|
var next = r.value.slice(r.to + 1, nextTo);
|
|
var dn = next.length + 1;
|
|
applyEdit(r.from, nextTo, next + "\n" + block, r.start + dn, r.end + dn);
|
|
}
|
|
}
|
|
|
|
function duplicateLines(down) {
|
|
var r = lineRange();
|
|
var block = r.value.slice(r.from, r.to);
|
|
var text = block + "\n" + block;
|
|
var shift = down ? block.length + 1 : 0;
|
|
applyEdit(r.from, r.to, text, r.start + shift, r.end + shift);
|
|
}
|
|
|
|
function deleteLines() {
|
|
var r = lineRange();
|
|
var from = r.from, to = r.to;
|
|
if (to < r.value.length) to += 1;
|
|
else if (from > 0) from -= 1;
|
|
if (from === to) return;
|
|
|
|
var rest = r.value.slice(0, from) + r.value.slice(to);
|
|
var lineFrom = rest.lastIndexOf("\n", from - 1) + 1;
|
|
var lineTo = rest.indexOf("\n", lineFrom);
|
|
if (lineTo === -1) lineTo = rest.length;
|
|
var col = Math.min(Math.max(0, r.start - r.from), lineTo - lineFrom);
|
|
applyEdit(from, to, "", lineFrom + col, lineFrom + col);
|
|
}
|
|
|
|
ta.addEventListener("scroll", sync);
|
|
ta.addEventListener("input", function () { dirty = true; refresh(); });
|
|
ta.addEventListener("keydown", function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey &&
|
|
(e.key === "/" || e.code === "Slash" || e.code === "NumpadDivide")) {
|
|
e.preventDefault();
|
|
toggleComment();
|
|
return;
|
|
}
|
|
var isUp = e.key === "ArrowUp" || e.code === "ArrowUp";
|
|
var isDown = e.key === "ArrowDown" || e.code === "ArrowDown";
|
|
if (e.altKey && !e.ctrlKey && !e.metaKey && (isUp || isDown)) {
|
|
e.preventDefault();
|
|
if (e.shiftKey) duplicateLines(isDown);
|
|
else moveLines(isDown);
|
|
return;
|
|
}
|
|
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey &&
|
|
(e.key === "Delete" || e.code === "Delete")) {
|
|
e.preventDefault();
|
|
deleteLines();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab") return;
|
|
e.preventDefault();
|
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
|
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
|
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
dirty = true;
|
|
refresh();
|
|
});
|
|
|
|
repaint();
|
|
}
|
|
|
|
function confirmLeave() {
|
|
if (!dirty) return Promise.resolve(true);
|
|
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
|
}
|
|
|
|
setupCodeEditor();
|
|
|
|
document.querySelectorAll("[data-copy]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var box = document.getElementById(btn.dataset.copy);
|
|
if (!box) return;
|
|
var done = function () {
|
|
var old = btn.textContent;
|
|
btn.textContent = "복사됨";
|
|
setTimeout(function () { btn.textContent = old; }, 1500);
|
|
};
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
|
|
} else {
|
|
box.select();
|
|
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
|
|
}
|
|
});
|
|
});
|
|
|
|
var reloadBtn = document.getElementById("cf24-swiper-reload");
|
|
if (reloadBtn) {
|
|
reloadBtn.addEventListener("click", function () {
|
|
confirmLeave().then(function (ok) { if (ok) location.reload(); });
|
|
});
|
|
}
|
|
|
|
var form = document.getElementById("cf24-swiper-form");
|
|
if (form) {
|
|
form.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
window.erpConfirm(form.dataset.confirm).then(function (ok) {
|
|
if (!ok) return;
|
|
dirty = false;
|
|
form.submit();
|
|
});
|
|
});
|
|
}
|
|
|
|
window.addEventListener("beforeunload", function (e) {
|
|
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|