Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
341196e
chore: Spring AI 멀티 프로바이더 인프라 설정
jin2304 Mar 14, 2026
2043fab
feat(link-analysis): AI 링크 분석 백엔드 모듈 구현
jin2304 Mar 14, 2026
e5d77b8
feat(link-analysis): AI 링크 분석 프론트엔드 연동
jin2304 Mar 14, 2026
89cffef
style(ui): 폴더·링크 카드 색상 톤 조정
jin2304 Mar 14, 2026
a1e3ffb
Merge branch 'dev' of github.com:jin2304/SearchWeb_Spring into feat/S…
jin2304 Mar 14, 2026
702f7c1
refactor: config 패키지 구조 변경에 따른 import 경로 및 API 시그니처 수정
jin2304 Mar 14, 2026
e799a49
chore(security): 테스트 환경용 인증 우회 임시 코드 추가
jin2304 Mar 14, 2026
011004e
refactor(bookmark): 도메인 전용 예외 클래스(BookmarkException) 적용
jin2304 Mar 14, 2026
3a55a99
refactor(link-analysis): AI 분석 버튼 클릭 시 title·note 항상 최신값으로 갱신
jin2304 Mar 14, 2026
b747422
docs(backend): 도메인 전용 Exception 및 config 패키지 규칙 추가
jin2304 Mar 14, 2026
bc66852
security(link-analysis): SSRF 방어 및 프롬프트 인젝션 차단
jin2304 Mar 14, 2026
da61fab
refactor(link-analysis): DTO를 Requests/Responses로 분리
jin2304 Mar 14, 2026
89c54c4
feat(link-analysis): PageContent에 url 필드 추가 및 메타데이터 추출 개선
jin2304 Mar 14, 2026
6cc9f5d
fix(config): Groq 기본 활성화 및 AiConfig 개선
jin2304 Mar 14, 2026
610d5b4
fix(frontend): 재분석 시 AI 추천 초기화 및 접근성 개선
jin2304 Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
}

dependencies {
Expand All @@ -35,6 +36,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.jsoup:jsoup:1.17.2'
implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
7 changes: 6 additions & 1 deletion docs/Backend/01. backend-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@

## 5) 도메인/에러 규칙
- 도메인 객체 생성은 `@Builder`를 사용하고, Builder로 생성한다.
- 에러 코드는 도메인별로 커스텀 `*ErrorCode` 작성한다.
- 에러 코드는 도메인별로 커스텀 `*ErrorCode` enum을 작성한다.
- 에러 코드는 짧고 명확하게 유지한다.
- 각 도메인은 전용 Exception 클래스(`*Exception`)를 작성하여 사용한다.
- 모든 커스텀 예외는 `com.web.SearchWeb.config.exception.BusinessException`을 상속받는다.
- 에러 코드 인터페이스: `com.web.SearchWeb.config.exception.ErrorCode`
- 예시: `linkanalysis` 도메인 → `LinkAnalysisException`

## 6) Config 배치 규칙
- Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
- 공통 프레임워크 관련 Config(예외, 응답 등)는 `config.exception`, `config.common` 등에 배치한다.
Comment on lines 34 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

마크다운 포맷팅 수정 필요

헤딩 전후에 빈 줄이 필요합니다.

📝 수정 제안
   - 예시: `linkanalysis` 도메인 → `LinkAnalysisException`
 
+
 ## 6) Config 배치 규칙
 - Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 35-35: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/Backend/01`. backend-convention.md around lines 34 - 37, Add a blank
line immediately before the "## 6) Config 배치 규칙" heading and ensure there is an
empty line after the heading (i.e., between the heading and the following list)
so the Markdown renders correctly; update the section around the "## 6) Config
배치 규칙" heading to include these blank lines.

- 예시: 아이템 도메인 Config -> `config/item/ItemConfig.java`

## 7) 머지 기준
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/my-links/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export default function MyLinksPage() {
className="bg-white dark:bg-card-dark rounded-lg p-2.5 border border-gray-100 dark:border-gray-800 hover:shadow-sm hover:border-purple-200 dark:hover:border-purple-900/50 transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between focus:ring-1 focus:ring-purple-300 outline-none hover:bg-purple-50/50 dark:hover:bg-purple-900/10"
>
<div className="flex justify-between items-start">
<div className="p-1.5 bg-purple-50 dark:bg-purple-900/20 text-purple-500 rounded-md flex items-center justify-center w-8 h-8">
<div className="p-1.5 bg-purple-50/80 dark:bg-purple-900/15 text-gray-400 rounded-md flex items-center justify-center w-8 h-8">
<span className="material-symbols-outlined text-[16px]">folder_open</span>
</div>
<button type="button" className="text-gray-300 hover:text-purple-500">
Expand Down
385 changes: 320 additions & 65 deletions frontend/src/components/dialogs/SaveLinkDialog.tsx

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions frontend/src/components/my-links/FolderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,28 @@ interface FolderCardProps {

const COLOR_MAPS = {
blue: {
bg: 'bg-blue-500',
groupHover: 'group-hover:bg-blue-500'
bg: 'bg-blue-400',
groupHover: 'group-hover:bg-blue-400'
},
purple: {
bg: 'bg-purple-500',
groupHover: 'group-hover:bg-purple-500'
bg: 'bg-purple-400',
groupHover: 'group-hover:bg-purple-400'
},
green: {
bg: 'bg-green-500',
groupHover: 'group-hover:bg-green-500'
bg: 'bg-green-400',
groupHover: 'group-hover:bg-green-400'
},
amber: {
bg: 'bg-amber-500',
groupHover: 'group-hover:bg-amber-500'
bg: 'bg-amber-400',
groupHover: 'group-hover:bg-amber-400'
},
rose: {
bg: 'bg-rose-500',
groupHover: 'group-hover:bg-rose-500'
bg: 'bg-rose-400',
groupHover: 'group-hover:bg-rose-400'
},
indigo: {
bg: 'bg-indigo-500',
groupHover: 'group-hover:bg-indigo-500'
bg: 'bg-indigo-400',
groupHover: 'group-hover:bg-indigo-400'
}
};

Expand Down
86 changes: 36 additions & 50 deletions frontend/src/components/my-links/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function LinkItem({
return (
<div
ref={itemRef}
className={`group relative flex items-center p-3 rounded-xl transition-all cursor-pointer border ${(isBulkEditMode && isSelected) || (isTitleEditing || isNoteEditing || isTagEditing) ? 'bg-purple-50/50 dark:bg-purple-900/10 border-purple-200 dark:border-purple-800' : 'border-transparent hover:border-purple-200/50 dark:hover:border-purple-800/50 hover:bg-purple-50/60 dark:hover:bg-purple-900/10'}`}
className={`group relative z-0 hover:z-20 flex items-center p-3 rounded-xl transition-all duration-300 cursor-pointer border ${(isBulkEditMode && isSelected) || (isTitleEditing || isNoteEditing || isTagEditing) ? 'bg-purple-50/40 dark:bg-purple-900/10 border-purple-200/60 dark:border-purple-800/60' : 'border-transparent hover:border-purple-200/30 dark:hover:border-purple-800/30 hover:bg-purple-50/40 dark:hover:bg-purple-900/5'}`}
onClick={() => {
if (isBulkEditMode && onToggleSelect) {
onToggleSelect(data.bookmarkId);
Expand Down Expand Up @@ -241,9 +241,9 @@ function LinkItem({
)}
</div>
<div className="ml-3 flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-12">
<div className="flex items-center w-full">
{/* Title Area | 제목 영역 (호버 시 전체 제목 및 URL 표시) */}
<div className="w-[240px] shrink-0 flex items-center h-[28px]">
<div className="flex-1 flex items-center min-w-0 h-[28px]">
{isTitleEditing ? (
<input
ref={titleInputRef}
Expand All @@ -256,50 +256,11 @@ function LinkItem({
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<h4
className="text-[10.5px] font-semibold text-gray-900 dark:text-gray-100 truncate pr-2 group-hover:whitespace-normal group-hover:line-clamp-2 transition-colors cursor-pointer w-full border border-transparent flex items-center px-0 leading-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setIsTitleEditing(true);
}}
title="더블클릭으로 제목 수정"
>{titleContent}</h4>
</>
<h4
className="text-[10.5px] font-semibold text-gray-900 dark:text-gray-100 truncate pr-2 group-hover:whitespace-normal group-hover:line-clamp-2 transition-colors cursor-pointer w-full border border-transparent flex items-center px-0 leading-tight"
>{titleContent}</h4>
)}
</div>

{/* Note Area | 메모/설명 영역 */}
<div className="flex-1 flex items-center h-[28px] relative group/note overflow-hidden">
<div className={`w-full flex items-center gap-1.5 text-gray-400 dark:text-gray-500 pointer-events-auto h-full px-0.5
${isNoteEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(e) => {
e.stopPropagation();
if (!isNoteEditing) setIsNoteEditing(true);
}}
>
<div className="flex items-center justify-center h-full shrink-0 mr-1.5">
<span
className="material-symbols-outlined !text-[15px] hover:text-purple-500 transition-colors flex items-center justify-center"
style={{ fontVariationSettings: "'wght' 300" }}
>edit_note</span>
</div>
{isNoteEditing ? (
<input
ref={noteInputRef}
type="text"
className="flex-1 text-[10.5px] font-medium bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/50 rounded-md outline-none text-gray-900 dark:text-gray-100 px-2 h-full py-0 leading-none focus:border-gray-300 dark:focus:border-gray-600"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
onBlur={(e) => handleNoteEditComplete(e)}
onKeyDown={handleNoteKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<p className="flex-1 text-[10.5px] font-medium truncate hover:text-gray-600 dark:hover:text-gray-300 border border-transparent h-full flex items-center px-0 py-0 leading-none">{noteContent || 'Add description...'}</p>
)}
</div>
</div>
</div>

{/* Folder Info & Quick Move | 폴더 정보 및 빠른 이동 */}
Expand All @@ -316,9 +277,9 @@ function LinkItem({
}}
title="폴더 이동"
>
<span className="material-symbols-outlined !text-[11px] scale-90">folder</span>
<span className="material-symbols-outlined !text-[11px] scale-90 text-slate-400">folder</span>
<span>{folders?.find(f => f.memberFolderId === data.memberFolderId)?.folderName ?? 'Unordered'}</span>
<span className={`material-symbols-outlined !text-[10px] transition-transform ${isFolderDropdownOpen ? 'rotate-180' : ''}`}>expand_more</span>
<span className={`material-symbols-outlined !text-[10px] text-slate-400 transition-transform ${isFolderDropdownOpen ? 'rotate-180' : ''}`}>expand_more</span>
</button>

{isFolderDropdownOpen && (
Expand Down Expand Up @@ -387,6 +348,31 @@ function LinkItem({
</div>
</div>

{/* Note Edit Input | 메모 편집창 (편집 모드 시에만 나타남) */}
{isNoteEditing && (
<div className="flex-1 min-w-0 ml-4 h-[28px] z-10">
<input
ref={noteInputRef}
type="text"
className="w-full text-[10.5px] font-medium bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/50 rounded-md outline-none text-gray-900 dark:text-gray-100 px-2 h-full py-0 leading-none focus:border-gray-300 dark:focus:border-gray-600"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
onBlur={(e) => handleNoteEditComplete(e)}
onKeyDown={handleNoteKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
</div>
)}

{/* Tooltip for full note content (위치 우측 복구 & 꼬리만 왼쪽 유지) */}
{!isNoteEditing && noteContent && !isDropdownOpen && (
<div className="absolute right-12 top-1/2 -translate-y-1/2 mr-2 w-max max-w-[280px] z-[60] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 bg-white/95 dark:bg-gray-800/90 backdrop-blur-md text-gray-700 dark:text-gray-200 text-[11px] font-medium p-3 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700/50 whitespace-normal break-words leading-relaxed pointer-events-none translate-x-1 group-hover:translate-x-0 text-left">
<div className="absolute top-1/2 -translate-y-1/2 -left-1.5 w-3 h-3 bg-white/95 dark:bg-gray-800/90 transform rotate-45 border-b border-l border-gray-100 dark:border-gray-700/50"></div>
<div className="relative z-10 break-all xl:break-words">{noteContent}</div>
</div>
)}

<div className="shrink-0 ml-4 flex items-center justify-end w-8 relative" ref={dropdownRef}>
{!isBulkEditMode && !(isNoteEditing || isTitleEditing) && (
<span className={`text-[8px] text-gray-400 whitespace-nowrap transition-opacity duration-200 absolute right-0 pointer-events-none ${isDropdownOpen ? 'opacity-0' : 'group-hover:opacity-0'}`}>
Expand Down Expand Up @@ -630,7 +616,7 @@ export function RightPanel() {
{/* Left: Title and Count | 좌측: 폴더명 및 링크 개수 */}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 dark:bg-purple-900/20 text-purple-500 rounded-md flex items-center justify-center w-8 h-8">
<div className="p-1.5 bg-slate-50 dark:bg-slate-900/20 text-slate-400 rounded-md flex items-center justify-center w-8 h-8">
<span className="material-symbols-outlined text-[16px] block">folder_open</span>
</div>
<h2 className="text-sm font-bold text-gray-900 dark:text-white">{currentFolderName}</h2>
Expand Down Expand Up @@ -920,7 +906,7 @@ export function RightPanel() {
{/* All Folders Section | 모든 폴더 목록 섹션 */}
<div className="pt-1 pb-2">
<div className="px-3 py-2 text-[11px] font-bold text-slate-500 flex items-center gap-1.5">
<span className="material-symbols-outlined !text-[14px] text-[#7c3aed]">folder</span>
<span className="material-symbols-outlined !text-[14px] text-slate-400">folder</span>
All Folders
</div>

Expand All @@ -940,7 +926,7 @@ export function RightPanel() {
}}
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-gray-800 transition-all text-left group bg-white/50"
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 shrink-0 bg-slate-100 group-hover:bg-violet-50 group-hover:text-violet-500 transition-colors">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 shrink-0 bg-slate-100 group-hover:bg-slate-200 group-hover:text-slate-500 transition-colors">
<span className="material-symbols-outlined !text-[18px]">folder_open</span>
</div>
<div className="flex-1 min-w-0">
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/api/linkAnalysisApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { fetchClient } from './fetchClient';
import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis';

/**
* AI 링크 분석 (POST /api/link-analysis/analyze)
*/
async function analyzeLink(url: string): Promise<LinkAnalysisResponse> {
return fetchClient<LinkAnalysisResponse>('/api/link-analysis/analyze', {
method: 'POST',
body: JSON.stringify({ url }),
});
}

/**
* [분석 Hook] AI로 링크를 분석하여 제목/설명/태그/폴더를 추천받습니다.
*/
export function useAnalyzeLink() {
return useMutation({
mutationFn: analyzeLink,
});
}
17 changes: 17 additions & 0 deletions frontend/src/lib/types/linkAnalysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface LinkAnalysisResponse {
title: string;
description: string | null;
suggestedTags: TagSuggestion[] | null;
suggestedFolder: FolderSuggestion | null;
}

export interface TagSuggestion {
tagName: string;
isExisting: boolean;
}

export interface FolderSuggestion {
memberFolderId: number | null;
folderName: string;
isExisting: boolean;
}
2 changes: 2 additions & 0 deletions lombok.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Lombok이 생성자 주입 시 필드의 @Qualifier 어노테이션을 생성자 파라미터로 복사하도록 설정.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.web.SearchWeb.bookmark.error;

import com.web.SearchWeb.config.exception.BusinessException;
import com.web.SearchWeb.config.exception.ErrorCode;
import lombok.Getter;

@Getter
public class BookmarkException extends BusinessException {

private BookmarkException(ErrorCode errorCode) {
super(errorCode);
}

public static BookmarkException of(ErrorCode errorCode) {
return new BookmarkException(errorCode);
}
}
Loading
Loading