From 8d7b6f9b5c84ca5c6604ac972d1d508a8e9488e9 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 19 Apr 2026 22:05:31 +0900
Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EB=AF=B8=EB=B6=84=EB=A5=98=20?=
=?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=8F=B4=EB=8D=94=20=EB=8F=84?=
=?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AC=B4?=
=?UTF-8?q?=EA=B2=B0=EC=84=B1=20=EA=B0=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- init_postgres.sql: folder_type 컬럼 추가 및 시스템 폴더 유니크 제약 설정
- MemberFolder.java: folderType 필드 및 markAsUnorganized 메서드 추가
- MemberFolderServiceImpl.java: 미분류 폴더 자동 생성 및 보장 로직 구현
- CustomOAuth2MemberService.java: 소셜 가입 시 기본 폴더 생성 연동
- FolderErrorCode.java: 시스템 폴더 관련 에러 코드 추가
- MemberFolderResponses.java: DTO에 folderType 필드 추가
- FolderCard.tsx: 시스템 폴더 제어 제한 및 UI 반영
---
.../src/components/my-links/FolderCard.tsx | 23 ++++++-
frontend/src/lib/types/folder.ts | 3 +
.../controller/dto/MemberFolderResponses.java | 2 +
.../folder/dao/MemberFolderJpaDao.java | 8 +++
.../SearchWeb/folder/domain/FolderType.java | 9 +++
.../SearchWeb/folder/domain/MemberFolder.java | 21 ++++++
.../folder/error/FolderErrorCode.java | 4 +-
.../folder/service/MemberFolderService.java | 7 +-
.../service/MemberFolderServiceImpl.java | 65 ++++++++++++++++++-
.../service/CustomOAuth2MemberService.java | 15 ++++-
src/main/resources/db/init_postgres.sql | 9 ++-
11 files changed, 157 insertions(+), 9 deletions(-)
create mode 100644 src/main/java/com/web/SearchWeb/folder/domain/FolderType.java
diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx
index f7b78a0..881b40c 100644
--- a/frontend/src/components/my-links/FolderCard.tsx
+++ b/frontend/src/components/my-links/FolderCard.tsx
@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { FolderResponse } from '@/lib/types/folder';
import { useFolderStore } from '@/lib/store/folderStore';
+import { useLinkStore } from '@/lib/store/linkStore';
import { FolderEditModal, FolderDeleteModal, FolderMoveModal } from './FolderManagementModals';
interface FolderCardProps {
@@ -15,8 +16,12 @@ const MENU_OFFSET_Y = 4; // 버튼과 메뉴 사이의 상하 간격
export function FolderCard({ folder, color }: FolderCardProps) {
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
+ const searchQuery = useFolderStore((s) => s.searchQuery);
+ const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery);
const [showMenu, setShowMenu] = useState(false); // 드롭다운 메뉴 표시 여부
const [modalType, setModalType] = useState<'edit' | 'delete' | 'move' | null>(null); // 현재 열린 모달 타입
+ // 시스템 폴더(미분류)는 Move/Delete 불가 (Rename 만 허용)
+ const isSystemFolder = folder.folderType === 'UNORGANIZED';
// DOM 참조 (메뉴 외부 클릭 감지 및 위치 계산용)
const menuRef = useRef(null);
@@ -71,7 +76,13 @@ export function FolderCard({ folder, color }: FolderCardProps) {
// 카드 클릭 시 폴더 선택 처리 (메뉴가 열려있지 않을 때만)
const handleCardClick = () => {
if (!showMenu) {
+ // 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거
setSelectedFolderId(folder.memberFolderId);
+
+ // 기존 폴더 검색어가 있다면 링크 검색어로 전달 (경험 최적화)
+ if (searchQuery) {
+ setLinkSearchQuery(searchQuery);
+ }
}
};
@@ -84,6 +95,10 @@ export function FolderCard({ folder, color }: FolderCardProps) {
// 메뉴 항목 클릭 시 모달 열기 및 메뉴 닫기
const handleMenuItemClick = (type: 'edit' | 'delete' | 'move', e: React.MouseEvent) => {
e.stopPropagation();
+ // 시스템 폴더는 Move/Delete 를 허용하지 않음
+ if (isSystemFolder && (type === 'move' || type === 'delete')) {
+ return;
+ }
setModalType(type);
setShowMenu(false);
};
@@ -103,7 +118,7 @@ export function FolderCard({ folder, color }: FolderCardProps) {
className={`bg-white dark:bg-card-dark rounded-lg p-2.5 border border-gray-200/70 dark:border-white/5 shadow-sm hover:shadow-md hover:border-purple-300 dark:hover:border-white/10 transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between focus:ring-1 focus:ring-purple-300 dark:focus:ring-purple-500 outline-none hover:bg-purple-50/30 dark:hover:bg-white/[0.03] relative overflow-visible ${color || ''}`}
>
-
+
folder_open
@@ -148,7 +163,8 @@ export function FolderCard({ folder, color }: FolderCardProps) {
handleMenuItemClick('move', e)}
- className="w-full text-left px-3 py-2 text-[11px] text-gray-700 dark:text-white hover:bg-purple-50 dark:hover:bg-purple-600/20 flex items-center gap-2.5 transition-colors"
+ disabled={isSystemFolder}
+ className="w-full text-left px-3 py-2 text-[11px] text-gray-700 dark:text-white hover:bg-purple-50 dark:hover:bg-purple-600/20 flex items-center gap-2.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
>
drive_file_move
Move
@@ -156,7 +172,8 @@ export function FolderCard({ folder, color }: FolderCardProps) {
handleMenuItemClick('delete', e)}
- className="w-full text-left px-3 py-2 text-[11px] text-rose-500 dark:text-red-400 hover:bg-rose-50 dark:hover:bg-purple-600/20 flex items-center gap-2.5 transition-colors"
+ disabled={isSystemFolder}
+ className="w-full text-left px-3 py-2 text-[11px] text-rose-500 dark:text-red-400 hover:bg-rose-50 dark:hover:bg-purple-600/20 flex items-center gap-2.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
>
delete
Delete
diff --git a/frontend/src/lib/types/folder.ts b/frontend/src/lib/types/folder.ts
index 6da8119..149735e 100644
--- a/frontend/src/lib/types/folder.ts
+++ b/frontend/src/lib/types/folder.ts
@@ -2,12 +2,15 @@
export type { ApiResponse } from './apiResponse';
// 백엔드 MemberFolderResponses DTO 대응
+export type FolderType = 'CUSTOM' | 'UNORGANIZED';
+
export interface FolderResponse {
memberFolderId: number;
ownerMemberId: number;
parentFolderId: number | null;
folderName: string;
description: string | null;
+ folderType: FolderType;
}
// POST /api/folders 요청 바디
diff --git a/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java b/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java
index fb6655d..8d1a9b8 100644
--- a/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java
+++ b/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java
@@ -12,6 +12,7 @@ public class MemberFolderResponses {
private final Long parentFolderId;
private final String folderName;
private final String description;
+ private final String folderType;
// Entity -> DTO 변환을 위한 정적 팩토리 메서드
public static MemberFolderResponses from(MemberFolder folder) {
@@ -21,6 +22,7 @@ public static MemberFolderResponses from(MemberFolder folder) {
.parentFolderId(folder.getParentFolderId())
.folderName(folder.getFolderName())
.description(folder.getDescription())
+ .folderType(folder.getFolderType().name())
.build();
}
}
diff --git a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
index fdafe18..dc3310c 100644
--- a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
+++ b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
@@ -1,7 +1,9 @@
package com.web.SearchWeb.folder.dao;
+import com.web.SearchWeb.folder.domain.FolderType;
import com.web.SearchWeb.folder.domain.MemberFolder;
import java.util.List;
+import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberFolderJpaDao extends JpaRepository {
@@ -20,4 +22,10 @@ public interface MemberFolderJpaDao extends JpaRepository {
boolean existsByOwnerMemberIdAndParentFolderIdAndFolderName(Long ownerMemberId, Long parentFolderId, String normalizedFolderName);
boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName);
+
+ // 시스템 폴더(UNORGANIZED) 조회 — 한 사용자당 최대 1개 (partial unique index 로 보장)
+ Optional findFirstByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType);
+
+ // 루트에 같은 이름 폴더가 이미 있는지 조회 (흡수(absorb) 처리에 사용)
+ Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String folderName);
}
diff --git a/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java b/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java
new file mode 100644
index 0000000..5766722
--- /dev/null
+++ b/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java
@@ -0,0 +1,9 @@
+package com.web.SearchWeb.folder.domain;
+
+/**
+ * 폴더의 타입을 정의하는 Enum.
+ */
+public enum FolderType {
+ CUSTOM, // 사용자가 직접 생성한 일반 폴더
+ UNORGANIZED // 시스템에서 자동 생성/관리하는 미분류 폴더
+}
diff --git a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
index 7ae81b8..01043d1 100644
--- a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
+++ b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
@@ -1,5 +1,7 @@
package com.web.SearchWeb.folder.domain;
+import com.web.SearchWeb.folder.error.FolderErrorCode;
+import com.web.SearchWeb.folder.error.FolderException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -12,6 +14,9 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@@ -32,6 +37,19 @@ public class MemberFolder {
private String folderName;
@Column(name = "description", length = 500)
private String description;
+ @Enumerated(EnumType.STRING)
+ @Column(name = "folder_type", nullable = false, length = 20)
+ @Builder.Default
+ private FolderType folderType = FolderType.CUSTOM;
+
+ public boolean isUnorganized() {
+ return FolderType.UNORGANIZED.equals(this.folderType);
+ }
+
+ /** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */
+ public void markAsUnorganized() {
+ this.folderType = FolderType.UNORGANIZED;
+ }
public void changeInfo(String folderName, String description) {
if (folderName == null || folderName.isBlank()) {
@@ -42,6 +60,9 @@ public void changeInfo(String folderName, String description) {
}
public void changeParent(Long newParentFolderId) {
+ if (isUnorganized()) {
+ throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);
+ }
this.parentFolderId = newParentFolderId;
}
}
diff --git a/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java b/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java
index 5d0e72e..366da96 100644
--- a/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java
+++ b/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java
@@ -13,7 +13,9 @@ public enum FolderErrorCode implements ErrorCode {
FOLDER_FORBIDDEN(HttpStatus.FORBIDDEN,"Foo3" ,"접근이 제한된 폴더입니다." ),
INVALID_FOLDER_NAME(HttpStatus.BAD_REQUEST,"F004","폴더명으로 적절하지 않습니다" ),
INVALID_FOLDER_MOVE(HttpStatus.BAD_REQUEST, "F005", "유효하지 않은 폴더 이동입니다."),
- FOLDER_NOT_EMPTY(HttpStatus.BAD_REQUEST, "F006", "하위 폴더 또는 북마크가 남아 있어 삭제할 수 없습니다.");
+ FOLDER_NOT_EMPTY(HttpStatus.BAD_REQUEST, "F006", "하위 폴더 또는 북마크가 남아 있어 삭제할 수 없습니다."),
+ SYSTEM_FOLDER_IMMUTABLE(HttpStatus.CONFLICT, "F007", "기본 폴더는 삭제하거나 이동할 수 없습니다."),
+ DEFAULT_FOLDER_NAME_CONFLICT(HttpStatus.CONFLICT, "F008", "같은 이름의 폴더가 이미 존재하여 기본 폴더를 생성할 수 없습니다.");
private final HttpStatus status;
private final String code;
diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java
index 073047d..f3f8eda 100644
--- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java
+++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java
@@ -2,7 +2,6 @@
import com.web.SearchWeb.folder.domain.MemberFolder;
import java.util.List;
-import java.util.Optional;
public interface MemberFolderService {
@@ -19,4 +18,10 @@ public interface MemberFolderService {
void move(Long memberId, Long memberFolderId, Long newParentFolderId);
void delete(Long memberId, Long memberFolderId);
+
+ /** 없으면 생성하고, 있으면 기존 ID를 반환. idempotent. (미분류(UNORGANIZED) 폴더를 보장) */
+ Long getOrCreateUnorganizedFolderId(Long memberId);
+
+ /** 미분류 폴더를 단건으로 반환한다. 없으면 getOrCreate 경로로 생성 후 반환. */
+ MemberFolder getDefaultFolder(Long memberId);
}
diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
index 98d8324..083c1fd 100644
--- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
@@ -2,18 +2,25 @@
import com.web.SearchWeb.bookmark.dao.BookmarkDao;
import com.web.SearchWeb.folder.dao.MemberFolderJpaDao;
+import com.web.SearchWeb.folder.domain.FolderType;
import com.web.SearchWeb.folder.domain.MemberFolder;
import com.web.SearchWeb.folder.error.FolderErrorCode;
import com.web.SearchWeb.folder.error.FolderException;
import java.util.List;
+import java.util.Optional;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+@Slf4j
@Service
@RequiredArgsConstructor
public class MemberFolderServiceImpl implements MemberFolderService {
+ private static final String UNORGANIZED_FOLDER_NAME = "미분류";
+
private final MemberFolderJpaDao memberFolderJpaRepository;
private final BookmarkDao bookmarkDao;
@@ -97,9 +104,11 @@ public MemberFolder get(Long memberId, Long memberFolderId) {
}
@Override
- @Transactional(readOnly = true)
+ @Transactional
public List listRootFolders(Long memberId, Long ownerMemberId) {
validateOwner(memberId, ownerMemberId);
+ // 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성
+ getOrCreateUnorganizedFolderId(ownerMemberId);
return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId);
}
@@ -151,6 +160,10 @@ public void update(Long memberId, Long memberFolderId, String folderName, String
public void move(Long memberId, Long memberFolderId, Long newParentFolderId) {
MemberFolder folder = getOwnedFolder(memberId, memberFolderId);
+ if (folder.isUnorganized()) {
+ throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);
+ }
+
if ((folder.getParentFolderId() == null && newParentFolderId == null)
|| (folder.getParentFolderId() != null && folder.getParentFolderId().equals(newParentFolderId))) {
return;
@@ -193,7 +206,11 @@ public void move(Long memberId, Long memberFolderId, Long newParentFolderId) {
@Override
@Transactional
public void delete(Long memberId, Long memberFolderId) {
- getOwnedFolder(memberId, memberFolderId);
+ MemberFolder folder = getOwnedFolder(memberId, memberFolderId);
+
+ if (folder.isUnorganized()) {
+ throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);
+ }
if (memberFolderJpaRepository.existsByParentFolderId(memberFolderId)
|| bookmarkDao.existsActiveBookmarkInFolder(memberFolderId)) {
@@ -203,6 +220,50 @@ public void delete(Long memberId, Long memberFolderId) {
memberFolderJpaRepository.deleteById(memberFolderId);
}
+ @Override
+ @Transactional
+ public Long getOrCreateUnorganizedFolderId(Long memberId) {
+ // 1. 이미 시스템 폴더가 존재하면 해당 ID 반환 (idempotent)
+ Optional existing = memberFolderJpaRepository
+ .findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED);
+ if (existing.isPresent()) {
+ return existing.get().getMemberFolderId();
+ }
+
+ // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환
+ Optional sameName = memberFolderJpaRepository
+ .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME);
+ if (sameName.isPresent()) {
+ MemberFolder existingFolder = sameName.get();
+ existingFolder.markAsUnorganized();
+ return memberFolderJpaRepository.save(existingFolder).getMemberFolderId();
+ }
+
+ // 3. 신규 생성 — 동시 호출은 partial unique index 로 DB 에서 차단, catch 후 재조회
+ try {
+ MemberFolder newFolder = MemberFolder.builder()
+ .ownerMemberId(memberId)
+ .folderName(UNORGANIZED_FOLDER_NAME)
+ .folderType(FolderType.UNORGANIZED)
+ .build();
+ return memberFolderJpaRepository.save(newFolder).getMemberFolderId();
+ } catch (DataIntegrityViolationException e) {
+ // 동시 요청으로 인한 중복 생성 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환
+ return memberFolderJpaRepository
+ .findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED)
+ .orElseThrow(() -> new FolderException(FolderErrorCode.DEFAULT_FOLDER_NAME_CONFLICT))
+ .getMemberFolderId();
+ }
+ }
+
+ @Override
+ @Transactional
+ public MemberFolder getDefaultFolder(Long memberId) {
+ Long id = getOrCreateUnorganizedFolderId(memberId);
+ return memberFolderJpaRepository.findById(id)
+ .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND));
+ }
+
private MemberFolder getOwnedFolder(Long memberId, Long memberFolderId) {
MemberFolder folder = memberFolderJpaRepository.findById(memberFolderId)
.orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND));
diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java
index 0d077ee..2675547 100644
--- a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java
+++ b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java
@@ -1,6 +1,7 @@
package com.web.SearchWeb.member.service;
import com.web.SearchWeb.auth.error.AuthErrorCode;
+import com.web.SearchWeb.folder.service.MemberFolderService;
import com.web.SearchWeb.member.dao.MemberDao;
import com.web.SearchWeb.member.domain.Member;
import com.web.SearchWeb.member.dto.CustomOAuth2Member;
@@ -9,6 +10,7 @@
import com.web.SearchWeb.member.dto.Response.NaverResponse;
import com.web.SearchWeb.member.dto.Response.OAuth2Response;
import com.web.SearchWeb.member.error.MemberErrorCode;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@@ -24,13 +26,16 @@
* 소셜 로그인(OAuth2) 사용자 정보 처리 및 DB 동기화 서비스.
* -DefaultOAuth2UserService를 상속받아 소셜 서비스로부터 받은 유저 데이터를 가공함.
*/
+@Slf4j
@Service
public class CustomOAuth2MemberService extends DefaultOAuth2UserService {
private final MemberDao memberDao;
+ private final MemberFolderService memberFolderService;
- public CustomOAuth2MemberService(MemberDao memberDao) {
+ public CustomOAuth2MemberService(MemberDao memberDao, MemberFolderService memberFolderService) {
this.memberDao = memberDao;
+ this.memberFolderService = memberFolderService;
}
@@ -92,6 +97,14 @@ else if ("kakao".equals(registrationId)) {
);
}
}
+
+ // 신규 가입자의 미분류 폴더 보장 (실패해도 가입 흐름은 진행 — listRootFolders lazy 경로에서 재시도됨)
+ try {
+ memberFolderService.getOrCreateUnorganizedFolderId(existMember.getMemberId());
+ } catch (Exception e) {
+ log.warn("default folder creation failed on social signup (memberId={}): {}",
+ existMember.getMemberId(), e.getMessage());
+ }
}
else {
// 소셜 제공자 정보가 실제로 바뀐 경우에만 최소 필드만 동기화(변경감지)
diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql
index 2bb0fa4..6b0ee5b 100644
--- a/src/main/resources/db/init_postgres.sql
+++ b/src/main/resources/db/init_postgres.sql
@@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS "member_folder" (
"parent_folder_id" int,
"folder_name" varchar(80) NOT NULL,
"description" text,
+ "folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL,
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL,
"deleted_at" timestamptz,
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS "member_folder" (
"updated_by_member_id" bigint,
"deleted_by_member_id" bigint,
CONSTRAINT pk_member_folder PRIMARY KEY ("member_folder_id"),
+ CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED')),
CONSTRAINT fk_member_folder_parent_folder_id
FOREIGN KEY ("parent_folder_id") REFERENCES "member_folder"("member_folder_id"),
CONSTRAINT fk_member_folder_owner_member_id
@@ -406,7 +408,8 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" (
CONSTRAINT ck_member_saved_link_category_source CHECK (category_source in ('system','member')),
CONSTRAINT ck_member_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)),
CONSTRAINT fk_member_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id"),
- CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id")
+ CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id"),
+ CONSTRAINT fk_member_saved_link_member_folder_id FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id")
);
CREATE TABLE IF NOT EXISTS "link_enrichment_feedback" (
@@ -572,6 +575,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_parent_name
CREATE INDEX IF NOT EXISTS idx_member_folder_name ON "member_folder" ("folder_name");
CREATE INDEX IF NOT EXISTS idx_member_folder_created ON "member_folder" ("created_at");
CREATE INDEX IF NOT EXISTS idx_member_folder_deleted ON "member_folder" ("deleted_at");
+-- 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_owner_unorganized
+ ON "member_folder" ("owner_member_id")
+ WHERE folder_type = 'UNORGANIZED' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_member_tag_owner ON "member_tag" ("owner_member_id");
CREATE INDEX IF NOT EXISTS idx_member_tag_deleted_at ON "member_tag" ("deleted_at");
From af34c6515f506a1c0a18825b60699f3c8d42f0c6 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 19 Apr 2026 22:40:03 +0900
Subject: [PATCH 02/16] =?UTF-8?q?feat(backend):=20=EB=B6=81=EB=A7=88?=
=?UTF-8?q?=ED=81=AC=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B2=80=EC=83=89=20?=
=?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20?=
=?UTF-8?q?=EC=83=81=EC=9C=84=20=ED=8F=B4=EB=8D=94=20=EB=A7=A4=EC=B9=AD=20?=
=?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- BookmarkSearchResponse: 검색 성능 확보를 위한 모델링: 북마크 목록과 함께 검색어에 매칭되는 상위 폴더 ID 목록을 한 번의 요청으로 반환하기 위한 응담용 DTO 정의.
- BookmarkServiceImpl: 서비스 로직 고도화: 단순 북마크 조회를 넘어 검색어와 매칭되는 북마크가 속한 모든 상위 폴더 ID를 중복 없이 추출하여 O(M) 수준의 폴더 필터링 기반 마련.
- MybatisBookmarkDao: 데이터 소스 접근 계층 효율화: 검색어 기반으로 매칭되는 폴더의 계층 구조를 탐색하고 유효한 폴더 ID 리스트를 반환하는 전용 DB 접근 인터페이스 추가.
- bookmark-mapper.xml: SQL 쿼리 성능 최적화: 동적 ORDER BY 처리 및 복합 검색 조건에 따른 북마크 및 매칭 폴더 ID 추출 로직의 MyBatis Mapper 구현.
- BookmarkApiController: API 인터페이스 최적화: 최적화된 검색 결과 DTO를 클라이언트에 전달하기 위한 엔드포인트 반환 타입 변경 및 데이터 무결성 검증.
---
.../controller/BookmarkApiController.java | 9 ++++---
.../dto/BookmarkSearchResponse.java | 25 +++++++++++++++++++
.../SearchWeb/bookmark/dao/BookmarkDao.java | 3 +++
.../bookmark/dao/MybatisBookmarkDao.java | 12 ++++++---
.../bookmark/service/BookmarkService.java | 5 ++--
.../bookmark/service/BookmarkServiceImpl.java | 13 +++++++---
.../mypage/controller/MyPageController.java | 2 +-
src/main/resources/mapper/bookmark-mapper.xml | 20 +++++++++++++++
8 files changed, 76 insertions(+), 13 deletions(-)
create mode 100644 src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
index d22b95e..f82dde8 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
@@ -2,6 +2,7 @@
import com.web.SearchWeb.bookmark.controller.dto.BookmarkRequests;
+import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse;
import com.web.SearchWeb.bookmark.domain.Bookmark;
import com.web.SearchWeb.bookmark.service.BookmarkService;
import com.web.SearchWeb.config.common.ApiResponse;
@@ -67,15 +68,15 @@ public ResponseEntity> selectBookmark(
/**
- * 북마크 목록 조회
+ * 북마크 목록 조회 (검색 결과 및 매칭 폴더 포함)
*/
@GetMapping
- public ResponseEntity>> selectBookmarkList(
+ public ResponseEntity> selectBookmarkList(
@CurrentMemberId Long memberId,
@ModelAttribute BookmarkRequests.SearchDto searchDto) {
- List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId));
- return ResponseEntity.ok(ApiResponse.success(bookmarks));
+ BookmarkSearchResponse results = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId));
+ return ResponseEntity.ok(ApiResponse.success(results));
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
new file mode 100644
index 0000000..4ad8160
--- /dev/null
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
@@ -0,0 +1,25 @@
+package com.web.SearchWeb.bookmark.controller.dto;
+
+import com.web.SearchWeb.bookmark.domain.Bookmark;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 북마크 검색 결과 응답 DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BookmarkSearchResponse {
+
+ // 검색된 북마크 목록
+ private List bookmarks;
+
+ // 검색 조건(쿼리)에 매칭되는 링크가 하나라도 포함된 폴더 ID 목록
+ private List matchingFolderIds;
+}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
index 5c1111d..ab42dfe 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
@@ -17,6 +17,9 @@ public interface BookmarkDao {
//북마크 목록 조회
List selectBookmarkList(BookmarkSearchCommand searchCommand);
+ // 검색 조건에 매칭되는 북마크들이 속한 고유 폴더 ID 목록 조회
+ List selectMatchingFolderIds(BookmarkSearchCommand searchCommand);
+
//북마크 수정
int updateBookmark(Bookmark bookmark);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
index aad5784..b246e7c 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
@@ -39,15 +39,21 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) {
}
- /**
- * 북마크 목록 조회
- */
@Override
public List selectBookmarkList(BookmarkSearchCommand searchCommand) {
return mapper.selectBookmarkList(searchCommand);
}
+ /**
+ * 매칭 폴더 ID 목록 조회
+ */
+ @Override
+ public List selectMatchingFolderIds(BookmarkSearchCommand searchCommand) {
+ return mapper.selectMatchingFolderIds(searchCommand);
+ }
+
+
/**
* 북마크 수정
*/
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
index 08fa037..45837c4 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
@@ -1,5 +1,6 @@
package com.web.SearchWeb.bookmark.service;
+import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse;
import com.web.SearchWeb.bookmark.domain.Bookmark;
import com.web.SearchWeb.bookmark.domain.Link;
// import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto;
@@ -17,8 +18,8 @@ Long insertBookmark(Long memberId, String url, Long memberFolderId, String displ
//북마크 단일 조회
Bookmark selectBookmark(Long memberId, Long bookmarkId);
- //북마크 목록 조회
- List selectBookmarkList(BookmarkSearchCommand command);
+ //북마크 목록 조회 (검색 결과 및 매칭 폴더 포함)
+ BookmarkSearchResponse selectBookmarkList(BookmarkSearchCommand command);
//북마크 수정
Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String displayTitle,
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
index d370a9c..40ab4bf 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
@@ -1,5 +1,6 @@
package com.web.SearchWeb.bookmark.service;
+import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse;
import com.web.SearchWeb.bookmark.dao.BookmarkDao;
import com.web.SearchWeb.bookmark.domain.Bookmark;
import com.web.SearchWeb.bookmark.domain.Link;
@@ -100,11 +101,17 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) {
/**
- * 북마크 목록 조회
+ * 북마크 목록 조회 (검색 결과 및 매칭 폴더 포함)
*/
@Override
- public List selectBookmarkList(BookmarkSearchCommand command) {
- return bookmarkDao.selectBookmarkList(command);
+ public BookmarkSearchResponse selectBookmarkList(BookmarkSearchCommand command) {
+ List bookmarks = bookmarkDao.selectBookmarkList(command);
+ List matchingFolderIds = bookmarkDao.selectMatchingFolderIds(command);
+
+ return BookmarkSearchResponse.builder()
+ .bookmarks(bookmarks)
+ .matchingFolderIds(matchingFolderIds)
+ .build();
}
diff --git a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
index 481fb2b..daaa076 100644
--- a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
+++ b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
@@ -104,7 +104,7 @@ public ResponseEntity> insertBookmark(@PathVariable final Lo
public ResponseEntity>> getBookmarks(
@PathVariable final Long memberId,
@ModelAttribute BookmarkRequests.SearchDto searchDto) {
- List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId));
+ List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)).getBookmarks();
return ResponseEntity.ok(ApiResponse.success(bookmarks));
}
diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml
index 26bb2e1..548c3e4 100644
--- a/src/main/resources/mapper/bookmark-mapper.xml
+++ b/src/main/resources/mapper/bookmark-mapper.xml
@@ -123,6 +123,26 @@
+
+
+ SELECT DISTINCT msl.member_folder_id
+ FROM member_saved_link msl
+ JOIN link l
+ ON msl.link_id = l.link_id
+ WHERE msl.created_by_member_id = #{memberId}
+ AND msl.deleted_at IS NULL
+
+ AND msl.member_folder_id = #{folderId}
+
+
+ AND msl.primary_category_id = #{categoryId}
+
+
+ AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
+
+
+
+
SELECT * FROM link
From 74e52efa4453b6236d9ed63e07c8cafaa111138a Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 19 Apr 2026 22:40:40 +0900
Subject: [PATCH 03/16] =?UTF-8?q?feat(frontend):=20=EB=B6=81=EB=A7=88?=
=?UTF-8?q?=ED=81=AC=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B2=80=EC=83=89=20?=
=?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20?=
=?UTF-8?q?=EC=83=81=EC=9C=84=20=ED=8F=B4=EB=8D=94=20=EB=A7=A4=EC=B9=AD=20?=
=?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- RightPanel: 사용자 인터페이스 반응성 개선: 잦은 상태 변경에 따른 불필요한 API 요청 및 렌더링 부하를 방지하기 위해 검색어 입력창에 디바운스(Debounce) 메커니즘을 적용하여 쾌적한 UX 최적화.
- folderStore: 상태 관리 아키텍처 정립: 검색어, 검색 범위(Folders/All) 상태를 중앙 집중적으로 제어하기 위한 Zustand 스토어 프로퍼티 설계 및 액션 추가. (Saved today 관련 필드 제외)
- linkStore: 검색 컨텍스트 연동 강화: 사이드바의 특정 폴더 내 북마크 목록과 메인 페이지의 폴더 목록 검색어 상태를 실시간으로 동기화하여 서비스 전반의 검색 일관성 확보.
- bookmarkApi: API 통신 모듈 고도화: 백엔드의 BookmarkSearchResponse 구조 변경에 대응하기 위한 React Query 훅의 응답 타입 정의 수정 및 데이터 패칭 안정성 개선.
- my-links_search_feature_plan.md: 기술 설계 문서화: 검색 기능의 전반적인 데이터 흐름, 디바운싱 전략 및 폴더-북마크 매칭 알고리즘에 대한 상세 기술 사양 명세화.
---
docs/Plan/03. my-links_search_feature_plan.md | 453 ++++++++++++++++++
.../src/components/dialogs/SaveLinkDialog.tsx | 11 +-
.../src/components/my-links/RightPanel.tsx | 36 +-
frontend/src/lib/api/bookmarkApi.ts | 8 +-
frontend/src/lib/store/folderStore.ts | 13 +-
frontend/src/lib/store/linkStore.ts | 1 +
frontend/src/lib/types/bookmark.ts | 6 +
7 files changed, 516 insertions(+), 12 deletions(-)
create mode 100644 docs/Plan/03. my-links_search_feature_plan.md
diff --git a/docs/Plan/03. my-links_search_feature_plan.md b/docs/Plan/03. my-links_search_feature_plan.md
new file mode 100644
index 0000000..bb6e316
--- /dev/null
+++ b/docs/Plan/03. my-links_search_feature_plan.md
@@ -0,0 +1,453 @@
+# My Links 검색 기능 계획서
+
+> **문서 정보**
+> - **버전:** v0.1.0 (Draft)
+> - **작성일:** 2026-04-14
+> - **관련 화면:** `/my-links`
+> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [KPI 측정 설계](./02.%20kpi_measurement_design.md), [Frontend Architecture](../Fontend/01.%20frontend_architecture.md)
+> - **문서 목적:** `/my-links` 검색 기능의 UX 정책, 결과 구조, 데이터 규칙, 구현 범위를 정의한다.
+
+
+
+## 1. 개요
+
+`/my-links` 화면은 현재 좌측 메인 영역과 우측 링크 패널의 역할이 분리되어 있다.
+
+- 좌측 메인 영역: 폴더 탐색, 폴더 생성, 전체 구조 탐색
+- 우측 패널: 선택된 폴더 기준 링크 조회, 정렬, 태그 필터, 링크 편집
+
+검색 기능 역시 이 역할 분리를 유지하면서도, 사용자는 일관된 검색 경험을 느낄 수 있어야 한다.
+따라서 본 계획서는 다음 원칙을 채택한다.
+
+- **검색 경험은 통일한다.**
+- **검색 범위와 결과 해석은 영역별로 분리한다.**
+- **전체검색에서는 폴더와 링크를 모두 보여주되, 결과를 섞지 않고 섹션으로 분리한다.**
+- **링크가 여러 폴더에 저장된 경우, 포함된 폴더 정보를 명시적으로 보여준다.**
+
+
+
+## 2. 문제 정의
+
+현재 `/my-links` 상단에는 `Search folders or links` 형태의 입력창이 존재하지만, 실제 검색 범위와 결과 정책은 정의되어 있지 않다.
+
+이 상태에서 검색을 바로 구현하면 다음 문제가 생길 수 있다.
+
+- 사용자가 지금 찾는 대상이 폴더인지 링크인지 명확하지 않다.
+- 전체검색 시 폴더 결과와 링크 결과가 한 리스트에 섞이면 결과 문맥이 깨진다.
+- 동일 링크가 여러 폴더에 존재할 때 결과를 어떻게 보여줄지 일관된 기준이 없다.
+- 좌측 메인 검색과 우측 패널 검색의 역할이 겹치면 인터랙션이 혼란스러워진다.
+
+
+
+## 3. 목표
+
+### 3.1 사용자 목표
+
+- 사용자가 폴더를 빠르게 찾을 수 있어야 한다.
+- 사용자가 현재 선택한 폴더 안에서 링크를 빠르게 찾을 수 있어야 한다.
+- 사용자가 전체검색을 통해 폴더와 링크를 한 번에 탐색할 수 있어야 한다.
+- 사용자가 검색 결과에서 링크가 어떤 폴더들에 포함되어 있는지 즉시 이해할 수 있어야 한다.
+
+### 3.2 제품 목표
+
+- 검색 대상과 결과 구조를 명확히 분리하여 혼란을 줄인다.
+- 현재 데이터 구조를 크게 깨지 않고 1차 버전을 빠르게 구현한다.
+- 이후 전역 검색 고도화와 이벤트 로깅으로 확장 가능한 기반을 만든다.
+
+
+
+## 4. UX 원칙
+
+### 4.1 검색 경험 통일, 검색 범위 분리
+
+검색창의 시각 스타일, 입력 방식, 아이콘, 인터랙션은 최대한 통일한다.
+다만 각 영역이 담당하는 검색 범위는 다음과 같이 분리한다.
+
+| 영역 | 기본 역할 | 검색 대상 | 기본 범위 |
+| :--- | :--- | :--- | :--- |
+| 좌측 메인 검색 | 탐색 진입점 | 폴더, 링크 | 기본은 폴더 검색 |
+| 우측 패널 검색 | 현재 작업 맥락 유지 | 링크 | 현재 선택된 폴더 내부 링크 |
+
+### 4.2 기본값은 좁고, 확장은 명시적으로
+
+좌측 검색은 기본적으로 `폴더만 검색`으로 시작한다.
+전체검색이 필요한 사용자를 위해 검색 범위 토글을 제공한다.
+
+권장 범위 토글:
+
+- `폴더만`
+- `전체`
+
+기본 선택:
+
+- `폴더만`
+
+선택 이유:
+
+- `/my-links` 좌측 영역의 핵심 역할이 폴더 탐색이기 때문이다.
+- 전체검색을 기본으로 두면 결과 해석 비용이 커진다.
+- 우측 패널의 링크 검색과 역할이 겹치지 않도록 해야 한다.
+
+
+
+## 5. 검색 범위 정책
+
+### 5.1 좌측 메인 검색
+
+좌측 검색창은 두 가지 모드를 가진다.
+
+#### A. 폴더만 검색
+
+- 폴더 이름 기준 검색
+- 결과는 폴더 카드 또는 폴더 리스트로 표시
+- 기존 폴더 탐색 흐름을 해치지 않는 범위에서 동작
+
+#### B. 전체검색
+
+- 검색어와 매칭되는 폴더 결과와 링크 결과를 모두 보여줌
+- 단, 한 리스트에 섞지 않고 별도 섹션으로 분리
+- 상단에 결과 탭 또는 칩으로 `전체 | 폴더 | 링크` 필터 제공 가능
+
+### 5.2 우측 패널 검색
+
+- 검색 대상은 링크만
+- 검색 범위는 현재 `selectedFolderId` 기준으로 조회된 링크 목록
+- 태그 필터, 정렬과 함께 사용 가능해야 함
+- placeholder는 `Search links in this folder` 수준으로 명확화
+
+
+
+## 6. 전체검색 결과 구조
+
+### 6.1 핵심 정책
+
+전체검색에서 검색어가 폴더와 링크 모두에 매칭되면 **둘 다 보여준다.**
+단, 결과를 한 리스트로 섞지 않고 다음과 같이 분리한다.
+
+- `Folders`
+- `Links`
+
+이는 "전체검색을 켰다"는 사용자의 의도가 "어디 있든 관련 있는 것을 다 찾고 싶다"에 가깝기 때문이다.
+
+### 6.2 결과 구성 예시
+
+```text
+[ 검색창: aaa ] [전체]
+
+8 results
+[All] [Folders 5] [Links 6]
+
+Folders
+- aaa 프로젝트 (폴더명 직접 매칭)
+- aaa 참고자료 (폴더명 직접 매칭)
+- Frontend (링크 'aaa 공식문서' 포함)
+- Study (링크 'aaa 공식문서' 포함)
+- Work (링크 'How to use aaa' 포함)
+
+Links
+- aaa 공식문서
+ 포함 폴더: Frontend / Study / Reference
+- How to use aaa
+ 포함 폴더: Work / Archive
+```
+
+**주요 변경:**
+- 링크에 포함된 폴더(Frontend, Study, Work 등)도 **Folders 섹션에 함께 표시**
+- 폴더 클릭 시 우측 패널 링크 검색창에 검색어('aaa')가 자동 입력되어, 해당 폴더의 매칭 링크만 표시
+
+### 6.3 UI 표현 규칙
+
+- 검색창 바로 아래에 총 결과 수를 표시한다.
+- 그 아래에 `All`, `Folders`, `Links` 탭 또는 칩을 둘 수 있다.
+- `Folders` 섹션은 폴더 결과만 렌더링한다.
+- `Links` 섹션은 링크 결과만 렌더링한다.
+- 링크 카드 내부에 `포함 폴더` 메타정보를 표시한다.
+
+### 6.4 결과를 섞지 않는 이유
+
+- 폴더 결과와 링크 결과는 사용자가 기대하는 액션이 다르다.
+- 폴더 결과는 "해당 폴더로 이동"이 주된 액션이다.
+- 링크 결과는 "링크 열기" 또는 "링크가 저장된 위치 확인"이 주된 액션이다.
+- 섞어서 보여주면 정보는 많아지지만 이해 비용이 크게 증가한다.
+
+
+
+## 7. 링크 중복 및 포함 폴더 표시 정책
+
+### 7.1 문제 상황
+
+전체검색에서 동일한 링크가 여러 폴더에 저장될 수 있다. 예를 들어 동일 링크가 `Frontend`, `Study` 두 폴더에 모두 저장된 경우가 있다.
+
+### 7.2 기본 정책
+
+전체검색 결과에서 링크 카드에는 **해당 링크가 포함된 폴더를 모두 보여준다.**
+
+- 링크 결과는 링크 자체의 정보와 함께 렌더링
+- 링크 카드 내부에 `포함 폴더` 목록 표시
+- 폴더 배지 클릭 시 해당 폴더로 이동 가능하도록 설계
+
+### 7.3 폴더 표시 규칙
+
+- 1~3개 폴더는 모두 바로 노출
+- 폴더가 4개 이상이면 `+N more` 패턴 고려
+- hover, 클릭, 확장 패널 등으로 전체 폴더 목록 확인 가능하도록 확장 가능
+
+### 7.4 의미 분리
+
+다음 두 정보는 같은 "폴더"처럼 보이지만 역할이 다르다.
+
+- `Folders` 섹션의 폴더: 검색어에 직접 매칭된 검색 결과
+- 링크 카드의 포함 폴더: 해당 링크가 저장된 위치 정보
+
+따라서 반드시 위치를 분리해서 보여줘야 한다.
+
+
+
+## 8. 데이터 및 API 설계 방향
+
+### 8.1 현재 코드 기준 확인 사항
+
+현재 프론트엔드에서는 북마크 조회 API가 `query` 파라미터를 받을 수 있다.
+
+- `frontend/src/lib/api/bookmarkApi.ts`
+- `frontend/src/lib/types/bookmark.ts`
+
+또한 백엔드 `BookmarkSearchCommand`와 MyBatis 매퍼에서도 `query`를 받고 있다.
+
+- `src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java`
+- `src/main/resources/mapper/bookmark-mapper.xml`
+
+단, 현재 매퍼 기준 링크 검색은 `display_title` 기준으로만 동작한다.
+
+```sql
+AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
+```
+
+반면 폴더 목록 API는 현재 검색 전용 파라미터가 없다.
+
+- `frontend/src/lib/api/folderApi.ts`
+
+따라서 1차 구현은 다음 전략이 현실적이다.
+
+- 폴더 검색: 이미 로드된 폴더 목록 기반 클라이언트 필터
+- 우측 링크 검색: 기존 `query` 파라미터 활용
+- 전체검색 1차: 폴더는 클라이언트 필터, 링크는 서버 검색 조합
+
+### 8.2 전체검색 결과 응답 방향
+
+향후 전체검색을 서버 중심으로 고도화할 경우 아래 형태의 응답 구조를 권장한다.
+
+```json
+{
+ "query": "aaa",
+ "folders": [
+ {
+ "folderId": 1,
+ "folderName": "aaa 프로젝트"
+ }
+ ],
+ "links": [
+ {
+ "linkKey": "canonical-or-original-url",
+ "title": "aaa 공식문서",
+ "url": "https://example.com/aaa",
+ "matchedBookmarkIds": [10, 25],
+ "includedFolders": [
+ { "folderId": 3, "folderName": "Frontend" },
+ { "folderId": 7, "folderName": "Study" }
+ ]
+ }
+ ]
+}
+```
+
+### 8.3 링크 식별 기준
+
+링크 중복 묶음 또는 관계 계산 시 권장 우선순위는 다음과 같다.
+
+1. `canonicalUrl`
+2. `originalUrl`
+3. 필요 시 `linkId`
+
+단, 1차 구현에서는 저장 단위가 북마크인 만큼 무리한 통합보다는 "링크 카드에 포함 폴더를 보여주는 방향"을 우선 적용한다.
+
+
+
+## 9. 구현 단계 제안
+
+### 9.1 1단계: 빠른 UX 정착
+
+목표:
+
+- 검색 범위를 명확히 나누고 기본 UX를 안정화
+
+범위:
+
+- 좌측 검색창 범위 토글 추가: `폴더만 | 전체`
+- 좌측 `폴더만` 검색: 로컬 폴더 필터
+- 우측 링크 검색: 현재 폴더 기준 `query` 연동
+- placeholder 및 문구 정리
+
+제외:
+
+- 폴더와 링크를 동시에 묶는 서버 통합 검색 API
+- 복잡한 dedupe 로직
+
+### 9.2 2단계: 전체검색 결과 섹션화
+
+목표:
+
+- 전체검색에서 폴더와 링크를 동시에 보여주는 구조 도입
+
+범위:
+
+- 좌측 검색 결과 영역 추가
+- `Folders` / `Links` 섹션 분리
+- 결과 수 표시
+- `전체 | 폴더 | 링크` 보기 필터 추가 가능
+
+### 9.3 3단계: 포함 폴더 정보 고도화
+
+목표:
+
+- 링크 결과의 저장 위치를 명확히 보여줌
+
+범위:
+
+- 링크 카드 내 `포함 폴더` 배지 표시
+- 폴더 배지 클릭 시 해당 폴더 컨텍스트 이동
+- 폴더 수가 많을 때 축약 및 확장 UX 추가
+
+### 9.4 4단계: 검색 품질 개선
+
+목표:
+
+- 검색 정확도와 탐색 속도 개선
+
+범위:
+
+- 링크 검색 범위를 `displayTitle` 외 `note`, `domain`, `tag`, `link title`까지 확장 검토
+- 폴더 검색 API 또는 통합 검색 API 검토
+- 디바운스, 캐싱, 최근 검색어, 키보드 탐색 추가
+
+
+
+## 10. 프론트엔드 구성 제안
+
+### 10.1 좌측 메인 영역
+
+추가 상태 예시:
+
+- `searchScope: 'folders' | 'all'`
+- `searchQuery: string`
+- `searchView: 'all' | 'folders' | 'links'`
+
+렌더링 흐름:
+
+- 검색어 없음: 기존 폴더 대시보드 유지
+- 검색어 있음 + `폴더만`: 폴더 결과만 표시
+- 검색어 있음 + `전체`: `Folders` / `Links` 결과 섹션 표시
+
+### 10.2 우측 패널
+
+추가 상태 예시:
+
+- `linkSearchQuery: string`
+
+렌더링 흐름:
+
+- 현재 `selectedFolderId` 유지
+- 정렬, 태그 필터와 함께 검색 적용
+- 검색 상태에서도 폴더 컨텍스트를 잃지 않음
+
+**폴더 클릭 시 자동 검색 입력 (신규):**
+
+- 좌측 전체검색의 Folders 섹션에서 폴더를 클릭 시:
+ ```typescript
+ handleFolderClick(folderId) {
+ bookmarkStore.setSelectedFolder(folderId);
+ bookmarkStore.setLinkSearchQuery(currentSearchQuery); // 현재 검색어 자동 입력
+ }
+ ```
+- 우측 패널 링크 검색창에는 `linkSearchQuery` 상태가 반영되어 미리 입력된 상태로 표시
+- 사용자는 X 버튼으로 검색어를 삭제하거나, 새로운 검색어를 직접 입력 가능
+- 다른 폴더를 클릭하면 `linkSearchQuery`는 새로운 검색어로 자동 업데이트됨
+
+
+
+## 11. 이벤트 로깅 제안
+
+검색 기능이 실제로 사용되는지 확인하기 위해 아래 이벤트를 추적한다.
+
+| 이벤트 코드 | 설명 | 우선순위 |
+| :--- | :--- | :--- |
+| `SEARCH_EXECUTE` | 검색 실행 | P0 |
+| `SEARCH_SCOPE_CHANGE` | `폴더만` / `전체` 범위 전환 | P0 |
+| `SEARCH_RESULT_CLICK_FOLDER` | 검색 결과에서 폴더 클릭 (좌측 Folders 섹션) | P0 |
+| `SEARCH_RESULT_CLICK_LINK` | 검색 결과에서 링크 클릭 | P0 |
+| `SEARCH_CONTEXT_AUTO_INPUT` | 폴더 클릭 → 우측 패널 검색어 자동 입력 | P0 |
+| `SEARCH_RESULT_CLICK_INCLUDED_FOLDER` | 링크 카드 내부 포함 폴더 클릭 | P1 |
+| `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 |
+
+권장 속성 예시:
+
+```json
+{
+ "query": "aaa",
+ "scope": "folders | all | current_folder_links",
+ "folder_result_count": 2,
+ "link_result_count": 6,
+ "selected_folder_id": 15
+}
+```
+
+
+
+## 12. 검증 포인트
+
+### 12.1 UX 검증
+
+- 사용자가 좌측 검색과 우측 검색의 차이를 직관적으로 이해하는가
+- 전체검색 결과에서 폴더와 링크가 섞이지 않아 이해가 쉬운가
+- 링크 카드의 포함 폴더가 정보 과밀 없이 읽히는가
+
+### 12.2 기능 검증
+
+- `폴더만` 검색 시 폴더만 결과에 노출되는가
+- `전체` 검색 시 폴더와 링크가 모두 노출되는가
+- 우측 검색은 현재 선택된 폴더 범위를 벗어나지 않는가
+- 링크가 여러 폴더에 저장된 경우 포함 폴더가 모두 표시되는가
+
+### 12.3 성능 검증
+
+- 검색 입력 시 불필요한 전체 재렌더가 과도하지 않은가
+- 우측 패널 검색 시 API 호출 빈도가 과도하지 않은가
+- 전체검색 결과 수가 많을 때 레이아웃 붕괴 없이 유지되는가
+
+
+
+## 13. 비목표
+
+이번 계획서 범위에서 바로 포함하지 않는 항목은 다음과 같다.
+
+- 완전한 글로벌 검색 모달 별도 도입
+- 고급 랭킹 알고리즘
+- 자연어 검색
+- 팀 폴더/팀 링크까지 포함한 통합 검색
+- 모바일 전용 별도 검색 UX
+
+
+
+## 14. 결론
+
+`/my-links` 검색 기능은 "한 검색창으로 모든 것을 해결"하기보다, **좌측은 탐색, 우측은 현재 작업 컨텍스트 유지**라는 구조를 살려야 한다.
+
+최종 방향은 다음과 같다.
+
+- 좌측 검색 기본값은 `폴더만 검색`
+- 좌측에서 필요 시 `전체검색`으로 확장
+- 전체검색은 폴더와 링크를 모두 보여주되, 반드시 `Folders` / `Links` 섹션으로 분리
+- 링크 결과에는 `포함 폴더`를 명시적으로 표시
+- 우측 패널 검색은 현재 선택된 폴더 안의 링크 검색으로 유지
+
+이 방향은 현재 코드 구조와도 잘 맞고, 1차 구현부터 단계적으로 확장하기에도 적합하다.
diff --git a/frontend/src/components/dialogs/SaveLinkDialog.tsx b/frontend/src/components/dialogs/SaveLinkDialog.tsx
index 5778a9c..e3460fe 100644
--- a/frontend/src/components/dialogs/SaveLinkDialog.tsx
+++ b/frontend/src/components/dialogs/SaveLinkDialog.tsx
@@ -277,7 +277,12 @@ export function SaveLinkDialog() {
}
);
} else {
- saveBookmark(selectedFolderId);
+ // Quick Save 경로: 폴더 미지정 시 미분류(UNORGANIZED) 폴더로 저장
+ const fallbackFolderId =
+ selectedFolderId
+ ?? folders?.find((f) => f.folderType === 'UNORGANIZED')?.memberFolderId
+ ?? null;
+ saveBookmark(fallbackFolderId);
}
};
@@ -398,7 +403,7 @@ export function SaveLinkDialog() {
link
-
복사한 링크 붙여넣기
+
Paste copied link
{/* 부드럽고 존재감 있는 곡선형 SVG 꼬리표 - 크기 확대 및 실루엣 최적화 */}
error
- URL은 http:// 또는 https://로 시작해야 합니다.
+ URL must start with http:// or https://
)}
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index 4334d8f..3359676 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -5,6 +5,7 @@ import { useBookmarks, useDeleteBookmark, useUpdateBookmark } from '@/lib/api/bo
import { useTags } from '@/lib/api/tagApi';
import { useFolderStore } from '@/lib/store/folderStore';
import { useAuthStore } from '@/lib/store/authStore';
+import { useLinkStore } from '@/lib/store/linkStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import type { BookmarkResponse } from '@/lib/types/bookmark';
@@ -495,6 +496,26 @@ export function RightPanel() {
const { rightPanelOpen } = useUIStore(); // 패널 오픈 여부
const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID
const memberId = useAuthStore((s) => s.member?.memberId);
+ const linkSearchQuery = useLinkStore((s) => s.filters.searchQuery); // 현재 검색어 상태 구독
+ const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수
+
+ // 검색어 디바운스 처리를 위한 로컬 상태
+ const [pendingSearch, setPendingSearch] = useState(linkSearchQuery);
+
+ // 외부(스토어)에서 검색어가 변경될 경우 로컬 상태와 동기화
+ useEffect(() => {
+ setPendingSearch(linkSearchQuery);
+ }, [linkSearchQuery]);
+
+ // 디바운스 로직: pendingSearch가 변경되면 250ms 후에 스토어 업데이트
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ if (pendingSearch !== linkSearchQuery) {
+ setLinkSearchQuery(pendingSearch);
+ }
+ }, 250);
+ return () => clearTimeout(handler);
+ }, [pendingSearch, setLinkSearchQuery, linkSearchQuery]);
// --- API Data Fetching (React Query) | 서버 데이터 조회 ---
const { data: myFolders, isLoading: isFoldersLoading } = useFolders(memberId); // 폴더 목록
@@ -516,12 +537,15 @@ export function RightPanel() {
// UI 정렬 옵션을 백엔드 파라미터로 매핑
const backendSort = sortOption === 'newest' ? 'Newest' as const : sortOption === 'oldest' ? 'Oldest' as const : 'Alphabetical' as const;
- // 북마크 데이터 조회
- const { data: bookmarks, isLoading: isBookmarksLoading } = useBookmarks({
+ // 북마크 데이터 조회 (검색 결과 및 매칭 폴더 IDs 포함)
+ const { data: bookmarksData, isLoading: isBookmarksLoading } = useBookmarks({
folderId: selectedFolderId,
sort: backendSort,
+ query: linkSearchQuery,
});
+ const bookmarks = bookmarksData?.bookmarks;
+
// API 뮤테이션 (수정/삭제)
const deleteBookmarkMutation = useDeleteBookmark();
const updateBookmarkMutation = useUpdateBookmark();
@@ -779,9 +803,11 @@ export function RightPanel() {
{/* Search Bar | 검색창 */}
search
- setPendingSearch(e.target.value)}
className="bg-transparent border-none focus:outline-none focus:ring-0 text-[9px] text-gray-700 dark:text-gray-200 placeholder-gray-400 w-full p-0 h-4"
/>
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts
index 437ff44..f4ac6e3 100644
--- a/frontend/src/lib/api/bookmarkApi.ts
+++ b/frontend/src/lib/api/bookmarkApi.ts
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchClient } from './fetchClient';
import type {
BookmarkResponse,
+ BookmarkSearchResponse,
BookmarkSearchParams,
CreateBookmarkRequest,
UpdateBookmarkRequest,
@@ -11,7 +12,7 @@ import type {
* 북마크(링크) 목록 조회 (GET /api/bookmarks)
* @param params 검색 필터 (폴더 ID, 정렬, 검색어 등)
*/
-async function fetchBookmarks(params: BookmarkSearchParams): Promise {
+async function fetchBookmarks(params: BookmarkSearchParams): Promise {
const searchParams = new URLSearchParams();
if (params.folderId != null) searchParams.set('folderId', String(params.folderId));
if (params.sort) searchParams.set('sort', params.sort);
@@ -19,7 +20,7 @@ async function fetchBookmarks(params: BookmarkSearchParams): Promise(`/api/bookmarks${qs ? `?${qs}` : ''}`);
+ return fetchClient(`/api/bookmarks${qs ? `?${qs}` : ''}`);
}
/**
@@ -71,10 +72,11 @@ async function analyzeUrl(url: string): Promise {
/**
* [조회 Hook] 북마크 목록을 가져오고 캐싱합니다.
*/
-export function useBookmarks(params: BookmarkSearchParams) {
+export function useBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['bookmarks', params], // 검색 조건이 바뀌면 새로운 쿼리로 취급
queryFn: () => fetchBookmarks(params),
+ enabled: options?.enabled,
});
}
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index 6fb6494..d859f1f 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -2,11 +2,22 @@ import { create } from 'zustand';
interface FolderStore {
selectedFolderId: number | null;
+ searchScope: 'folders' | 'all';
+ searchQuery: string;
+
setSelectedFolderId: (id: number | null) => void;
+ setSearchScope: (scope: 'folders' | 'all') => void;
+ setSearchQuery: (query: string) => void;
+ clearSearch: () => void;
}
export const useFolderStore = create((set) => ({
- selectedFolderId: null,
+ selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨)
+ searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함)
+ searchQuery: '', // 폴더 목록 필터링용 검색어
setSelectedFolderId: (id) => set({ selectedFolderId: id }),
+ setSearchScope: (scope) => set({ searchScope: scope }),
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ clearSearch: () => set({ searchQuery: '', searchScope: 'all' }),
}));
diff --git a/frontend/src/lib/store/linkStore.ts b/frontend/src/lib/store/linkStore.ts
index e4e5466..93448aa 100644
--- a/frontend/src/lib/store/linkStore.ts
+++ b/frontend/src/lib/store/linkStore.ts
@@ -23,6 +23,7 @@ export const useLinkStore = create((set) => ({
sortType: 'latest',
},
+ // 검색어 상태 업데이트 (다른 컴포넌트 자동 반영)
setSearchQuery: (query) =>
set((state) => ({ filters: { ...state.filters, searchQuery: query } })),
diff --git a/frontend/src/lib/types/bookmark.ts b/frontend/src/lib/types/bookmark.ts
index 6ff1a7a..b50ba4d 100644
--- a/frontend/src/lib/types/bookmark.ts
+++ b/frontend/src/lib/types/bookmark.ts
@@ -26,6 +26,12 @@ export interface BookmarkResponse {
updatedAt: string | null;
}
+// 검색 결과 응답 타입
+export interface BookmarkSearchResponse {
+ bookmarks: BookmarkResponse[];
+ matchingFolderIds: number[];
+}
+
// POST /api/bookmarks 요청 바디
export interface CreateBookmarkRequest {
memberFolderId?: number | null;
From cddfad45d441f8898563a0a83e8211eef63c1af5 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 19 Apr 2026 22:40:54 +0900
Subject: [PATCH 04/16] =?UTF-8?q?feat(frontend):=20=ED=8F=B4=EB=8D=94=20?=
=?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=95=EA=B5=90?=
=?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?=
=?UTF-8?q?=EC=8A=A4=20=EA=B8=80=EB=A1=9C=EB=B2=8C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- page.tsx: 인터페이스 글로벌화 수행: My-Links 페이지 내 모든 한국어 레이블을 영문(Recently Added 등)으로 변환하여 시각적 전문성 및 서비스 확장성 확보. (Saved today 관련 로직 제외)
- page.tsx: 데이터 정렬 및 시각적 계층화 최적화: 미분류(Unorganized) 시스템 폴더를 목록 최상단에 고정 배치하고, 다른 폴더들을 최신순/이름순으로 정렬하는 직관적인 레이아웃 로직 구현.
- 04. unorganized_folder_introduction_plan.md: 시스템 폴더 아키텍처 설계 문서화: 미분류 폴더 도입 배경, DB 유니크 제약 사항 및 시스템 자동 생성 로직에 대한 상세 기술 명세 수립.
---
...4. unorganized_folder_introduction_plan.md | 349 ++++++++++++++++++
frontend/src/app/my-links/page.tsx | 200 ++++++++--
2 files changed, 516 insertions(+), 33 deletions(-)
create mode 100644 docs/Plan/04. unorganized_folder_introduction_plan.md
diff --git a/docs/Plan/04. unorganized_folder_introduction_plan.md b/docs/Plan/04. unorganized_folder_introduction_plan.md
new file mode 100644
index 0000000..92bdcd4
--- /dev/null
+++ b/docs/Plan/04. unorganized_folder_introduction_plan.md
@@ -0,0 +1,349 @@
+# 미분류(디폴트) 폴더 도입 계획서
+
+> **문서 정보**
+> - **버전:** v0.1.0 (Draft)
+> - **작성일:** 2026-04-18
+> - **관련 화면:** `/my-links`, `SaveLinkDialog`
+> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [My Links 검색 기능 계획서](./03.%20my-links_search_feature_plan.md)
+> - **문서 목적:** "미분류" 개념을 **시스템 폴더(`folder_type='UNORGANIZED'`)** 로 정의하고, 이를 도입하기 위한 데이터 모델·서비스·UI 변경 사항을 정의한다.
+
+
+
+## 1. 개요
+
+`/my-links` 화면 상단에는 `Unorganized` 칩이 존재하지만, 현재 정의·동작이 비어 있다 (`frontend/src/app/my-links/page.tsx:179`).
+
+기존 시스템에서 "미분류"를 정의할 수 있는 방법은 두 가지였다.
+
+- ⓐ **태그 부재 기반** — 해시태그가 비어 있는 링크를 미분류로 간주
+- ⓑ **물리적 시스템 폴더 기반** — `folder_type='UNORGANIZED'` 인 시스템 폴더를 자동 생성하고, 분류되지 않은 링크는 이 폴더에 저장
+
+본 계획서는 사전 인터뷰 결과(`deep-interview` round 4 / 최종 ambiguity 14%)에 따라 **ⓑ 디폴트 폴더 방식**을 채택한다.
+이유:
+- 폴더와 태그가 직교 개념(현재 코드 기준)이라 태그 부재 = 미분류 정의는 의미가 약함
+- 사용자가 미분류 칩을 클릭했을 때 동작을 **기존 폴더 클릭과 동일하게** 통일할 수 있음 (`setSelectedFolderId(defaultFolderId)`)
+- AI 분류 실패/사용자 미선택 케이스 둘 다 같은 위치로 모이므로 회수(回收) 동선이 단순해짐
+
+
+
+## 2. 문제 정의
+
+현재 구조에서 디폴트 폴더 도입을 막고 있는 결손은 다음과 같다.
+
+1. **데이터 모델**: `member_folder` 테이블과 `MemberFolder` 엔티티 모두 폴더 타입(`NORMAL`, `UNORGANIZED` 등)을 식별할 수 있는 필드가 없다.
+2. **자동 생성 시점 부재**: 회원가입(소셜) 흐름(`CustomOAuth2MemberService.loadUser` 73~95행)에서 폴더 생성 호출이 없다. 기존 회원에 대한 백필 경로도 없다.
+3. **불변 정책 부재**: `MemberFolderServiceImpl.delete/move/update` 가 시스템 폴더에 대한 가드 없이 일반 폴더와 동일하게 동작한다.
+4. **저장 경로 분기 부재**: `SaveLinkDialog.handleSave` (`frontend/src/components/dialogs/SaveLinkDialog.tsx:261`) 가 `selectedFolderId === null && !pendingNewFolderName` 일 때 `memberFolderId: null` 을 전송하는데, 이를 받는 백엔드는 폴더 NULL 을 허용하지 않는다(현 스키마: `"member_folder_id" int NOT NULL`). → **폴더 미선택 저장 자체가 불가능**.
+5. **UI**: `Unorganized` 칩에 onClick 미연결, `FolderCard` 메뉴(이름변경/이동/삭제)가 시스템 폴더에서도 동일하게 표시됨.
+
+
+
+## 3. 목표
+
+### 3.1 사용자 목표
+
+- 사용자가 폴더를 정하지 않고도 링크를 저장할 수 있다 (퀵 세이브 경로).
+- 사용자가 `Unorganized` 칩을 누르면 미분류 폴더의 링크 목록을 즉시 볼 수 있다.
+- 사용자는 미분류 폴더를 실수로 삭제·이동할 수 없다 (이름 변경은 허용).
+
+### 3.2 제품 목표
+
+- 시스템 폴더를 데이터 모델 차원에서 일급(first-class)으로 표현한다.
+- 신규/기존 사용자 모두에게 정확히 1개의 미분류 폴더를 보장한다 (idempotent).
+- AI 폴더 추천 실패·미선택 같은 후속 시나리오를 같은 디폴트 폴더로 모은다.
+
+### 3.3 비목표 (이번 이터레이션 제외)
+
+- AI 폴더 추천이 실패했을 때 자동으로 디폴트 폴더로 폴백하는 로직 (Future Section)
+- 폴더 이름의 다국어화 / 사용자 커스텀 (이번에는 한국어 `미분류` 고정)
+- 디폴트 폴더 안의 링크에 대한 "이주" 기능 (다른 폴더로 옮기면 자동 비분류 해제 등)
+
+
+
+## 4. 결정 사항 요약 (인터뷰 산출)
+
+| 항목 | 결정 | 비고 |
+| :--- | :--- | :--- |
+| 미분류 정의 | 시스템 폴더(`folder_type='UNORGANIZED'`)에 보관된 링크 | 태그 부재 정의 폐기 |
+| 마이그레이션 전략 | **회원가입 로직 변경 + 기존 회원 lazy 생성** | 옵션 3 (권장안) |
+| 디폴트 폴더 제약 | 삭제 ✗ / 이름 변경 ✓ / 이동 ✗ / 자식 ✓ | 옵션 B (느슨한) |
+| 저장 정책 | **이번 이터레이션에 Quick Save 포함** | 옵션 2 (권장안) |
+| AI 실패 폴백 | 본 이터레이션 제외 | Future Section |
+
+
+
+## 5. 데이터 모델 변경
+
+### 5.1 `member_folder` 테이블
+
+`src/main/resources/db/init_postgres.sql` (96~113행) 의 정의에 컬럼·제약 추가.
+
+```sql
+-- 1. 폴더 타입 컬럼 추가 (기본값 CUSTOM)
+ALTER TABLE "member_folder"
+ ADD COLUMN IF NOT EXISTS "folder_type" varchar(20) NOT NULL DEFAULT 'CUSTOM';
+
+-- 2. 타입 제약 추가 (향후 TRASH, ARCHIVE 등 확장 가능)
+ALTER TABLE "member_folder"
+ DROP CONSTRAINT IF EXISTS ck_member_folder_type,
+ ADD CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED'));
+
+-- 3. 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재하도록 유니크 제약
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_owner_unorganized
+ ON "member_folder" ("owner_member_id")
+ WHERE folder_type = 'UNORGANIZED' AND deleted_at IS NULL;
+
+-- 4. member_saved_link 테이블 정합성 정리 및 FK 추가
+-- (주의: FK 추가 전, member_folder_id가 유효하지 않은 데이터가 있는지 점검 필수)
+ALTER TABLE "member_saved_link"
+ ADD CONSTRAINT fk_member_saved_link_member_folder_id
+ FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id");
+```
+
+`init_postgres.sql` 본체에도 컬럼/제약/인덱스를 반영하여 신규 환경에서 자동 적용되도록 한다.
+특히 `member_saved_link` 정의 시점에 `member_folder_id`에 대한 FK가 누락되어 있으므로 이를 보강한다.
+
+### 5.2 `MemberFolder` 엔티티
+
+`src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java`:
+
+- `private String folderType;` 필드 추가 (기본값 `"CUSTOM"`).
+- `isUnorganized()` 편의 메서드 추가.
+- `changeParent()` / `delete*` 같이 자기 자신을 변경하는 도메인 메서드들이 시스템 폴더 위반을 방어할 수 있도록, 메서드 레벨에서 `if (isUnorganized()) throw new BusinessException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);` 가드를 추가한다 (이름 변경 `changeInfo` 는 가드 제외).
+
+### 5.3 `FolderErrorCode` 추가
+
+`src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java` 에 다음을 추가:
+
+- `SYSTEM_FOLDER_IMMUTABLE` (HTTP 409, "기본 폴더는 삭제하거나 이동할 수 없습니다.")
+- `DEFAULT_FOLDER_NAME_CONFLICT` (HTTP 409, 이미 같은 이름의 폴더가 있어 디폴트 폴더 자동 생성 실패 시)
+
+
+
+## 6. 백엔드 변경 사항
+
+### 6.1 서비스 인터페이스 확장
+
+`src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java`:
+
+```java
+/** 미분류 폴더 보장. 없으면 생성, 있으면 기존 ID 반환. idempotent. */
+Long getOrCreateUnorganizedFolderId(Long memberId);
+
+/** 미분류 폴더 단건 조회. 없으면 getOrCreate 호출 후 반환. */
+MemberFolder getDefaultFolder(Long memberId);
+```
+
+### 6.2 `MemberFolderServiceImpl` 변경
+
+- **`getOrCreateUnorganizedFolderId(memberId)`** 신규 구현
+ - `findByOwnerAndFolderType(memberId, "UNORGANIZED")` 로 조회 → 있으면 그 ID 반환.
+ - 없으면 `MemberFolder.builder().ownerMemberId(memberId).folderName("미분류").folderType("UNORGANIZED").build()` 로 저장.
+ - 동시 호출(중복 INSERT)은 5.1의 부분 유니크 인덱스가 막고, `DataIntegrityViolationException` catch 후 재조회로 복구.
+- **`delete(memberId, folderId)`** 가드 추가
+ - 조회 후 `if (folder.isUnorganized()) throw FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE` (현재 자식/북마크 가드 앞단).
+- **`move(memberId, folderId, newParentFolderId)`** 가드 추가
+ - 동일하게 시스템 폴더면 차단.
+- **`update(memberId, ...)`** 는 가드 없음 (이름 변경 허용 — 결정 5.B).
+- **`listRootFolders(memberId, ownerMemberId)`** 끝부분에 lazy 보장 추가
+ - `getOrCreateUnorganizedFolderId(ownerMemberId)` 를 1회 호출하여 기존 회원에게도 자동 백필.
+ - 호출은 idempotent 하므로 매 요청마다 안전. (성능 영향은 7.2 참고)
+
+### 6.3 회원가입 흐름 통합
+
+**소셜 회원가입** — `CustomOAuth2MemberService.loadUser` (73~95행)
+
+```java
+if (existMember == null) {
+ // 기존 코드: insertSocialMember(...)
+ memberDao.insertSocialMember(member);
+ existMember = member;
+ // 신규: 디폴트 폴더 생성. 실패해도 가입 자체는 진행시켜야 하므로 try-catch 후 로그.
+ try {
+ memberFolderService.getOrCreateUnorganizedFolderId(existMember.getMemberId());
+ } catch (Exception e) {
+ log.warn("default folder creation failed (will be lazily created on first listRootFolders): {}", e.getMessage());
+ }
+}
+```
+
+`MemberFolderService` 의존성을 생성자 주입으로 추가. 순환 의존성 방지를 위해 `MemberFolderService` 가 `member` 도메인을 역방향으로 참조하지 않는지 점검 (현재 `MemberFolderServiceImpl` 은 `MemberFolderRepository` 만 사용하므로 안전).
+
+**로컬 회원가입** — 별도 `JoinController` 가 코드베이스에 존재하지 않음 (탐색 결과). 향후 로컬 회원가입을 추가할 때 `MemberServiceImpl` 의 신규 가입 메서드에서 동일하게 호출하도록 가이드만 명시.
+
+**기존 회원 백필** — 6.2의 `listRootFolders` lazy 호출이 담당. 별도 마이그레이션 SQL은 작성하지 않는다.
+
+### 6.4 컨트롤러
+
+`MemberFolderController` 자체에는 변경이 없다 (서비스 가드가 도메인 규칙을 책임짐). 단, 응답 DTO `MemberFolderResponses` 에 `isSystem` 필드를 추가하여 프론트에 노출한다.
+
+```java
+public class MemberFolderResponses {
+ public Long memberFolderId;
+ public Long ownerMemberId;
+ public Long parentFolderId;
+ public String folderName;
+ public String description;
+ public String folderType; // ← 추가
+ public static MemberFolderResponses from(MemberFolder f) { ... }
+}
+```
+
+
+
+## 7. 보안 / 인가 / 성능
+
+### 7.1 보안 / 인가
+
+- **소유권 가드**는 기존 `validateOwner(memberId, folder)` 로직 그대로 유지. `is_system` 은 추가 제약일 뿐 소유권 검사를 대체하지 않는다.
+- **`@OwnerCheck` AOP** 가 적용된 메서드들은 이미 다른 사용자의 폴더 접근을 차단하므로, `getOrCreateUnorganizedFolderId` 도 `memberId` 인자만 신뢰한다 (Controller 진입점으로 주입된 값을 사용).
+- **DELETE/MOVE 우회 시도**: 클라이언트가 `folderType` 응답을 무시하고 강제로 `DELETE /api/folders/{id}` 를 호출해도 6.2의 서비스 가드에서 `409` 로 차단한다. 컨트롤러 레벨에서 별도 검증을 두지 않는다 (단일 진실 공급원: 서비스).
+
+### 7.2 성능
+
+- **`listRootFolders` 마다 ensure 호출**의 비용은 인덱스 룩업 1회(부분 유니크 인덱스 적중) → 정상 케이스에서 무시 가능 수준.
+- 부담이 측정되면 후속 단계에서 다음 두 옵션을 검토:
+ - 옵션 A: 회원가입 시점에만 생성하고, 기존 회원에 대해서는 1회성 마이그레이션 SQL 실행.
+ - 옵션 B: 메모리/Redis 캐시에 "이 회원은 이미 디폴트 폴더 보유" 플래그를 두고, 첫 호출 이후 ensure 스킵.
+- 본 이터레이션은 단순성을 우선하여 옵션 A/B 모두 보류.
+
+### 7.3 트랜잭션 / 동시성
+
+- `getOrCreateUnorganizedFolderId` 는 `@Transactional` 로 감싸고, INSERT 실패 시 재조회(read-after-write) 후 반환.
+- 부분 유니크 인덱스(`uq_member_folder_owner_system`)가 동시 회원가입·동시 첫 호출의 경합을 DB 레벨에서 차단.
+
+
+
+## 8. 프론트엔드 변경 사항
+
+### 8.1 타입 확장
+
+`frontend/src/lib/types/folder.ts` 의 `FolderResponse` 에 `folderType: 'CUSTOM' | 'UNORGANIZED'` 추가.
+
+### 8.2 `Unorganized` 칩 클릭 동작
+
+`frontend/src/app/my-links/page.tsx:179` 의 placeholder 버튼:
+
+```tsx
+const defaultFolder = folders?.find(f => f.folderType === 'UNORGANIZED');
+
+ defaultFolder && setSelectedFolderId(defaultFolder.memberFolderId)}
+ disabled={!defaultFolder}
+ className="..."
+>
+ inventory_2
+ Unorganized
+
+```
+
+폴더 카드 클릭과 동일한 동작이므로 별도 분기·별도 패널 없음. 우측 패널은 자동으로 미분류 폴더의 북마크를 조회한다 (`useBookmarks({ folderId })`).
+
+### 8.3 폴더 카드 메뉴 가드
+
+`frontend/src/components/my-links/FolderCard.tsx`:
+
+- 시스템 폴더이면 메뉴 자체를 숨길지, "Move/Delete" 항목만 비활성화할지 결정. 결정 5.B(이름 변경 허용)에 따라 **메뉴는 표시하되 Move/Delete 항목만 disabled** 로 처리 (`folderType === 'UNORGANIZED'`).
+- 카드 좌상단 아이콘에 미분류 표식(예: `inventory_2`) 추가하여 시각적으로 구분.
+
+### 8.4 폴더 목록 정렬
+
+`useFolders` 응답을 화면에 표시할 때 디폴트 폴더는 항상 **목록 최상단** 또는 **별도 섹션**에 위치하도록 정렬 (UX 결정은 디자인 단계에서 확정).
+일단 1차에서는 "최상단 고정" 으로 단순 구현.
+
+### 8.5 Quick Save 경로 (결정 5.옵션 2)
+
+`frontend/src/components/dialogs/SaveLinkDialog.tsx`:
+
+- `handleSave` (261행) 에서 `selectedFolderId === null && !pendingNewFolderName` 인 경우, 디폴트 폴더 ID로 저장하도록 보강.
+ ```tsx
+ const targetFolderId = selectedFolderId
+ ?? folders?.find(f => f.folderType === 'UNORGANIZED')?.memberFolderId
+ ?? null;
+ ```
+- "Save to Workspace" 버튼 옆 또는 메인 버튼에 `Quick Save` 라벨/툴팁 추가 (디자인 단에서 라벨 확정).
+- `useBookmarks`/`useCreateBookmark` 등 API 호출은 변경 없음. 백엔드는 `member_folder_id` NOT NULL 제약을 유지하면서 디폴트 폴더 ID를 받는다.
+
+### 8.6 RightPanel 빈 상태
+
+`frontend/src/components/my-links/RightPanel.tsx:329` 의 `'Unordered'` 폴백 라벨 제거 또는 디폴트 폴더에 한해 "AI 분류 대기 중인 링크" 같은 안내 카피로 대체 (선택 사항).
+
+
+
+## 9. 구현 순서
+
+### 9.1 1단계 — DB / 도메인 (백엔드 단독 머지 가능)
+
+1. `init_postgres.sql` 에 컬럼·인덱스 반영 + 마이그레이션 스크립트 작성.
+2. `MemberFolder` 엔티티 + Repository 메서드(`findByOwnerMemberIdAndIsSystemTrue`) 추가.
+3. `FolderErrorCode` 신규 코드 추가.
+
+### 9.2 2단계 — 서비스 레이어
+
+4. `MemberFolderService#getOrCreateUnorganizedFolderId`, `getDefaultFolder` 추가.
+5. `MemberFolderServiceImpl` 의 delete/move 가드 추가.
+6. `listRootFolders` 끝에서 `getOrCreateUnorganizedFolderId` 호출.
+7. **단위 테스트**:
+ - `getOrCreateUnorganizedFolderId` 가 idempotent 한가
+ - 시스템 폴더 delete/move 차단
+ - 시스템 폴더 update(이름 변경) 허용
+ - 동시 호출 시 단일 폴더만 생성됨 (부분 유니크 인덱스 검증)
+
+### 9.3 3단계 — 회원가입 통합
+
+8. `CustomOAuth2MemberService` 에 `MemberFolderService` 의존성 주입 + `getOrCreateUnorganizedFolderId` 호출.
+9. **통합 테스트**: 신규 OAuth 가입 → 폴더 목록 조회 시 미분류 폴더 1건 보장.
+
+### 9.4 4단계 — API 계약
+
+10. `MemberFolderResponses.isSystem` 필드 추가.
+11. 프론트 타입 `FolderResponse.folderType` 추가.
+
+### 9.5 5단계 — 프론트엔드 UI
+
+12. `Unorganized` 칩 onClick 연결 (`page.tsx:179`).
+13. `FolderCard` 메뉴 가드 + 시각적 표식.
+14. 디폴트 폴더 최상단 고정 정렬.
+15. `SaveLinkDialog.handleSave` 의 폴더 미선택 → 디폴트 폴더 fallback.
+
+### 9.6 6단계 — 검증 / QA
+
+16. E2E: 신규 가입 → 링크 저장(폴더 미선택) → My Links 의 Unorganized 칩 클릭 → 방금 저장한 링크 노출 확인.
+17. 기존 회원: 첫 `/my-links` 진입 → 미분류 폴더 자동 생성 확인.
+18. 디폴트 폴더 삭제·이동 시 409 반환 / 토스트 메시지 확인.
+19. 동시 가입(부하 테스트) 시 디폴트 폴더 중복 생성 없음 확인.
+
+
+
+## 10. 위험 및 완화
+
+| 위험 | 영향 | 완화책 |
+| :--- | :--- | :--- |
+| 동일 사용자가 같은 이름("미분류") 폴더를 이미 만들어 둠 | `getOrCreateUnorganizedFolderId` 의 INSERT 가 `uq_member_folder_parent_name` 위반 | catch 후 해당 폴더의 `folder_type`을 `UNORGANIZED`로 업데이트하여 흡수, 또는 시스템 폴더 이름을 `미분류 (기본)` 로 회피. 1차에는 흡수 채택. |
+| 데이터 기존 오염 가능성 | `member_saved_link` 에 존재하지 않는 폴더 ID가 있으면 FK 추가 시 에러 발생 | 마이그레이션 SQL 실행 전 `SELECT ... WHERE member_folder_id NOT IN (SELECT ...)` 로 전수 조사 및 유효한 폴더(UNORGANIZED 등)로 업데이트 필요 |
+| `listRootFolders` 마다 ensure 호출 → 트랜잭션 비용 | RPS 높을 때 약간의 부하 증가 | 7.2 의 옵션 A/B 로 후속 최적화 |
+| 사용자가 "미분류" 라는 이름을 일반 폴더에 쓰고 싶어함 | 명명 충돌 혼란 | 시스템 폴더는 `is_system` 으로만 식별, 일반 폴더 이름은 자유. UI에서 시스템 폴더에 별도 아이콘 표기 |
+| AI 분류 실패 시 별도 대응 부재 (이번 이터레이션) | UX 미세 결손 | Future Section: AI 실패 시 디폴트 폴더로 자동 fallback |
+
+
+
+## 11. Future Section
+
+- **AI 폴더 추천 실패 시 자동 폴백**: `BookmarkService.create` 가 폴더 ID 미결정 상태로 호출되면 디폴트 폴더로 흡수하고 별도 플래그(`category_source='system'` 등)로 회수 가능하게 표시.
+- **다국어화**: `미분류` 라는 폴더명을 지역 설정에 따라 표시 (백엔드 저장 이름은 고정, 프론트에서 i18n 변환).
+- **자동 정리**: 디폴트 폴더 안의 링크 중 일정 시간이 지난 것을 사용자에게 분류 제안하는 inbox-like UX.
+
+
+
+## 12. 결론
+
+본 계획서는 "미분류" 의 정의를 **`is_system=true` 디폴트 폴더 1개** 로 일원화하고, 그 도입을 다음 5단계로 진행한다.
+
+- **데이터 모델**: `folder_type` 컬럼 + 부분 유니크 인덱스 + `member_saved_link` FK 추가
+- **서비스**: `getOrCreateUnorganizedFolderId` (idempotent) + delete/move 가드 (타입 기반 식별)
+- **회원가입 통합**: OAuth 가입 시 즉시 생성, 기존 회원은 `listRootFolders` 에서 lazy 백필
+- **API/타입**: `folderType` 필드 노출
+- **UI**: `Unorganized` 칩 onClick + `FolderCard` 메뉴 가드 + `SaveLinkDialog` Quick Save 폴백 (타입 기반 식별)
+
+각 단계가 독립적으로 머지 가능하며, 1~3단계 머지 후 4~5단계가 그 위에 안전하게 빌드된다.
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index 8e6db92..c454dc1 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -4,23 +4,41 @@ import { FolderCard } from '@/components/my-links/FolderCard';
import { RightPanel } from '@/components/my-links/RightPanel';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
+import { useBookmarks } from '@/lib/api/bookmarkApi';
import { useFolderStore } from '@/lib/store/folderStore';
+import { useLinkStore } from '@/lib/store/linkStore';
import { useAuthStore } from '@/lib/store/authStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
-import { useState } from 'react';
-
+import { useState, useEffect } from 'react';
const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
+
export default function MyLinksPage() {
- const { toggleRightPanel } = useUIStore();
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
- const memberId = useAuthStore((s) => s.member?.memberId);
- const isAuthInitializing = useAuthStore((s) => s.isInitializing);
- const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId);
-
- // 인증 정보를 복구 중이거나 아직 폴더 목록을 가져오는 중이면 로딩 상태로 간주
+ const searchQuery = useFolderStore((s) => s.searchQuery); // 폴더 검색어 상태
+ const setSearchQuery = useFolderStore((s) => s.setSearchQuery); // 폴더 검색어 변경 함수
+ const searchScope = useFolderStore((s) => s.searchScope); // 검색 범위 ('folders' | 'all')
+ const setSearchScope = useFolderStore((s) => s.setSearchScope); // 검색 범위 변경 함수
+ const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
+ const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
+ const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
+
+ // 인증 또는 폴더 로딩 중
const isLoading = isAuthInitializing || isFoldersLoading;
- const pinnedFolders = folders?.slice(0, 5) ?? [];
+ // 시스템 폴더(미분류)는 Pinned 섹션에 섞지 않는다
+ const pinnedFolders = (folders ?? []).filter((f) => f.folderType !== 'UNORGANIZED').slice(0, 5);
+ const unorganizedFolder = folders?.find((f) => f.folderType === 'UNORGANIZED');
+
+ const [pendingQuery, setPendingQuery] = useState(''); // 입력 중인 검색어
+
+ // 검색어 디바운스 처리 (이슈 M1 해결)
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setSearchQuery(pendingQuery);
+ }, 250);
+
+ return () => clearTimeout(handler);
+ }, [pendingQuery, setSearchQuery]);
const [folderSort, setFolderSort] = useState('recently');
const folderSortOptions: SortOption[] = [
@@ -29,6 +47,44 @@ export default function MyLinksPage() {
{ id: 'count', label: 'Link Count', icon: 'list_alt' }
];
+ // 상단 고정 폴더 배지 클릭 처리
+ const handleFolderBadgeClick = (folderId: number) => {
+ setSelectedFolderId(folderId); // 선택된 폴더 변경
+ useLinkStore.getState().setSearchQuery(searchQuery); // 현재 폴더 검색어를 링크 검색어로 동기화
+ };
+
+ // 검색 필터링 로직
+ const isSearching = searchQuery.trim().length > 0;
+
+ // 북마크 데이터 조회 조건 결정:
+ // 1. 전체 범위 검색 중이거나
+ // 2. 오늘 저장 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
+ const shouldFetchBookmarks = (searchScope === 'all' && isSearching);
+ const { data: allBookmarks } = useBookmarks(
+ shouldFetchBookmarks ? (isSearching && searchScope === 'all' ? { query: searchQuery } : {}) : {},
+ { enabled: shouldFetchBookmarks }
+ );
+
+ // 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N))
+ const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []);
+
+ // 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M))
+ let searchResultFolders = isSearching
+ ? (folders ?? []).filter((folder) => {
+ const queryLower = searchQuery.toLowerCase();
+ // 1. 폴더명 자체에 검색어가 포함된 경우
+ if (folder.folderName.toLowerCase().includes(queryLower)) return true;
+ // 2. 검색 범위가 '전체'인 경우, 백엔드가 알려준 '매칭 북마크 포함 폴더'인지 확인
+ return searchScope === 'all' && matchingFolderIdsSet.has(folder.memberFolderId);
+ })
+ : (folders ?? []);
+
+ const searchResultFolderCount = searchResultFolders.length;
+
+ // 필터 또는 검색 활성화 시 결과 표시
+ // 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
+ const isShowingFiltered = isSearching;
+
return (
{/* Main Content Area */}
@@ -36,43 +92,116 @@ export default function MyLinksPage() {
{/* Top Search & Filter Section */}
-
-
+
+
{/* auto_awesome */}
-
setPendingQuery(e.target.value)}
/>
search
-
-
-
+
+
+
+
+
tune Filter
-
- inventory_2 Unorganized
+
+ {/* Search Scope Buttons */}
+ setSearchScope('all')}
+ className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
+ searchScope === 'all'
+ ? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-600 text-purple-700 dark:text-purple-300'
+ : 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
+ }`}
+ >
+ library_add
+ All
-
+ setSearchScope('folders')}
+ className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
+ searchScope === 'folders'
+ ? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-600 text-purple-700 dark:text-purple-300'
+ : 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
+ }`}
+ >
+ folder
+ Folders
+
+
+
+ unorganizedFolder && setSelectedFolderId(unorganizedFolder.memberFolderId)}
+ disabled={!unorganizedFolder}
+ className="flex items-center justify-center gap-1 px-2.5 py-1 bg-white dark:bg-card-dark border border-gray-200 dark:border-gray-700 rounded-full text-[9px] font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ folder Unorganized
+
+
mark_email_unread Unread
-
+ {/*
+
priority_high High Priority
-
-
+
+ */}
+
today Saved today
+
+ {isShowingFiltered && (
+
+ {`${searchResultFolderCount} results`}
+
+ )}
+ {/* 검색어 있음 또는 필터 활성화: 폴더 카드 그리드로 검색 결과 표시 */}
+ {isShowingFiltered && (
+
+
+
+ Search Results: {searchResultFolderCount} folders found
+
+
+
+ {searchResultFolders.length === 0 ? (
+
+
+ search_off
+ No results found
+
+
+ ) : (
+ searchResultFolders.map((folder) => (
+
+ ))
+ )}
+
+
+ )}
+
+ {/* 검색어 없음 & 필터 미활성화: 기존 대시보드 */}
+ {!isShowingFiltered && (
+ <>
{/* Pinned Folders Top Section - Strictly 5 Columns Desktop */}
{pinnedFolders.length > 0 && (
@@ -83,11 +212,11 @@ export default function MyLinksPage() {
key={folder.memberFolderId}
role="button"
tabIndex={0}
- onClick={() => setSelectedFolderId(folder.memberFolderId)}
+ onClick={() => handleFolderBadgeClick(folder.memberFolderId)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- setSelectedFolderId(folder.memberFolderId);
+ handleFolderBadgeClick(folder.memberFolderId);
}
}}
className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`}
@@ -114,15 +243,15 @@ export default function MyLinksPage() {
My Folder
-
+
-
- useUIStore.getState().toggleCreateFolderDialog(true)}
className="group flex items-center gap-1 px-2 py-1 border border-gray-200/80 dark:border-white/8 rounded-md text-[10px] font-medium transition-all duration-200 bg-white/50 dark:bg-slate-900/50 text-gray-600 dark:text-white hover:bg-purple-50/50 dark:hover:bg-purple-900/20 hover:border-purple-200/50 dark:hover:border-purple-500/30"
@@ -130,7 +259,7 @@ export default function MyLinksPage() {
folder
Create Folder
- useUIStore.getState().toggleSaveLinkDialog(true)}
className="flex items-center space-x-1 text-[10px] font-bold text-white bg-[linear-gradient(135deg,#6d28d9_0%,#8b5cf6_50%,#a78bfa_100%)] hover:opacity-90 border-none rounded-md px-2.5 py-1 transition-all shadow-md shadow-purple-500/20 hover:scale-[1.02]"
@@ -142,13 +271,13 @@ export default function MyLinksPage() {
-
+
{/* ── 로딩 상태 ── */}
{isLoading && (
progress_activity
- 폴더를 불러오는 중...
+ Loading folders...
)}
@@ -158,7 +287,7 @@ export default function MyLinksPage() {
error
- 폴더 목록을 불러오지 못했습니다: {error.message}
+ Failed to load folders: {error.message}
)}
@@ -168,13 +297,16 @@ export default function MyLinksPage() {
folder_off
- 아직 생성된 폴더가 없습니다
+ No folders created yet
)}
{/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */}
{(folders ? [...folders] : []).sort((a, b) => {
+ // 시스템(미분류) 폴더는 항상 최상단 고정
+ if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
+ if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId;
if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName);
return 0;
@@ -183,6 +315,8 @@ export default function MyLinksPage() {
))}
+ >
+ )}
From c3343fa9c9a983c50b8a754ff2606859b0ae31be Mon Sep 17 00:00:00 2001
From: jin2304
Date: Mon, 20 Apr 2026 20:24:00 +0900
Subject: [PATCH 05/16] =?UTF-8?q?'=EC=98=A4=EB=8A=98=20=EC=A0=80=EC=9E=A5?=
=?UTF-8?q?=ED=95=9C=20=EB=A7=81=ED=81=AC'=20=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20UX=20?=
=?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- dateUtils: 공통 유틸리티 생성: 날짜 비교 로직의 중복을 제거하고 코드 재사용성을 높이기 위해 'isCreatedToday' 헬퍼 함수를 별도 유틸리티 파일로 분리 및 외부 모듈화.
- RightPanel: 링크 필터링 기능 신규 도입: 'savedTodayFilter' 상태에 따른 실시간 클라이언트 사이드 필터링 로직을 신규 적용하여, 오늘 저장된 링크만 선별적으로 확인할 수 있는 기능 구현.
- MyLinksPage: 전역 필터링 UX 최적화: 'Saved today' 필터 활성화 시 기존 폴더 선택 상태를 초기화(null 처리)함으로써 사용자에게 오늘 저장된 모든 링크를 전역적으로 조망할 수 있는 직관적인 뷰 모드 제공.
- MyLinksPage: 코드 클린업: 로컬에 중복 정의되었던 날짜 확인 함수를 제거하고 'dateUtils' 공통 모듈을 참조하도록 리팩토링 수행.
- globals: 시각적 피드백 일관성 확보: 모든 'button' 태그 및 'role=button' 요소에 'cursor-pointer' 스타일을 전역 적용하여 클릭 가능한 요소에 대한 사용자 인터랙션 인지 기능 강화.
---
frontend/src/app/globals.css | 3 +
frontend/src/app/my-links/page.tsx | 394 +++++++++---------
.../src/components/my-links/FolderCard.tsx | 8 +-
.../src/components/my-links/RightPanel.tsx | 10 +-
frontend/src/lib/dateUtils.ts | 13 +
frontend/src/lib/store/folderStore.ts | 8 +-
6 files changed, 237 insertions(+), 199 deletions(-)
create mode 100644 frontend/src/lib/dateUtils.ts
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 6ef3513..5f3e631 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -165,6 +165,9 @@
* {
@apply border-border outline-ring/50;
}
+ button, [role="button"] {
+ @apply cursor-pointer;
+ }
body {
/* bg, text is handled via layout.tsx classes directly */
}
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index c454dc1..956835c 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -4,41 +4,32 @@ import { FolderCard } from '@/components/my-links/FolderCard';
import { RightPanel } from '@/components/my-links/RightPanel';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
-import { useBookmarks } from '@/lib/api/bookmarkApi';
import { useFolderStore } from '@/lib/store/folderStore';
-import { useLinkStore } from '@/lib/store/linkStore';
import { useAuthStore } from '@/lib/store/authStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
-import { useState, useEffect } from 'react';
-const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
+import { useBookmarks } from '@/lib/api/bookmarkApi';
+import { useLinkStore } from '@/lib/store/linkStore';
+import { useState } from 'react';
+import { isCreatedToday } from '@/lib/dateUtils';
+const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
export default function MyLinksPage() {
+ const { toggleRightPanel } = useUIStore();
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
const searchQuery = useFolderStore((s) => s.searchQuery); // 폴더 검색어 상태
const setSearchQuery = useFolderStore((s) => s.setSearchQuery); // 폴더 검색어 변경 함수
const searchScope = useFolderStore((s) => s.searchScope); // 검색 범위 ('folders' | 'all')
const setSearchScope = useFolderStore((s) => s.setSearchScope); // 검색 범위 변경 함수
+ const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // '오늘 저장' 필터 상태
+ const toggleSavedTodayFilter = useFolderStore((s) => s.toggleSavedTodayFilter); // 필터 토글 함수
const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
// 인증 또는 폴더 로딩 중
const isLoading = isAuthInitializing || isFoldersLoading;
- // 시스템 폴더(미분류)는 Pinned 섹션에 섞지 않는다
- const pinnedFolders = (folders ?? []).filter((f) => f.folderType !== 'UNORGANIZED').slice(0, 5);
- const unorganizedFolder = folders?.find((f) => f.folderType === 'UNORGANIZED');
-
- const [pendingQuery, setPendingQuery] = useState(''); // 입력 중인 검색어
-
- // 검색어 디바운스 처리 (이슈 M1 해결)
- useEffect(() => {
- const handler = setTimeout(() => {
- setSearchQuery(pendingQuery);
- }, 250);
-
- return () => clearTimeout(handler);
- }, [pendingQuery, setSearchQuery]);
+ const pinnedFolders = folders?.slice(0, 5) ?? [];
const [folderSort, setFolderSort] = useState('recently');
const folderSortOptions: SortOption[] = [
@@ -59,7 +50,7 @@ export default function MyLinksPage() {
// 북마크 데이터 조회 조건 결정:
// 1. 전체 범위 검색 중이거나
// 2. 오늘 저장 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
- const shouldFetchBookmarks = (searchScope === 'all' && isSearching);
+ const shouldFetchBookmarks = (searchScope === 'all' && isSearching) || savedTodayFilter;
const { data: allBookmarks } = useBookmarks(
shouldFetchBookmarks ? (isSearching && searchScope === 'all' ? { query: searchQuery } : {}) : {},
{ enabled: shouldFetchBookmarks }
@@ -67,6 +58,13 @@ export default function MyLinksPage() {
// 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N))
const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []);
+ const todayFolderIdsSet = new Set(
+ savedTodayFilter && allBookmarks?.bookmarks
+ ? allBookmarks.bookmarks
+ .filter((b) => isCreatedToday(b.createdAt))
+ .map((b) => b.memberFolderId)
+ : []
+ );
// 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M))
let searchResultFolders = isSearching
@@ -79,11 +77,18 @@ export default function MyLinksPage() {
})
: (folders ?? []);
+ // '오늘 저장한 링크' 필터링 최적화 (O(M))
+ if (savedTodayFilter) {
+ searchResultFolders = searchResultFolders.filter((folder) =>
+ todayFolderIdsSet.has(folder.memberFolderId)
+ );
+ }
+
const searchResultFolderCount = searchResultFolders.length;
// 필터 또는 검색 활성화 시 결과 표시
// 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
- const isShowingFiltered = isSearching;
+ const isShowingFiltered = isSearching || savedTodayFilter;
return (
@@ -92,106 +97,118 @@ export default function MyLinksPage() {
{/* Top Search & Filter Section */}
-
-
+
+
- {/* auto_awesome */}
+ search
-
setPendingQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
/>
-
- search
-
+
+ {/* Search Scope Toggle (Folders / All) */}
+
+ setSearchScope('all')}
+ className={`px-3 py-1 text-[9px] font-bold rounded-md transition-all ${
+ searchScope === 'all'
+ ? 'bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-300 shadow-sm'
+ : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
+ }`}
+ >
+ All
+
+ setSearchScope('folders')}
+ className={`px-2.5 py-1 text-[9px] font-bold rounded-md transition-all ${
+ searchScope === 'folders'
+ ? 'bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-300 shadow-sm'
+ : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
+ }`}
+ >
+ Folders
+
+
+
+ {searchQuery && (
+
{
+ setSearchQuery('');
+ // 검색어 삭제 시 필요하면 범위도 초기화하거나 유지 (여기선 유지)
+ }}
+ className="flex items-center justify-center h-7 w-7 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors mr-0.5"
+ >
+ close
+
+ )}
-
-
-
-
-
+
+
+
tune Filter
-
- {/* Search Scope Buttons */}
-
setSearchScope('all')}
- className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
- searchScope === 'all'
- ? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-600 text-purple-700 dark:text-purple-300'
- : 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
- }`}
+
- library_add
- All
+ inventory_2 Unorganized
+
+
+ mark_email_unread Unread
+
+ priority_high High Priority
+
setSearchScope('folders')}
+ onClick={() => {
+ const nextValue = !savedTodayFilter;
+ toggleSavedTodayFilter();
+ if (nextValue) {
+ setSelectedFolderId(null);
+ }
+ }}
className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
- searchScope === 'folders'
+ savedTodayFilter
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-600 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
- folder
- Folders
-
-
-
- unorganizedFolder && setSelectedFolderId(unorganizedFolder.memberFolderId)}
- disabled={!unorganizedFolder}
- className="flex items-center justify-center gap-1 px-2.5 py-1 bg-white dark:bg-card-dark border border-gray-200 dark:border-gray-700 rounded-full text-[9px] font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
- >
- folder Unorganized
-
-
- mark_email_unread Unread
-
- {/*
-
- priority_high High Priority
-
- */}
-
today Saved today
-
- {isShowingFiltered && (
-
- {`${searchResultFolderCount} results`}
-
- )}
{/* 검색어 있음 또는 필터 활성화: 폴더 카드 그리드로 검색 결과 표시 */}
{isShowingFiltered && (
-
-
- Search Results: {searchResultFolderCount} folders found
+
+
+ search
+ {savedTodayFilter ? 'Saved Today' : 'Search Results'}: {searchResultFolderCount} folders found
+
- {searchResultFolders.length === 0 ? (
-
-
- search_off
- No results found
-
+ {searchResultFolderCount === 0 ? (
+
+ search_off
+ No results match your search
) : (
- searchResultFolders.map((folder) => (
+ searchResultFolders.sort((a, b) => {
+ if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
+ if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
+ return 0;
+ }).map((folder) => (
))
)}
@@ -202,122 +219,113 @@ export default function MyLinksPage() {
{/* 검색어 없음 & 필터 미활성화: 기존 대시보드 */}
{!isShowingFiltered && (
<>
- {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */}
- {pinnedFolders.length > 0 && (
-
-
Pinned
-
- {pinnedFolders.map((folder, idx) => (
-
handleFolderBadgeClick(folder.memberFolderId)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- handleFolderBadgeClick(folder.memberFolderId);
- }
- }}
- className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`}
- >
-
-
-
folder
+ {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */}
+ {pinnedFolders.length > 0 && (
+
+
Pinned
+
+ {pinnedFolders.map((folder, idx) => (
+
handleFolderBadgeClick(folder.memberFolderId)}
+ className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`}
+ >
+
+
+ folder
+
+
+ more_vert
+
+
+
+
{folder.folderName}
+
+
-
- more_vert
-
-
-
-
{folder.folderName}
-
-
+ ))}
- ))}
-
-
- )}
-
-
-
-
My Folder
-
-
-
-
- useUIStore.getState().toggleCreateFolderDialog(true)}
- className="group flex items-center gap-1 px-2 py-1 border border-gray-200/80 dark:border-white/8 rounded-md text-[10px] font-medium transition-all duration-200 bg-white/50 dark:bg-slate-900/50 text-gray-600 dark:text-white hover:bg-purple-50/50 dark:hover:bg-purple-900/20 hover:border-purple-200/50 dark:hover:border-purple-500/30"
- >
- folder
- Create Folder
-
- useUIStore.getState().toggleSaveLinkDialog(true)}
- className="flex items-center space-x-1 text-[10px] font-bold text-white bg-[linear-gradient(135deg,#6d28d9_0%,#8b5cf6_50%,#a78bfa_100%)] hover:opacity-90 border-none rounded-md px-2.5 py-1 transition-all shadow-md shadow-purple-500/20 hover:scale-[1.02]"
- >
- add
- Save Link
-
-
-
-
-
-
- {/* ── 로딩 상태 ── */}
- {isLoading && (
-
-
- progress_activity
- Loading folders...
-
- )}
+ )}
- {/* ── 에러 상태 ── */}
- {error && (
-
-
-
error
-
Failed to load folders: {error.message}
+
- )}
-
- {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */}
- {!isLoading && folders && folders.length === 0 && (
-
-
-
folder_off
-
No folders created yet
+
+
+
+ useUIStore.getState().toggleCreateFolderDialog(true)}
+ className="group flex items-center gap-1 px-2 py-1 border border-gray-200/80 dark:border-white/8 rounded-md text-[10px] font-medium transition-all duration-200 bg-white/50 dark:bg-slate-900/50 text-gray-600 dark:text-white hover:bg-purple-50/50 dark:hover:bg-purple-900/20 hover:border-purple-200/50 dark:hover:border-purple-500/30"
+ >
+ folder
+ Create Folder
+
+ useUIStore.getState().toggleSaveLinkDialog(true)}
+ className="flex items-center space-x-1 text-[10px] font-bold text-white bg-[linear-gradient(135deg,#6d28d9_0%,#8b5cf6_50%,#a78bfa_100%)] hover:opacity-90 border-none rounded-md px-2.5 py-1 transition-all shadow-md shadow-purple-500/20 hover:scale-[1.02]"
+ >
+ add
+ Save Link
+
- )}
- {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */}
- {(folders ? [...folders] : []).sort((a, b) => {
- // 시스템(미분류) 폴더는 항상 최상단 고정
- if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
- if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
- if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId;
- if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName);
- return 0;
- }).map((folder) => (
-
- ))}
+
+ {/* ── 로딩 상태 ── */}
+ {isLoading && (
+
+
+ progress_activity
+ Loading folders...
+
+
+ )}
-
+ {/* ── 에러 상태 ── */}
+ {error && (
+
+
+ error
+ Failed to load folders: {error.message}
+
+
+ )}
+
+ {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */}
+ {!isLoading && folders && folders.length === 0 && (
+
+
+ folder_off
+ No folders created yet
+
+
+ )}
+
+ {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */}
+ {(folders ? [...folders] : []).sort((a, b) => {
+ // 시스템(미분류) 폴더는 항상 최상단 고정
+ if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
+ if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
+ if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId;
+ if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName);
+ return 0;
+ }).map((folder) => (
+
+ ))}
+
>
)}
-
{/* Right Sidebar Panel */}
diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx
index 881b40c..b759d26 100644
--- a/frontend/src/components/my-links/FolderCard.tsx
+++ b/frontend/src/components/my-links/FolderCard.tsx
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
import type { FolderResponse } from '@/lib/types/folder';
import { useFolderStore } from '@/lib/store/folderStore';
import { useLinkStore } from '@/lib/store/linkStore';
+import { useUIStore } from '@/lib/store/uiStore';
import { FolderEditModal, FolderDeleteModal, FolderMoveModal } from './FolderManagementModals';
interface FolderCardProps {
@@ -76,13 +77,16 @@ export function FolderCard({ folder, color }: FolderCardProps) {
// 카드 클릭 시 폴더 선택 처리 (메뉴가 열려있지 않을 때만)
const handleCardClick = () => {
if (!showMenu) {
- // 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거
+ // 1. 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거
setSelectedFolderId(folder.memberFolderId);
- // 기존 폴더 검색어가 있다면 링크 검색어로 전달 (경험 최적화)
+ // 2. 기존 폴더 검색어가 있다면 링크 검색어로 전이 (사용자 요청: 클릭 시 링크 필터링되도록)
if (searchQuery) {
setLinkSearchQuery(searchQuery);
}
+
+ // 3. 우측 패널이 닫혀있다면 자동으로 열어줌
+ useUIStore.getState().toggleRightPanel(true);
}
};
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index 3359676..f9d7b5e 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/lib/store/authStore';
import { useLinkStore } from '@/lib/store/linkStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import type { BookmarkResponse } from '@/lib/types/bookmark';
+import { isCreatedToday } from '@/lib/dateUtils';
/**
* 날짜 문자열을 받아 현재 시간 기준 상대적인 시간(예: Just now, 5m ago)으로 변환합니다.
@@ -498,6 +499,7 @@ export function RightPanel() {
const memberId = useAuthStore((s) => s.member?.memberId);
const linkSearchQuery = useLinkStore((s) => s.filters.searchQuery); // 현재 검색어 상태 구독
const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수
+ const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // 오늘 저장 필터 상태
// 검색어 디바운스 처리를 위한 로컬 상태
const [pendingSearch, setPendingSearch] = useState(linkSearchQuery);
@@ -577,12 +579,16 @@ export function RightPanel() {
};
}, [isTagDropdownOpen, isMoreMenuOpen]);
- // Client-side tag filtering logic | 태그 필터링 처리 (클라이언트 사이드 필터링)
+ // Client-side filtering logic (Tags & Saved Today)
const allLinks = bookmarks ?? [];
- const filteredLinks = selectedTags.length > 0
+ let filteredLinks = selectedTags.length > 0
? allLinks.filter(link => link.tags?.some(tag => selectedTags.includes(tag)))
: allLinks;
+ if (savedTodayFilter) {
+ filteredLinks = filteredLinks.filter(link => isCreatedToday(link.createdAt));
+ }
+
/** 태그 선택/해제 */
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
diff --git a/frontend/src/lib/dateUtils.ts b/frontend/src/lib/dateUtils.ts
new file mode 100644
index 0000000..532cd64
--- /dev/null
+++ b/frontend/src/lib/dateUtils.ts
@@ -0,0 +1,13 @@
+/**
+ * 날짜 문자열이 '오늘'인지 확인하는 헬퍼 함수
+ */
+export function isCreatedToday(createdAtStr: string): boolean {
+ if (!createdAtStr) return false;
+ const createdDate = new Date(createdAtStr);
+ const today = new Date();
+ return (
+ createdDate.getFullYear() === today.getFullYear() &&
+ createdDate.getMonth() === today.getMonth() &&
+ createdDate.getDate() === today.getDate()
+ );
+}
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index d859f1f..e875fd4 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -4,20 +4,24 @@ interface FolderStore {
selectedFolderId: number | null;
searchScope: 'folders' | 'all';
searchQuery: string;
+ savedTodayFilter: boolean;
setSelectedFolderId: (id: number | null) => void;
setSearchScope: (scope: 'folders' | 'all') => void;
setSearchQuery: (query: string) => void;
+ toggleSavedTodayFilter: () => void;
clearSearch: () => void;
}
export const useFolderStore = create
((set) => ({
selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨)
- searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함)
+ searchScope: 'folders', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함)
searchQuery: '', // 폴더 목록 필터링용 검색어
+ savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부
setSelectedFolderId: (id) => set({ selectedFolderId: id }),
setSearchScope: (scope) => set({ searchScope: scope }),
setSearchQuery: (query) => set({ searchQuery: query }),
- clearSearch: () => set({ searchQuery: '', searchScope: 'all' }),
+ toggleSavedTodayFilter: () => set((state) => ({ savedTodayFilter: !state.savedTodayFilter })),
+ clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false }),
}));
From b228a81e42e55802874705a1cc0ca33427c5f0f1 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sat, 25 Apr 2026 15:38:30 +0900
Subject: [PATCH 06/16] =?UTF-8?q?feat(backend):=20=EB=AF=B8=EC=97=B4?=
=?UTF-8?q?=EB=9E=8C=20=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?=
=?UTF-8?q?=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=9D=BD=EC=9D=8C=20=EC=83=81?=
=?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
init_postgres.sql: 데이터 구조 확장: 북마크별 조회수(view_count)와 마지막 읽은 시간(last_viewed_at) 컬럼을 추가하여 읽음 상태 추적 기반 마련.
BookmarkApiController: API 기능 확장: 링크 읽음 처리 엔드포인트 추가 및 검색 API에 '미열람 항목만 보기' 필터링 파라미터 연동.
BookmarkServiceImpl: 비즈니스 로직 구현: 링크 클릭 시 읽음 상태 업데이트 처리 및 조건에 따른 미열람 데이터 필터링 로직 적용.
bookmark-mapper.xml: 쿼리 최적화: 조회수 기반 필터링 처리를 위한 동적 SQL 및 조회수 업데이트(Update) 쿼리 정의.
---
.../controller/BookmarkApiController.java | 13 ++++++++++++
.../controller/dto/BookmarkRequests.java | 2 ++
.../SearchWeb/bookmark/dao/BookmarkDao.java | 3 +++
.../bookmark/dao/MybatisBookmarkDao.java | 9 ++++++++
.../SearchWeb/bookmark/domain/Bookmark.java | 3 +++
.../bookmark/service/BookmarkService.java | 3 +++
.../bookmark/service/BookmarkServiceImpl.java | 13 ++++++++++++
.../command/BookmarkSearchCommand.java | 1 +
src/main/resources/db/init_postgres.sql | 7 +++++++
src/main/resources/mapper/bookmark-mapper.xml | 21 +++++++++++++++++++
10 files changed, 75 insertions(+)
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
index f82dde8..833261c 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
@@ -115,6 +115,19 @@ public ResponseEntity> deleteBookmark(
}
+ /**
+ * 북마크 조회 기록
+ */
+ @PatchMapping("/{bookmarkId}/read")
+ public ResponseEntity> recordView(
+ @CurrentMemberId Long memberId,
+ @PathVariable Long bookmarkId) {
+
+ bookmarkService.recordView(memberId, bookmarkId);
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+
/**
* 북마크 확인
*/
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
index 56e248c..ad02a33 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
@@ -38,6 +38,7 @@ public static class SearchDto {
public String sort = "Newest"; // 기본값 설정
public String query;
public Long categoryId;
+ public Boolean unreadOnly;
public BookmarkSearchCommand toCommand(Long memberId) {
return BookmarkSearchCommand.builder()
@@ -46,6 +47,7 @@ public BookmarkSearchCommand toCommand(Long memberId) {
.sort(this.sort)
.query(this.query)
.categoryId(this.categoryId)
+ .unreadOnly(this.unreadOnly)
.build();
}
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
index ab42dfe..e75ea8a 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
@@ -26,6 +26,9 @@ public interface BookmarkDao {
//북마크 삭제
int deleteBookmark(Long memberId, Long bookmarkId);
+ //북마크 조회수 증가 (읽음 처리)
+ int incrementViewCount(Long bookmarkId, Long memberId);
+
//북마크 태그 연결 삭제
int deleteBookmarkTags(Long bookmarkId, Long memberId);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
index b246e7c..da97722 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
@@ -81,6 +81,15 @@ public int deleteBookmarkTags(Long bookmarkId, Long memberId) {
}
+ /**
+ * 북마크 조회수 증가 (읽음 처리)
+ */
+ @Override
+ public int incrementViewCount(Long bookmarkId, Long memberId) {
+ return mapper.incrementViewCount(bookmarkId, memberId);
+ }
+
+
/**
* 북마크 삭제 (Link ID 기반 - soft delete)
*/
diff --git a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java
index 0dd050b..d36f143 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java
@@ -1,6 +1,7 @@
package com.web.SearchWeb.bookmark.domain;
import java.math.BigDecimal;
+import java.time.OffsetDateTime;
import java.util.List;
import com.web.SearchWeb.common.domain.BaseEntity;
@@ -33,6 +34,8 @@ public class Bookmark extends BaseEntity {
private Long primaryCategoryId; // 주 카테고리 ID (선택, AI 분류 또는 사용자 지정)
private String categorySource; // 카테고리 출처 ('system', 'member', default 'system')
private BigDecimal categoryScore; // 카테고리 정확도 점수 (0.0 ~ 1.0)
+ private Integer viewCount; // 조회 수 (클릭 1회당 +1, 0이면 unread)
+ private OffsetDateTime lastViewedAt; // 마지막 조회 시각 (NULL이면 아직 안 읽음)
// Link 객체 (Association)
private Link link; // Link 테이블과 조인된 객체
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
index 45837c4..9872bd1 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
@@ -28,6 +28,9 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String
//북마크 삭제
Long deleteBookmark(Long memberId, Long bookmarkId);
+ //북마크 조회 기록
+ void recordView(Long memberId, Long bookmarkId);
+
// 링크 조회 또는 생성 (URL 정규화)
Link getOrCreateLink(String url, Long createdByMemberId);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
index 40ab4bf..d91f2d1 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
@@ -159,6 +159,19 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId,
}
+ /**
+ * 북마크 조회 기록 (view_count 증가, last_viewed_at 업데이트)
+ */
+ @Override
+ @Transactional
+ public void recordView(Long memberId, Long bookmarkId) {
+ int affected = bookmarkDao.incrementViewCount(bookmarkId, memberId);
+ if (affected == 0) {
+ throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
+ }
+ }
+
+
/**
* 북마크 삭제 (soft delete)
*/
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
index 8ac2ce3..c2b8dee 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
@@ -18,4 +18,5 @@ public class BookmarkSearchCommand {
private String sort; // 정렬 기준 (Newest, Oldest)
private String query; // 검색어 (display_title 검색)
private Long categoryId; // primary_category_id로 필터 (태그 대체)
+ private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회
}
\ No newline at end of file
diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql
index 6b0ee5b..335176a 100644
--- a/src/main/resources/db/init_postgres.sql
+++ b/src/main/resources/db/init_postgres.sql
@@ -398,6 +398,8 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" (
"primary_category_id" int,
"category_source" varchar(10) DEFAULT 'system' NOT NULL,
"category_score" numeric(5,4),
+ "view_count" integer DEFAULT 0 NOT NULL,
+ "last_viewed_at" timestamptz,
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL,
"deleted_at" timestamptz,
@@ -407,11 +409,16 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" (
CONSTRAINT pk_member_saved_link PRIMARY KEY ("member_saved_link_id"),
CONSTRAINT ck_member_saved_link_category_source CHECK (category_source in ('system','member')),
CONSTRAINT ck_member_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)),
+ CONSTRAINT ck_member_saved_link_view_count_nonneg CHECK (view_count >= 0),
CONSTRAINT fk_member_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id"),
CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id"),
CONSTRAINT fk_member_saved_link_member_folder_id FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id")
);
+-- (idempotent) 기존 DB에 대해 view_count / last_viewed_at 컬럼 추가
+ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
+ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "last_viewed_at" timestamptz;
+
CREATE TABLE IF NOT EXISTS "link_enrichment_feedback" (
"link_enrichment_feedback_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
"link_enrichment_id" int NOT NULL,
diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml
index 548c3e4..388a006 100644
--- a/src/main/resources/mapper/bookmark-mapper.xml
+++ b/src/main/resources/mapper/bookmark-mapper.xml
@@ -17,6 +17,8 @@
+
+
@@ -107,6 +109,9 @@
AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
+
+
+ AND msl.view_count = 0
ORDER BY
@@ -140,9 +145,25 @@
AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
+
+ AND msl.view_count = 0
+
+
+
+ UPDATE member_saved_link
+ SET view_count = view_count + 1,
+ last_viewed_at = now(),
+ updated_at = now(),
+ updated_by_member_id = #{memberId}
+ WHERE member_saved_link_id = #{bookmarkId}
+ AND created_by_member_id = #{memberId}
+ AND deleted_at IS NULL
+
+
+
SELECT * FROM link
From 56fb247f45dbb6488ea372190f8a90dbfc88d505 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sat, 25 Apr 2026 15:38:31 +0900
Subject: [PATCH 07/16] =?UTF-8?q?feat(frontend):=20=EB=AF=B8=EC=97=B4?=
=?UTF-8?q?=EB=9E=8C=20=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B3=B5=ED=86=B5=20=EB=93=9C=EB=A1=AD?=
=?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=ED=8E=B8=EC=9D=98=EC=84=B1=20=EA=B0=9C?=
=?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
folderStore: 검색 및 필터 기능 확대: 아직 읽지 않은 링크만 모아보는 필터와 '링크 전용' 검색 기능을 추가하여 정보 접근성 개선.
bookmarkApi: 실시간 읽음 표시 연동: 링크 클릭 시 즉시 읽음 상태로 변경되고, 목록이 자동으로 업데이트되도록 비동기 데이터 동기화 구현.
SortDropdown: 컴포넌트 활용도 증대: 드롭다운의 좌우 정렬 방향 선택 기능과 버튼 모양을 자유롭게 바꿀 수 있는 커스텀 구조 적용.
bookmark.ts: 데이터 규격 정의: 새로 추가된 조회수, 읽은 시간 등 서버 데이터 형식을 타입 시스템에 반영하여 코드 안정성 확보.
---
frontend/src/components/ui/SortDropdown.tsx | 57 +++++++++++++--------
frontend/src/lib/api/bookmarkApi.ts | 25 +++++++++
frontend/src/lib/store/folderStore.ts | 12 +++--
frontend/src/lib/types/bookmark.ts | 3 ++
4 files changed, 73 insertions(+), 24 deletions(-)
diff --git a/frontend/src/components/ui/SortDropdown.tsx b/frontend/src/components/ui/SortDropdown.tsx
index 34d3bb8..35a043b 100644
--- a/frontend/src/components/ui/SortDropdown.tsx
+++ b/frontend/src/components/ui/SortDropdown.tsx
@@ -13,6 +13,12 @@ interface SortDropdownProps {
options: SortOption[];
className?: string;
panelClassName?: string;
+ align?: 'left' | 'right';
+ renderTrigger?: (props: {
+ selectedOption: SortOption | null;
+ isOpen: boolean;
+ toggle: () => void
+ }) => React.ReactNode;
}
/**
@@ -25,6 +31,8 @@ export function SortDropdown({
options,
className = "",
panelClassName = "",
+ align = 'left',
+ renderTrigger,
}: SortDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
@@ -58,30 +66,39 @@ export function SortDropdown({
return (
{/* Trigger Button */}
-
hasOptions && setIsOpen(!isOpen)}
- className={cn(
- "group flex items-center gap-1 px-2 py-1 border border-gray-200/60 dark:border-white/8 rounded-md text-[10px] font-medium transition-all duration-200 bg-white/50 dark:bg-slate-900/50 text-gray-600 dark:text-white",
- hasOptions
- ? "hover:bg-purple-50/50 dark:hover:bg-purple-900/20 hover:border-purple-200/50 dark:hover:border-purple-500/30"
- : "opacity-50 cursor-not-allowed grayscale-[0.5]"
- )}
- >
- filter_list
- {selectedOption?.label || (hasOptions ? '' : '선택 불가')}
- expand_more
-
+ {renderTrigger ? (
+ renderTrigger({
+ selectedOption,
+ isOpen,
+ toggle: () => hasOptions && setIsOpen(!isOpen)
+ })
+ ) : (
+
hasOptions && setIsOpen(!isOpen)}
+ className={cn(
+ "group flex items-center gap-1 px-2 py-1 border border-gray-200/60 dark:border-white/8 rounded-md text-[10px] font-medium transition-all duration-200 bg-white/50 dark:bg-slate-900/50 text-gray-600 dark:text-white",
+ hasOptions
+ ? "hover:bg-purple-50/50 dark:hover:bg-purple-900/20 hover:border-purple-200/50 dark:hover:border-purple-500/30"
+ : "opacity-50 cursor-not-allowed grayscale-[0.5]"
+ )}
+ >
+ filter_list
+ {selectedOption?.label || (hasOptions ? '' : '선택 불가')}
+ expand_more
+
+ )}
{/* Dropdown Menu */}
{hasOptions && isOpen && (
@@ -92,7 +109,7 @@ export function SortDropdown({
key={option.id}
type="button"
onClick={() => handleOptionClick(option.id)}
- className="group/item flex items-center gap-2.5 px-3.5 py-2 text-[10px] font-medium hover:bg-purple-50/50 dark:hover:bg-purple-600/20 transition-all text-left w-full relative"
+ className="group/item flex items-center gap-2.5 px-3 py-1.5 mx-1 rounded-lg text-[10px] font-medium hover:bg-purple-100/60 dark:hover:bg-purple-900/30 transition-all text-left relative"
>
{option.icon}
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts
index f4ac6e3..e2474a4 100644
--- a/frontend/src/lib/api/bookmarkApi.ts
+++ b/frontend/src/lib/api/bookmarkApi.ts
@@ -18,6 +18,7 @@ async function fetchBookmarks(params: BookmarkSearchParams): Promise(`/api/bookmarks${qs ? `?${qs}` : ''}`);
@@ -56,6 +57,16 @@ async function deleteBookmark(bookmarkId: number): Promise {
});
}
+/**
+ * 북마크 조회 기록 (PATCH /api/bookmarks/{bookmarkId}/read)
+ * - 서버에서 view_count += 1, last_viewed_at = now() 처리
+ */
+async function recordBookmarkView(bookmarkId: number): Promise {
+ await fetchClient(`/api/bookmarks/${bookmarkId}/read`, {
+ method: 'PATCH',
+ });
+}
+
/**
* URL 분석 (GET /api/bookmarks/analyze)
* @param url 분석할 URL
@@ -121,6 +132,20 @@ export function useDeleteBookmark() {
});
}
+/**
+ * [조회 기록 Hook] 북마크 열람 시 view_count 증가 및 읽음 처리.
+ * 성공 시 'bookmarks' 캐시를 무효화하여 Unread 필터 목록 등이 자동 갱신됩니다.
+ */
+export function useRecordBookmarkView() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: recordBookmarkView,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
+ },
+ });
+}
+
/**
* [분석 Hook] URL에서 제목을 추출합니다.
*/
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index e875fd4..cbac877 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -2,26 +2,30 @@ import { create } from 'zustand';
interface FolderStore {
selectedFolderId: number | null;
- searchScope: 'folders' | 'all';
+ searchScope: 'folders' | 'all' | 'links';
searchQuery: string;
savedTodayFilter: boolean;
+ unreadFilter: boolean;
setSelectedFolderId: (id: number | null) => void;
- setSearchScope: (scope: 'folders' | 'all') => void;
+ setSearchScope: (scope: 'folders' | 'all' | 'links') => void;
setSearchQuery: (query: string) => void;
toggleSavedTodayFilter: () => void;
+ toggleUnreadFilter: () => void;
clearSearch: () => void;
}
export const useFolderStore = create((set) => ({
selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨)
- searchScope: 'folders', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함)
+ searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함, 'links': 링크만)
searchQuery: '', // 폴더 목록 필터링용 검색어
savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부
+ unreadFilter: false, // 아직 읽지 않은 링크(view_count = 0)만 표시 여부
setSelectedFolderId: (id) => set({ selectedFolderId: id }),
setSearchScope: (scope) => set({ searchScope: scope }),
setSearchQuery: (query) => set({ searchQuery: query }),
toggleSavedTodayFilter: () => set((state) => ({ savedTodayFilter: !state.savedTodayFilter })),
- clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false }),
+ toggleUnreadFilter: () => set((state) => ({ unreadFilter: !state.unreadFilter })),
+ clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false, unreadFilter: false }),
}));
diff --git a/frontend/src/lib/types/bookmark.ts b/frontend/src/lib/types/bookmark.ts
index b50ba4d..3b18277 100644
--- a/frontend/src/lib/types/bookmark.ts
+++ b/frontend/src/lib/types/bookmark.ts
@@ -20,6 +20,8 @@ export interface BookmarkResponse {
displayTitle: string;
note: string | null;
primaryCategoryId: number | null;
+ viewCount: number;
+ lastViewedAt: string | null;
tags: string[];
link: LinkResponse | null;
createdAt: string;
@@ -57,4 +59,5 @@ export interface BookmarkSearchParams {
sort?: 'Newest' | 'Oldest' | 'Alphabetical';
query?: string;
categoryId?: number | null;
+ unreadOnly?: boolean;
}
From 9e4b93885bca801c43bd8d53aeef73cabaee6958 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sat, 25 Apr 2026 15:38:32 +0900
Subject: [PATCH 08/16] =?UTF-8?q?refactor(frontend):=20=EA=B2=80=EC=83=89?=
=?UTF-8?q?=20=EB=B2=94=EC=9C=84(=EC=A0=84=EC=B2=B4/=ED=8F=B4=EB=8D=94/?=
=?UTF-8?q?=EB=A7=81=ED=81=AC)=20=ED=95=84=ED=84=B0=EB=A7=81=20=EB=B2=84?=
=?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UI=20=EB=94=94?=
=?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
page.tsx: 검색 편의성 강화: 검색창에서 '전체, 폴더, 링크' 중 범위를 골라 검색할 수 있는 기능을 추가하고, 안 읽은 링크만 모아보는 버튼을 배치하여 검색 경험 개선.
RightPanel: UI 심미성 개선: 닫기 버튼들을 세련된 원형 배지 스타일로 통일하고 회전 애니메이션을 추가하여 프리미엄 디자인 구현.
FolderCard: 레이아웃 최적화: 검색 결과 및 폴더 선택 시의 시각적 효과를 강화하고 컴포넌트 내부 아이콘과 텍스트 정렬 상태를 정밀하게 조정.
---
frontend/src/app/my-links/page.tsx | 167 ++++++++++++++----
.../src/components/my-links/FolderCard.tsx | 17 +-
.../src/components/my-links/RightPanel.tsx | 82 +++++++--
3 files changed, 211 insertions(+), 55 deletions(-)
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index 956835c..a3632fd 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -9,20 +9,24 @@ import { useAuthStore } from '@/lib/store/authStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import { useBookmarks } from '@/lib/api/bookmarkApi';
import { useLinkStore } from '@/lib/store/linkStore';
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { isCreatedToday } from '@/lib/dateUtils';
+import { cn } from '@/lib/utils';
const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
export default function MyLinksPage() {
const { toggleRightPanel } = useUIStore();
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
+ const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID
const searchQuery = useFolderStore((s) => s.searchQuery); // 폴더 검색어 상태
const setSearchQuery = useFolderStore((s) => s.setSearchQuery); // 폴더 검색어 변경 함수
const searchScope = useFolderStore((s) => s.searchScope); // 검색 범위 ('folders' | 'all')
const setSearchScope = useFolderStore((s) => s.setSearchScope); // 검색 범위 변경 함수
const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // '오늘 저장' 필터 상태
const toggleSavedTodayFilter = useFolderStore((s) => s.toggleSavedTodayFilter); // 필터 토글 함수
+ const unreadFilter = useFolderStore((s) => s.unreadFilter); // 'Unread' 필터 상태 (view_count = 0)
+ const toggleUnreadFilter = useFolderStore((s) => s.toggleUnreadFilter); // Unread 필터 토글 함수
const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
@@ -38,21 +42,44 @@ export default function MyLinksPage() {
{ id: 'count', label: 'Link Count', icon: 'list_alt' }
];
+ const scopeOptions: SortOption[] = [
+ { id: 'all', label: 'All', icon: 'filter_alt' },
+ { id: 'folders', label: 'Folders', icon: 'folder' },
+ { id: 'links', label: 'Links', icon: 'link' },
+ ];
+
// 상단 고정 폴더 배지 클릭 처리
const handleFolderBadgeClick = (folderId: number) => {
- setSelectedFolderId(folderId); // 선택된 폴더 변경
+ const currentSelectedId = useFolderStore.getState().selectedFolderId;
+ if (currentSelectedId === folderId) {
+ setSelectedFolderId(null);
+ } else {
+ setSelectedFolderId(folderId); // 선택된 폴더 변경
+ }
useLinkStore.getState().setSearchQuery(searchQuery); // 현재 폴더 검색어를 링크 검색어로 동기화
};
+ // all 또는 links 검색 스코프일 때 자동으로 전체 폴더 선택 해제 및 링크 검색어 동기화
+ useEffect(() => {
+ if (searchScope === 'all' || searchScope === 'links') {
+ useLinkStore.getState().setSearchQuery(searchQuery);
+ setSelectedFolderId(null);
+ }
+ }, [searchQuery, searchScope, setSelectedFolderId]);
+
// 검색 필터링 로직
const isSearching = searchQuery.trim().length > 0;
// 북마크 데이터 조회 조건 결정:
- // 1. 전체 범위 검색 중이거나
- // 2. 오늘 저장 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
- const shouldFetchBookmarks = (searchScope === 'all' && isSearching) || savedTodayFilter;
+ // 1. 전체 또는 링크 범위 검색 중이거나
+ // 2. 오늘 저장 필터가 활성화된 경우
+ // 3. Unread 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
+ const shouldFetchBookmarks = ((searchScope === 'all' || searchScope === 'links') && isSearching) || savedTodayFilter || unreadFilter;
+ const bookmarkParams: Parameters[0] = {};
+ if (isSearching && (searchScope === 'all' || searchScope === 'links')) bookmarkParams.query = searchQuery;
+ if (unreadFilter) bookmarkParams.unreadOnly = true;
const { data: allBookmarks } = useBookmarks(
- shouldFetchBookmarks ? (isSearching && searchScope === 'all' ? { query: searchQuery } : {}) : {},
+ bookmarkParams,
{ enabled: shouldFetchBookmarks }
);
@@ -65,15 +92,25 @@ export default function MyLinksPage() {
.map((b) => b.memberFolderId)
: []
);
+ const unreadFolderIdsSet = new Set(
+ unreadFilter && allBookmarks?.bookmarks
+ ? allBookmarks.bookmarks.map((b) => b.memberFolderId)
+ : []
+ );
// 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M))
let searchResultFolders = isSearching
? (folders ?? []).filter((folder) => {
const queryLower = searchQuery.toLowerCase();
- // 1. 폴더명 자체에 검색어가 포함된 경우
- if (folder.folderName.toLowerCase().includes(queryLower)) return true;
- // 2. 검색 범위가 '전체'인 경우, 백엔드가 알려준 '매칭 북마크 포함 폴더'인지 확인
- return searchScope === 'all' && matchingFolderIdsSet.has(folder.memberFolderId);
+
+ // 검색 범위에 따른 매칭 확인
+ const matchFolderName = (searchScope === 'all' || searchScope === 'folders')
+ && folder.folderName.toLowerCase().includes(queryLower);
+
+ const matchBookmark = (searchScope === 'all' || searchScope === 'links')
+ && matchingFolderIdsSet.has(folder.memberFolderId);
+
+ return matchFolderName || matchBookmark;
})
: (folders ?? []);
@@ -84,11 +121,33 @@ export default function MyLinksPage() {
);
}
+ // 'Unread' 필터링 (O(M)): 안 읽은 북마크가 포함된 폴더만 유지
+ if (unreadFilter) {
+ searchResultFolders = searchResultFolders.filter((folder) =>
+ unreadFolderIdsSet.has(folder.memberFolderId)
+ );
+ }
+
const searchResultFolderCount = searchResultFolders.length;
+ const matchedLinksCount = allBookmarks?.bookmarks?.length ?? 0;
+
+ // 헤더 메시지 생성
+ let headerLabel = '';
+ if (unreadFilter) {
+ headerLabel = `Unread: ${searchResultFolderCount} folders found`;
+ } else if (savedTodayFilter) {
+ headerLabel = `Saved Today: ${searchResultFolderCount} folders found`;
+ } else if (searchScope === 'all') {
+ headerLabel = `${searchResultFolderCount} folders & ${matchedLinksCount} links found`;
+ } else if (searchScope === 'links') {
+ headerLabel = `${matchedLinksCount} links found in ${searchResultFolderCount} folders`;
+ } else {
+ headerLabel = `${searchResultFolderCount} folders found`;
+ }
// 필터 또는 검색 활성화 시 결과 표시
// 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
- const isShowingFiltered = isSearching || savedTodayFilter;
+ const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter;
return (
@@ -111,29 +170,29 @@ export default function MyLinksPage() {
onChange={(e) => setSearchQuery(e.target.value)}
/>
- {/* Search Scope Toggle (Folders / All) */}
-
- setSearchScope('all')}
- className={`px-3 py-1 text-[9px] font-bold rounded-md transition-all ${
- searchScope === 'all'
- ? 'bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-300 shadow-sm'
- : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
- }`}
- >
- All
-
- setSearchScope('folders')}
- className={`px-2.5 py-1 text-[9px] font-bold rounded-md transition-all ${
- searchScope === 'folders'
- ? 'bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-300 shadow-sm'
- : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
- }`}
- >
- Folders
-
-
+ {/* Search Scope Toggle Dropdown using common component */}
+
setSearchScope(val as 'all' | 'folders' | 'links')}
+ options={scopeOptions}
+ className="shrink-0 mr-1 ml-1"
+ panelClassName="w-28"
+ renderTrigger={({ selectedOption, isOpen, toggle }) => (
+
+
+ {selectedOption?.icon || 'filter_alt'}
+
+
+ )}
+ />
{searchQuery && (
- close
+
+
+
)}
@@ -161,12 +230,28 @@ export default function MyLinksPage() {
>
inventory_2 Unorganized
-
+ {
+ const nextValue = !unreadFilter;
+ toggleUnreadFilter();
+ if (nextValue) {
+ setSelectedFolderId(null);
+ }
+ }}
+ className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
+ unreadFilter
+ ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-600 text-blue-700 dark:text-blue-300'
+ : 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
+ }`}
+ >
mark_email_unread Unread
+ {/*
priority_high High Priority
+ */}
{
@@ -193,7 +278,7 @@ export default function MyLinksPage() {
search
- {savedTodayFilter ? 'Saved Today' : 'Search Results'}: {searchResultFolderCount} folders found
+ {headerLabel}
@@ -230,7 +315,11 @@ export default function MyLinksPage() {
role="button"
tabIndex={0}
onClick={() => handleFolderBadgeClick(folder.memberFolderId)}
- className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`}
+ className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer outline-none hover:shadow-md ${
+ selectedFolderId === folder.memberFolderId
+ ? 'ring-2 ring-purple-400 ring-offset-2'
+ : ''
+ }`}
>
diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx
index b759d26..86970fc 100644
--- a/frontend/src/components/my-links/FolderCard.tsx
+++ b/frontend/src/components/my-links/FolderCard.tsx
@@ -17,6 +17,7 @@ const MENU_OFFSET_Y = 4; // 버튼과 메뉴 사이의 상하 간격
export function FolderCard({ folder, color }: FolderCardProps) {
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
+ const currentSelectedFolderId = useFolderStore((s) => s.selectedFolderId);
const searchQuery = useFolderStore((s) => s.searchQuery);
const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery);
const [showMenu, setShowMenu] = useState(false); // 드롭다운 메뉴 표시 여부
@@ -77,8 +78,12 @@ export function FolderCard({ folder, color }: FolderCardProps) {
// 카드 클릭 시 폴더 선택 처리 (메뉴가 열려있지 않을 때만)
const handleCardClick = () => {
if (!showMenu) {
- // 1. 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거
- setSelectedFolderId(folder.memberFolderId);
+ // 1. 이미 선택된 폴더를 다시 클릭하면 선택 해제, 아니면 해당 폴더 선택
+ if (currentSelectedFolderId === folder.memberFolderId) {
+ setSelectedFolderId(null);
+ } else {
+ setSelectedFolderId(folder.memberFolderId);
+ }
// 2. 기존 폴더 검색어가 있다면 링크 검색어로 전이 (사용자 요청: 클릭 시 링크 필터링되도록)
if (searchQuery) {
@@ -107,6 +112,8 @@ export function FolderCard({ folder, color }: FolderCardProps) {
setShowMenu(false);
};
+ const isSelected = currentSelectedFolderId === folder.memberFolderId;
+
return (
<>
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index f9d7b5e..cb1de89 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
-import { useBookmarks, useDeleteBookmark, useUpdateBookmark } from '@/lib/api/bookmarkApi';
+import { useBookmarks, useDeleteBookmark, useUpdateBookmark, useRecordBookmarkView } from '@/lib/api/bookmarkApi';
import { useTags } from '@/lib/api/tagApi';
import { useFolderStore } from '@/lib/store/folderStore';
import { useAuthStore } from '@/lib/store/authStore';
@@ -43,6 +43,7 @@ function LinkItem({
onUpdateTitle,
onUpdateFolder,
onUpdateTags,
+ onOpenLink,
}: {
data: BookmarkResponse;
isBulkEditMode?: boolean;
@@ -54,6 +55,7 @@ function LinkItem({
onUpdateTitle?: (id: number, title: string) => void;
onUpdateFolder?: (id: number, folderId: number) => void;
onUpdateTags?: (id: number, tags: string) => void;
+ onOpenLink?: (bookmark: BookmarkResponse) => void;
}) {
// --- State Management | 상태 관리 ---
const [isNoteEditing, setIsNoteEditing] = useState(false); // 메모 편집 모드 여부
@@ -229,6 +231,7 @@ function LinkItem({
if (isBulkEditMode && onToggleSelect) {
onToggleSelect(data.bookmarkId);
} else if (!isNoteEditing && !isTitleEditing && data.link?.originalUrl) {
+ onOpenLink?.(data);
window.open(data.link.originalUrl, '_blank', 'noopener,noreferrer');
}
}}
@@ -445,12 +448,26 @@ function LinkItem({
}}
title={(isNoteEditing || isTitleEditing) ? "Cancel editing" : "More actions"}
>
-
- {(isNoteEditing || isTitleEditing) ? 'close' : 'more_vert'}
-
+ {(isNoteEditing || isTitleEditing) ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
{/* Dropdown Menu | 드롭다운 메뉴 (수정/삭제) */}
@@ -500,6 +517,7 @@ export function RightPanel() {
const linkSearchQuery = useLinkStore((s) => s.filters.searchQuery); // 현재 검색어 상태 구독
const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수
const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // 오늘 저장 필터 상태
+ const unreadFilter = useFolderStore((s) => s.unreadFilter); // Unread 필터 상태 (view_count = 0)
// 검색어 디바운스 처리를 위한 로컬 상태
const [pendingSearch, setPendingSearch] = useState(linkSearchQuery);
@@ -544,13 +562,20 @@ export function RightPanel() {
folderId: selectedFolderId,
sort: backendSort,
query: linkSearchQuery,
+ unreadOnly: unreadFilter || undefined,
});
const bookmarks = bookmarksData?.bookmarks;
- // API 뮤테이션 (수정/삭제)
+ // API 뮤테이션 (수정/삭제/조회기록)
const deleteBookmarkMutation = useDeleteBookmark();
const updateBookmarkMutation = useUpdateBookmark();
+ const recordViewMutation = useRecordBookmarkView();
+
+ /** 링크 카드 클릭 시 조회 기록 (view_count += 1, last_viewed_at = now()) */
+ const handleOpenLink = (bookmark: BookmarkResponse) => {
+ recordViewMutation.mutate(bookmark.bookmarkId);
+ };
// 대량 편집 관련 상태
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false); // 헤더 더보기 메뉴
@@ -863,9 +888,19 @@ export function RightPanel() {
{tag}
toggleTag(tag)}
- className="flex items-center justify-center rounded-sm hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors opacity-70 hover:opacity-100"
+ className="group/tag-close flex items-center justify-center rounded-sm hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors opacity-70 hover:opacity-100"
>
- close
+
+
+
))}
@@ -911,6 +946,7 @@ export function RightPanel() {
onUpdateTitle={handleUpdateTitle}
onUpdateFolder={handleUpdateFolder}
onUpdateTags={handleUpdateTags}
+ onOpenLink={handleOpenLink}
/>
))
) : (
@@ -962,9 +998,19 @@ export function RightPanel() {
- close
+
+
+
@@ -986,9 +1032,19 @@ export function RightPanel() {
setIsMoveModalOpen(false)}
- className="w-7 h-7 flex items-center justify-center rounded-full text-slate-400 hover:text-slate-800 hover:bg-slate-50 dark:hover:bg-gray-700 transition-colors"
+ className="group/modal-close w-[22px] h-[22px] aspect-square flex items-center justify-center rounded-full bg-slate-200/70 dark:bg-gray-700/50 text-slate-500 hover:text-slate-800 hover:bg-slate-300 dark:hover:bg-gray-600 transition-all duration-200 shrink-0"
>
- close
+
+
+
From db6d50580d87760047821dd1bcbb2aa974200f9d Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sat, 25 Apr 2026 16:44:56 +0900
Subject: [PATCH 09/16] =?UTF-8?q?feat(frontend):=20=EB=AF=B8=EB=B6=84?=
=?UTF-8?q?=EB=A5=98=20=ED=95=84=ED=84=B0=20UI=20=EA=B5=AC=ED=98=84=20?=
=?UTF-8?q?=EB=B0=8F=20=EC=9A=B0=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EB=8F=99?=
=?UTF-8?q?=EC=A0=81=20=EC=A0=9C=EB=AA=A9=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
folderStore: 상태 관리 확장: 시스템 미분류(Unorganized) 폴더만 선별하여 표시하기 위한 'unorganizedFilter' 상태 및 토글 액션 추가.
page.tsx: 미분류 필터 UI 구현: 필터 섹션에 'Unorganized' 버튼을 추가하고, 필터 활성화 시 시스템 폴더만 필터링하여 그리드에 표시하는 로직 적용.
page.tsx: 검색 결과 헤더 개선: 미분류 필터 활성화 시 검색 결과 상단에 'Unorganized: N folders found' 형태의 가이드 문구가 표시되도록 처리.
RightPanel: 사용자 인지 편의성 개선: 현재 적용된 필터(Unread, Saved today, Unorganized) 정보를 우측 패널 헤더 제목에 실시간으로 결합하여 표시하는 동적 타이틀 로직 구현.
RightPanel: 시각적 일관성 확보: 폴더 선택 여부와 필터 활성화 조합에 따라 '폴더명 + 필터명' 또는 '필터명'만 표시되도록 조건부 렌더링 최적화.
---
frontend/src/app/my-links/page.tsx | 32 ++++++++++++++++---
.../src/components/my-links/RightPanel.tsx | 23 +++++++++++--
frontend/src/lib/store/folderStore.ts | 6 +++-
3 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index a3632fd..8b50ace 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -27,6 +27,8 @@ export default function MyLinksPage() {
const toggleSavedTodayFilter = useFolderStore((s) => s.toggleSavedTodayFilter); // 필터 토글 함수
const unreadFilter = useFolderStore((s) => s.unreadFilter); // 'Unread' 필터 상태 (view_count = 0)
const toggleUnreadFilter = useFolderStore((s) => s.toggleUnreadFilter); // Unread 필터 토글 함수
+ const unorganizedFilter = useFolderStore((s) => s.unorganizedFilter); // 미분류 필터 상태
+ const toggleUnorganizedFilter = useFolderStore((s) => s.toggleUnorganizedFilter); // 미분류 필터 토글 함수
const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
@@ -128,12 +130,21 @@ export default function MyLinksPage() {
);
}
+ // '미분류' 필터링 (O(M)): 미분류 시스템 폴더만 유지
+ if (unorganizedFilter) {
+ searchResultFolders = searchResultFolders.filter((folder) =>
+ folder.folderType === 'UNORGANIZED'
+ );
+ }
+
const searchResultFolderCount = searchResultFolders.length;
const matchedLinksCount = allBookmarks?.bookmarks?.length ?? 0;
// 헤더 메시지 생성
let headerLabel = '';
- if (unreadFilter) {
+ if (unorganizedFilter) {
+ headerLabel = `Unorganized: ${searchResultFolderCount} folders found`;
+ } else if (unreadFilter) {
headerLabel = `Unread: ${searchResultFolderCount} folders found`;
} else if (savedTodayFilter) {
headerLabel = `Saved Today: ${searchResultFolderCount} folders found`;
@@ -147,7 +158,7 @@ export default function MyLinksPage() {
// 필터 또는 검색 활성화 시 결과 표시
// 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
- const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter;
+ const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter || unorganizedFilter;
return (
@@ -224,9 +235,20 @@ export default function MyLinksPage() {
tune Filter
-
{
+ const nextValue = !unorganizedFilter;
+ toggleUnorganizedFilter();
+ if (nextValue) {
+ setSelectedFolderId(null);
+ }
+ }}
+ className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
+ unorganizedFilter
+ ? 'bg-rose-50 dark:bg-rose-900/20 border-rose-200 dark:border-rose-600 text-rose-700 dark:text-rose-300'
+ : 'bg-white dark:bg-card-dark border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
+ }`}
>
inventory_2 Unorganized
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index cb1de89..72eedf2 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -518,6 +518,7 @@ export function RightPanel() {
const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수
const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // 오늘 저장 필터 상태
const unreadFilter = useFolderStore((s) => s.unreadFilter); // Unread 필터 상태 (view_count = 0)
+ const unorganizedFilter = useFolderStore((s) => s.unorganizedFilter); // 미분류 필터 상태
// 검색어 디바운스 처리를 위한 로컬 상태
const [pendingSearch, setPendingSearch] = useState(linkSearchQuery);
@@ -755,8 +756,24 @@ export function RightPanel() {
}
};
- // Determine the current folder name | 현재 폴더 이름 결정
- const currentFolderName = myFolders?.find(f => f.memberFolderId === selectedFolderId)?.folderName ?? 'All Links';
+ // Determine the display title based on selected folder and active filters | 선택된 폴더 및 활성 필터에 따른 제목 결정
+ const folderName = myFolders?.find(f => f.memberFolderId === selectedFolderId)?.folderName;
+
+ const activeFilters: string[] = [];
+ if (unreadFilter) activeFilters.push('Unread');
+ if (savedTodayFilter) activeFilters.push('Saved today');
+ if (unorganizedFilter) activeFilters.push('Unorganized');
+
+ let displayTitle = folderName || 'All Links';
+
+ if (activeFilters.length > 0) {
+ const filtersLabel = activeFilters.join(' + ');
+ if (folderName) {
+ displayTitle = `${folderName} + ${filtersLabel}`;
+ } else {
+ displayTitle = filtersLabel;
+ }
+ }
if (!rightPanelOpen) return null;
@@ -774,7 +791,7 @@ export function RightPanel() {
folder_open
-
{currentFolderName}
+
{displayTitle}
{filteredLinks.length} Links
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index cbac877..2b85045 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -6,12 +6,14 @@ interface FolderStore {
searchQuery: string;
savedTodayFilter: boolean;
unreadFilter: boolean;
+ unorganizedFilter: boolean;
setSelectedFolderId: (id: number | null) => void;
setSearchScope: (scope: 'folders' | 'all' | 'links') => void;
setSearchQuery: (query: string) => void;
toggleSavedTodayFilter: () => void;
toggleUnreadFilter: () => void;
+ toggleUnorganizedFilter: () => void;
clearSearch: () => void;
}
@@ -21,11 +23,13 @@ export const useFolderStore = create((set) => ({
searchQuery: '', // 폴더 목록 필터링용 검색어
savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부
unreadFilter: false, // 아직 읽지 않은 링크(view_count = 0)만 표시 여부
+ unorganizedFilter: false, // 미분류 폴더만 표시 여부
setSelectedFolderId: (id) => set({ selectedFolderId: id }),
setSearchScope: (scope) => set({ searchScope: scope }),
setSearchQuery: (query) => set({ searchQuery: query }),
toggleSavedTodayFilter: () => set((state) => ({ savedTodayFilter: !state.savedTodayFilter })),
toggleUnreadFilter: () => set((state) => ({ unreadFilter: !state.unreadFilter })),
- clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false, unreadFilter: false }),
+ toggleUnorganizedFilter: () => set((state) => ({ unorganizedFilter: !state.unorganizedFilter })),
+ clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false, unreadFilter: false, unorganizedFilter: false }),
}));
From dca9bda9c49603c809b59c4e758a8cc031cb6d93 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 26 Apr 2026 17:41:16 +0900
Subject: [PATCH 10/16] =?UTF-8?q?fix(frontend):=20=EA=B2=80=EC=83=89=20?=
=?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C,=20?=
=?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=8F=B4=EB=8D=94=20=EC=B4=88=EA=B8=B0?=
=?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20=EB=8F=99=EA=B8=B0?=
=?UTF-8?q?=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
folderStore.ts: 필터 버튼 토글 시 Zustand 최신 state 기준으로 nextValue 계산 및 필터 활성화 시 selectedFolderId 초기화 처리로 렌더 시점 stale 값 참조 가능성 제거.
page.tsx: Saved Today와 Unread 필터의 좌측 폴더 결과를 bookmarks 배열 재계산 대신 백엔드 matchingFolderIds 기준으로 변경하여 우측 링크 패널과 동일 조건 사용.
page.tsx: 미분류 필터 활성화 시 미분류 폴더 ID를 bookmarkParams.folderId로 전달하여 좌측 폴더 결과와 우측 링크 조회 조건 동기화.
folder.ts, FolderCard.tsx, SaveLinkDialog.tsx, RightPanel.tsx: UNORGANIZED 문자열 직접 비교를 FOLDER_TYPE 상수 비교로 통일하여 폴더 타입 비교 안정성 확보.
page.tsx: 필터와 검색 조건별 emptyFilteredMessage 분기 추가로 좌측 빈 상태 안내 개선.
---
frontend/src/app/my-links/page.tsx | 79 ++++++++-----------
.../src/components/dialogs/SaveLinkDialog.tsx | 3 +-
.../src/components/my-links/FolderCard.tsx | 4 +-
.../src/components/my-links/RightPanel.tsx | 7 +-
frontend/src/lib/store/folderStore.ts | 33 +++++++-
frontend/src/lib/types/folder.ts | 7 +-
6 files changed, 79 insertions(+), 54 deletions(-)
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index 8b50ace..c8d8436 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -10,8 +10,8 @@ import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import { useBookmarks } from '@/lib/api/bookmarkApi';
import { useLinkStore } from '@/lib/store/linkStore';
import { useState, useEffect, useRef } from 'react';
-import { isCreatedToday } from '@/lib/dateUtils';
import { cn } from '@/lib/utils';
+import { FOLDER_TYPE } from '@/lib/types/folder';
const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
@@ -72,33 +72,29 @@ export default function MyLinksPage() {
// 검색 필터링 로직
const isSearching = searchQuery.trim().length > 0;
+ // 미분류 폴더 ID 찾기
+ const unorganizedFolder = folders?.find(f => f.folderType === FOLDER_TYPE.UNORGANIZED);
+ const unorganizedFolderId = unorganizedFolder?.memberFolderId;
+
// 북마크 데이터 조회 조건 결정:
// 1. 전체 또는 링크 범위 검색 중이거나
// 2. 오늘 저장 필터가 활성화된 경우
- // 3. Unread 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
- const shouldFetchBookmarks = ((searchScope === 'all' || searchScope === 'links') && isSearching) || savedTodayFilter || unreadFilter;
+ // 3. Unread 필터가 활성화된 경우
+ // 4. 미분류 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
+ const shouldFetchBookmarks = ((searchScope === 'all' || searchScope === 'links') && isSearching) || savedTodayFilter || unreadFilter || unorganizedFilter;
const bookmarkParams: Parameters[0] = {};
if (isSearching && (searchScope === 'all' || searchScope === 'links')) bookmarkParams.query = searchQuery;
if (unreadFilter) bookmarkParams.unreadOnly = true;
+ if (savedTodayFilter) bookmarkParams.savedTodayOnly = true;
+ if (unorganizedFilter && unorganizedFolderId) bookmarkParams.folderId = unorganizedFolderId;
const { data: allBookmarks } = useBookmarks(
bookmarkParams,
{ enabled: shouldFetchBookmarks }
);
// 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N))
+ // matchingFolderIdsSet은 백엔드에서 정확하게 필터링된 폴더들
const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []);
- const todayFolderIdsSet = new Set(
- savedTodayFilter && allBookmarks?.bookmarks
- ? allBookmarks.bookmarks
- .filter((b) => isCreatedToday(b.createdAt))
- .map((b) => b.memberFolderId)
- : []
- );
- const unreadFolderIdsSet = new Set(
- unreadFilter && allBookmarks?.bookmarks
- ? allBookmarks.bookmarks.map((b) => b.memberFolderId)
- : []
- );
// 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M))
let searchResultFolders = isSearching
@@ -116,24 +112,26 @@ export default function MyLinksPage() {
})
: (folders ?? []);
- // '오늘 저장한 링크' 필터링 최적화 (O(M))
+ // '오늘 저장한 링크' 필터링 (O(M))
+ // 백엔드에서 이미 savedTodayOnly=true로 필터링되었으므로 matchingFolderIdsSet이 정확함
if (savedTodayFilter) {
searchResultFolders = searchResultFolders.filter((folder) =>
- todayFolderIdsSet.has(folder.memberFolderId)
+ matchingFolderIdsSet.has(folder.memberFolderId)
);
}
// 'Unread' 필터링 (O(M)): 안 읽은 북마크가 포함된 폴더만 유지
+ // 백엔드에서 이미 unreadOnly=true로 필터링되었으므로 matchingFolderIdsSet이 정확함 (불완전한 bookmarks 페이지 대신)
if (unreadFilter) {
searchResultFolders = searchResultFolders.filter((folder) =>
- unreadFolderIdsSet.has(folder.memberFolderId)
+ matchingFolderIdsSet.has(folder.memberFolderId)
);
}
// '미분류' 필터링 (O(M)): 미분류 시스템 폴더만 유지
if (unorganizedFilter) {
searchResultFolders = searchResultFolders.filter((folder) =>
- folder.folderType === 'UNORGANIZED'
+ folder.folderType === FOLDER_TYPE.UNORGANIZED
);
}
@@ -159,6 +157,15 @@ export default function MyLinksPage() {
// 필터 또는 검색 활성화 시 결과 표시
// 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter || unorganizedFilter;
+ const emptyFilteredMessage = (() => {
+ if (unreadFilter && savedTodayFilter) return 'No unread links saved today';
+ if (unorganizedFilter && savedTodayFilter) return 'No unorganized links saved today';
+ if (unreadFilter) return 'No unread links';
+ if (savedTodayFilter) return 'No links saved today';
+ if (unorganizedFilter) return 'No unorganized links';
+ if (isSearching) return 'No results match your search';
+ return 'No matching folders';
+ })();
return (
@@ -237,13 +244,7 @@ export default function MyLinksPage() {
{
- const nextValue = !unorganizedFilter;
- toggleUnorganizedFilter();
- if (nextValue) {
- setSelectedFolderId(null);
- }
- }}
+ onClick={toggleUnorganizedFilter}
className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
unorganizedFilter
? 'bg-rose-50 dark:bg-rose-900/20 border-rose-200 dark:border-rose-600 text-rose-700 dark:text-rose-300'
@@ -254,13 +255,7 @@ export default function MyLinksPage() {
{
- const nextValue = !unreadFilter;
- toggleUnreadFilter();
- if (nextValue) {
- setSelectedFolderId(null);
- }
- }}
+ onClick={toggleUnreadFilter}
className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
unreadFilter
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-600 text-blue-700 dark:text-blue-300'
@@ -276,13 +271,7 @@ export default function MyLinksPage() {
*/}
{
- const nextValue = !savedTodayFilter;
- toggleSavedTodayFilter();
- if (nextValue) {
- setSelectedFolderId(null);
- }
- }}
+ onClick={toggleSavedTodayFilter}
className={`flex items-center justify-center gap-1 px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors shadow-sm border ${
savedTodayFilter
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-600 text-purple-700 dark:text-purple-300'
@@ -308,12 +297,12 @@ export default function MyLinksPage() {
{searchResultFolderCount === 0 ? (
search_off
- No results match your search
+ {emptyFilteredMessage}
) : (
searchResultFolders.sort((a, b) => {
- if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
- if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
+ if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1;
+ if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1;
return 0;
}).map((folder) => (
@@ -426,8 +415,8 @@ export default function MyLinksPage() {
{/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */}
{(folders ? [...folders] : []).sort((a, b) => {
// 시스템(미분류) 폴더는 항상 최상단 고정
- if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1;
- if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1;
+ if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1;
+ if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1;
if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId;
if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName);
return 0;
diff --git a/frontend/src/components/dialogs/SaveLinkDialog.tsx b/frontend/src/components/dialogs/SaveLinkDialog.tsx
index e3460fe..3762edc 100644
--- a/frontend/src/components/dialogs/SaveLinkDialog.tsx
+++ b/frontend/src/components/dialogs/SaveLinkDialog.tsx
@@ -12,6 +12,7 @@ import { useCreateBookmark, useAnalyzeUrl } from '@/lib/api/bookmarkApi';
import { useAnalyzeLink } from '@/lib/api/linkAnalysisApi';
import { useAuthStore } from '@/lib/store/authStore';
import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis';
+import { FOLDER_TYPE } from '@/lib/types/folder';
// 디자인 시안에서 추출한 커스텀 테마 매핑
const theme = {
@@ -280,7 +281,7 @@ export function SaveLinkDialog() {
// Quick Save 경로: 폴더 미지정 시 미분류(UNORGANIZED) 폴더로 저장
const fallbackFolderId =
selectedFolderId
- ?? folders?.find((f) => f.folderType === 'UNORGANIZED')?.memberFolderId
+ ?? folders?.find((f) => f.folderType === FOLDER_TYPE.UNORGANIZED)?.memberFolderId
?? null;
saveBookmark(fallbackFolderId);
}
diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx
index 86970fc..49f4c67 100644
--- a/frontend/src/components/my-links/FolderCard.tsx
+++ b/frontend/src/components/my-links/FolderCard.tsx
@@ -2,7 +2,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import type { FolderResponse } from '@/lib/types/folder';
+import { FOLDER_TYPE, type FolderResponse } from '@/lib/types/folder';
import { useFolderStore } from '@/lib/store/folderStore';
import { useLinkStore } from '@/lib/store/linkStore';
import { useUIStore } from '@/lib/store/uiStore';
@@ -23,7 +23,7 @@ export function FolderCard({ folder, color }: FolderCardProps) {
const [showMenu, setShowMenu] = useState(false); // 드롭다운 메뉴 표시 여부
const [modalType, setModalType] = useState<'edit' | 'delete' | 'move' | null>(null); // 현재 열린 모달 타입
// 시스템 폴더(미분류)는 Move/Delete 불가 (Rename 만 허용)
- const isSystemFolder = folder.folderType === 'UNORGANIZED';
+ const isSystemFolder = folder.folderType === FOLDER_TYPE.UNORGANIZED;
// DOM 참조 (메뉴 외부 클릭 감지 및 위치 계산용)
const menuRef = useRef(null);
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index 72eedf2..d5b5a54 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -542,6 +542,10 @@ export function RightPanel() {
const { data: myFolders, isLoading: isFoldersLoading } = useFolders(memberId); // 폴더 목록
const { data: tagsData } = useTags(memberId); // 전체 태그 목록
+ // 미분류 폴더 ID 찾기
+ const unorganizedFolder = myFolders?.find(f => f.folderType === 'UNORGANIZED');
+ const unorganizedFolderId = unorganizedFolder?.memberFolderId;
+
// --- Local UI State | UI 전용 로컬 상태 ---
const [selectedTags, setSelectedTags] = useState([]); // 선택된 필터 태그
const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false); // 태그 필터 드롭다운 오픈 여부
@@ -560,10 +564,11 @@ export function RightPanel() {
// 북마크 데이터 조회 (검색 결과 및 매칭 폴더 IDs 포함)
const { data: bookmarksData, isLoading: isBookmarksLoading } = useBookmarks({
- folderId: selectedFolderId,
+ folderId: unorganizedFilter && unorganizedFolderId ? unorganizedFolderId : selectedFolderId,
sort: backendSort,
query: linkSearchQuery,
unreadOnly: unreadFilter || undefined,
+ savedTodayOnly: savedTodayFilter || undefined,
});
const bookmarks = bookmarksData?.bookmarks;
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index 2b85045..889e858 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -28,8 +28,33 @@ export const useFolderStore = create((set) => ({
setSelectedFolderId: (id) => set({ selectedFolderId: id }),
setSearchScope: (scope) => set({ searchScope: scope }),
setSearchQuery: (query) => set({ searchQuery: query }),
- toggleSavedTodayFilter: () => set((state) => ({ savedTodayFilter: !state.savedTodayFilter })),
- toggleUnreadFilter: () => set((state) => ({ unreadFilter: !state.unreadFilter })),
- toggleUnorganizedFilter: () => set((state) => ({ unorganizedFilter: !state.unorganizedFilter })),
- clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false, unreadFilter: false, unorganizedFilter: false }),
+ toggleSavedTodayFilter: () => set((state) => {
+ const nextValue = !state.savedTodayFilter;
+ return {
+ savedTodayFilter: nextValue,
+ selectedFolderId: nextValue ? null : state.selectedFolderId,
+ };
+ }),
+ toggleUnreadFilter: () => set((state) => {
+ const nextValue = !state.unreadFilter;
+ return {
+ unreadFilter: nextValue,
+ selectedFolderId: nextValue ? null : state.selectedFolderId,
+ };
+ }),
+ toggleUnorganizedFilter: () => set((state) => {
+ const nextValue = !state.unorganizedFilter;
+ return {
+ unorganizedFilter: nextValue,
+ selectedFolderId: nextValue ? null : state.selectedFolderId,
+ };
+ }),
+ clearSearch: () => set({
+ selectedFolderId: null,
+ searchQuery: '',
+ searchScope: 'all',
+ savedTodayFilter: false,
+ unreadFilter: false,
+ unorganizedFilter: false,
+ }),
}));
diff --git a/frontend/src/lib/types/folder.ts b/frontend/src/lib/types/folder.ts
index 149735e..c9d7d62 100644
--- a/frontend/src/lib/types/folder.ts
+++ b/frontend/src/lib/types/folder.ts
@@ -2,7 +2,12 @@
export type { ApiResponse } from './apiResponse';
// 백엔드 MemberFolderResponses DTO 대응
-export type FolderType = 'CUSTOM' | 'UNORGANIZED';
+export const FOLDER_TYPE = {
+ CUSTOM: 'CUSTOM',
+ UNORGANIZED: 'UNORGANIZED',
+} as const;
+
+export type FolderType = typeof FOLDER_TYPE[keyof typeof FOLDER_TYPE];
export interface FolderResponse {
memberFolderId: number;
From c30d827897f6283697fb0f8595dba6e36ac58585 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 26 Apr 2026 18:27:00 +0900
Subject: [PATCH 11/16] =?UTF-8?q?feat(bookmark):=20=EB=B6=81=EB=A7=88?=
=?UTF-8?q?=ED=81=AC=20=EB=AA=A9=EB=A1=9D=20limit=20offset=20=ED=8E=98?=
=?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
bookmarkApi.ts: 북마크 목록 요청에 savedTodayOnly, limit, offset query parameter 전달 로직 추가 및 useInfiniteBookmarks hook 도입으로 페이지 단위 조회 지원.
bookmark.ts: BookmarkSearchParams에 savedTodayOnly, limit, offset 필드 추가 및 BookmarkSearchResponse에 totalCount 필드 추가로 페이징 응답 타입 확장.
RightPanel.tsx: useInfiniteBookmarks 기반 목록 조회와 IntersectionObserver 하단 감지 로직 추가로 우측 패널 무한 스크롤 적용.
RightPanel.tsx: 여러 페이지의 bookmarks를 flatMap으로 병합하고 totalCount 기준 링크 개수 표시로 제한 조회 환경의 목록 카운트 정합성 확보.
---
.../src/components/my-links/RightPanel.tsx | 71 +++++++++++++++----
frontend/src/lib/api/bookmarkApi.ts | 29 ++++++--
frontend/src/lib/types/bookmark.ts | 4 ++
3 files changed, 87 insertions(+), 17 deletions(-)
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index d5b5a54..79c1d03 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -1,14 +1,14 @@
import { useState, useRef, useEffect } from 'react';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
-import { useBookmarks, useDeleteBookmark, useUpdateBookmark, useRecordBookmarkView } from '@/lib/api/bookmarkApi';
+import { useInfiniteBookmarks, useDeleteBookmark, useUpdateBookmark, useRecordBookmarkView } from '@/lib/api/bookmarkApi';
import { useTags } from '@/lib/api/tagApi';
import { useFolderStore } from '@/lib/store/folderStore';
import { useAuthStore } from '@/lib/store/authStore';
import { useLinkStore } from '@/lib/store/linkStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import type { BookmarkResponse } from '@/lib/types/bookmark';
-import { isCreatedToday } from '@/lib/dateUtils';
+import { FOLDER_TYPE } from '@/lib/types/folder';
/**
* 날짜 문자열을 받아 현재 시간 기준 상대적인 시간(예: Just now, 5m ago)으로 변환합니다.
@@ -543,7 +543,7 @@ export function RightPanel() {
const { data: tagsData } = useTags(memberId); // 전체 태그 목록
// 미분류 폴더 ID 찾기
- const unorganizedFolder = myFolders?.find(f => f.folderType === 'UNORGANIZED');
+ const unorganizedFolder = myFolders?.find(f => f.folderType === FOLDER_TYPE.UNORGANIZED);
const unorganizedFolderId = unorganizedFolder?.memberFolderId;
// --- Local UI State | UI 전용 로컬 상태 ---
@@ -562,16 +562,45 @@ export function RightPanel() {
// UI 정렬 옵션을 백엔드 파라미터로 매핑
const backendSort = sortOption === 'newest' ? 'Newest' as const : sortOption === 'oldest' ? 'Oldest' as const : 'Alphabetical' as const;
- // 북마크 데이터 조회 (검색 결과 및 매칭 폴더 IDs 포함)
- const { data: bookmarksData, isLoading: isBookmarksLoading } = useBookmarks({
+ // 북마크 데이터 조회 (무한 스크롤 페이징 지원)
+ const {
+ data: infiniteData,
+ isLoading: isBookmarksLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage
+ } = useInfiniteBookmarks({
folderId: unorganizedFilter && unorganizedFolderId ? unorganizedFolderId : selectedFolderId,
sort: backendSort,
query: linkSearchQuery,
unreadOnly: unreadFilter || undefined,
savedTodayOnly: savedTodayFilter || undefined,
+ limit: 100, // 한 페이지당 100개씩 로드
});
- const bookmarks = bookmarksData?.bookmarks;
+ // 모든 페이지의 북마크를 하나의 배열로 펼침
+ const bookmarks = infiniteData?.pages.flatMap(page => page.bookmarks) ?? [];
+ const totalCount = infiniteData?.pages[0]?.totalCount ?? 0;
+
+ // 스크롤 하단 감지를 위한 Ref 및 Observer
+ const observerTarget = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ { threshold: 0.1 } // 10% 정도 보일 때 미리 로드
+ );
+
+ if (observerTarget.current) {
+ observer.observe(observerTarget.current);
+ }
+
+ return () => observer.disconnect();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// API 뮤테이션 (수정/삭제/조회기록)
const deleteBookmarkMutation = useDeleteBookmark();
@@ -610,15 +639,21 @@ export function RightPanel() {
};
}, [isTagDropdownOpen, isMoreMenuOpen]);
- // Client-side filtering logic (Tags & Saved Today)
+ // Client-side filtering logic (Tags only; date/unread/folder filters are handled by the API)
const allLinks = bookmarks ?? [];
let filteredLinks = selectedTags.length > 0
? allLinks.filter(link => link.tags?.some(tag => selectedTags.includes(tag)))
: allLinks;
-
- if (savedTodayFilter) {
- filteredLinks = filteredLinks.filter(link => isCreatedToday(link.createdAt));
- }
+ const emptyLinksMessage = (() => {
+ if (selectedTags.length > 0) return 'No links match selected tags';
+ if (unreadFilter && savedTodayFilter) return 'No unread links saved today';
+ if (unorganizedFilter && savedTodayFilter) return 'No unorganized links saved today';
+ if (unreadFilter) return 'No unread links';
+ if (savedTodayFilter) return 'No links saved today';
+ if (unorganizedFilter) return 'No unorganized links';
+ if (linkSearchQuery.trim()) return 'No links match your search';
+ return 'No links saved yet';
+ })();
/** 태그 선택/해제 */
const toggleTag = (tag: string) => {
@@ -798,7 +833,7 @@ export function RightPanel() {
{displayTitle}
-
{filteredLinks.length} Links
+
{totalCount} Links
{/* Right: Action Buttons & Search | 우측 액션 버튼 및 검색창 */}
@@ -974,9 +1009,19 @@ export function RightPanel() {
) : (
search_off
-
{selectedTags.length > 0 ? 'No links match selected tags' : 'No links saved yet'}
+
{emptyLinksMessage}
)}
+
+ {/* Infinite Scroll Trigger & Loader | 무한 스크롤 트리거 및 로딩 표시 */}
+
+ {isFetchingNextPage && (
+
+ progress_activity
+ Loading more...
+
+ )}
+
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts
index e2474a4..9d62d40 100644
--- a/frontend/src/lib/api/bookmarkApi.ts
+++ b/frontend/src/lib/api/bookmarkApi.ts
@@ -1,4 +1,4 @@
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient, useInfiniteQuery, type InfiniteData } from '@tanstack/react-query';
import { fetchClient } from './fetchClient';
import type {
BookmarkResponse,
@@ -19,6 +19,9 @@ async function fetchBookmarks(params: BookmarkSearchParams): Promise
(`/api/bookmarks${qs ? `?${qs}` : ''}`);
@@ -85,22 +88,40 @@ async function analyzeUrl(url: string): Promise {
*/
export function useBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) {
return useQuery({
- queryKey: ['bookmarks', params], // 검색 조건이 바뀌면 새로운 쿼리로 취급
+ queryKey: ['bookmarks', params],
queryFn: () => fetchBookmarks(params),
enabled: options?.enabled,
});
}
+/**
+ * [무한 스크롤 조회 Hook] 북마크 목록을 페이징하여 가져옵니다.
+ */
+export function useInfiniteBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) {
+ const limit = params.limit ?? 100;
+ return useInfiniteQuery({
+ queryKey: ['bookmarks', 'infinite', params],
+ queryFn: ({ pageParam = 0 }) => fetchBookmarks({ ...params, limit, offset: pageParam as number }),
+ getNextPageParam: (lastPage, allPages) => {
+ const currentCount = allPages.length * limit;
+ if (currentCount < lastPage.totalCount) {
+ return currentCount;
+ }
+ return undefined;
+ },
+ initialPageParam: 0,
+ enabled: options?.enabled,
+ });
+}
+
/**
* [생성 Hook] 새로운 북마크를 생성합니다.
- * 성공 시 'bookmarks' 키를 가진 모든 캐시를 무효화하여 목록을 갱신합니다.
*/
export function useCreateBookmark() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createBookmark,
onSuccess: () => {
- // 캐시 무효화: 목록 화면이 자동으로 다시 그려짐
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
},
});
diff --git a/frontend/src/lib/types/bookmark.ts b/frontend/src/lib/types/bookmark.ts
index 3b18277..72c7abc 100644
--- a/frontend/src/lib/types/bookmark.ts
+++ b/frontend/src/lib/types/bookmark.ts
@@ -32,6 +32,7 @@ export interface BookmarkResponse {
export interface BookmarkSearchResponse {
bookmarks: BookmarkResponse[];
matchingFolderIds: number[];
+ totalCount: number;
}
// POST /api/bookmarks 요청 바디
@@ -60,4 +61,7 @@ export interface BookmarkSearchParams {
query?: string;
categoryId?: number | null;
unreadOnly?: boolean;
+ savedTodayOnly?: boolean;
+ limit?: number;
+ offset?: number;
}
From db17097a015852727ab6aa1e95ee3e8d0225e72f Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 26 Apr 2026 18:28:29 +0900
Subject: [PATCH 12/16] =?UTF-8?q?fix(bookmark):=20=EA=B2=80=EC=83=89?=
=?UTF-8?q?=EC=96=B4=20escape=20=EB=B0=8F=20MyBatis=20=ED=8C=8C=EB=9D=BC?=
=?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EB=B0=94=EC=9D=B8=EB=94=A9=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BookmarkRequests.java: SearchDto query trim 처리 및 역슬래시, 퍼센트, 언더스코어 escape 적용으로 LIKE 와일드카드 입력에 의한 의도치 않은 검색 범위 확장 방지.
BookmarkRequests.java: unreadOnly와 savedTodayOnly 요청 값을 Boolean.TRUE.equals로 Command에 전달하여 null 요청값의 false 의미 정규화.
BookmarkRequests.java, BookmarkSearchCommand.java: savedTodayOnly, limit, offset 검색 조건 추가 및 limit 기본값/최대값, offset 최소값 정규화로 조회 조건 안정성 확보.
bookmark-mapper.xml: LOWER(display_title) LIKE 조건에 ESCAPE '\' 적용 및 Asia/Seoul 기준 savedTodayOnly 일자 범위 조건 추가로 검색/날짜 필터 정합성 확보.
bookmark-mapper.xml: bookmarkSearchPredicates 공통 SQL 분리 및 selectBookmarkList, selectMatchingFolderIds, countBookmarkList 조건 일관성 확보.
bookmark-mapper.xml: selectBookmarkList를 북마크 row 제한 후 tag join하는 subquery 구조로 변경하여 태그 join으로 인한 limit 왜곡 방지.
BookmarkDao.java: 다중 파라미터 DAO 메서드에 @Param 명시 추가로 MyBatis mapper 파라미터 이름 매핑 안정성 확보.
MybatisBookmarkDao.java, BookmarkServiceImpl.java, BookmarkSearchResponse.java: countBookmarkList 조회 경로와 totalCount 응답 추가로 제한 조회의 전체 개수 제공.
---
.../controller/BookmarkApiController.java | 10 +-
.../controller/dto/BookmarkRequests.java | 88 ++++++++++++------
.../dto/BookmarkSearchResponse.java | 3 +
.../SearchWeb/bookmark/dao/BookmarkDao.java | 22 +++--
.../bookmark/dao/MybatisBookmarkDao.java | 5 +
.../bookmark/service/BookmarkServiceImpl.java | 2 +
.../command/BookmarkSearchCommand.java | 5 +
.../mypage/controller/MyPageController.java | 10 +-
src/main/resources/mapper/bookmark-mapper.xml | 93 ++++++++++++-------
9 files changed, 157 insertions(+), 81 deletions(-)
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
index 833261c..45f4b9d 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
@@ -92,11 +92,11 @@ public ResponseEntity> updateBookmark(
Long updatedBookmarkId = bookmarkService.updateBookmark(
memberId,
bookmarkId,
- request.memberFolderId,
- request.displayTitle,
- request.note,
- request.primaryCategoryId,
- request.tags
+ request.getMemberFolderId(),
+ request.getDisplayTitle(),
+ request.getNote(),
+ request.getPrimaryCategoryId(),
+ request.getTags()
);
return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId));
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
index ad02a33..7a82f9e 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
@@ -3,8 +3,9 @@
import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand;
import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
/**
* Bookmark Controller 전용 Request DTO
@@ -26,45 +27,74 @@ public static class CreateDto {
public String tags; // 태그 문자열 (공백/콤마 구분)
}
-
- /**
- * 북마크 목록 조회 요청 Dto (Search Params)
- */
- @Data
+ @Getter
+ @Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchDto {
- public Long folderId;
- public String sort = "Newest"; // 기본값 설정
- public String query;
- public Long categoryId;
- public Boolean unreadOnly;
+ private static final int DEFAULT_LIMIT = 100;
+ private static final int MAX_LIMIT = 200;
+
+ private Long folderId;
+ private String sort = "Newest";
+ private String query;
+ private Long categoryId;
+ private Boolean unreadOnly;
+ private Boolean savedTodayOnly;
+ private Integer limit;
+ private Integer offset;
public BookmarkSearchCommand toCommand(Long memberId) {
return BookmarkSearchCommand.builder()
- .memberId(memberId)
- .folderId(this.folderId)
- .sort(this.sort)
- .query(this.query)
- .categoryId(this.categoryId)
- .unreadOnly(this.unreadOnly)
- .build();
+ .memberId(memberId)
+ .folderId(this.folderId)
+ .sort(this.sort)
+ .query(normalizeQuery(this.query))
+ .categoryId(this.categoryId)
+ .unreadOnly(Boolean.TRUE.equals(this.unreadOnly))
+ .savedTodayOnly(Boolean.TRUE.equals(this.savedTodayOnly))
+ .limit(normalizeLimit(this.limit))
+ .offset(normalizeOffset(this.offset))
+ .build();
}
- }
+ private static int normalizeLimit(Integer limit) {
+ if (limit == null) {
+ return DEFAULT_LIMIT;
+ }
+ return Math.max(1, Math.min(limit, MAX_LIMIT));
+ }
- /**
- * 북마크 수정 요청 Dto
- */
- @Data
+ private static int normalizeOffset(Integer offset) {
+ if (offset == null) {
+ return 0;
+ }
+ return Math.max(0, offset);
+ }
+
+ private static String normalizeQuery(String query) {
+ if (query == null) {
+ return null;
+ }
+ String trimmed = query.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ return trimmed
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_");
+ }
+ }
+
+ @Getter
@NoArgsConstructor
@AllArgsConstructor
public static class UpdateDto {
- public Long memberFolderId; // 폴더 ID
- public String displayTitle; // 표시 제목
- public String note; // 메모
- public Long primaryCategoryId; // 카테고리 ID
- public String tags; // 태그 문자열 (공백/콤마 구분)
+ private Long memberFolderId;
+ private String displayTitle;
+ private String note;
+ private Long primaryCategoryId;
+ private String tags;
}
-
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
index 4ad8160..1958473 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
@@ -22,4 +22,7 @@ public class BookmarkSearchResponse {
// 검색 조건(쿼리)에 매칭되는 링크가 하나라도 포함된 폴더 ID 목록
private List matchingFolderIds;
+
+ // 전체 검색 결과 개수
+ private int totalCount;
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
index e75ea8a..c23aa17 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
@@ -4,6 +4,7 @@
import com.web.SearchWeb.bookmark.domain.Link;
import com.web.SearchWeb.bookmark.dto.MemberTagResultDto;
import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand;
+import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -12,7 +13,7 @@ public interface BookmarkDao {
int insertBookmark(Bookmark bookmark);
//북마크 단일 조회
- Bookmark selectBookmark(Long memberId, Long bookmarkId);
+ Bookmark selectBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId);
//북마크 목록 조회
List selectBookmarkList(BookmarkSearchCommand searchCommand);
@@ -20,23 +21,26 @@ public interface BookmarkDao {
// 검색 조건에 매칭되는 북마크들이 속한 고유 폴더 ID 목록 조회
List selectMatchingFolderIds(BookmarkSearchCommand searchCommand);
+ // 검색 조건에 맞는 전체 북마크 개수 조회
+ int countBookmarkList(BookmarkSearchCommand searchCommand);
+
//북마크 수정
int updateBookmark(Bookmark bookmark);
//북마크 삭제
- int deleteBookmark(Long memberId, Long bookmarkId);
+ int deleteBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId);
//북마크 조회수 증가 (읽음 처리)
- int incrementViewCount(Long bookmarkId, Long memberId);
+ int incrementViewCount(@Param("bookmarkId") Long bookmarkId, @Param("memberId") Long memberId);
//북마크 태그 연결 삭제
- int deleteBookmarkTags(Long bookmarkId, Long memberId);
+ int deleteBookmarkTags(@Param("bookmarkId") Long bookmarkId, @Param("memberId") Long memberId);
//북마크 삭제 (Link ID 기반 - soft delete)
- int deleteBookmarkByLink(Long memberId, Long linkId);
+ int deleteBookmarkByLink(@Param("memberId") Long memberId, @Param("linkId") Long linkId);
//북마크 링크 중복 확인 (동일 폴더에 동일 링크)
- int checkBookmarkExists(Long memberId, Long folderId, Long linkId);
+ int checkBookmarkExists(@Param("memberId") Long memberId, @Param("folderId") Long folderId, @Param("linkId") Long linkId);
//상세 링크 조회 (정규화된 URL 기반)
Link selectLinkByCanonicalUrl(String url);
@@ -45,13 +49,13 @@ public interface BookmarkDao {
int insertLink(Link link);
//URL 기반 북마크 존재 여부 확인 (Board Bridge용)
- int checkBookmarkExistsByUrl(Long memberId, String url);
+ int checkBookmarkExistsByUrl(@Param("memberId") Long memberId, @Param("url") String url);
// 태그 등록 및 조회 (Insert & Select)
- List insertAndSelectTags(Long memberId, List tagNames);
+ List insertAndSelectTags(@Param("memberId") Long memberId, @Param("tagNames") List tagNames);
// 북마크-태그 연결 일괄 추가 (Bulk Insert)
- int insertBookmarkTags(Long bookmarkId, List tagIds);
+ int insertBookmarkTags(@Param("bookmarkId") Long bookmarkId, @Param("tagIds") List tagIds);
// 폴더 내 활성 북마크 존재 여부
boolean existsActiveBookmarkInFolder(Long memberFolderId);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
index da97722..93d08a2 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
@@ -53,6 +53,11 @@ public List selectMatchingFolderIds(BookmarkSearchCommand searchCommand) {
return mapper.selectMatchingFolderIds(searchCommand);
}
+ @Override
+ public int countBookmarkList(BookmarkSearchCommand searchCommand) {
+ return mapper.countBookmarkList(searchCommand);
+ }
+
/**
* 북마크 수정
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
index d91f2d1..32c8001 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
@@ -107,10 +107,12 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) {
public BookmarkSearchResponse selectBookmarkList(BookmarkSearchCommand command) {
List bookmarks = bookmarkDao.selectBookmarkList(command);
List matchingFolderIds = bookmarkDao.selectMatchingFolderIds(command);
+ int totalCount = bookmarkDao.countBookmarkList(command);
return BookmarkSearchResponse.builder()
.bookmarks(bookmarks)
.matchingFolderIds(matchingFolderIds)
+ .totalCount(totalCount)
.build();
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
index c2b8dee..e552835 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
@@ -19,4 +19,9 @@ public class BookmarkSearchCommand {
private String query; // 검색어 (display_title 검색)
private Long categoryId; // primary_category_id로 필터 (태그 대체)
private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회
+ private Boolean savedTodayOnly; // true이면 오늘 생성된 북마크만 조회
+ @Builder.Default
+ private Integer limit = 100; // list query limit
+ @Builder.Default
+ private Integer offset = 0; // list query offset
}
\ No newline at end of file
diff --git a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
index daaa076..c4a0f7b 100644
--- a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
+++ b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
@@ -133,11 +133,11 @@ public ResponseEntity> updateBookmark(
Long updatedBookmarkId = bookmarkService.updateBookmark(
memberId,
bookmarkId,
- request.memberFolderId,
- request.displayTitle,
- request.note,
- request.primaryCategoryId,
- request.tags
+ request.getMemberFolderId(),
+ request.getDisplayTitle(),
+ request.getNote(),
+ request.getPrimaryCategoryId(),
+ request.getTags()
);
return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId));
}
diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml
index 388a006..bc02e71 100644
--- a/src/main/resources/mapper/bookmark-mapper.xml
+++ b/src/main/resources/mapper/bookmark-mapper.xml
@@ -90,7 +90,27 @@
SELECT msl.*,
l.original_url, l.canonical_url, l.domain, l.thumbnail_url,
mt.tag_name
- FROM member_saved_link msl
+ FROM (
+ SELECT msl.*
+ FROM member_saved_link msl
+ JOIN link l
+ ON msl.link_id = l.link_id
+
+ ORDER BY
+
+
+ msl.updated_at DESC, msl.member_saved_link_id DESC
+
+
+ msl.updated_at ASC, msl.member_saved_link_id ASC
+
+
+ msl.updated_at DESC, msl.member_saved_link_id DESC
+
+
+ LIMIT #{limit, jdbcType=INTEGER}
+ OFFSET #{offset, jdbcType=INTEGER}
+ ) msl
JOIN link l
ON msl.link_id = l.link_id
LEFT JOIN member_saved_link_tag mslt
@@ -99,32 +119,19 @@
LEFT JOIN member_tag mt
ON mslt.member_tag_id = mt.member_tag_id
AND mt.deleted_at IS NULL
- WHERE msl.created_by_member_id = #{memberId}
- AND msl.deleted_at IS NULL
-
- AND msl.member_folder_id = #{folderId}
-
-
- AND msl.primary_category_id = #{categoryId}
-
-
- AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
-
-
- AND msl.view_count = 0
-
ORDER BY
- msl.updated_at DESC
+ msl.updated_at DESC, msl.member_saved_link_id DESC
- msl.updated_at ASC
+ msl.updated_at ASC, msl.member_saved_link_id ASC
- msl.updated_at DESC
+ msl.updated_at DESC, msl.member_saved_link_id DESC
-
+ ,
+ mt.tag_name ASC
@@ -134,21 +141,41 @@
FROM member_saved_link msl
JOIN link l
ON msl.link_id = l.link_id
- WHERE msl.created_by_member_id = #{memberId}
- AND msl.deleted_at IS NULL
-
- AND msl.member_folder_id = #{folderId}
-
-
- AND msl.primary_category_id = #{categoryId}
-
-
- AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
-
-
- AND msl.view_count = 0
-
+
+
+
+
+ SELECT COUNT(*)
+ FROM member_saved_link msl
+ JOIN link l
+ ON msl.link_id = l.link_id
+
+
+
+
+
+
+ AND msl.created_by_member_id = #{memberId}
+ AND msl.deleted_at IS NULL
+
+ AND msl.member_folder_id = #{folderId}
+
+
+ AND msl.primary_category_id = #{categoryId}
+
+
+ AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' ESCAPE '\'
+
+
+ AND msl.view_count = 0
+
+
+ AND msl.created_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul')
+ AND msl.created_at < ((date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') + INTERVAL '1 day') AT TIME ZONE 'Asia/Seoul')
+
+
+
From d811abe19a90196d8b2d879780832e0fedb6cc3f Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 26 Apr 2026 18:29:12 +0900
Subject: [PATCH 13/16] =?UTF-8?q?refactor(bookmark):=20Request=20DTO=20pub?=
=?UTF-8?q?lic=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BookmarkRequests.java: CreateDto public 필드를 private 필드와 @Getter, @NoArgsConstructor, @AllArgsConstructor 기반 구조로 변경하여 요청 DTO 직접 필드 접근 제거.
BookmarkRequests.java: UpdateDto public 필드를 private 필드와 getter-only 구조로 변경하여 setter 노출 범위 축소.
BookmarkRequests.java: SearchDto는 @ModelAttribute query parameter 바인딩 안정성을 위해 @Getter/@Setter 유지 및 요청 DTO별 setter 노출 범위 분리.
BookmarkApiController.java: 북마크 생성 요청 처리에서 request 필드 직접 접근을 getter 호출로 변경하여 DTO 캡슐화 규칙 준수.
---
.../controller/BookmarkApiController.java | 12 ++++-----
.../controller/dto/BookmarkRequests.java | 27 +++++++++----------
2 files changed, 19 insertions(+), 20 deletions(-)
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
index 45f4b9d..a5593c0 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
@@ -40,12 +40,12 @@ public ResponseEntity> insertBookmark(
Long bookmarkId = bookmarkService.insertBookmark(
memberId,
- request.url,
- request.memberFolderId,
- request.displayTitle,
- request.note,
- request.primaryCategoryId,
- request.tags
+ request.getUrl(),
+ request.getMemberFolderId(),
+ request.getDisplayTitle(),
+ request.getNote(),
+ request.getPrimaryCategoryId(),
+ request.getTags()
);
return ResponseEntity
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
index 7a82f9e..aec5813 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
@@ -8,23 +8,22 @@
import lombok.Setter;
/**
- * Bookmark Controller 전용 Request DTO
- * - Controller에서만 사용하며, Service 계층에는 개별 파라미터로 전달
+ * Bookmark controller request DTOs.
*/
public class BookmarkRequests {
- /**
- * 북마크 생성 요청 Dto
- */
- public static class CreateDto {
- public Long bookmarkId; // 북마크 ID (PK, Insert 시 생성된 키 저장용)
- public Long memberFolderId; // 폴더 ID (null이면 기본 폴더)
- public String displayTitle; // 표시 제목
- public String url; // 저장할 URL
- public String note; // 메모
- public Long primaryCategoryId; // 카테고리 ID
- public Long createdByMemberId; // 저장한 회원
- public String tags; // 태그 문자열 (공백/콤마 구분)
+ @Getter
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class CreateDto {
+ private Long bookmarkId;
+ private Long memberFolderId;
+ private String displayTitle;
+ private String url;
+ private String note;
+ private Long primaryCategoryId;
+ private Long createdByMemberId;
+ private String tags;
}
@Getter
From c8a0f3b61c85d89303ec4422e058efd33c647e47 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Sun, 26 Apr 2026 18:31:43 +0900
Subject: [PATCH 14/16] =?UTF-8?q?fix(frontend):=20=EC=9D=BD=EC=9D=8C=20?=
=?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20=ED=95=84=EC=9A=94=ED=95=9C=20?=
=?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=BA=90=EC=8B=9C=EB=A7=8C=20?=
=?UTF-8?q?=EA=B0=B1=EC=8B=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
bookmarkApi.ts: useRecordBookmarkView 성공 시 전체 ['bookmarks'] 쿼리 무조건 invalidate 제거 및 캐시 내 대상 bookmark만 로컬 갱신.
bookmarkApi.ts: 일반 북마크 목록 캐시는 viewCount + 1, lastViewedAt 현재 시간 갱신으로 서버의 누적 조회수 정책과 프론트 캐시 의미 일치.
bookmarkApi.ts: unreadOnly 쿼리는 읽은 bookmark를 캐시에서 제거하고 totalCount를 감소시킨 뒤 unread 쿼리만 좁게 invalidate하여 불필요한 전체 재조회 방지.
bookmarkApi.ts: 일반 응답과 useInfiniteQuery 응답을 모두 처리하는 cache data 타입 가드 추가로 기존 목록 조회와 무한 스크롤 조회의 읽음 상태 갱신 지원.
---
frontend/src/lib/api/bookmarkApi.ts | 76 +++++++++++++++++++++++++++--
1 file changed, 73 insertions(+), 3 deletions(-)
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts
index 9d62d40..bb86590 100644
--- a/frontend/src/lib/api/bookmarkApi.ts
+++ b/frontend/src/lib/api/bookmarkApi.ts
@@ -70,6 +70,68 @@ async function recordBookmarkView(bookmarkId: number): Promise {
});
}
+function queryKeyHasUnreadOnly(queryKey: readonly unknown[]): boolean {
+ return queryKey.some((part) => {
+ if (!part || typeof part !== 'object' || Array.isArray(part)) return false;
+ return (part as { unreadOnly?: unknown }).unreadOnly === true;
+ });
+}
+
+function updateReadStateInResponse(
+ data: BookmarkSearchResponse,
+ bookmarkId: number,
+ removeFromUnreadQuery: boolean,
+): BookmarkSearchResponse {
+ let changed = false;
+ let removed = false;
+ const now = new Date().toISOString();
+ const bookmarks = data.bookmarks.flatMap((bookmark) => {
+ if (bookmark.bookmarkId !== bookmarkId) return [bookmark];
+ changed = true;
+ if (removeFromUnreadQuery) {
+ removed = true;
+ return [];
+ }
+ return [{
+ ...bookmark,
+ viewCount: (bookmark.viewCount ?? 0) + 1,
+ lastViewedAt: now,
+ }];
+ });
+
+ if (!changed) return data;
+ return {
+ ...data,
+ bookmarks,
+ totalCount: removed ? Math.max(data.totalCount - 1, 0) : data.totalCount,
+ };
+}
+
+function isInfiniteBookmarkData(data: unknown): data is InfiniteData {
+ return Boolean(data && typeof data === 'object' && Array.isArray((data as { pages?: unknown }).pages));
+}
+
+function isBookmarkSearchResponse(data: unknown): data is BookmarkSearchResponse {
+ return Boolean(data && typeof data === 'object' && Array.isArray((data as { bookmarks?: unknown }).bookmarks));
+}
+
+function updateReadStateInCacheData(
+ data: unknown,
+ bookmarkId: number,
+ removeFromUnreadQuery: boolean,
+): unknown {
+ if (isInfiniteBookmarkData(data)) {
+ return {
+ ...data,
+ pages: data.pages.map((page) => updateReadStateInResponse(page, bookmarkId, removeFromUnreadQuery)),
+ };
+ }
+ if (isBookmarkSearchResponse(data)) {
+ return updateReadStateInResponse(data, bookmarkId, removeFromUnreadQuery);
+ }
+ return data;
+}
+
/**
* URL 분석 (GET /api/bookmarks/analyze)
* @param url 분석할 URL
@@ -155,14 +217,22 @@ export function useDeleteBookmark() {
/**
* [조회 기록 Hook] 북마크 열람 시 view_count 증가 및 읽음 처리.
- * 성공 시 'bookmarks' 캐시를 무효화하여 Unread 필터 목록 등이 자동 갱신됩니다.
*/
export function useRecordBookmarkView() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: recordBookmarkView,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
+ onSuccess: (_, bookmarkId) => {
+ const bookmarkQueries = queryClient.getQueryCache().findAll({ queryKey: ['bookmarks'] });
+ bookmarkQueries.forEach((query) => {
+ const removeFromUnreadQuery = queryKeyHasUnreadOnly(query.queryKey);
+ queryClient.setQueryData(query.queryKey, (oldData) =>
+ updateReadStateInCacheData(oldData, bookmarkId, removeFromUnreadQuery)
+ );
+ });
+ queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === 'bookmarks' && queryKeyHasUnreadOnly(query.queryKey),
+ });
},
});
}
From f8e35456939e4b8528af4709f8802433000c1327 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Mon, 27 Apr 2026 20:30:53 +0900
Subject: [PATCH 15/16] =?UTF-8?q?feat(backend):=20=EB=B6=81=EB=A7=88?=
=?UTF-8?q?=ED=81=AC=20=EA=B2=80=EC=83=89=20=ED=95=84=ED=84=B0=20=EA=B3=A0?=
=?UTF-8?q?=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?=
=?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BookmarkSearchCommand: 검색 기능 확장: unreadOnly(미열람), savedTodayOnly(당일 저장) 필터 속성 추가 및 조회 성능 최적화를 위한 필드 타입 조정.
bookmark-mapper.xml: 쿼리 동적 처리: MyBatis의 태그를 사용하여 unreadOnly 및 savedTodayOnly 조건부 검색 로직 구현 및 ID 기반 단건 조회(findById) 쿼리 추가.
BookmarkServiceImpl: 예외 처리 정밀화: 북마크 조회/수정/삭제 실패 시 validateBookmarkOwner 메서드를 통해 '존재하지 않음(404)'과 '권한 없음(403)'을 명확히 구분하여 응답 정확도 확보.
BookmarkServiceImpl: 서비스 연동 강화: 북마크 생성 시 폴더 미지정 상태일 경우 MemberFolderService를 통해 사용자의 '미분류' 폴더를 자동 조회하거나 생성하는 로직 추가.
BookmarkApiController: API 인터페이스 최적화: recordView(읽음 처리) 호출 시 증가된 조회수와 마지막 열람 시간이 반영된 최신 북마크 객체를 반환하도록 수정.
BookmarkErrorCode: 에러 코드 추가: 접근 권한 위반 시 사용될 ACCESS_DENIED(403) 코드 정의.
---
.../controller/BookmarkApiController.java | 6 +--
.../SearchWeb/bookmark/dao/BookmarkDao.java | 3 ++
.../bookmark/dao/MybatisBookmarkDao.java | 8 ++++
.../bookmark/error/BookmarkErrorCode.java | 3 +-
.../bookmark/service/BookmarkService.java | 2 +-
.../bookmark/service/BookmarkServiceImpl.java | 36 +++++++++++++---
.../command/BookmarkSearchCommand.java | 9 ++--
.../folder/dao/MemberFolderJpaDao.java | 4 +-
.../SearchWeb/folder/domain/MemberFolder.java | 1 +
.../service/MemberFolderServiceImpl.java | 41 ++++++++++++-------
src/main/resources/mapper/bookmark-mapper.xml | 16 +++++---
11 files changed, 95 insertions(+), 34 deletions(-)
diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
index a5593c0..1fcef4c 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
@@ -119,12 +119,12 @@ public ResponseEntity> deleteBookmark(
* 북마크 조회 기록
*/
@PatchMapping("/{bookmarkId}/read")
- public ResponseEntity> recordView(
+ public ResponseEntity> recordView(
@CurrentMemberId Long memberId,
@PathVariable Long bookmarkId) {
- bookmarkService.recordView(memberId, bookmarkId);
- return ResponseEntity.ok(ApiResponse.success(null));
+ Bookmark updatedBookmark = bookmarkService.recordView(memberId, bookmarkId);
+ return ResponseEntity.ok(ApiResponse.success(updatedBookmark));
}
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
index c23aa17..7dd161d 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
@@ -14,6 +14,9 @@ public interface BookmarkDao {
//북마크 단일 조회
Bookmark selectBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId);
+
+ //북마크 기본 조회 (ID 기반, 권한 체크용)
+ Bookmark findById(@Param("bookmarkId") Long bookmarkId);
//북마크 목록 조회
List selectBookmarkList(BookmarkSearchCommand searchCommand);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
index 93d08a2..31ac004 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
@@ -38,6 +38,14 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) {
return mapper.selectBookmark(memberId, bookmarkId);
}
+ /**
+ * 북마크 기본 조회 (ID 기반, 권한 체크용)
+ */
+ @Override
+ public Bookmark findById(Long bookmarkId) {
+ return mapper.findById(bookmarkId);
+ }
+
@Override
public List selectBookmarkList(BookmarkSearchCommand searchCommand) {
diff --git a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java
index 360ea47..3be2591 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java
@@ -9,7 +9,8 @@
@RequiredArgsConstructor
public enum BookmarkErrorCode implements ErrorCode {
DUPLICATE_BOOKMARK(HttpStatus.CONFLICT, "B001", "이미 존재하는 북마크입니다."),
- BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다.");
+ BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."),
+ ACCESS_DENIED(HttpStatus.FORBIDDEN, "B003", "해당 북마크에 대한 권한이 없습니다.");
private final HttpStatus status;
private final String code;
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
index 9872bd1..4e9c6df 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
@@ -29,7 +29,7 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String
Long deleteBookmark(Long memberId, Long bookmarkId);
//북마크 조회 기록
- void recordView(Long memberId, Long bookmarkId);
+ Bookmark recordView(Long memberId, Long bookmarkId);
// 링크 조회 또는 생성 (URL 정규화)
Link getOrCreateLink(String url, Long createdByMemberId);
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
index 32c8001..5113ad9 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
@@ -18,6 +18,7 @@
import com.web.SearchWeb.config.exception.CommonErrorCode;
import com.web.SearchWeb.bookmark.dto.MemberTagResultDto;
+import com.web.SearchWeb.folder.service.MemberFolderService;
import com.web.SearchWeb.linkanalysis.service.LinkMetadataExtractor;
import java.net.URI;
import java.util.ArrayList;
@@ -33,11 +34,13 @@ public class BookmarkServiceImpl implements BookmarkService {
private final BookmarkDao bookmarkDao;
private final LinkMetadataExtractor linkMetadataExtractor;
+ private final MemberFolderService memberFolderService;
@Autowired
- public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor) {
+ public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor, MemberFolderService memberFolderService) {
this.bookmarkDao = bookmarkDao;
this.linkMetadataExtractor = linkMetadataExtractor;
+ this.memberFolderService = memberFolderService;
}
@@ -51,10 +54,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin
// 링크 조회 또는 생성
Link link = getOrCreateLink(url, memberId);
- // TODO: 링크 분석 및 폴더 서비스 완성 후 제거 - 임시 기본 폴더 ID 설정
+ // 폴더 미지정 시 사용자의 미분류(UNORGANIZED) 폴더 ID를 조회하거나 생성
if (memberFolderId == null) {
- memberFolderId = 1L; // 임시 하드코딩 값
- log.warn("memberFolderId가 null이어서 임시 기본값(1)을 사용합니다. 링크 분석 및 폴더 서비스 연동 후 제거 필요.");
+ memberFolderId = memberFolderService.getOrCreateUnorganizedFolderId(memberId);
}
// Entity 생성
@@ -94,6 +96,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin
public Bookmark selectBookmark(Long memberId, Long bookmarkId) {
Bookmark bookmark = bookmarkDao.selectBookmark(memberId, bookmarkId);
if (bookmark == null) {
+ // 상세 조회를 통한 원인 파악 (404 vs 403)
+ validateBookmarkOwner(bookmarkId, memberId);
+ // 위에서 예외가 발생하지 않았다면 (그럴 리 없지만) 기본 NotFound 처리
throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
}
return bookmark;
@@ -139,6 +144,8 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId,
int result = bookmarkDao.updateBookmark(bookmark);
if (result == 0) {
+ // 수정 실패 시 원인 파악 (404 vs 403)
+ validateBookmarkOwner(bookmarkId, memberId);
throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
}
@@ -166,11 +173,14 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId,
*/
@Override
@Transactional
- public void recordView(Long memberId, Long bookmarkId) {
+ public Bookmark recordView(Long memberId, Long bookmarkId) {
int affected = bookmarkDao.incrementViewCount(bookmarkId, memberId);
if (affected == 0) {
+ // 업데이트 실패 시 원인 파악 (404 vs 403)
+ validateBookmarkOwner(bookmarkId, memberId);
throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
}
+ return bookmarkDao.selectBookmark(memberId, bookmarkId);
}
@@ -184,6 +194,8 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) {
int result = bookmarkDao.deleteBookmark(memberId, bookmarkId);
if (result == 0) {
+ // 삭제 실패 시 원인 파악 (404 vs 403)
+ validateBookmarkOwner(bookmarkId, memberId);
throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
}
@@ -196,6 +208,20 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) {
+ /**
+ * 북마크 소유권 및 존재 여부 검증 (에러 구분용)
+ */
+ private void validateBookmarkOwner(Long bookmarkId, Long memberId) {
+ Bookmark bookmark = bookmarkDao.findById(bookmarkId);
+ if (bookmark == null || bookmark.getDeletedAt() != null) {
+ throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND);
+ }
+ if (!bookmark.getCreatedByMemberId().equals(memberId)) {
+ throw BookmarkException.of(BookmarkErrorCode.ACCESS_DENIED);
+ }
+ }
+
+
// ========== Helper Methods ==========
/**
* 링크 조회 또는 생성 (URL 정규화)
diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
index e552835..a53748d 100644
--- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
+++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
@@ -1,5 +1,6 @@
package com.web.SearchWeb.bookmark.service.command;
+import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -9,8 +10,8 @@
* Bookmark 검색 요청 Command
*/
@Getter
-@NoArgsConstructor
-@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class BookmarkSearchCommand {
private Long memberId; // created_by_member_id로 필터
@@ -21,7 +22,7 @@ public class BookmarkSearchCommand {
private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회
private Boolean savedTodayOnly; // true이면 오늘 생성된 북마크만 조회
@Builder.Default
- private Integer limit = 100; // list query limit
+ private Integer limit = 100; // 최대 조회 개수 (Limit)
@Builder.Default
- private Integer offset = 0; // list query offset
+ private Integer offset = 0; // 조회 시작 위치 (Offset)
}
\ No newline at end of file
diff --git a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
index dc3310c..2a88282 100644
--- a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
+++ b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
@@ -23,9 +23,11 @@ public interface MemberFolderJpaDao extends JpaRepository {
boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName);
+ boolean existsByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType);
+
// 시스템 폴더(UNORGANIZED) 조회 — 한 사용자당 최대 1개 (partial unique index 로 보장)
Optional findFirstByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType);
// 루트에 같은 이름 폴더가 이미 있는지 조회 (흡수(absorb) 처리에 사용)
- Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String folderName);
+ Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName);
}
diff --git a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
index 01043d1..0604400 100644
--- a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
+++ b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
@@ -48,6 +48,7 @@ public boolean isUnorganized() {
/** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */
public void markAsUnorganized() {
+ if (isUnorganized()) return;
this.folderType = FolderType.UNORGANIZED;
}
diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
index 083c1fd..bae1262 100644
--- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
+++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
@@ -11,7 +11,10 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@@ -23,6 +26,12 @@ public class MemberFolderServiceImpl implements MemberFolderService {
private final MemberFolderJpaDao memberFolderJpaRepository;
private final BookmarkDao bookmarkDao;
+ private MemberFolderService self;
+
+ @Autowired
+ public void setSelf(@Lazy MemberFolderService self) {
+ this.self = self;
+ }
@Override
@Transactional
@@ -104,11 +113,13 @@ public MemberFolder get(Long memberId, Long memberFolderId) {
}
@Override
- @Transactional
+ @Transactional(readOnly = true)
public List listRootFolders(Long memberId, Long ownerMemberId) {
validateOwner(memberId, ownerMemberId);
// 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성
- getOrCreateUnorganizedFolderId(ownerMemberId);
+ if (!memberFolderJpaRepository.existsByOwnerMemberIdAndFolderType(ownerMemberId, FolderType.UNORGANIZED)) {
+ self.getOrCreateUnorganizedFolderId(ownerMemberId);
+ }
return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId);
}
@@ -221,7 +232,7 @@ public void delete(Long memberId, Long memberFolderId) {
}
@Override
- @Transactional
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
public Long getOrCreateUnorganizedFolderId(Long memberId) {
// 1. 이미 시스템 폴더가 존재하면 해당 ID 반환 (idempotent)
Optional existing = memberFolderJpaRepository
@@ -230,25 +241,27 @@ public Long getOrCreateUnorganizedFolderId(Long memberId) {
return existing.get().getMemberFolderId();
}
- // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환
- Optional sameName = memberFolderJpaRepository
- .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME);
- if (sameName.isPresent()) {
- MemberFolder existingFolder = sameName.get();
- existingFolder.markAsUnorganized();
- return memberFolderJpaRepository.save(existingFolder).getMemberFolderId();
- }
-
- // 3. 신규 생성 — 동시 호출은 partial unique index 로 DB 에서 차단, catch 후 재조회
+ // 2 & 3. 동시 호출은 partial unique index 로 DB 에서 차단됨 -> catch 후 재조회
try {
+ // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환
+ Optional sameName = memberFolderJpaRepository
+ .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME);
+ if (sameName.isPresent()) {
+ MemberFolder existingFolder = sameName.get();
+ existingFolder.markAsUnorganized();
+ return memberFolderJpaRepository.save(existingFolder).getMemberFolderId();
+ }
+
+ // 3. 신규 생성
MemberFolder newFolder = MemberFolder.builder()
.ownerMemberId(memberId)
.folderName(UNORGANIZED_FOLDER_NAME)
.folderType(FolderType.UNORGANIZED)
.build();
return memberFolderJpaRepository.save(newFolder).getMemberFolderId();
+
} catch (DataIntegrityViolationException e) {
- // 동시 요청으로 인한 중복 생성 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환
+ // 동시 요청으로 인한 중복 생성/전환 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환
return memberFolderJpaRepository
.findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED)
.orElseThrow(() -> new FolderException(FolderErrorCode.DEFAULT_FOLDER_NAME_CONFLICT))
diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml
index bc02e71..5fa1092 100644
--- a/src/main/resources/mapper/bookmark-mapper.xml
+++ b/src/main/resources/mapper/bookmark-mapper.xml
@@ -85,6 +85,14 @@
+
+
+ SELECT *
+ FROM member_saved_link
+ WHERE member_saved_link_id = #{bookmarkId}
+
+
+
SELECT msl.*,
@@ -167,10 +175,10 @@
AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' ESCAPE '\'
-
+
AND msl.view_count = 0
-
+
AND msl.created_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul')
AND msl.created_at < ((date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') + INTERVAL '1 day') AT TIME ZONE 'Asia/Seoul')
@@ -182,9 +190,7 @@
UPDATE member_saved_link
SET view_count = view_count + 1,
- last_viewed_at = now(),
- updated_at = now(),
- updated_by_member_id = #{memberId}
+ last_viewed_at = now()
WHERE member_saved_link_id = #{bookmarkId}
AND created_by_member_id = #{memberId}
AND deleted_at IS NULL
From 625c67330e4ccc3b2ccfc00e66392697ca602d94 Mon Sep 17 00:00:00 2001
From: jin2304
Date: Mon, 27 Apr 2026 20:31:00 +0900
Subject: [PATCH 16/16] =?UTF-8?q?feat(frontend):=20=EC=8B=A4=EC=8B=9C?=
=?UTF-8?q?=EA=B0=84=20=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=83=89=20UI=20=EC=82=AC=EC=9A=A9?=
=?UTF-8?q?=EC=9E=90=20=EA=B2=BD=ED=97=98=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
bookmarkApi.ts: 캐시 동기화 최적화: recordBookmarkView 실행 시 서버에서 반환한 최신 데이터를 React Query 캐시에 즉시 반영하여 클라이언트와 서버 간 상태 무결성 확보.
folderStore.ts: 상태 관리 구조 개선: 중복되는 필터 토글 로직을 공통 헬퍼 함수로 통합하여 코드 중복 제거 및 검색 범위(SearchScope) 타입 정의를 통한 타입 안정성 강화.
folderUtils.ts: 로직 모듈화: 폴더 정렬 및 미분류 폴더 최상단 고정 로직을 공통 유틸리티로 분리하여 재사용성 확보.
RightPanel.tsx: 피드백 메시지 고도화: 활성화된 필터(미분류, 오늘 저장 등) 조합에 따라 검색 결과가 없을 때 표시되는 문구를 동적으로 생성하여 직관적인 UI 제공.
page.tsx: 레이스 컨디션 해결: 폴더 목록 로딩 완료 전 미분류 폴더 ID를 참조하여 발생하는 쿼리 오류 방지를 위한 enabled 옵션 조건 제어.
SaveLinkDialog.tsx: 데이터 바인딩 안정화: 링크 저장 과정에서 폴더 선택 상태가 비정상적으로 초기화되거나 누락되는 현상 방지를 위한 로직 보완.
---
frontend/src/app/my-links/page.tsx | 174 +++++++++++-------
.../src/components/dialogs/SaveLinkDialog.tsx | 11 +-
.../src/components/my-links/FolderCard.tsx | 16 +-
.../src/components/my-links/RightPanel.tsx | 38 +++-
frontend/src/lib/api/bookmarkApi.ts | 28 ++-
frontend/src/lib/store/folderStore.ts | 78 ++++----
frontend/src/lib/utils/folderUtils.ts | 15 ++
7 files changed, 216 insertions(+), 144 deletions(-)
create mode 100644 frontend/src/lib/utils/folderUtils.ts
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index c8d8436..2fa9482 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -4,16 +4,15 @@ import { FolderCard } from '@/components/my-links/FolderCard';
import { RightPanel } from '@/components/my-links/RightPanel';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
-import { useFolderStore } from '@/lib/store/folderStore';
+import { useFolderStore, type SearchScope } from '@/lib/store/folderStore';
import { useAuthStore } from '@/lib/store/authStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import { useBookmarks } from '@/lib/api/bookmarkApi';
import { useLinkStore } from '@/lib/store/linkStore';
-import { useState, useEffect, useRef } from 'react';
-import { cn } from '@/lib/utils';
import { FOLDER_TYPE } from '@/lib/types/folder';
-
-const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
+import { useState, useEffect, useMemo } from 'react';
+import { cn } from '@/lib/utils';
+import { compareFolders } from '@/lib/utils/folderUtils';
export default function MyLinksPage() {
const { toggleRightPanel } = useUIStore();
@@ -31,6 +30,25 @@ export default function MyLinksPage() {
const toggleUnorganizedFilter = useFolderStore((s) => s.toggleUnorganizedFilter); // 미분류 필터 토글 함수
const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
+
+ // 검색어 디바운스 처리를 위한 로컬 상태
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+
+ // 외부(스토어)에서 검색어가 직접 변경될 경우(예: 초기화 버튼) 로컬 상태와 동기화
+ useEffect(() => {
+ setLocalSearchQuery(searchQuery);
+ }, [searchQuery]);
+
+ // 디바운스 로직: localSearchQuery가 변경되면 250ms 후에 스토어 업데이트
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery(localSearchQuery);
+ }
+ }, 250);
+ return () => clearTimeout(handler);
+ }, [localSearchQuery, searchQuery, setSearchQuery]);
+
const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
// 인증 또는 폴더 로딩 중
@@ -57,17 +75,33 @@ export default function MyLinksPage() {
setSelectedFolderId(null);
} else {
setSelectedFolderId(folderId); // 선택된 폴더 변경
+
+ // 선택 시에만 수행: 현재 폴더 검색어를 링크 검색어로 동기화
+ if (searchQuery) {
+ useLinkStore.getState().setSearchQuery(searchQuery);
+ }
+
+ // 선택 시에만 수행: 우측 패널 열기
+ toggleRightPanel(true);
}
- useLinkStore.getState().setSearchQuery(searchQuery); // 현재 폴더 검색어를 링크 검색어로 동기화
};
- // all 또는 links 검색 스코프일 때 자동으로 전체 폴더 선택 해제 및 링크 검색어 동기화
+ // 검색 범위가 '전체' 또는 '링크'로 변경될 때 폴더 선택 해제
useEffect(() => {
if (searchScope === 'all' || searchScope === 'links') {
- useLinkStore.getState().setSearchQuery(searchQuery);
setSelectedFolderId(null);
}
- }, [searchQuery, searchScope, setSelectedFolderId]);
+ }, [searchScope, setSelectedFolderId]);
+
+ // 검색어 동기화: 검색 범위가 '전체' 또는 '링크'일 때만 링크 스토어와 검색어 연동
+ useEffect(() => {
+ if (searchScope === 'all' || searchScope === 'links') {
+ const currentLinkQuery = useLinkStore.getState().filters.searchQuery;
+ if (currentLinkQuery !== searchQuery) {
+ useLinkStore.getState().setSearchQuery(searchQuery);
+ }
+ }
+ }, [searchQuery, searchScope]);
// 검색 필터링 로직
const isSearching = searchQuery.trim().length > 0;
@@ -81,7 +115,13 @@ export default function MyLinksPage() {
// 2. 오늘 저장 필터가 활성화된 경우
// 3. Unread 필터가 활성화된 경우
// 4. 미분류 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
- const shouldFetchBookmarks = ((searchScope === 'all' || searchScope === 'links') && isSearching) || savedTodayFilter || unreadFilter || unorganizedFilter;
+ // 단, 미분류 필터가 활성화되었지만 아직 폴더 정보(unorganizedFolderId)를 불러오지 못한 경우 요청을 보류합니다.
+ const shouldFetchBookmarks =
+ ((searchScope === 'all' || searchScope === 'links') && isSearching) ||
+ savedTodayFilter ||
+ unreadFilter ||
+ (unorganizedFilter && unorganizedFolderId != null);
+
const bookmarkParams: Parameters[0] = {};
if (isSearching && (searchScope === 'all' || searchScope === 'links')) bookmarkParams.query = searchQuery;
if (unreadFilter) bookmarkParams.unreadOnly = true;
@@ -96,46 +136,49 @@ export default function MyLinksPage() {
// matchingFolderIdsSet은 백엔드에서 정확하게 필터링된 폴더들
const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []);
- // 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M))
- let searchResultFolders = isSearching
- ? (folders ?? []).filter((folder) => {
- const queryLower = searchQuery.toLowerCase();
-
- // 검색 범위에 따른 매칭 확인
- const matchFolderName = (searchScope === 'all' || searchScope === 'folders')
- && folder.folderName.toLowerCase().includes(queryLower);
-
- const matchBookmark = (searchScope === 'all' || searchScope === 'links')
- && matchingFolderIdsSet.has(folder.memberFolderId);
+ // --- Folder Logic (Filtering & Sorting) ---
+
+ // 1. 검색/필터링 결과 폴더 목록 메모이제이션
+ const processedSearchResultFolders = useMemo(() => {
+ if (!folders) return [];
+
+ // 필터링 로직 수행
+ let filtered = isSearching
+ ? folders.filter((folder) => {
+ const queryLower = searchQuery.toLowerCase();
- return matchFolderName || matchBookmark;
- })
- : (folders ?? []);
-
- // '오늘 저장한 링크' 필터링 (O(M))
- // 백엔드에서 이미 savedTodayOnly=true로 필터링되었으므로 matchingFolderIdsSet이 정확함
- if (savedTodayFilter) {
- searchResultFolders = searchResultFolders.filter((folder) =>
- matchingFolderIdsSet.has(folder.memberFolderId)
- );
- }
+ // 검색 범위에 따른 매칭 확인
+ const matchFolderName = (searchScope === 'all' || searchScope === 'folders')
+ && folder.folderName.toLowerCase().includes(queryLower);
+
+ const matchBookmark = (searchScope === 'all' || searchScope === 'links')
+ && matchingFolderIdsSet.has(folder.memberFolderId);
+
+ return matchFolderName || matchBookmark;
+ })
+ : [...folders];
- // 'Unread' 필터링 (O(M)): 안 읽은 북마크가 포함된 폴더만 유지
- // 백엔드에서 이미 unreadOnly=true로 필터링되었으므로 matchingFolderIdsSet이 정확함 (불완전한 bookmarks 페이지 대신)
- if (unreadFilter) {
- searchResultFolders = searchResultFolders.filter((folder) =>
- matchingFolderIdsSet.has(folder.memberFolderId)
- );
- }
+ // 추가 필터링 (오늘 저장, 안 읽음, 미분류)
+ if (savedTodayFilter) {
+ filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId));
+ }
+ if (unreadFilter) {
+ filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId));
+ }
+ if (unorganizedFilter) {
+ filtered = filtered.filter((f) => f.folderType === FOLDER_TYPE.UNORGANIZED);
+ }
- // '미분류' 필터링 (O(M)): 미분류 시스템 폴더만 유지
- if (unorganizedFilter) {
- searchResultFolders = searchResultFolders.filter((folder) =>
- folder.folderType === FOLDER_TYPE.UNORGANIZED
- );
- }
+ // 최종 정렬 적용
+ return [...filtered].sort((a, b) => compareFolders(a, b, folderSort));
+ }, [folders, isSearching, searchQuery, searchScope, matchingFolderIdsSet, savedTodayFilter, unreadFilter, unorganizedFilter, folderSort]);
+
+ // 2. 대시보드용 전체 폴더 목록 메모이제이션 (정렬만 적용)
+ const processedAllFolders = useMemo(() => {
+ return (folders ?? []).slice().sort((a, b) => compareFolders(a, b, folderSort));
+ }, [folders, folderSort]);
- const searchResultFolderCount = searchResultFolders.length;
+ const searchResultFolderCount = processedSearchResultFolders.length;
const matchedLinksCount = allBookmarks?.bookmarks?.length ?? 0;
// 헤더 메시지 생성
@@ -167,6 +210,14 @@ export default function MyLinksPage() {
return 'No matching folders';
})();
+ const PINNED_COLORS = [
+ 'bg-gradient-to-br from-indigo-500 to-purple-600',
+ 'bg-gradient-to-br from-blue-500 to-cyan-500',
+ 'bg-gradient-to-br from-emerald-500 to-teal-600',
+ 'bg-gradient-to-br from-rose-500 to-pink-600',
+ 'bg-gradient-to-br from-amber-500 to-orange-600'
+ ];
+
return (
{/* Main Content Area */}
@@ -184,14 +235,14 @@ export default function MyLinksPage() {
className="w-full bg-transparent border-none focus:outline-none focus:ring-0 text-xs py-1.5 px-1 text-gray-800 dark:text-gray-100 placeholder-gray-400 font-normal"
placeholder="Search folders or links"
type="text"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
+ value={localSearchQuery}
+ onChange={(e) => setLocalSearchQuery(e.target.value)}
/>
{/* Search Scope Toggle Dropdown using common component */}
setSearchScope(val as 'all' | 'folders' | 'links')}
+ onChange={(val) => setSearchScope(val as SearchScope)}
options={scopeOptions}
className="shrink-0 mr-1 ml-1"
panelClassName="w-28"
@@ -217,7 +268,6 @@ export default function MyLinksPage() {
type="button"
onClick={() => {
setSearchQuery('');
- // 검색어 삭제 시 필요하면 범위도 초기화하거나 유지 (여기선 유지)
}}
className="group/close flex items-center justify-center h-[18px] w-[18px] aspect-square bg-gray-200/70 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-full transition-all duration-200 mr-1.5 shrink-0"
>
@@ -264,21 +314,16 @@ export default function MyLinksPage() {
>
mark_email_unread Unread
- {/*
-
- priority_high High Priority
-
- */}
- today Saved today
+ today Saved today
@@ -300,11 +345,7 @@ export default function MyLinksPage() {
{emptyFilteredMessage}
) : (
- searchResultFolders.sort((a, b) => {
- if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1;
- if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1;
- return 0;
- }).map((folder) => (
+ processedSearchResultFolders.map((folder) => (
))
)}
@@ -413,14 +454,7 @@ export default function MyLinksPage() {
)}
{/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */}
- {(folders ? [...folders] : []).sort((a, b) => {
- // 시스템(미분류) 폴더는 항상 최상단 고정
- if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1;
- if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1;
- if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId;
- if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName);
- return 0;
- }).map((folder) => (
+ {processedAllFolders.map((folder) => (
))}
diff --git a/frontend/src/components/dialogs/SaveLinkDialog.tsx b/frontend/src/components/dialogs/SaveLinkDialog.tsx
index 3762edc..d928886 100644
--- a/frontend/src/components/dialogs/SaveLinkDialog.tsx
+++ b/frontend/src/components/dialogs/SaveLinkDialog.tsx
@@ -62,7 +62,7 @@ export function SaveLinkDialog() {
// --- API 연동 (React Query Hooks) ---
const memberId = useAuthStore((s) => s.member?.memberId);
- const { data: folders } = useFolders(memberId); // 기존 폴더 목록 조회
+ const { data: folders, isLoading: isFoldersLoading } = useFolders(memberId); // 기존 폴더 목록 조회
const { data: tagsData } = useTags(memberId); // 기존 태그 목록 조회
const createBookmarkMutation = useCreateBookmark(); // 북마크 생성 API 연동
const createFolderMutation = useCreateFolder(); // 폴더 생성 API 연동
@@ -283,6 +283,13 @@ export function SaveLinkDialog() {
selectedFolderId
?? folders?.find((f) => f.folderType === FOLDER_TYPE.UNORGANIZED)?.memberFolderId
?? null;
+
+ // [추가] 폴더 목록 로딩이 끝났음에도 불구하고 대상 폴더를 결정할 수 없는 경우 예외 처리
+ if (fallbackFolderId === null) {
+ alert('폴더 정보를 불러오는 중이거나 폴더가 존재하지 않습니다. 잠시 후 다시 시도해주세요.');
+ return;
+ }
+
saveBookmark(fallbackFolderId);
}
};
@@ -824,7 +831,7 @@ export function SaveLinkDialog() {
{(createBookmarkMutation.isPending || createFolderMutation.isPending) ? 'Saving...' : 'Save to Workspace'}
diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx
index 49f4c67..2dc6ab7 100644
--- a/frontend/src/components/my-links/FolderCard.tsx
+++ b/frontend/src/components/my-links/FolderCard.tsx
@@ -83,15 +83,15 @@ export function FolderCard({ folder, color }: FolderCardProps) {
setSelectedFolderId(null);
} else {
setSelectedFolderId(folder.memberFolderId);
+
+ // 2. 선택 시에만 수행: 기존 폴더 검색어가 있다면 링크 검색어로 전이
+ if (searchQuery) {
+ setLinkSearchQuery(searchQuery);
+ }
+
+ // 3. 선택 시에만 수행: 우측 패널이 닫혀있다면 자동으로 열어줌
+ useUIStore.getState().toggleRightPanel(true);
}
-
- // 2. 기존 폴더 검색어가 있다면 링크 검색어로 전이 (사용자 요청: 클릭 시 링크 필터링되도록)
- if (searchQuery) {
- setLinkSearchQuery(searchQuery);
- }
-
- // 3. 우측 패널이 닫혀있다면 자동으로 열어줌
- useUIStore.getState().toggleRightPanel(true);
}
};
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx
index 79c1d03..3af3c50 100644
--- a/frontend/src/components/my-links/RightPanel.tsx
+++ b/frontend/src/components/my-links/RightPanel.tsx
@@ -9,6 +9,7 @@ import { useLinkStore } from '@/lib/store/linkStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
import type { BookmarkResponse } from '@/lib/types/bookmark';
import { FOLDER_TYPE } from '@/lib/types/folder';
+import { compareFolders } from '@/lib/utils/folderUtils';
/**
* 날짜 문자열을 받아 현재 시간 기준 상대적인 시간(예: Just now, 5m ago)으로 변환합니다.
@@ -337,7 +338,7 @@ function LinkItem({
{isFolderDropdownOpen && (
Move to
- {folders?.map(folder => (
+ {(folders ? [...folders] : []).sort((a, b) => compareFolders(a, b, 'a-z')).map(folder => (
{
if (selectedTags.length > 0) return 'No links match selected tags';
- if (unreadFilter && savedTodayFilter) return 'No unread links saved today';
- if (unorganizedFilter && savedTodayFilter) return 'No unorganized links saved today';
- if (unreadFilter) return 'No unread links';
- if (savedTodayFilter) return 'No links saved today';
- if (unorganizedFilter) return 'No unorganized links';
- if (linkSearchQuery.trim()) return 'No links match your search';
- return 'No links saved yet';
+
+ const activeFilters: string[] = [];
+ if (unreadFilter) activeFilters.push('unread');
+ if (unorganizedFilter) activeFilters.push('unorganized');
+ if (savedTodayFilter) activeFilters.push('saved today');
+
+ const hasQuery = linkSearchQuery.trim().length > 0;
+
+ if (activeFilters.length === 0) {
+ if (hasQuery) return 'No links match your search';
+ return 'No links saved yet';
+ }
+
+ const filterText = activeFilters.join(' and ');
+
+ if (hasQuery) {
+ return `No ${filterText} links match your search`;
+ }
+
+ // 기본 필터 메시지 (복합 필터의 경우 기존 가독성 유지)
+ if (unreadFilter && savedTodayFilter && activeFilters.length === 2) return 'No unread links saved today';
+ if (unorganizedFilter && savedTodayFilter && activeFilters.length === 2) return 'No unorganized links saved today';
+
+ return `No ${filterText} links found`;
})();
/** 태그 선택/해제 */
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts
index bb86590..52dcb8a 100644
--- a/frontend/src/lib/api/bookmarkApi.ts
+++ b/frontend/src/lib/api/bookmarkApi.ts
@@ -64,8 +64,8 @@ async function deleteBookmark(bookmarkId: number): Promise {
* 북마크 조회 기록 (PATCH /api/bookmarks/{bookmarkId}/read)
* - 서버에서 view_count += 1, last_viewed_at = now() 처리
*/
-async function recordBookmarkView(bookmarkId: number): Promise {
- await fetchClient(`/api/bookmarks/${bookmarkId}/read`, {
+async function recordBookmarkView(bookmarkId: number): Promise {
+ return fetchClient(`/api/bookmarks/${bookmarkId}/read`, {
method: 'PATCH',
});
}
@@ -79,24 +79,19 @@ function queryKeyHasUnreadOnly(queryKey: readonly unknown[]): boolean {
function updateReadStateInResponse(
data: BookmarkSearchResponse,
- bookmarkId: number,
+ updatedBookmark: BookmarkResponse,
removeFromUnreadQuery: boolean,
): BookmarkSearchResponse {
let changed = false;
let removed = false;
- const now = new Date().toISOString();
const bookmarks = data.bookmarks.flatMap((bookmark) => {
- if (bookmark.bookmarkId !== bookmarkId) return [bookmark];
+ if (bookmark.bookmarkId !== updatedBookmark.bookmarkId) return [bookmark];
changed = true;
if (removeFromUnreadQuery) {
removed = true;
return [];
}
- return [{
- ...bookmark,
- viewCount: (bookmark.viewCount ?? 0) + 1,
- lastViewedAt: now,
- }];
+ return [updatedBookmark];
});
if (!changed) return data;
@@ -117,17 +112,17 @@ function isBookmarkSearchResponse(data: unknown): data is BookmarkSearchResponse
function updateReadStateInCacheData(
data: unknown,
- bookmarkId: number,
+ updatedBookmark: BookmarkResponse,
removeFromUnreadQuery: boolean,
): unknown {
if (isInfiniteBookmarkData(data)) {
return {
...data,
- pages: data.pages.map((page) => updateReadStateInResponse(page, bookmarkId, removeFromUnreadQuery)),
+ pages: data.pages.map((page) => updateReadStateInResponse(page, updatedBookmark, removeFromUnreadQuery)),
};
}
if (isBookmarkSearchResponse(data)) {
- return updateReadStateInResponse(data, bookmarkId, removeFromUnreadQuery);
+ return updateReadStateInResponse(data, updatedBookmark, removeFromUnreadQuery);
}
return data;
}
@@ -222,16 +217,17 @@ export function useRecordBookmarkView() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: recordBookmarkView,
- onSuccess: (_, bookmarkId) => {
+ onSuccess: (updatedBookmark) => {
const bookmarkQueries = queryClient.getQueryCache().findAll({ queryKey: ['bookmarks'] });
bookmarkQueries.forEach((query) => {
const removeFromUnreadQuery = queryKeyHasUnreadOnly(query.queryKey);
- queryClient.setQueryData(query.queryKey, (oldData) =>
- updateReadStateInCacheData(oldData, bookmarkId, removeFromUnreadQuery)
+ queryClient.setQueryData(query.queryKey, (oldData: unknown) =>
+ updateReadStateInCacheData(oldData, updatedBookmark, removeFromUnreadQuery)
);
});
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'bookmarks' && queryKeyHasUnreadOnly(query.queryKey),
+ refetchType: 'none',
});
},
});
diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts
index 889e858..96cb88e 100644
--- a/frontend/src/lib/store/folderStore.ts
+++ b/frontend/src/lib/store/folderStore.ts
@@ -1,15 +1,17 @@
import { create } from 'zustand';
+export type SearchScope = 'all' | 'folders' | 'links';
+
interface FolderStore {
selectedFolderId: number | null;
- searchScope: 'folders' | 'all' | 'links';
+ searchScope: SearchScope;
searchQuery: string;
savedTodayFilter: boolean;
unreadFilter: boolean;
unorganizedFilter: boolean;
setSelectedFolderId: (id: number | null) => void;
- setSearchScope: (scope: 'folders' | 'all' | 'links') => void;
+ setSearchScope: (scope: SearchScope) => void;
setSearchQuery: (query: string) => void;
toggleSavedTodayFilter: () => void;
toggleUnreadFilter: () => void;
@@ -17,44 +19,40 @@ interface FolderStore {
clearSearch: () => void;
}
-export const useFolderStore = create((set) => ({
- selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨)
- searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함, 'links': 링크만)
- searchQuery: '', // 폴더 목록 필터링용 검색어
- savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부
- unreadFilter: false, // 아직 읽지 않은 링크(view_count = 0)만 표시 여부
- unorganizedFilter: false, // 미분류 폴더만 표시 여부
-
- setSelectedFolderId: (id) => set({ selectedFolderId: id }),
- setSearchScope: (scope) => set({ searchScope: scope }),
- setSearchQuery: (query) => set({ searchQuery: query }),
- toggleSavedTodayFilter: () => set((state) => {
- const nextValue = !state.savedTodayFilter;
- return {
- savedTodayFilter: nextValue,
- selectedFolderId: nextValue ? null : state.selectedFolderId,
- };
- }),
- toggleUnreadFilter: () => set((state) => {
- const nextValue = !state.unreadFilter;
+export const useFolderStore = create((set) => {
+ // 공통 필터 토글 헬퍼 함수
+ const toggleFilter = (state: FolderStore, key: 'savedTodayFilter' | 'unreadFilter' | 'unorganizedFilter') => {
+ const nextValue = !state[key];
return {
- unreadFilter: nextValue,
+ [key]: nextValue,
+ // 필터가 활성화될 때 현재 선택된 폴더를 해제 (검색 결과 뷰로 전환 위함)
selectedFolderId: nextValue ? null : state.selectedFolderId,
};
- }),
- toggleUnorganizedFilter: () => set((state) => {
- const nextValue = !state.unorganizedFilter;
- return {
- unorganizedFilter: nextValue,
- selectedFolderId: nextValue ? null : state.selectedFolderId,
- };
- }),
- clearSearch: () => set({
- selectedFolderId: null,
- searchQuery: '',
- searchScope: 'all',
- savedTodayFilter: false,
- unreadFilter: false,
- unorganizedFilter: false,
- }),
-}));
+ };
+
+ return {
+ selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨)
+ searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함, 'links': 링크만)
+ searchQuery: '', // 폴더 목록 필터링용 검색어
+ savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부
+ unreadFilter: false, // 아직 읽지 않은 링크(view_count = 0)만 표시 여부
+ unorganizedFilter: false, // 미분류 폴더만 표시 여부
+
+ setSelectedFolderId: (id) => set({ selectedFolderId: id }),
+ setSearchScope: (scope) => set({ searchScope: scope }),
+ setSearchQuery: (query) => set({ searchQuery: query }),
+
+ toggleSavedTodayFilter: () => set((state) => toggleFilter(state, 'savedTodayFilter')),
+ toggleUnreadFilter: () => set((state) => toggleFilter(state, 'unreadFilter')),
+ toggleUnorganizedFilter: () => set((state) => toggleFilter(state, 'unorganizedFilter')),
+
+ clearSearch: () => set({
+ selectedFolderId: null,
+ searchQuery: '',
+ searchScope: 'all',
+ savedTodayFilter: false,
+ unreadFilter: false,
+ unorganizedFilter: false,
+ }),
+ };
+});
diff --git a/frontend/src/lib/utils/folderUtils.ts b/frontend/src/lib/utils/folderUtils.ts
new file mode 100644
index 0000000..4179308
--- /dev/null
+++ b/frontend/src/lib/utils/folderUtils.ts
@@ -0,0 +1,15 @@
+import { FOLDER_TYPE, type FolderResponse } from '@/lib/types/folder';
+
+/**
+ * 미분류(UNORGANIZED) 폴더를 항상 최상단에 배치하고,
+ * 그 외의 폴더들은 선택된 정렬 방식에 따라 정렬하는 비교 함수입니다.
+ */
+export const compareFolders = (a: FolderResponse, b: FolderResponse, sortType?: string) => {
+ if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1;
+ if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1;
+
+ if (sortType === 'recently') return b.memberFolderId - a.memberFolderId;
+ if (sortType === 'a-z') return a.folderName.localeCompare(b.folderName);
+
+ return 0;
+};