This commit is contained in:
lunaticbum 2025-09-23 15:37:51 +09:00
parent d6043543a1
commit 0ce20e4bf1
8 changed files with 634 additions and 49 deletions

View File

@ -40,7 +40,7 @@ class AppConfig : WebMvcConfigurer {
"/bums/where.bs", "/bums/where.bs",
"/user/info", // "내 정보" 페이지도 추가하면 좋습니다. "/user/info", // "내 정보" 페이지도 추가하면 좋습니다.
"/tlg/repotToMe.bjx", "/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs", "/user/login.bjx", "/user/login.bs", "/user/signup.bs", "/user/login.bjx","/bookmarks/**",
"/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx" "/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx"
) )
} }

View File

@ -889,19 +889,18 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
// [수정] 서비스 호출 시 필터 파라미터 전달 // [수정] 서비스 호출 시 필터 파라미터 전달
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle() val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle()
vm.modelMap["bookmarksPage"] = bookmarksPage.map { // [수정] Page 객체의 내용을 변환하는 로직 추가
val processedBookmarksPage = bookmarksPage.map { bookmark ->
it.contentUrls = arrayListOf<String>().apply { // 'images' 필드가 비어있고 구 버전 'contentUrls'에 데이터가 있다면 변환
if (it.thumbnailUrl.isNullOrEmpty() == false) { if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
add(it.thumbnailUrl!!) bookmark.copy(
images = bookmark.contentUrls.map { url -> BookmarkImage(url = url, isVisible = true) }
)
} else {
bookmark
} }
if (it.userSelectedImageUrl.isNullOrEmpty() == false) {
add(it.userSelectedImageUrl!!)
}
addAll(it.contentUrls)
}
it
} }
vm.modelMap["bookmarksPage"] = processedBookmarksPage
// [추가] 뷰에서 사용할 필터 목록과 현재 선택된 필터 전달 // [추가] 뷰에서 사용할 필터 목록과 현재 선택된 필터 전달
vm.modelMap["allCategories"] = bookmarkService.findAllDistinctCategories().collectList().awaitSingle() vm.modelMap["allCategories"] = bookmarkService.findAllDistinctCategories().collectList().awaitSingle()
@ -972,7 +971,20 @@ class BookmarkController(private val bookmarkService: WebBookmarkService,
pageable: Pageable pageable: Pageable
): ResponseEntity<Page<WebBookmark>> { ): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle() val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
return ResponseEntity.ok(bookmarksPage) // val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle()
// [수정] Page 객체의 내용을 변환하는 로직 추가
val processedBookmarksPage = bookmarksPage.map { bookmark ->
// 'images' 필드가 비어있고 구 버전 'contentUrls'에 데이터가 있다면 변환
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark.copy(
images = bookmark.contentUrls.map { url -> BookmarkImage(url = url, isVisible = true) }
)
} else {
bookmark
}
}
return ResponseEntity.ok(processedBookmarksPage)
} }
/** /**
@ -1041,7 +1053,17 @@ class BookmarkApiController(
): ResponseEntity<Page<WebBookmark>> { ): ResponseEntity<Page<WebBookmark>> {
// 기존 서비스 메서드를 그대로 사용하여 사용자 권한에 맞는 북마크 목록을 가져옴 // 기존 서비스 메서드를 그대로 사용하여 사용자 권한에 맞는 북마크 목록을 가져옴
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle() val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
return ResponseEntity.ok(bookmarksPage) val processedBookmarksPage = bookmarksPage.map { bookmark ->
// 'images' 필드가 비어있고 구 버전 'contentUrls'에 데이터가 있다면 변환
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark.copy(
images = bookmark.contentUrls.map { url -> BookmarkImage(url = url, isVisible = true) }
)
} else {
bookmark
}
}
return ResponseEntity.ok(processedBookmarksPage)
} }
data class BookmarkDataDto( data class BookmarkDataDto(
@ -1225,7 +1247,7 @@ class BookmarkApiController(
@PathVariable id: String, @PathVariable id: String,
@RequestBody request: BookmarkUpdateRequest, @RequestBody request: BookmarkUpdateRequest,
@AuthenticationPrincipal userDetails: UserDetails? @AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> { // [수정] 반환 타입 변경 ): ResponseEntity<*> {
logService.log("북마크 업데이트 요청: ID=$id, 사용자=${userDetails?.username}") logService.log("북마크 업데이트 요청: ID=$id, 사용자=${userDetails?.username}")
// 1. 사용자 인증 정보 확인 // 1. 사용자 인증 정보 확인
@ -1247,12 +1269,14 @@ class BookmarkApiController(
.body(mapOf("message" to "이 북마크를 수정할 권한이 없습니다.")) .body(mapOf("message" to "이 북마크를 수정할 권한이 없습니다."))
} }
// 4. 업데이트 실행 // 4. [수정됨] 안전한 업데이트 실행
val updatedBookmark = existingBookmark.copy( val updatedBookmark = existingBookmark.copy(
title = request.title ?: existingBookmark.title, title = request.title ?: existingBookmark.title,
userComment = request.userComment, // 요청된 값이 null이 아닐 경우에만 업데이트하도록 변경
userComment = request.userComment ?: existingBookmark.userComment,
visibility = request.visibility ?: existingBookmark.visibility, visibility = request.visibility ?: existingBookmark.visibility,
category = request.category, // 요청된 값이 null이 아닐 경우에만 업데이트하도록 변경
category = request.category ?: existingBookmark.category,
tags = request.tags ?: existingBookmark.tags tags = request.tags ?: existingBookmark.tags
) )
@ -1267,6 +1291,134 @@ class BookmarkApiController(
} }
} }
// [이 함수를 BookmarkApiController 내부에 추가]
@PostMapping("/{id}/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
suspend fun addImagesToBookmark(
@PathVariable id: String,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> {
if (userDetails == null || uploadPath.isNullOrBlank()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build<Unit>()
}
var bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build<Unit>()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build<Unit>()
}
// [신규] 기존 contentUrls를 새로운 images 형식으로 마이그레이션하는 로직
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark = bookmark.copy(
images = bookmark.contentUrls.map { BookmarkImage(url = it, isVisible = true) },
contentUrls = emptyList() // 이전 필드는 비워줍니다.
)
}
val newImages = files.mapNotNull { file ->
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
file.transferTo(targetPath.toFile())
// 새로운 BookmarkImage 객체로 생성
BookmarkImage(url = "/api/images/$uniqueFilename", isVisible = true)
} catch (e: Exception) {
null
}
}
val updatedBookmark = bookmark.copy(
images = bookmark.images + newImages
)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
data class ImageUrlRequest(val imageUrl: String)
// [신규] 이미지 '보임/숨김' 상태 변경을 위한 DTO
data class ImageVisibilityRequest(val imageUrl: String)
// [이 함수를 BookmarkApiController 내부에 추가 또는 교체]
// 기존 DELETE에서 PUT으로 변경하고, 로직을 '숨김' 처리로 변경합니다.
@PutMapping("/{id}/images/visibility")
suspend fun updateImageVisibility(
@PathVariable id: String,
@RequestBody request: ImageVisibilityRequest,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> {
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build<Unit>()
}
var bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build<Unit>()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build<Unit>()
}
// [신규] 마이그레이션 로직 추가
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark = bookmark.copy(
images = bookmark.contentUrls.map { BookmarkImage(url = it, isVisible = true) },
contentUrls = emptyList()
)
}
val updatedImages = bookmark.images.map {
if (it.url == request.imageUrl) {
it.copy(isVisible = !it.isVisible) // isVisible 상태를 반전시킵니다.
} else {
it
}
}
val updatedBookmark = bookmark.copy(images = updatedImages)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
// [이 함수도 BookmarkApiController 내부에 추가]
@DeleteMapping("/{id}/images")
suspend fun removeImageFromBookmark(
@PathVariable id: String,
@RequestBody request: ImageUrlRequest,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<Any> {
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
}
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
// 물리적 파일 삭제 (선택 사항이지만 권장)
try {
val filename = request.imageUrl.substringAfterLast("/")
val filePath = Paths.get(uploadPath, filename)
if (filePath.exists()) {
Files.delete(filePath)
}
} catch (e: Exception) {
logService.log("Failed to delete image file: ${request.imageUrl}, Error: ${e.message}")
}
val updatedBookmark = bookmark.copy(
contentUrls = bookmark.contentUrls.filter { it != request.imageUrl }
)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
} }

View File

@ -1060,6 +1060,11 @@ enum class BookmarkType {
IMAGE, // 하나 이상의 이미지 IMAGE, // 하나 이상의 이미지
VIDEO // 하나 이상의 비디오 VIDEO // 하나 이상의 비디오
} }
data class BookmarkImage(
val url: String = "",
var isVisible: Boolean = true
)
@Document(collection = "WebBookmark") @Document(collection = "WebBookmark")
data class WebBookmark( data class WebBookmark(
@BsonId @BsonId
@ -1070,7 +1075,10 @@ data class WebBookmark(
// [신규] 북마크 타입 (URL, IMAGE, VIDEO 등) // [신규] 북마크 타입 (URL, IMAGE, VIDEO 등)
var bookmarkType: String = BookmarkType.URL.name, var bookmarkType: String = BookmarkType.URL.name,
// [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능) // [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능)
var contentUrls: List<String> = emptyList(), @Deprecated("Use images list instead")
var contentUrls: List<String> = emptyList(), // 이전 버전과의 호환성을 위해 남겨둡니다.
var images: List<BookmarkImage> = emptyList(), // URL과 'isVisible' 상태를 함께 저장하는 새 필드
var title: String? = null, // 페이지 제목 var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그) var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그) var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)

View File

@ -4083,3 +4083,102 @@ a.btn_layerClose:hover {
.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */ .where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */
/* display: none; */ /* display: none; */
} }
/* --- [NEW] 북마크 수정 팝업 스타일 --- */
.images-list-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f9f9f9;
min-height: 80px;
margin-bottom: 1em;
max-height: 230px; /* 이미지 목록 영역의 최대 높이를 230px로 제한 */
overflow-y: auto; /* 내용이 넘칠 경우 세로 스크롤바 자동 표시 */
}
/* 이미지 목록 스크롤바 스타일 (선택 사항) */
.images-list-container::-webkit-scrollbar {
width: 8px;
}
.images-list-container::-webkit-scrollbar-track {
background: #e9e9e9;
border-radius: 10px;
}
.images-list-container::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 10px;
}
.image-preview-item {
position: relative;
width: 100px;
height: 100px;
}
.image-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.delete-image-btn {
position: absolute;
top: -5px;
right: -5px;
background: red;
color: white;
border: none;
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
font-weight: bold;
line-height: 22px;
text-align: center;
}
.form-control-wrapper {
margin-top: 1em;
padding: 0.75em;
border: 1px solid #ddd;
border-radius: 5px;
cursor: pointer;
}
.tag-display-box {
min-height: 24px;
}
.tag-display-box .tag-item {
display: inline-block;
background-color: #e9e9e9;
border-radius: 5px;
padding: 3px 8px;
font-size: 0.9em;
margin: 2px;
}
.image-preview-item.is-hidden img {
opacity: 0.3;
filter: grayscale(100%);
}
.image-preview-item .toggle-visibility-btn {
position: absolute;
top: 5px;
right: 5px;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 16px;
line-height: 28px;
text-align: center;
padding: 0;
}
.image-preview-item.is-hidden .toggle-visibility-btn {
background: #28a745; /* 숨겨진 아이템을 다시 보이게 하는 버튼은 초록색으로 표시 */
}

View File

@ -1840,9 +1840,26 @@ function renderStagedBookmarkCategory() {
area.innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory} <span class="remove-tag" onclick="stagedBookmarkCategory=''; renderStagedBookmarkCategory();">X</span></span>` : '<i>선택된 카테고리 없음</i>'; area.innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory} <span class="remove-tag" onclick="stagedBookmarkCategory=''; renderStagedBookmarkCategory();">X</span></span>` : '<i>선택된 카테고리 없음</i>';
} }
function applyBookmarkCategory() { function applyBookmarkCategory() {
document.getElementById(bookmarkPopupTargets.inputId).value = stagedBookmarkCategory; // 1. 숨겨진 input 필드에 선택한 카테고리 값 저장
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkCategory ? `<span class="tag-item">${stagedBookmarkCategory}</span>` : '카테고리 선택'; const inputEl = document.getElementById(bookmarkPopupTargets.inputId);
closePopup(); if (inputEl) {
inputEl.value = stagedBookmarkCategory;
}
// 2. 메인 수정 팝업의 표시 영역(display)을 업데이트
const displayEl = document.getElementById(bookmarkPopupTargets.displayId);
if (displayEl) {
if (stagedBookmarkCategory) {
// 선택한 카테고리가 있으면 태그 아이템으로 표시
displayEl.innerHTML = `<span class="tag-item">${stagedBookmarkCategory}</span>`;
} else {
// 선택한 카테고리가 없으면 기본 텍스트로 복원
displayEl.innerHTML = `<span>카테고리 선택</span>`;
}
}
// 3. 카테고리 선택 팝업만 닫기
document.getElementById('bookmark-category-popup').style.display = 'none';
} }
/** /**
@ -1905,7 +1922,246 @@ function renderStagedBookmarkTags() {
} }
function applyBookmarkTags() { function applyBookmarkTags() {
const tagsString = stagedBookmarkTags.join(','); const tagsString = stagedBookmarkTags.join(',');
document.getElementById(bookmarkPopupTargets.inputId).value = tagsString;
document.getElementById(bookmarkPopupTargets.displayId).innerHTML = stagedBookmarkTags.map(tag => `<span class="tag-item">#${tag}</span>`).join(' ') || '태그 선택'; // 1. 숨겨진 input 필드에 선택한 태그 값들 저장
closePopup(); const inputEl = document.getElementById(bookmarkPopupTargets.inputId);
if (inputEl) {
inputEl.value = tagsString;
}
// 2. 메인 수정 팝업의 표시 영역(display)을 업데이트
const displayEl = document.getElementById(bookmarkPopupTargets.displayId);
if (displayEl) {
if (stagedBookmarkTags && stagedBookmarkTags.length > 0) {
// 선택한 태그가 있으면 각 태그를 아이템으로 만들어 표시
displayEl.innerHTML = stagedBookmarkTags.map(tag => `<span class="tag-item">#${tag}</span>`).join(' ');
} else {
// 선택한 태그가 없으면 기본 텍스트로 복원
displayEl.innerHTML = `<span>태그 선택</span>`;
}
}
// 3. 태그 선택 팝업만 닫기
document.getElementById('bookmark-tag-popup').style.display = 'none';
}
/**
* [수정된 최종 함수] '수정' 버튼 클릭 팝업을 열고 기존 북마크 데이터를 불러오는 함수
* @param {HTMLElement} buttonElement - 클릭된 버튼 요소 ('this')
*/
async function openBookmarkEditPopup(buttonElement) {
// 1. [핵심 수정] 버튼 요소에서 실제 bookmarkId 값을 추출합니다.
const bookmarkId = buttonElement.getAttribute('data-bookmark-id');
try {
const response = await fetch(`/api/bookmarks/${bookmarkId}`, {
headers: { 'Authorization': `Bearer ${serverData.token}` }
});
if (!response.ok) {
throw new Error('북마크 정보를 불러오는 데 실패했습니다.');
}
const bookmark = await response.json();
// 2. 팝업창의 각 필드에 데이터 채우기
document.getElementById('edit-bookmark-id').value = bookmark.id;
document.getElementById('edit-bookmark-title').value = bookmark.title || '';
document.getElementById('edit-bookmark-comment').value = bookmark.userComment || '';
document.getElementById('edit-bookmark-visibility').value = bookmark.visibility;
const category = bookmark.category || '';
document.getElementById('edit-bookmark-category').value = category;
document.getElementById('edit-bookmark-category-display').innerHTML = category ? `<span class="tag-item">${category}</span>` : '<span>카테고리 선택</span>';
const tags = bookmark.tags || [];
document.getElementById('edit-bookmark-tags').value = tags.join(',');
document.getElementById('edit-bookmark-tags-display').innerHTML = tags.map(t => `<span class="tag-item">#${t}</span>`).join(' ') || '<span>태그 선택</span>';
// 3. 이미지 목록 표시하기 (숨김/복구 기능 포함)
const imagesListDiv = document.getElementById('edit-bookmark-images-list');
imagesListDiv.innerHTML = '';
let imageList = bookmark.images || [];
if (imageList.length === 0 && bookmark.contentUrls && bookmark.contentUrls.length > 0) {
imageList = bookmark.contentUrls.map(url => ({ url: url, isVisible: true }));
}
imageList.forEach(image => {
const imageItem = document.createElement('div');
imageItem.className = `image-preview-item ${!image.isVisible ? 'is-hidden' : ''}`;
imageItem.innerHTML = `
<img src="${serverData.apiBaseUrl + image.url}" alt="Bookmark Image">
<button class="toggle-visibility-btn" onclick="toggleBookmarkImageVisibility(this, '${bookmark.id}', '${image.url}')">
${image.isVisible ? '👁️' : '🚫'}
</button>
`;
imagesListDiv.appendChild(imageItem);
});
// 4. 파일 추가 input에 이벤트 리스너 연결
const imageInput = document.getElementById('add-bookmark-image-input');
const newImageInput = imageInput.cloneNode(true);
imageInput.parentNode.replaceChild(newImageInput, imageInput);
newImageInput.addEventListener('change', (event) => {
uploadBookmarkImages(bookmark.id, event.target.files);
});
// 5. 팝업 열기
const dummyEl = document.createElement('div');
dummyEl.setAttribute('to', '#bookmark-edit-popup');
openPopup(dummyEl);
} catch (error) {
showAlert('오류', error.message, 'error');
}
}
/**
* [수정된 함수] 이미지의 '보임/숨김' 상태를 서버에 업데이트하고 UI를 즉시 변경합니다.
* @param {HTMLElement} button - 클릭된 버튼 요소 (this)
* @param {string} bookmarkId - 북마크 ID
* @param {string} imageUrl - 상태를 변경할 이미지의 URL
*/
async function toggleBookmarkImageVisibility(button, bookmarkId, imageUrl) {
try {
const response = await fetch(`/api/bookmarks/${bookmarkId}/images/visibility`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${serverData.token}`,
'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
},
body: JSON.stringify({ imageUrl: imageUrl })
});
if (!response.ok) {
throw new Error('이미지 상태 변경에 실패했습니다.');
}
const updatedBookmark = await response.json();
// [핵심 수정] 더 이상 페이지 전체를 새로고침하지 않고,
// 전달받은 'button' 요소를 기준으로 직접 UI를 변경합니다.
if (button) {
const imageItem = button.closest('.image-preview-item');
const imageInfo = updatedBookmark.images.find(img => img.url === imageUrl);
if (imageInfo && imageItem) {
// 버튼 아이콘과 부모 div의 'is-hidden' 클래스를 직접 제어합니다.
button.innerHTML = imageInfo.isVisible ? '👁️' : '🚫';
imageItem.classList.toggle('is-hidden', !imageInfo.isVisible);
}
}
} catch (error) {
showAlert('오류', error.message, 'error');
}
}
/**
* 북마크의 텍스트 정보(메타데이터) 서버에 저장하는 함수
*/
async function submitBookmarkUpdate() {
const bookmarkId = document.getElementById('edit-bookmark-id').value;
const dataToUpdate = {
title: document.getElementById('edit-bookmark-title').value,
userComment: document.getElementById('edit-bookmark-comment').value,
visibility: document.getElementById('edit-bookmark-visibility').value,
category: document.getElementById('edit-bookmark-category').value,
tags: document.getElementById('edit-bookmark-tags').value.split(',').filter(t => t) // 빈 태그 제거
};
try {
const response = await fetch(`/api/bookmarks/${bookmarkId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${serverData.token}`,
'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
},
body: JSON.stringify(dataToUpdate)
});
if (!response.ok) {
throw new Error('북마크 업데이트에 실패했습니다.');
}
showAlert('성공', '북마크가 성공적으로 업데이트되었습니다.', 'success');
closePopup();
location.reload(); // 페이지 새로고침하여 변경사항 확인
} catch (error) {
showAlert('오류', error.message, 'error');
}
}
/**
* 새로운 이미지를 서버에 업로드하고 북마크에 추가하는 함수
* @param {string} bookmarkId - 북마크 ID
* @param {FileList} files - 사용자가 선택한 파일 목록
*/
async function uploadBookmarkImages(bookmarkId, files) {
if (!files.length) return;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
try {
const response = await fetch(`/api/bookmarks/${bookmarkId}/images`, { // 새 API 엔드포인트
method: 'POST',
headers: {
'Authorization': `Bearer ${serverData.token}`,
'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
},
body: formData
});
if (!response.ok) {
throw new Error('이미지 업로드에 실패했습니다.');
}
// 업로드 성공 후, 팝업 내용을 최신 정보로 다시 로드
showAlert('성공', '이미지가 추가되었습니다.', 'success');
openBookmarkEditPopup(bookmarkId);
} catch (error) {
showAlert('오류', error.message, 'error');
}
}
/**
* 기존 이미지를 북마크에서 삭제하는 함수
* @param {string} bookmarkId - 북마크 ID
* @param {string} imageUrl - 삭제할 이미지의 URL
* @param {HTMLElement} buttonElement - 클릭된 삭제 버튼
*/
async function deleteBookmarkImage(bookmarkId, imageUrl, buttonElement) {
if (!await showConfirm('확인', '이 이미지를 정말 삭제하시겠습니까?')) {
return;
}
try {
const response = await fetch(`/api/bookmarks/${bookmarkId}/images`, { // 새 API 엔드포인트
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${serverData.token}`,
'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content')
},
body: JSON.stringify({ imageUrl: imageUrl })
});
if (!response.ok) {
throw new Error('이미지 삭제에 실패했습니다.');
}
// 화면에서 즉시 이미지 제거
buttonElement.parentElement.remove();
showAlert('성공', '이미지가 삭제되었습니다.', 'success');
} catch (error) {
showAlert('오류', error.message, 'error');
}
} }

View File

@ -7,11 +7,52 @@
<head> <head>
<title>Bookmarks</title> <title>Bookmarks</title>
<style> <style>
.scrollable-content { /* --- [수정] 북마크 이미지 영역 스크롤 및 카드 크기 고정 --- */
max-height: 500px; /* 콘텐츠 영역의 최대 높이를 지정 */
max-width: 500px; /* 1. 각 북마크 카드의 전체 높이를 720px로 고정합니다. */
overflow-y: auto; /* 세로 내용이 넘칠 경우 스크롤바 자동 생성 */ .swiper-slide .box.feature {
-webkit-overflow-scrolling: touch; /* 모바일에서 부드러운 스크롤 효과 */ height: 720px;
padding-bottom: 50px;
}
/* 2. 여러 이미지를 담는 컨테이너의 스타일을 정의합니다. */
.image-flick-container {
height: 450px; /* 이미지 영역의 높이를 450px로 고정합니다. (값 조절 가능) */
overflow-y: auto; /* 이 높이를 넘어가는 이미지는 세로 스크롤됩니다. */
border: 1px solid #eee;
border-radius: 5px;
background-color: #f0f0f0; /* 스크롤 영역 배경색을 살짝 추가 */
margin-bottom: 1em; /* 이미지 영역과 텍스트 영역 사이의 간격 */
}
/* 3. 스크롤 영역 내부의 이미지 스타일을 지정합니다. */
.image-flick-container img {
width: 100%; /* 이미지를 컨테이너 너비에 맞춥니다. */
height: auto; /* 이미지 비율을 유지합니다. */
display: block;
margin-bottom: 10px; /* 이미지들 사이에 약간의 간격을 줍니다. */
}
.image-flick-container img:last-child {
margin-bottom: 0;
}
/* 4. (선택) 스크롤바 디자인을 개선합니다. */
.image-flick-container::-webkit-scrollbar {
width: 8px;
}
.image-flick-container::-webkit-scrollbar-track {
background: #e9e9e9;
}
.image-flick-container::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 10px;
}
.image-flick-container::-webkit-scrollbar-thumb:hover {
background: #999;
} }
</style> </style>
<link rel="stylesheet" href="https://unpkg.com/swiper/swiper-bundle.min.css" /> <link rel="stylesheet" href="https://unpkg.com/swiper/swiper-bundle.min.css" />
@ -67,20 +108,26 @@
<section class="box feature" style="margin: 0; height: 100%; display: flex; flex-direction: column;"> <section class="box feature" style="margin: 0; height: 100%; display: flex; flex-direction: column;">
<div th:switch="${bookmark.bookmarkType}"> <div th:switch="${bookmark.bookmarkType}">
<div th:case="'IMAGE'" class="image-flick-container scrollable-content"> <div th:case="'IMAGE'" class="image-flick-container">
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" /> <th:block th:each="image : ${bookmark.images}" th:if="${image.isVisible}">
<img th:src="${apiBaseUrl + image.url}" alt="Bookmark Image" />
</th:block>
</div> </div>
<div th:case="'VIDEO'" class="video-container" th:if="${!#lists.isEmpty(bookmark.contentUrls)}"> <div th:case="'VIDEO'" class="video-container bookmark-image-container">
<th:block th:each="image : ${bookmark.images}" th:if="${image.isVisible and #lists.size(bookmark.images) > 0}">
<video controls style="width: 100%;"> <video controls style="width: 100%;">
<source th:src="${apiBaseUrl + bookmark.contentUrls[0]}" type="video/mp4"> <source th:src="${apiBaseUrl + image.url}" type="video/mp4">
</video> </video>
</th:block>
</div> </div>
<a th:case="'URL'" <a th:case="'URL'"
href="javascript:void(0);" href="javascript:void(0);"
th:data-url="${bookmark.url}" th:data-url="${bookmark.url}"
th:data-title="${bookmark.title}" th:data-title="${bookmark.title}"
onclick="showBookmarkOptions(this)" class="image featured scrollable-content"> onclick="showBookmarkOptions(this)" class="bookmark-image-container">
<img th:each="imageUrl : ${bookmark.contentUrls}" th:src="${apiBaseUrl + imageUrl}" alt="Bookmark Image" /> <th:block th:each="image : ${bookmark.images}" th:if="${image.isVisible}">
<img th:src="${apiBaseUrl + mage.url}" alt="Bookmark Image" />
</th:block>
</a> </a>
</div> </div>
@ -90,6 +137,14 @@
<h3 th:text="${bookmark.title}">북마크 제목</h3> <h3 th:text="${bookmark.title}">북마크 제목</h3>
<p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}"></p> <p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}"></p>
</header> </header>
<div class="bookmark-meta-container" style="margin: 1em 0;">
<div th:if="${bookmark.category != null and !#strings.isEmpty(bookmark.category)}">
<span class="tag-item" th:text="${bookmark.category}"></span>
</div>
<div th:if="${bookmark.tags != null and !#lists.isEmpty(bookmark.tags)}">
<span th:each="tag : ${bookmark.tags}" class="tag-item" th:text="'#' + ${tag}"></span>
</div>
</div>
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p> <p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
</div> </div>
@ -104,6 +159,12 @@
<button class="button alt small" th:onclick="toggleCommentSection([[${bookmark.id}]])"> <button class="button alt small" th:onclick="toggleCommentSection([[${bookmark.id}]])">
💬 Comments 💬 Comments
</button> </button>
<button class="button alt small"
th:if="${#authentication.principal != null && #authentication.principal instanceof T(org.springframework.security.core.userdetails.UserDetails) && #authentication.principal.username == bookmark.userId}"
th:data-bookmark-id="${bookmark.id}"
onclick="openBookmarkEditPopup(this)">
✏️ 수정
</button>
</div> </div>
<section class="comment-section" th:id="|comment-section-${bookmark.id}|" style="display: none; margin-top: 1em; text-align: left;"> <section class="comment-section" th:id="|comment-section-${bookmark.id}|" style="display: none; margin-top: 1em; text-align: left;">
@ -116,7 +177,7 @@
</th:block> </th:block>
<div sec:authorize="isAnonymous()" style="padding: 1em; text-align: center; border: 1px dashed #ccc; margin-bottom: 1em;"> <div sec:authorize="isAnonymous()" style="padding: 1em; text-align: center; border: 1px dashed #ccc; margin-bottom: 1em;">
<p style="margin:0;">댓글을 작성하려면 <a th:href="@{/home.bs(action='login')}">로그인</a>이 필요합니다.</p> <p style="margin:0;">댓글을 작성하려면 <a href="javascript:void(0);" class="open-login-popup" to="#loginPopup">로그인</a>이 필요합니다.</p>
</div> </div>
<div th:id="|comments-list-${bookmark.id}|" style="margin-top: 1em;"> <div th:id="|comments-list-${bookmark.id}|" style="margin-top: 1em;">

View File

@ -57,7 +57,8 @@
enc: /*[[${enc ?: ''}]]*/, enc: /*[[${enc ?: ''}]]*/,
keyword: /*[[${keyword ?: ''}]]*/, keyword: /*[[${keyword ?: ''}]]*/,
// --- [핵심 추가] --- // --- [핵심 추가] ---
token: /*[[${jwtToken}]]*/ token: /*[[${jwtToken}]]*/,
apiBaseUrl : /*[[${apiBaseUrl}]]*/
}; };
</script> </script>
</th:block> </th:block>

View File

@ -74,7 +74,8 @@
</div> </div>
</div> </div>
<div id="bookmark-edit-popup" class="pop_layer">
<div id="bookmark-edit-popup" class="pop_layer" style="max-width: 600px;">
<div class="pop_container"> <div class="pop_container">
<div class="pop_conts"> <div class="pop_conts">
<h2>북마크 수정</h2> <h2>북마크 수정</h2>
@ -94,20 +95,27 @@
</select> </select>
<div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('edit-bookmark-category-display', 'edit-bookmark-category')"> <div class="form-control-wrapper" onclick="openBookmarkCategoryPopup('edit-bookmark-category-display', 'edit-bookmark-category')">
<strong>카테고리</strong> <strong>카테고리:</strong>
<div id="edit-bookmark-category-display" class="tag-display-box">카테고리 선택</div> <div id="edit-bookmark-category-display" class="tag-display-box">카테고리 선택</div>
</div> </div>
<input type="hidden" id="edit-bookmark-category"> <input type="hidden" id="edit-bookmark-category">
<div class="form-control-wrapper" onclick="openBookmarkTagPopup('edit-bookmark-tags-display', 'edit-bookmark-tags')"> <div class="form-control-wrapper" onclick="openBookmarkTagPopup('edit-bookmark-tags-display', 'edit-bookmark-tags')">
<strong>태그</strong> <strong>태그:</strong>
<div id="edit-bookmark-tags-display" class="tag-display-box">태그 선택</div> <div id="edit-bookmark-tags-display" class="tag-display-box">태그 선택</div>
</div> </div>
<input type="hidden" id="edit-bookmark-tags"> <input type="hidden" id="edit-bookmark-tags">
<hr>
<label>이미지 관리</label>
<div id="edit-bookmark-images-list" class="images-list-container">
</div>
<input type="file" id="add-bookmark-image-input" multiple accept="image/*" style="display: none;">
<button type="button" class="button small" onclick="document.getElementById('add-bookmark-image-input').click();">이미지 추가</button>
<div style="margin-top: 1.5em; text-align: right;"> <div style="margin-top: 1.5em; text-align: right;">
<button type="button" class="button primary" onclick="submitBookmarkUpdate()">변경사항 저장</button> <button type="button" class="button" onclick="submitBookmarkUpdate()">변경사항 저장</button>
<a href="#" class="button alt btn_layerClose">취소</a> <button type="button" class="button alt btn_layerClose">취소</button>
</div> </div>
</div> </div>
</div> </div>
@ -123,7 +131,7 @@
<input type="text" id="new-bookmark-category-input" placeholder="새 카테고리 입력 후 Enter"> <input type="text" id="new-bookmark-category-input" placeholder="새 카테고리 입력 후 Enter">
<div style="margin-top: 1.5em;"> <div style="margin-top: 1.5em;">
<button type="button" class="button primary" onclick="applyBookmarkCategory()">적용</button> <button type="button" class="button primary" onclick="applyBookmarkCategory()">적용</button>
<a href="#" class="button alt btn_layerClose">취소</a> <a href="javascript:void(0);" class="button alt" onclick="this.closest('.pop_layer').style.display='none'">취소</a>
</div> </div>
</div> </div>
</div> </div>
@ -139,7 +147,7 @@
<input type="text" id="new-bookmark-tag-input" placeholder="새 태그 입력 후 Enter"> <input type="text" id="new-bookmark-tag-input" placeholder="새 태그 입력 후 Enter">
<div style="margin-top: 1.5em;"> <div style="margin-top: 1.5em;">
<button type="button" class="button primary" onclick="applyBookmarkTags()">적용</button> <button type="button" class="button primary" onclick="applyBookmarkTags()">적용</button>
<a href="#" class="button alt btn_layerClose">취소</a> <a href="javascript:void(0);" class="button alt" onclick="this.closest('.pop_layer').style.display='none'">취소</a>
</div> </div>
</div> </div>
</div> </div>