Skip to content

Commit 59f22b9

Browse files
authored
Merge pull request #36 from Searchweb-Dev/feat/SW-63
Feat/sw 63 - 링크 자동 분석 및 분류
2 parents c81cd01 + 610d5b4 commit 59f22b9

31 files changed

Lines changed: 1652 additions & 230 deletions

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ configurations {
2121

2222
repositories {
2323
mavenCentral()
24+
maven { url 'https://repo.spring.io/milestone' }
2425
}
2526

2627
dependencies {
@@ -35,6 +36,9 @@ dependencies {
3536
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
3637
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
3738
implementation 'org.jsoup:jsoup:1.17.2'
39+
implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
40+
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
41+
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'
3842

3943
compileOnly 'org.projectlombok:lombok'
4044
runtimeOnly 'org.postgresql:postgresql'

docs/Backend/01. backend-convention.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,16 @@
2525

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

3135
## 6) Config 배치 규칙
3236
- Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
37+
- 공통 프레임워크 관련 Config(예외, 응답 등)는 `config.exception`, `config.common` 등에 배치한다.
3338
- 예시: 아이템 도메인 Config -> `config/item/ItemConfig.java`
3439

3540
## 7) 머지 기준

frontend/src/app/my-links/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export default function MyLinksPage() {
188188
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"
189189
>
190190
<div className="flex justify-between items-start">
191-
<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">
191+
<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">
192192
<span className="material-symbols-outlined text-[16px]">folder_open</span>
193193
</div>
194194
<button type="button" className="text-gray-300 hover:text-purple-500">

frontend/src/components/dialogs/SaveLinkDialog.tsx

Lines changed: 320 additions & 65 deletions
Large diffs are not rendered by default.

frontend/src/components/my-links/FolderCard.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,28 @@ interface FolderCardProps {
1111

1212
const COLOR_MAPS = {
1313
blue: {
14-
bg: 'bg-blue-500',
15-
groupHover: 'group-hover:bg-blue-500'
14+
bg: 'bg-blue-400',
15+
groupHover: 'group-hover:bg-blue-400'
1616
},
1717
purple: {
18-
bg: 'bg-purple-500',
19-
groupHover: 'group-hover:bg-purple-500'
18+
bg: 'bg-purple-400',
19+
groupHover: 'group-hover:bg-purple-400'
2020
},
2121
green: {
22-
bg: 'bg-green-500',
23-
groupHover: 'group-hover:bg-green-500'
22+
bg: 'bg-green-400',
23+
groupHover: 'group-hover:bg-green-400'
2424
},
2525
amber: {
26-
bg: 'bg-amber-500',
27-
groupHover: 'group-hover:bg-amber-500'
26+
bg: 'bg-amber-400',
27+
groupHover: 'group-hover:bg-amber-400'
2828
},
2929
rose: {
30-
bg: 'bg-rose-500',
31-
groupHover: 'group-hover:bg-rose-500'
30+
bg: 'bg-rose-400',
31+
groupHover: 'group-hover:bg-rose-400'
3232
},
3333
indigo: {
34-
bg: 'bg-indigo-500',
35-
groupHover: 'group-hover:bg-indigo-500'
34+
bg: 'bg-indigo-400',
35+
groupHover: 'group-hover:bg-indigo-400'
3636
}
3737
};
3838

frontend/src/components/my-links/RightPanel.tsx

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ function LinkItem({
203203
return (
204204
<div
205205
ref={itemRef}
206-
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'}`}
206+
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'}`}
207207
onClick={() => {
208208
if (isBulkEditMode && onToggleSelect) {
209209
onToggleSelect(data.bookmarkId);
@@ -241,9 +241,9 @@ function LinkItem({
241241
)}
242242
</div>
243243
<div className="ml-3 flex-1 flex flex-col min-w-0">
244-
<div className="flex items-center gap-12">
244+
<div className="flex items-center w-full">
245245
{/* Title Area | 제목 영역 (호버 시 전체 제목 및 URL 표시) */}
246-
<div className="w-[240px] shrink-0 flex items-center h-[28px]">
246+
<div className="flex-1 flex items-center min-w-0 h-[28px]">
247247
{isTitleEditing ? (
248248
<input
249249
ref={titleInputRef}
@@ -256,50 +256,11 @@ function LinkItem({
256256
onClick={(e) => e.stopPropagation()}
257257
/>
258258
) : (
259-
<>
260-
<h4
261-
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"
262-
onDoubleClick={(e) => {
263-
e.stopPropagation();
264-
setIsTitleEditing(true);
265-
}}
266-
title="더블클릭으로 제목 수정"
267-
>{titleContent}</h4>
268-
</>
259+
<h4
260+
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"
261+
>{titleContent}</h4>
269262
)}
270263
</div>
271-
272-
{/* Note Area | 메모/설명 영역 */}
273-
<div className="flex-1 flex items-center h-[28px] relative group/note overflow-hidden">
274-
<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
275-
${isNoteEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
276-
onClick={(e) => {
277-
e.stopPropagation();
278-
if (!isNoteEditing) setIsNoteEditing(true);
279-
}}
280-
>
281-
<div className="flex items-center justify-center h-full shrink-0 mr-1.5">
282-
<span
283-
className="material-symbols-outlined !text-[15px] hover:text-purple-500 transition-colors flex items-center justify-center"
284-
style={{ fontVariationSettings: "'wght' 300" }}
285-
>edit_note</span>
286-
</div>
287-
{isNoteEditing ? (
288-
<input
289-
ref={noteInputRef}
290-
type="text"
291-
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"
292-
value={noteContent}
293-
onChange={(e) => setNoteContent(e.target.value)}
294-
onBlur={(e) => handleNoteEditComplete(e)}
295-
onKeyDown={handleNoteKeyDown}
296-
onClick={(e) => e.stopPropagation()}
297-
/>
298-
) : (
299-
<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>
300-
)}
301-
</div>
302-
</div>
303264
</div>
304265

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

324285
{isFolderDropdownOpen && (
@@ -387,6 +348,31 @@ function LinkItem({
387348
</div>
388349
</div>
389350

351+
{/* Note Edit Input | 메모 편집창 (편집 모드 시에만 나타남) */}
352+
{isNoteEditing && (
353+
<div className="flex-1 min-w-0 ml-4 h-[28px] z-10">
354+
<input
355+
ref={noteInputRef}
356+
type="text"
357+
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"
358+
value={noteContent}
359+
onChange={(e) => setNoteContent(e.target.value)}
360+
onBlur={(e) => handleNoteEditComplete(e)}
361+
onKeyDown={handleNoteKeyDown}
362+
onClick={(e) => e.stopPropagation()}
363+
autoFocus
364+
/>
365+
</div>
366+
)}
367+
368+
{/* Tooltip for full note content (위치 우측 복구 & 꼬리만 왼쪽 유지) */}
369+
{!isNoteEditing && noteContent && !isDropdownOpen && (
370+
<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">
371+
<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>
372+
<div className="relative z-10 break-all xl:break-words">{noteContent}</div>
373+
</div>
374+
)}
375+
390376
<div className="shrink-0 ml-4 flex items-center justify-end w-8 relative" ref={dropdownRef}>
391377
{!isBulkEditMode && !(isNoteEditing || isTitleEditing) && (
392378
<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'}`}>
@@ -630,7 +616,7 @@ export function RightPanel() {
630616
{/* Left: Title and Count | 좌측: 폴더명 및 링크 개수 */}
631617
<div className="flex flex-col">
632618
<div className="flex items-center gap-2">
633-
<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">
619+
<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">
634620
<span className="material-symbols-outlined text-[16px] block">folder_open</span>
635621
</div>
636622
<h2 className="text-sm font-bold text-gray-900 dark:text-white">{currentFolderName}</h2>
@@ -920,7 +906,7 @@ export function RightPanel() {
920906
{/* All Folders Section | 모든 폴더 목록 섹션 */}
921907
<div className="pt-1 pb-2">
922908
<div className="px-3 py-2 text-[11px] font-bold text-slate-500 flex items-center gap-1.5">
923-
<span className="material-symbols-outlined !text-[14px] text-[#7c3aed]">folder</span>
909+
<span className="material-symbols-outlined !text-[14px] text-slate-400">folder</span>
924910
All Folders
925911
</div>
926912

@@ -940,7 +926,7 @@ export function RightPanel() {
940926
}}
941927
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"
942928
>
943-
<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">
929+
<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">
944930
<span className="material-symbols-outlined !text-[18px]">folder_open</span>
945931
</div>
946932
<div className="flex-1 min-w-0">
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import { fetchClient } from './fetchClient';
3+
import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis';
4+
5+
/**
6+
* AI 링크 분석 (POST /api/link-analysis/analyze)
7+
*/
8+
async function analyzeLink(url: string): Promise<LinkAnalysisResponse> {
9+
return fetchClient<LinkAnalysisResponse>('/api/link-analysis/analyze', {
10+
method: 'POST',
11+
body: JSON.stringify({ url }),
12+
});
13+
}
14+
15+
/**
16+
* [분석 Hook] AI로 링크를 분석하여 제목/설명/태그/폴더를 추천받습니다.
17+
*/
18+
export function useAnalyzeLink() {
19+
return useMutation({
20+
mutationFn: analyzeLink,
21+
});
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface LinkAnalysisResponse {
2+
title: string;
3+
description: string | null;
4+
suggestedTags: TagSuggestion[] | null;
5+
suggestedFolder: FolderSuggestion | null;
6+
}
7+
8+
export interface TagSuggestion {
9+
tagName: string;
10+
isExisting: boolean;
11+
}
12+
13+
export interface FolderSuggestion {
14+
memberFolderId: number | null;
15+
folderName: string;
16+
isExisting: boolean;
17+
}

lombok.config

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Lombok이 생성자 주입 시 필드의 @Qualifier 어노테이션을 생성자 파라미터로 복사하도록 설정.
2+
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.web.SearchWeb.bookmark.error;
2+
3+
import com.web.SearchWeb.config.exception.BusinessException;
4+
import com.web.SearchWeb.config.exception.ErrorCode;
5+
import lombok.Getter;
6+
7+
@Getter
8+
public class BookmarkException extends BusinessException {
9+
10+
private BookmarkException(ErrorCode errorCode) {
11+
super(errorCode);
12+
}
13+
14+
public static BookmarkException of(ErrorCode errorCode) {
15+
return new BookmarkException(errorCode);
16+
}
17+
}

0 commit comments

Comments
 (0)