feat(ui): apply inquiry list and modal design
Apply the supplied inquiry layout to the list and native write/detail dialogs while preserving existing API requests, author controls, attachments, comments and search. Keep legacy entry links and confirm before discarding drafts. Validation: 98 tests and syntax checks pass; mocked Chromium desktop/mobile and six author/session visibility scenarios pass.
This commit is contained in:
1 parent
dbf5dc3611
commit
c62630e22d
19 files changed
+1362
-277
No files matched your search
+301
-80
@@ -20,6 +20,7 @@
|
||||
};
|
||||
let authReady;
|
||||
let resolvedAuthUser = null;
|
||||
let inquiryUI = null;
|
||||
|
||||
function qs(selector, root) { return (root || document).querySelector(selector); }
|
||||
function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
|
||||
@@ -590,6 +591,14 @@
|
||||
let loadFailed = false;
|
||||
let receivedRemotePage = false;
|
||||
const searchStatus = qs('#searchStatus');
|
||||
const changes = new Map();
|
||||
const deleted = new Set();
|
||||
|
||||
function mergeChanges(remotePosts) {
|
||||
const result = remotePosts.filter(function (post) { return !deleted.has(post.id); }).map(function (post) { return changes.get(post.id) || post; });
|
||||
for (const post of changes.values()) if (!deleted.has(post.id) && !result.some(function (item) { return item.id === post.id; })) result.unshift(post);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPostCompany(post, user) { return getPostCompanyLabel(post, user); }
|
||||
|
||||
@@ -621,15 +630,19 @@
|
||||
const isNotice = post.category === "공지사항";
|
||||
const number = isNotice ? "공지" : filtered.length - ((page - 1) * pageSize + index);
|
||||
const status = normalizeStatus(post.status);
|
||||
const statusClass = status === "답변완료" ? "done" : (status === "문의검토" || status === "정밀검토" ? "review" : "");
|
||||
const statusClass = status === '완료' ? 'done' : status === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
|
||||
const company = getPostCompany(post, user);
|
||||
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
|
||||
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
|
||||
"<td>" + escapeHtml(company) + "</td><td>" + escapeHtml(post.department) + "</td><td>" + escapeHtml(post.author) + "</td>" +
|
||||
"<td class=\"subject\"><span class=\"subject-link\">" + (post.secret ? "<span class=\"lock\">🔒</span>" : "") + escapeHtml(post.title) + "</span>" + (!isNotice && post.date === "2026-09-17" ? "<span class=\"new-mark\">N</span>" : "") + "</td>" +
|
||||
"<td>" + escapeHtml(post.dateTime || post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
|
||||
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
|
||||
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
|
||||
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
|
||||
}).join("");
|
||||
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
|
||||
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
|
||||
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
|
||||
}); });
|
||||
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
|
||||
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
|
||||
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
|
||||
@@ -642,8 +655,8 @@
|
||||
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
|
||||
render();
|
||||
if (authReady) authReady.then(render);
|
||||
loadRemotePosts(function (result) { receivedRemotePage = true; posts = result.posts; render(); }).then(function (result) {
|
||||
posts = result.posts;
|
||||
loadRemotePosts(function (result) { receivedRemotePage = true; posts = mergeChanges(result.posts); render(); }).then(function (result) {
|
||||
posts = mergeChanges(result.posts);
|
||||
remoteLoaded = true;
|
||||
render();
|
||||
}).catch(function (error) {
|
||||
@@ -653,16 +666,26 @@
|
||||
render();
|
||||
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
|
||||
});
|
||||
return {
|
||||
upsert: function (post) { changes.set(post.id, post); posts = mergeChanges(posts); render(); },
|
||||
remove: function (id) { changes.delete(id); deleted.add(id); posts = posts.filter(function (post) { return post.id !== id; }); render(); }
|
||||
};
|
||||
}
|
||||
|
||||
function initWrite() {
|
||||
const form = qs('#qaForm');
|
||||
function initWrite(root) {
|
||||
const find = function (selector, container) { return qs(selector, container || root); };
|
||||
const form = find('#qaForm');
|
||||
if (!form) return;
|
||||
const user = getAuthUser();
|
||||
const errorBox = qs('#formError');
|
||||
if (!user) {
|
||||
qs('#writeFields').setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
const errorBox = find('#formError');
|
||||
Promise.resolve(authReady || user).then(function (profile) {
|
||||
for (const [selector, value] of [['#writeCompany', profile && profile.company], ['#writeDepartment', profile && profile.department], ['#writeAuthor', profile && (profile.name || profile.loginId)], ['#writeEmail', profile && profile.email]]) {
|
||||
const field = find(selector);
|
||||
if (field) field.value = value || '-';
|
||||
}
|
||||
const notice = find('[data-auth-state]');
|
||||
if (notice) notice.textContent = profile ? '로그인한 사용자 정보로 문의가 등록됩니다.' : '로그인 상태를 확인하지 못했습니다. 새로고침 후 다시 시도해주세요.';
|
||||
});
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
console.info('[QA] feedback submit started');
|
||||
@@ -671,17 +694,17 @@
|
||||
console.info('[QA] SSO user resolved:', Boolean(currentUser), currentUser ? { hasRequesterId: Boolean(currentUser.requesterId), hasTenantId: Boolean(currentUser.requesterTenantId), hasPhone: Boolean(currentUser.phone) } : null);
|
||||
if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; }
|
||||
if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; }
|
||||
const title = qs('#title').value.trim();
|
||||
const content = qs('#content').value.trim();
|
||||
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
|
||||
const title = find('#title').value.trim();
|
||||
const content = find('#content').value.trim();
|
||||
if (!find('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
|
||||
const feedbackId = makeUuid();
|
||||
const createdAt = new Date().toISOString();
|
||||
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: formatKoreanDateTime(createdAt, false), dateTime: formatKoreanDateTime(createdAt, true), createdAt: createdAt, status: '신규', secret: qs('#secret').checked, content: content };
|
||||
const submitButton = qs('#submitButton');
|
||||
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: find('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: formatKoreanDateTime(createdAt, false), dateTime: formatKoreanDateTime(createdAt, true), createdAt: createdAt, status: '신규', secret: find('#secret').checked, content: content };
|
||||
const submitButton = find('#submitButton');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = '등록 중...';
|
||||
try {
|
||||
const submission = await buildFeedbackSubmission(post, feedbackId, qs('#attachment').files);
|
||||
const submission = await buildFeedbackSubmission(post, feedbackId, find('#attachment').files);
|
||||
console.info('[QA] sending feedback to Worker:', config.createFeedbackPath);
|
||||
const result = await postToApi(config.createFeedbackPath, submission.body);
|
||||
console.info('[QA] Worker feedback response:', result);
|
||||
@@ -689,7 +712,8 @@
|
||||
post.attachments = submission.attachments;
|
||||
savePost(post);
|
||||
showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.');
|
||||
window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
|
||||
if (inquiryUI) inquiryUI.saved(post);
|
||||
else window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
|
||||
} catch (error) {
|
||||
console.error('[QA] feedback submit failed:', error);
|
||||
errorBox.textContent = error.message || '등록 중 오류가 발생했습니다.';
|
||||
@@ -700,14 +724,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function initDetail() {
|
||||
const detail = qs('#detail');
|
||||
async function initDetail(root, requestedId) {
|
||||
const find = function (selector, container) { return qs(selector, container || root); };
|
||||
const detail = find('#detail');
|
||||
if (!detail) return;
|
||||
const detailListLink = qs('.detail-actions > a', detail);
|
||||
const detailListLink = find('.detail-actions > a', detail);
|
||||
if (detailListLink) detailListLink.textContent = '목록으로';
|
||||
const bottomBackButton = qs('#backButton', detail);
|
||||
const bottomBackButton = find('#backButton', detail);
|
||||
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
|
||||
const id = new URLSearchParams(window.location.search).get("id") || "EG-1048";
|
||||
const id = requestedId || new URLSearchParams(window.location.search).get("id") || "EG-1048";
|
||||
const storedPost = getPosts().find(function (item) { return item.id === id; });
|
||||
let post = storedPost;
|
||||
try {
|
||||
@@ -718,42 +743,45 @@
|
||||
console.warn("[QA] feedback detail load failed:", error.message);
|
||||
}
|
||||
if (!post) {
|
||||
qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
|
||||
qs('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
|
||||
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
|
||||
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
|
||||
const commentBox = find('.comment-box');
|
||||
if (commentBox) commentBox.hidden = true;
|
||||
return;
|
||||
}
|
||||
qs('[data-detail-category]').textContent = post.category;
|
||||
qs('[data-detail-title]').textContent = post.title;
|
||||
find('[data-detail-category]').textContent = post.category;
|
||||
find('[data-detail-title]').textContent = post.title;
|
||||
const renderDetailStatus = function () {
|
||||
const statusElement = qs("[data-detail-status]");
|
||||
const statusElement = find("[data-detail-status]");
|
||||
const status = normalizeStatus(post.status) || "공지";
|
||||
if (!statusElement) return;
|
||||
statusElement.textContent = status;
|
||||
statusElement.classList.toggle("done", status === "완료");
|
||||
statusElement.classList.toggle("review", status !== "완료");
|
||||
if (statusElement.setAttribute) statusElement.setAttribute('data-status', status);
|
||||
};
|
||||
renderDetailStatus();
|
||||
qs('[data-detail-date]').textContent = post.dateTime || post.date;
|
||||
qs('[data-detail-author]').textContent = post.author;
|
||||
find('[data-detail-date]').textContent = post.dateTime || post.date;
|
||||
find('[data-detail-author]').textContent = post.author;
|
||||
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
|
||||
qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
|
||||
qs('[data-detail-department]').textContent = post.department;
|
||||
qs('[data-detail-content]').textContent = post.content;
|
||||
qs('[data-detail-secret]').hidden = !post.secret;
|
||||
const attachmentBox = qs('[data-detail-attachments]');
|
||||
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
|
||||
find('[data-detail-department]').textContent = post.department;
|
||||
find('[data-detail-content]').textContent = post.content;
|
||||
find('[data-detail-secret]').hidden = !post.secret;
|
||||
const attachmentBox = find('[data-detail-attachments]');
|
||||
const attachments = Array.isArray(post.attachments) ? post.attachments : [];
|
||||
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
|
||||
const file = normalizeFeedbackAttachment(attachment);
|
||||
if (!file.url) return '<div class="attachment-item">📎 ' + escapeHtml(file.name) + '</div>';
|
||||
return '<div class="attachment-item"><a href="' + escapeHtml(file.url) + '" target="_blank" rel="noopener noreferrer">📎 ' + escapeHtml(file.name) + '</a></div>';
|
||||
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
|
||||
const editButton = qs('#editButton');
|
||||
const deleteButton = qs('#deleteButton');
|
||||
const editForm = qs('#editForm');
|
||||
const editError = qs('#editError');
|
||||
const completionNotice = qs("#completionNotice");
|
||||
const completionDoneNotice = qs("#completionDoneNotice");
|
||||
const completionButton = qs("#completionButton");
|
||||
const editButton = find('#editButton');
|
||||
const deleteButton = find('#deleteButton');
|
||||
const editForm = find('#editForm');
|
||||
const editError = find('#editError');
|
||||
const completionNotice = find("#completionNotice");
|
||||
const completionDoneNotice = find("#completionDoneNotice");
|
||||
const completionButton = find("#completionButton");
|
||||
const renderCompletionNotice = function () {
|
||||
const owner = isPostOwner(post, detailUser);
|
||||
const completed = normalizeStatus(post.status) === "완료" || post.completionConfirmed;
|
||||
@@ -778,6 +806,7 @@
|
||||
post.completionConfirmed = true;
|
||||
post.status = "완료";
|
||||
updateStoredPost(post);
|
||||
if (inquiryUI) inquiryUI.changed(post);
|
||||
renderDetailStatus();
|
||||
renderCompletionNotice();
|
||||
showToast("문의가 완료 처리되었습니다.");
|
||||
@@ -789,49 +818,55 @@
|
||||
}
|
||||
});
|
||||
if (editButton && editForm) editButton.addEventListener('click', function () {
|
||||
qs('#editCategory').value = post.category || '일반문의';
|
||||
qs('#editTitle').value = post.title || '';
|
||||
qs('#editContent').value = post.content || '';
|
||||
qs('#editSecret').checked = Boolean(post.secret);
|
||||
find('#editCategory').value = post.category || '일반문의';
|
||||
find('#editTitle').value = post.title || '';
|
||||
find('#editContent').value = post.content || '';
|
||||
find('#editSecret').checked = Boolean(post.secret);
|
||||
editError.hidden = true;
|
||||
editError.style.display = 'none';
|
||||
editForm.hidden = false;
|
||||
detail.classList.add('is-editing');
|
||||
editForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (inquiryUI) inquiryUI.markClean(editForm);
|
||||
});
|
||||
const editCancel = find('#editCancel');
|
||||
if (editCancel && editForm) editCancel.addEventListener('click', function () {
|
||||
if (inquiryUI && !inquiryUI.discardForm(editForm)) return;
|
||||
editForm.reset(); editForm.hidden = true; detail.classList.remove('is-editing');
|
||||
if (inquiryUI) inquiryUI.markClean(editForm);
|
||||
});
|
||||
const editCancel = qs('#editCancel');
|
||||
if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; detail.classList.remove('is-editing'); });
|
||||
if (editForm) editForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
editError.hidden = true;
|
||||
editError.style.display = 'none';
|
||||
const title = qs('#editTitle').value.trim();
|
||||
const content = qs('#editContent').value.trim();
|
||||
const category = qs('#editCategory').value;
|
||||
const title = find('#editTitle').value.trim();
|
||||
const content = find('#editContent').value.trim();
|
||||
const category = find('#editCategory').value;
|
||||
if (!title || !content || !category) {
|
||||
editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.';
|
||||
editError.hidden = false;
|
||||
editError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
const saveButton = qs('button[type="submit"]', editForm);
|
||||
const saveButton = find('button[type="submit"]', editForm);
|
||||
saveButton.disabled = true;
|
||||
saveButton.textContent = '저장 중...';
|
||||
try {
|
||||
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: qs('#editSecret').checked ? 'true' : 'false' })
|
||||
body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: find('#editSecret').checked ? 'true' : 'false' })
|
||||
});
|
||||
post.category = category;
|
||||
post.title = title;
|
||||
post.content = content;
|
||||
post.secret = qs('#editSecret').checked;
|
||||
post.secret = find('#editSecret').checked;
|
||||
updateStoredPost(post);
|
||||
qs('[data-detail-category]').textContent = post.category;
|
||||
qs('[data-detail-title]').textContent = post.title;
|
||||
qs('[data-detail-content]').textContent = post.content;
|
||||
qs('[data-detail-secret]').hidden = !post.secret;
|
||||
if (inquiryUI) { inquiryUI.changed(post); inquiryUI.markClean(editForm); }
|
||||
find('[data-detail-category]').textContent = post.category;
|
||||
find('[data-detail-title]').textContent = post.title;
|
||||
find('[data-detail-content]').textContent = post.content;
|
||||
find('[data-detail-secret]').hidden = !post.secret;
|
||||
editForm.hidden = true;
|
||||
detail.classList.remove('is-editing');
|
||||
showToast('문의가 수정되었습니다.');
|
||||
@@ -850,7 +885,8 @@
|
||||
try {
|
||||
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' });
|
||||
removeStoredPost(post.id);
|
||||
window.location.href = 'index.html';
|
||||
if (inquiryUI) inquiryUI.deleted(post.id);
|
||||
else window.location.href = 'index.html';
|
||||
} catch (error) {
|
||||
deleteButton.disabled = false;
|
||||
showToast(error.message || '문의 삭제에 실패했습니다.');
|
||||
@@ -896,15 +932,34 @@
|
||||
}
|
||||
|
||||
let editingCommentId = null;
|
||||
const objectUrls = new Set();
|
||||
const previewUrls = new Set();
|
||||
function imageObjectUrl(file, preview) {
|
||||
const url = URL.createObjectURL(file);
|
||||
objectUrls.add(url);
|
||||
if (preview) previewUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
function clearImagePreview() {
|
||||
previewUrls.forEach(function (url) { URL.revokeObjectURL(url); objectUrls.delete(url); });
|
||||
previewUrls.clear();
|
||||
}
|
||||
if (inquiryUI) inquiryUI.onCleanup(root, function () { objectUrls.forEach(function (url) { URL.revokeObjectURL(url); }); });
|
||||
function isCommentOwner(comment, user) {
|
||||
return matchesAuthorId(comment.authorId, user);
|
||||
}
|
||||
|
||||
function renderComments(comments) {
|
||||
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
|
||||
qs('#commentCount').textContent = comments.length;
|
||||
const usedUrls = new Set(comments.flatMap(function (comment) {
|
||||
return comment.attachments.flatMap(function (attachment) { return [attachment.url, attachment.thumbnailUrl]; });
|
||||
}));
|
||||
objectUrls.forEach(function (url) {
|
||||
if (!usedUrls.has(url) && !previewUrls.has(url)) { URL.revokeObjectURL(url); objectUrls.delete(url); }
|
||||
});
|
||||
find('#commentCount').textContent = comments.length;
|
||||
const user = detailUser;
|
||||
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) {
|
||||
find('#comments').innerHTML = comments.length ? comments.map(function (comment) {
|
||||
const owner = Boolean(comment.id) && isCommentOwner(comment, user);
|
||||
const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner;
|
||||
const actions = owner ? '<div class="comment-actions"><button class="button button-outline" type="button" data-comment-edit="' + escapeHtml(comment.id) + '">수정</button><button class="button button-danger" type="button" data-comment-delete="' + escapeHtml(comment.id) + '">삭제</button></div>' : '';
|
||||
@@ -923,7 +978,7 @@
|
||||
}).catch(function (error) {
|
||||
console.warn('[QA] comments load failed:', error.message);
|
||||
});
|
||||
qs('#comments').addEventListener('click', async function (event) {
|
||||
find('#comments').addEventListener('click', async function (event) {
|
||||
const editButton = event.target.closest('[data-comment-edit]');
|
||||
const cancelButton = event.target.closest('[data-comment-cancel]');
|
||||
const saveButton = event.target.closest('[data-comment-save]');
|
||||
@@ -931,15 +986,19 @@
|
||||
if (editButton) {
|
||||
editingCommentId = editButton.getAttribute('data-comment-edit');
|
||||
comments = renderComments(comments);
|
||||
const input = qs('[data-comment-edit-input]');
|
||||
const input = find('[data-comment-edit-input]');
|
||||
if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
|
||||
return;
|
||||
}
|
||||
if (cancelButton) { editingCommentId = null; comments = renderComments(comments); return; }
|
||||
if (cancelButton) {
|
||||
const input = find('[data-comment-edit-input]');
|
||||
if (inquiryUI && input && !inquiryUI.discardEditor(input)) return;
|
||||
editingCommentId = null; comments = renderComments(comments); return;
|
||||
}
|
||||
if (saveButton) {
|
||||
const commentId = saveButton.getAttribute('data-comment-save');
|
||||
const comment = comments.find(function (item) { return item.id === commentId; });
|
||||
const input = qs('[data-comment-edit-input]');
|
||||
const input = find('[data-comment-edit-input]');
|
||||
const content = input ? input.value.trim() : '';
|
||||
if (!content) { showToast('댓글 내용을 입력해주세요.'); return; }
|
||||
saveButton.disabled = true;
|
||||
@@ -965,13 +1024,13 @@
|
||||
} catch (error) { showToast(error.message || '댓글 삭제에 실패했습니다.'); deleteButton.disabled = false; }
|
||||
}
|
||||
});
|
||||
const commentForm = qs('#commentForm');
|
||||
const commentForm = find('#commentForm');
|
||||
if (commentForm) commentForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const contentInput = qs('#commentContent');
|
||||
const errorBox = qs('#commentError');
|
||||
const submitButton = qs('#commentSubmit');
|
||||
const imageInput = qs('#commentImages');
|
||||
const contentInput = find('#commentContent');
|
||||
const errorBox = find('#commentError');
|
||||
const submitButton = find('#commentSubmit');
|
||||
const imageInput = find('#commentImages');
|
||||
const files = imageInput ? Array.prototype.slice.call(imageInput.files || []) : [];
|
||||
const content = contentInput.value.trim();
|
||||
errorBox.hidden = true;
|
||||
@@ -988,7 +1047,7 @@
|
||||
files.forEach(function (file) { payload.append('attachments', file, file.name); });
|
||||
}
|
||||
const result = await postToApi(commentsPath, payload);
|
||||
const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; });
|
||||
const localAttachments = files.map(function (file) { const url = imageObjectUrl(file); return { name: file.name, url: url, thumbnailUrl: url }; });
|
||||
const savedComment = normalizeComment(Object.assign({
|
||||
content: content,
|
||||
author: (getAuthUser() || {}).name || '작성자',
|
||||
@@ -1002,8 +1061,10 @@
|
||||
renderComments(comments);
|
||||
contentInput.value = '';
|
||||
if (imageInput) imageInput.value = '';
|
||||
const imagePreview = qs('#commentImagePreview');
|
||||
const imagePreview = find('#commentImagePreview');
|
||||
if (imagePreview) imagePreview.innerHTML = '';
|
||||
clearImagePreview();
|
||||
if (inquiryUI) inquiryUI.markClean(commentForm);
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
|
||||
errorBox.hidden = false;
|
||||
@@ -1012,21 +1073,181 @@
|
||||
submitButton.textContent = '댓글 등록';
|
||||
}
|
||||
});
|
||||
const commentImages = qs('#commentImages');
|
||||
const commentImagePreview = qs('#commentImagePreview');
|
||||
const commentImageLabel = qs('label[for="commentImages"]');
|
||||
const commentImages = find('#commentImages');
|
||||
const commentImagePreview = find('#commentImagePreview');
|
||||
const commentImageLabel = find('label[for="commentImages"]');
|
||||
if (commentImageLabel) commentImageLabel.textContent = '댓글 이미지 첨부';
|
||||
if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () {
|
||||
clearImagePreview();
|
||||
const files = Array.prototype.slice.call(commentImages.files || []);
|
||||
commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
const url = imageObjectUrl(file, true);
|
||||
return '<div class="comment-image-preview-item"><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(file.name) + '"><span>' + escapeHtml(file.name) + '</span></div>';
|
||||
}).join('');
|
||||
});
|
||||
refreshRemoteStatus(post).then(function (changed) {
|
||||
if (changed) renderDetailStatus();
|
||||
if (changed && (!inquiryUI || inquiryUI.isCurrent(root))) {
|
||||
renderDetailStatus();
|
||||
if (inquiryUI) inquiryUI.changed(post);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () { normalizePageHeading(); bindGlobal(); initList(); initWrite(); initDetail(); });
|
||||
function initInquiryUI(list) {
|
||||
const dialogs = { write: qs('#composeModal'), detail: qs('#detailModal') };
|
||||
if (!dialogs.write || !dialogs.detail) return;
|
||||
const baselines = new WeakMap();
|
||||
let active = null;
|
||||
let restoringHistory = false;
|
||||
let depth = 0;
|
||||
|
||||
function snapshot(form) {
|
||||
return JSON.stringify(Array.from(form.elements).filter(function (input) {
|
||||
return !input.readOnly && !input.disabled && !['submit', 'button'].includes(input.type);
|
||||
}).map(function (input) {
|
||||
return [input.id, input.type === 'file' ? Array.from(input.files).map(function (file) { return [file.name, file.size, file.lastModified]; }) : input.type === 'checkbox' ? input.checked : input.value];
|
||||
}));
|
||||
}
|
||||
|
||||
function dirty(form) { return !form.hidden && snapshot(form) !== baselines.get(form); }
|
||||
function dirtyEditor(input) { return input.value !== input.defaultValue; }
|
||||
function busy() { return active && Boolean(active.root.querySelector('button:disabled')); }
|
||||
function hasChanges() {
|
||||
return active && (qsa('form', active.root).some(dirty) || qsa('[data-comment-edit-input]', active.root).some(dirtyEditor));
|
||||
}
|
||||
function confirmDiscard() { return window.confirm('작성 중인 내용이 있습니다. 닫으면 입력한 내용이 사라집니다. 닫으시겠습니까?'); }
|
||||
function canLeave(force) {
|
||||
if (force || !active) return true;
|
||||
if (busy()) { showToast('처리가 끝난 후 닫을 수 있습니다.'); return false; }
|
||||
return !hasChanges() || confirmDiscard();
|
||||
}
|
||||
|
||||
function clear(restoreFocus) {
|
||||
if (!active) return;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.dialog.close();
|
||||
previous.cleanups.forEach(function (cleanup) { cleanup(); });
|
||||
const toast = qs('#toast');
|
||||
if (toast) document.body.append(toast);
|
||||
previous.dialog.querySelector('[data-modal-content]').replaceChildren();
|
||||
document.documentElement.classList.remove('inquiry-modal-open');
|
||||
if (restoreFocus) {
|
||||
window.scrollTo({ top: previous.scroll, behavior: 'instant' });
|
||||
const opener = previous.opener && previous.opener.isConnected ? previous.opener : qs('#writeButton') || qs('#boardHeading');
|
||||
if (opener) opener.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function routeURL(view, id) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('view'); url.searchParams.delete('id');
|
||||
if (view) url.searchParams.set('view', view);
|
||||
if (view === 'detail' && id) url.searchParams.set('id', id);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function mount(view, id, opener) {
|
||||
const scroll = active ? active.scroll : window.scrollY;
|
||||
const previousOpener = active && active.opener;
|
||||
clear(false);
|
||||
const dialog = dialogs[view];
|
||||
if (!dialog) return;
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('aria-busy', view === 'detail' ? 'true' : 'false');
|
||||
root.append(qs(view === 'write' ? '#writeTemplate' : '#detailTemplate').content.cloneNode(true));
|
||||
dialog.querySelector('[data-modal-content]').replaceChildren(root);
|
||||
const toast = qs('#toast');
|
||||
if (toast) dialog.append(toast);
|
||||
active = { view: view, id: id, root: root, dialog: dialog, opener: opener || previousOpener, scroll: scroll, cleanups: [] };
|
||||
qsa('form', root).forEach(function (form) { baselines.set(form, snapshot(form)); });
|
||||
document.documentElement.classList.add('inquiry-modal-open');
|
||||
dialog.showModal();
|
||||
const heading = root.querySelector('h2');
|
||||
if (heading) heading.focus({ preventScroll: true });
|
||||
try {
|
||||
if (view === 'write') initWrite(root);
|
||||
else if (id) await initDetail(root, id);
|
||||
else {
|
||||
root.querySelector('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
|
||||
root.querySelector('.comment-box').hidden = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (active && active.root === root) showToast('화면을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.');
|
||||
console.warn('[QA] dialog initialization failed:', error.message);
|
||||
} finally {
|
||||
root.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function open(view, id, opener, options) {
|
||||
options = options || {};
|
||||
if (!canLeave(options.force)) return;
|
||||
const url = routeURL(view, id);
|
||||
if (active || options.replace) history.replaceState({ qaModal: true }, '', url);
|
||||
else history.pushState({ qaModal: true }, '', url);
|
||||
depth = 1;
|
||||
mount(view, id, opener);
|
||||
}
|
||||
|
||||
function close(force) {
|
||||
if (!canLeave(force)) return;
|
||||
clear(true);
|
||||
if (depth === 1) history.back();
|
||||
else history.replaceState({ qaModal: false }, '', routeURL());
|
||||
}
|
||||
|
||||
inquiryUI = {
|
||||
open: open,
|
||||
isCurrent: function (root) { return Boolean(active && active.root === root); },
|
||||
markClean: function (form) { baselines.set(form, snapshot(form)); },
|
||||
discardForm: function (form) { return !dirty(form) || confirmDiscard(); },
|
||||
discardEditor: function (input) { return !dirtyEditor(input) || confirmDiscard(); },
|
||||
onCleanup: function (root, callback) { if (active && active.root === root) active.cleanups.push(callback); else callback(); },
|
||||
changed: function (post) { if (list) list.upsert(post); },
|
||||
saved: function (post) { if (list) list.upsert(post); open('detail', post.id, null, { force: true, replace: true }); },
|
||||
deleted: function (id) { if (list) list.remove(id); close(true); }
|
||||
};
|
||||
|
||||
qsa('[data-open-write]').forEach(function (button) {
|
||||
button.addEventListener('click', function (event) { event.preventDefault(); open('write', null, button); });
|
||||
});
|
||||
Object.values(dialogs).forEach(function (dialog) {
|
||||
dialog.addEventListener('cancel', function (event) { event.preventDefault(); close(false); });
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-modal-close]')) { close(false); return; }
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) close(false);
|
||||
});
|
||||
});
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
if (hasChanges() || busy()) { event.preventDefault(); event.returnValue = ''; }
|
||||
});
|
||||
window.addEventListener('popstate', function (event) {
|
||||
if (restoringHistory) { restoringHistory = false; return; }
|
||||
const nextDepth = event.state && event.state.qaModal ? 1 : 0;
|
||||
if (!canLeave(false)) {
|
||||
restoringHistory = true;
|
||||
history.go(depth > nextDepth ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
depth = nextDepth;
|
||||
const url = new URL(window.location.href);
|
||||
const view = url.searchParams.get('view');
|
||||
if (view === 'write' || view === 'detail') mount(view, url.searchParams.get('id'));
|
||||
else clear(true);
|
||||
});
|
||||
|
||||
const initial = new URL(window.location.href);
|
||||
const view = initial.searchParams.get('view');
|
||||
history.replaceState({ qaModal: false }, '', routeURL());
|
||||
if (view === 'write' || view === 'detail') open(view, initial.searchParams.get('id'));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
normalizePageHeading(); bindGlobal();
|
||||
const list = initList();
|
||||
if (qs('#composeModal')) initInquiryUI(list);
|
||||
else { initWrite(); initDetail(); }
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user