Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 152 additions & 21 deletions src/main/resources/templates/notifications/compose.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h1 class="h3 mb-1 fw-semibold">대상 공지 발송</h1>
<p class="text-muted-soft mb-0">닉네임으로 사용자를 선택해 관리자 메시지를 푸시 및 인앱 알림으로 발송합니다.</p>
<p class="text-muted-soft mb-0">ID 또는 닉네임으로 사용자를 선택해 관리자 메시지를 푸시 및 인앱 알림으로 발송합니다.</p>
</div>
<a th:href="@{/notices}" class="btn btn-outline-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-megaphone"></i>
Expand All @@ -32,11 +32,11 @@ <h1 class="h3 mb-1 fw-semibold">대상 공지 발송</h1>
<form id="notificationSendForm" class="row g-4" method="post" th:action="@{/notifications/send}"
autocomplete="off" onsubmit="return confirm('입력한 대상에게 공지를 발송할까요?');">
<div class="col-12">
<label class="form-label fw-semibold" for="recipientSearch">수신자 닉네임</label>
<label class="form-label fw-semibold" for="recipientSearch">수신자 검색</label>
<input id="notificationUserIds" name="userIds" type="hidden" th:value="${form.userIdsRaw}">
<div class="position-relative">
<input id="recipientSearch" type="search" class="form-control form-control-lg"
placeholder="닉네임을 입력해 검색하세요"
placeholder="ID 또는 닉네임 입력 (여러 ID는 콤마/공백으로 구분)"
autocomplete="off"
th:disabled="${!canSend}">
<div id="recipientSuggestions"
Expand All @@ -45,7 +45,8 @@ <h1 class="h3 mb-1 fw-semibold">대상 공지 발송</h1>
</div>
<div id="selectedRecipients" class="d-flex flex-wrap gap-2 mt-3"></div>
<div class="form-text">
닉네임 검색 결과에서 사용자를 선택하세요. 중복 선택은 자동으로 제외됩니다.
닉네임으로 검색해 결과를 클릭하거나, ID(숫자)를 직접 입력해 추가할 수 있습니다.
콤마/공백으로 구분해 여러 ID를 한 번에 추가하거나 Enter로 첫 번째 항목을 선택하세요.
</div>
</div>

Expand Down Expand Up @@ -159,12 +160,42 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
if (!user) {
return '';
}
if (user.nickname && user.nickname.charAt(0) === '#') {
if (!user.nickname) {
return '#' + user.id;
}
if (user.nickname.charAt(0) === '#') {
return user.nickname;
}
return user.nickname + ' (#' + user.id + ')';
}

function parseNumericTokens(raw) {
if (!raw) {
return null;
}
var tokens = raw.trim().split(/[,\s]+/).filter(Boolean);
if (tokens.length === 0) {
return null;
}
var seen = {};
var ids = [];
for (var i = 0; i < tokens.length; i++) {
if (!/^\d+$/.test(tokens[i])) {
return null;
}
var n = parseInt(tokens[i], 10);
if (!(n > 0)) {
return null;
}
var idStr = String(n);
if (!seen[idStr]) {
seen[idStr] = true;
ids.push(idStr);
}
}
return ids;
}

function syncSelectedRecipients() {
if (userIdsEl) {
userIdsEl.value = selectedUsers.map(function (user) {
Expand Down Expand Up @@ -243,7 +274,71 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
syncPreview();
}

function renderSuggestions(users) {
function addRecipientsByIds(ids) {
ids.forEach(function (rawId) {
var id = String(rawId);
var exists = selectedUsers.some(function (selected) {
return selected.id === id;
});
if (!exists) {
selectedUsers.push({ id: id, nickname: '#' + id });
}
});
if (searchEl) {
searchEl.value = '';
searchEl.focus();
}
hideSuggestions();
syncSelectedRecipients();
syncPreview();
}

function buildSuggestionButton(leftText, rightText, onClick) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
var leftSpan = document.createElement('span');
leftSpan.textContent = leftText;
var rightSpan = document.createElement('span');
rightSpan.className = 'text-muted small';
rightSpan.textContent = rightText;
button.appendChild(leftSpan);
button.appendChild(rightSpan);
button.addEventListener('click', onClick);
return button;
}

function renderBulkIdSuggestions(ids) {
if (!suggestionsEl) {
return;
}
suggestionsEl.innerHTML = '';
var unselected = ids.filter(function (id) {
return !selectedUsers.some(function (selected) {
return selected.id === id;
});
});
if (unselected.length === 0) {
showSuggestionMessage('입력한 ID가 모두 이미 추가되어 있습니다.');
return;
}
var preview = unselected.slice(0, 5).map(function (id) {
return '#' + id;
}).join(', ');
if (unselected.length > 5) {
preview += ' 외 ' + (unselected.length - 5) + '개';
}
suggestionsEl.appendChild(buildSuggestionButton(
unselected.length + '개 ID 일괄 추가',
preview,
function () {
addRecipientsByIds(unselected);
}
));
suggestionsEl.classList.remove('d-none');
}

function renderSuggestions(users, directAddId) {
if (!suggestionsEl) {
return;
}
Expand All @@ -253,22 +348,32 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
return selected.id === String(user.id);
});
});
if (candidates.length === 0) {
showSuggestionMessage('검색 결과가 없습니다.');
return;
var needsDirectAdd = directAddId
&& !selectedUsers.some(function (s) { return s.id === directAddId; })
&& !candidates.some(function (u) { return String(u.id) === directAddId; });
if (needsDirectAdd) {
suggestionsEl.appendChild(buildSuggestionButton(
'ID 그대로 추가',
'#' + directAddId,
function () {
addRecipientsByIds([directAddId]);
}
));
}
candidates.forEach(function (user) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
button.innerHTML = '<span></span><span class="text-muted small"></span>';
button.querySelector('span:first-child').textContent = user.nickname;
button.querySelector('span:last-child').textContent = '#' + user.id;
button.addEventListener('click', function () {
addRecipient(user);
});
suggestionsEl.appendChild(button);
var label = user.nickname || '(닉네임 없음)';
suggestionsEl.appendChild(buildSuggestionButton(
label,
'#' + user.id,
function () {
addRecipient(user);
}
));
});
if (!suggestionsEl.firstChild) {
showSuggestionMessage('검색 결과가 없습니다.');
return;
}
suggestionsEl.classList.remove('d-none');
}

Expand All @@ -278,6 +383,13 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
hideSuggestions();
return;
}
var numericIds = parseNumericTokens(trimmed);
if (numericIds && numericIds.length > 1) {
latestSearchSeq++;
renderBulkIdSuggestions(numericIds);
return;
}
var directAddId = numericIds && numericIds.length === 1 ? numericIds[0] : null;
var searchSeq = ++latestSearchSeq;
fetch('/notifications/users/search?q=' + encodeURIComponent(trimmed), {
headers: {
Expand All @@ -295,10 +407,15 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
if (searchSeq !== latestSearchSeq) {
return;
}
renderSuggestions(data && data.users ? data.users : []);
renderSuggestions(data && data.users ? data.users : [], directAddId);
})
.catch(function () {
if (searchSeq === latestSearchSeq) {
if (searchSeq !== latestSearchSeq) {
return;
}
if (directAddId) {
renderSuggestions([], directAddId);
} else {
showSuggestionMessage('사용자 검색에 실패했습니다.');
}
});
Expand Down Expand Up @@ -345,6 +462,20 @@ <h2 class="h6 fw-semibold mb-3">미리보기</h2>
searchEl.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
hideSuggestions();
return;
}
if (event.key === 'Enter') {
if (suggestionsEl && !suggestionsEl.classList.contains('d-none')) {
var firstAction = suggestionsEl.querySelector('button.list-group-item-action');
if (firstAction) {
event.preventDefault();
firstAction.click();
return;
}
}
if (searchEl.value.trim()) {
event.preventDefault();
}
}
});
}
Expand Down
13 changes: 9 additions & 4 deletions src/main/resources/templates/users/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h1 class="h3 mb-1 fw-semibold">사용자 목록</h1>
<p class="text-muted-soft mb-0">대상 공지 발송에 사용할 사용자 ID와 닉네임을 조회합니다.</p>
<p class="text-muted-soft mb-0">ID 또는 닉네임으로 사용자를 조회합니다.</p>
</div>
</div>

Expand All @@ -18,9 +18,9 @@ <h1 class="h3 mb-1 fw-semibold">사용자 목록</h1>
<div class="card-body card-body-comfy">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted" for="userQuery">닉네임 검색</label>
<label class="form-label small text-uppercase text-muted" for="userQuery">ID/닉네임 검색</label>
<input type="search" class="form-control" id="userQuery" name="q"
placeholder="닉네임 일부를 입력하세요" th:value="${query}"
placeholder="ID 또는 닉네임 일부를 입력하세요" th:value="${query}"
th:disabled="${!canViewUsers}">
</div>
<div class="col-md-2">
Expand Down Expand Up @@ -53,7 +53,12 @@ <h1 class="h3 mb-1 fw-semibold">사용자 목록</h1>
</tr>
<tr th:each="user : ${users}">
<td class="fw-semibold" th:text="${'#' + user.id()}">#501</td>
<td th:text="${user.nickname()}">솔바람</td>
<td>
<span th:if="${user.nickname() != null and !#strings.isEmpty(user.nickname())}"
th:text="${user.nickname()}">솔바람</span>
<span th:unless="${user.nickname() != null and !#strings.isEmpty(user.nickname())}"
class="text-muted small">(닉네임 없음)</span>
</td>
</tr>
</tbody>
</table>
Expand Down
Loading