© {new Date().getFullYear()}
- hafskjfha
+ SolidLoop-studio
All rights reserved.
diff --git a/app/header.tsx b/app/header.tsx
index b433cea..063a24d 100644
--- a/app/header.tsx
+++ b/app/header.tsx
@@ -64,22 +64,33 @@ const Header = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}
+ type NavItem = {
+ href: string;
+ label: string;
+ match: (pathname: string) => boolean;
+ };
+
+ const navDefs: NavItem[] = [
+ { href: "/word-combiner", label: "단어조합기", match: p => p === "/word-combiner" },
+ { href: "/manager-tool", label: "단어장 관리 도구", match: p => p.includes("/manager-tool") },
+ { href: "/words-docs", label: "단어장 공유", match: p => p.includes("/words-docs") },
+ { href: "/word", label: "오픈DB", match: p => p === "/word" || p.includes("/word/") },
+ { href: "/kkuko", label: "끄코", match: p => p === "/kkuko" || p.includes("/kkuko/") },
+ ];
+
+ const activeIndex = pathname === "/" ? -2 : pathname.includes("/admin") ? -3 : navDefs.findIndex(item => item.match(pathname));
+
const navItems = [
- { href: "/word-combiner", label: "단어조합기", isActive: pathname === "/word-combiner" },
- { href: "/manager-tool", label: "단어장 관리 도구", isActive: pathname.includes('manager-tool') },
- { href: "/words-docs", label: "단어장 공유", isActive: pathname.includes('words-docs') },
- { href: "/word", label: "오픈DB", isActive: pathname==='/word' || pathname.includes('/word/') },
- {
- href: "/extra-features",
- label: "기타 기능",
- isActive: !(pathname === "/word-combiner") &&
- !(pathname.includes('manager-tool')) &&
- !(pathname.includes('words-docs')) &&
- !(pathname === "/") &&
- !(pathname.includes('admin')) &&
- !(pathname.includes('/word/')) &&
- pathname !=='/word'
- }
+ ...navDefs.map((item, i) => ({
+ href: item.href,
+ label: item.label,
+ isActive: i === activeIndex,
+ })),
+ {
+ href: "/extra-features",
+ label: "기타 기능",
+ isActive: activeIndex === -1,
+ },
];
return (
diff --git a/app/kkuko/KkukoHome.tsx b/app/kkuko/KkukoHome.tsx
new file mode 100644
index 0000000..f39132c
--- /dev/null
+++ b/app/kkuko/KkukoHome.tsx
@@ -0,0 +1,70 @@
+"use client";
+import { ArrowRight, Trophy, User } from "lucide-react";
+import Link from "next/link";
+
+export default function KkukoHome() {
+ return (
+
+ {/* 제목 영역 */}
+
+
+ {/* 기능 설명 영역 */}
+
+ {/* 프로필 */}
+
+
+
+
+ 프로필
+
+
+
+ 끄투코리아의 유저 정보와 전적 등을 확인할 수 있습니다.
+
+
+
+
+
+
+
+ {/* 랭킹*/}
+
+
+
+
+ 랭킹
+
+
+
+ 각 모드별로 승리가 많은 유저들의 랭킹을 확인할 수 있습니다.
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/kkuko/page.tsx b/app/kkuko/page.tsx
new file mode 100644
index 0000000..9bf003e
--- /dev/null
+++ b/app/kkuko/page.tsx
@@ -0,0 +1,20 @@
+import KkukoHome from "./KkukoHome";
+
+export async function generateMetadata() {
+ return {
+ title: "끄코 유틸리티 - 끄코 정보",
+ description: '끄코 유틸리티 - 끄코 정보',
+ openGraph: {
+ title: "끄코 유틸리티 - 끄코 정보",
+ description: "끄코 유틸리티 - 끄코 정보",
+ type: "website",
+ url: "https://kkuko-utils.vercel.app/kkuko",
+ siteName: "끄코 유틸리티",
+ locale: "ko_KR",
+ },
+ };
+}
+
+export default function KkukoPage() {
+ return
;
+}
\ No newline at end of file
diff --git a/app/kkuko/profile/KkukoProfile.tsx b/app/kkuko/profile/KkukoProfile.tsx
new file mode 100644
index 0000000..23d21d3
--- /dev/null
+++ b/app/kkuko/profile/KkukoProfile.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { Search } from 'lucide-react';
+
+import { useKkukoProfile } from './hooks/useKkukoProfile';
+import ProfileSearch from './components/ProfileSearch';
+import ProfileHeader from './components/ProfileHeader';
+import ProfileStats from './components/ProfileStats';
+import ProfileRecords from './components/ProfileRecords';
+import ItemModal from './components/ItemModal';
+import ErrorModal from '../../components/ErrModal';
+
+export default function KkukoProfile() {
+ const searchParams = useSearchParams();
+ const {
+ profileData,
+ itemsData,
+ modesData,
+ loading,
+ error,
+ detailedError,
+ setDetailedError,
+ totalUserCount,
+ expRank,
+ recentSearches,
+ fetchProfile,
+ removeFromRecentSearches
+ } = useKkukoProfile();
+
+ const [showItemModal, setShowItemModal] = useState(false);
+
+ // Handle URL query parameters to trigger fetch
+ useEffect(() => {
+ const nick = searchParams.get('nick');
+ const id = searchParams.get('id');
+
+ if (nick) {
+ fetchProfile(nick, 'nick');
+ } else if (id) {
+ fetchProfile(id, 'id');
+ }
+ }, [searchParams, fetchProfile]);
+
+ return (
+
+ {/* Title Section */}
+
+ 끄투코리아 유저 조회 {totalUserCount > 0 && `(등록된 유저수 ${totalUserCount.toLocaleString()})`}
+
+
+ {/* Search Section */}
+
+
+ {/* Loading State */}
+ {loading && (
+
+ )}
+
+ {/* Error State */}
+ {error && (
+
+
{error}
+
+ 2026-01-18 17시 이후 게임 접속한 유저에 대해서 조회할 수 있습니다.
+
+
+ )}
+
+ {/* Empty State - No search yet */}
+ {!loading && !error && !profileData && (
+
+
+
+
+ 유저 검색
+
+
+ 닉네임 또는 ID로 끄투코리아 유저의 프로필을 조회할 수 있습니다.
+
+
+
📌 안내
+
+ - • 2026-01-18 17시 이후 게임 접속한 유저만 조회 가능합니다
+ - • 검색 후 최근 검색 기록이 표시됩니다
+ - • 실시간 접속 상태와 게임 전적을 확인할 수 있습니다
+
+
+
+
+ )}
+
+ {/* Profile Data */}
+ {profileData && !loading && (
+
+ {/* User Profile Section */}
+
+
+ {/* Equipment Section */}
+
setShowItemModal(true)}
+ />
+
+ {/* Records Section */}
+
+
+ )}
+
+ {/* Item Modal */}
+ {showItemModal && (
+
setShowItemModal(false)}
+ />
+ )}
+
+ {/* Error Modal */}
+ {detailedError && (
+ setDetailedError(null)}
+ />
+ )}
+
+ {/* Warning Message */}
+
+
+ ⚠️ 해당 데이터는 비공식 API를 사용하여 만들었으며 데이터가 항상 최신이거나 정확하다고 할 수 없습니다. 참고용으로만 사용해주세요.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/kkuko/profile/components/ItemModal.tsx b/app/kkuko/profile/components/ItemModal.tsx
new file mode 100644
index 0000000..4e6e5cd
--- /dev/null
+++ b/app/kkuko/profile/components/ItemModal.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import { ItemInfo, ProfileData, isSpecialOptions, ItemOption, SpecialOptions } from '@/types/kkuko.types';
+import TryRenderImg from '../../shared/components/TryRenderImg'
+import { getSlotName, extractColorFromLabel, parseDescription, getOptionName, formatNumber } from '../utils/profileHelper';
+import { NICKNAME_COLORS } from '../../shared/lib/const';
+
+interface ItemModalProps {
+ itemsData: ItemInfo[];
+ profileData: ProfileData | null;
+ onClose: () => void;
+}
+
+export default function ItemModal({ itemsData, profileData, onClose }: ItemModalProps) {
+ const isDarkTheme = typeof window !== 'undefined' ? localStorage.getItem('theme') === 'dark' : false;
+
+ const itemOptionsUI = (options: ItemOption | SpecialOptions) => {
+ const itemOptionUI = (key: string, value: number) => (
+
+ {getOptionName(key)}:
+ {value > 0 ? '+' : ''}{formatNumber(value * 1000)}{key[0] === 'g' ? '%p' : ''}
+
+ )
+
+ if (isSpecialOptions(options)) {
+ const relevantOptions = Date.now() >= options.date ? options.after : options.before;
+ return Object.entries(relevantOptions).filter(([_, v]) => v !== undefined && typeof v === 'number').map(([k, v]) =>
+ itemOptionUI(k, v as number)
+ );
+ } else {
+ return Object.entries(options).filter(([_, v]) => v !== undefined && typeof v === 'number').map(([k, v]) =>
+ itemOptionUI(k, v as number)
+ );
+ }
+ }
+
+ const itemImgPlaceholder = () => (
+
+ 아이템
이미지
+
+ )
+
+ return (
+
+
+
+
장착 아이템 목록
+
+
+
+ {itemsData.map((item) => {
+ const equipment = profileData?.equipment.find(eq => eq.itemId === item.id);
+ const slotName = equipment ? getSlotName(equipment.slot) : '알 수 없음';
+ const isNikItem = equipment?.slot === 'NIK';
+ const nikColor = isNikItem ? extractColorFromLabel(item.description, isDarkTheme) : undefined;
+
+ // Get the image group from equipment slot
+ const getImageGroup = () => {
+ if (!equipment) return null;
+ if (equipment.slot === 'NIK') return null; // No image for nickname items
+ if (equipment.slot === 'BDG' || equipment.slot === 'pbdg') return 'badge';
+ if (equipment.slot.startsWith('Ml') || equipment.slot.startsWith('Mr')) return 'hand';
+ // Remove 'M' prefix from slot name (e.g., 'Mavatar' -> 'avatar')
+ return equipment.slot.startsWith('M') ? equipment.slot.substring(1).toLowerCase() : equipment.slot.toLowerCase();
+ };
+
+ const imageGroup = getImageGroup();
+
+ return (
+
+
+ {/* Item Image */}
+ {imageGroup && (
+
+
+
+ )}
+
+
+
+
+ {item.name}
+
+
+ {slotName}
+
+
+
+ {parseDescription(item.description).map((part, i) => (
+
+ {part.text}
+
+ ))}
+
+
+ {itemOptionsUI(item.options)}
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/kkuko/profile/components/ProfileHeader.tsx b/app/kkuko/profile/components/ProfileHeader.tsx
new file mode 100644
index 0000000..af2d1a0
--- /dev/null
+++ b/app/kkuko/profile/components/ProfileHeader.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { ItemInfo, ProfileData } from '@/types/kkuko.types';
+import TryRenderImg from '../../shared/components/TryRenderImg';
+import ProfileAvatar from '../../shared/components/ProfileAvatar';
+import { getNicknameColor } from '../utils/profileHelper';
+
+interface ProfileHeaderProps {
+ profileData: ProfileData;
+ itemsData: ItemInfo[];
+ expRank: number | null;
+}
+
+export default function ProfileHeader({ profileData, itemsData, expRank }: ProfileHeaderProps) {
+ const isDarkTheme = typeof window !== 'undefined' ? localStorage.getItem('theme') === 'dark' : false;
+
+ const lvImgPlaceholder = () => (
+
+ Lv
+
+ );
+
+ return (
+
+
+ {/* Left: Character Image and Badges */}
+
+
+
+ {/* Badges (pbdg slot items) */}
+ {profileData.equipment.filter(eq => eq.slot === 'pbdg').length > 0 && (
+
+ {profileData.equipment
+ .filter(eq => eq.slot === 'pbdg')
+ .map(eq => {
+ const item = itemsData.find(i => i.id === eq.itemId);
+ if (!item) return null;
+ return (
+
+
+ }
+ url={`/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/badge/${item.id}.png`}
+ alt={item.name}
+ width={40}
+ height={40}
+ className="transition-opacity duration-300"
+ />
+
+
+ {item.name}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Right: User Info */}
+
+
+
+ {profileData.user.nickname}
+
+
{profileData.user.exordial}
+
ID: {profileData.user.id}
+
+
+
+
+
경험치
+
{profileData.user.exp.toLocaleString()} EXP
+ {expRank !== null && (
+
경험치 랭킹: #{expRank.toLocaleString()}
+ )}
+
+
+
+
레벨
+
+
+
+
+
+ Lv. {profileData.user.level}
+
+
+
+
+ {/*
+
마지막 관측
+
{formatObservedAt(profileData.user.observedAt)}
+
*/}
+
+
+
접속 상태
+
+ {profileData.presence.channelId ? (
+ 온라인
+ ) : (
+ 오프라인
+ )}
+
+ {profileData.presence.channelId ? (
+
채널: {profileData.presence.channelId}
+ ) : (
+ //
마지막 접속: {formatLastSeen(profileData.presence.updatedAt)}
+ <>>
+ )}
+
+
+
+
방 정보
+
+ {profileData.presence.roomId ? (
+ 방 {profileData.presence.roomId}
+ ) : (
+ 미입장
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/kkuko/profile/components/ProfileRecords.tsx b/app/kkuko/profile/components/ProfileRecords.tsx
new file mode 100644
index 0000000..1678298
--- /dev/null
+++ b/app/kkuko/profile/components/ProfileRecords.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { ProfileData, Mode } from '@/types/kkuko.types';
+import { groupRecordsByMode, getModeName, calculateWinRate } from '../utils/profileHelper';
+
+interface ProfileRecordsProps {
+ profileData: ProfileData;
+ modesData: Mode[];
+}
+
+export default function ProfileRecords({ profileData, modesData }: ProfileRecordsProps) {
+ const recordsByMode = groupRecordsByMode(profileData.record, modesData);
+
+ return (
+
+
전적
+
+ {Object.entries(recordsByMode).map(([groupName, records]) => (
+ records.length > 0 && (
+
+
+ {groupName === 'kor' ? '한국어' : groupName === 'eng' ? '영어' : '이벤트'}
+
+
+
+
+
+ | 모드 |
+ 총 게임 |
+ 승리 |
+ 승률 |
+ 경험치 |
+
+
+
+ {records.map((rec) => (
+
+ | {getModeName(rec.modeId, modesData)} |
+ {rec.total.toLocaleString()} |
+ {rec.win.toLocaleString()} |
+ {calculateWinRate(rec.win, rec.total)}% |
+ {rec.exp.toLocaleString()} |
+
+ ))}
+
+
+
+
+ )
+ ))}
+
+
+ );
+}
diff --git a/app/kkuko/profile/components/ProfileSearch.tsx b/app/kkuko/profile/components/ProfileSearch.tsx
new file mode 100644
index 0000000..bc91c84
--- /dev/null
+++ b/app/kkuko/profile/components/ProfileSearch.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import React, { useState, useRef, useEffect } from 'react';
+import { Search } from 'lucide-react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { SearchHistoryItem } from '../hooks/useRecentSearches';
+import { normalizeHangul } from '@/app/lib/hangulUtils';
+
+interface ProfileSearchProps {
+ loading: boolean;
+ recentSearches: SearchHistoryItem[];
+ onRemoveRecentSearch: (query: string, type: 'nick' | 'id') => void;
+ onSearch: (query: string, type: 'nick' | 'id') => void;
+}
+
+export default function ProfileSearch({ loading, recentSearches, onRemoveRecentSearch, onSearch }: ProfileSearchProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchType, setSearchType] = useState<'nick' | 'id'>('nick');
+ const [showDropdown, setShowDropdown] = useState(false);
+
+ const inputRef = useRef
(null);
+ const dropdownRef = useRef(null);
+
+ // Sync input with query params
+ useEffect(() => {
+ const nick = searchParams.get('nick');
+ const id = searchParams.get('id');
+
+ if (nick) {
+ setSearchQuery(nick);
+ setSearchType('nick');
+ // We assume parent handles the actual fetch via its own effect or we call onSearch here?
+ // Since the hook in parent reads useSearchParams, we DON'T need to call onSearch here.
+ // Just update UI state.
+ } else if (id) {
+ setSearchQuery(id);
+ setSearchType('id');
+ }
+ }, [searchParams]);
+
+ // Handle click outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ inputRef.current &&
+ !inputRef.current.contains(event.target as Node)
+ ) {
+ setShowDropdown(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const handleSearch = () => {
+ if (!searchQuery.trim()) return;
+
+ const trimmed = searchQuery.trim();
+ // Call parent fetch immediately so UI shows loading without waiting for navigation
+ onSearch(trimmed, searchType);
+
+ const queryParam = searchType === 'nick' ? 'nick' : 'id';
+ // update URL without adding a history entry
+ router.replace(`/kkuko/profile?${queryParam}=${encodeURIComponent(trimmed)}`);
+ setShowDropdown(false);
+ };
+
+ const handleRecentSearchClick = (search: SearchHistoryItem) => {
+ setSearchQuery(search.query);
+ setSearchType(search.type);
+ // call parent fetch immediately to reduce perceived latency
+ onSearch(search.query, search.type);
+ const queryParam = search.type === 'nick' ? 'nick' : 'id';
+ router.replace(`/kkuko/profile?${queryParam}=${encodeURIComponent(search.query)}`);
+ setShowDropdown(false);
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ const getFilteredSearches = () => {
+ if (!searchQuery.trim()) {
+ return recentSearches;
+ }
+
+ const normalizedQuery = normalizeHangul(searchQuery);
+
+ return recentSearches.filter(search => {
+ if (search.type !== searchType) return false;
+
+ const normalizedTarget = normalizeHangul(search.query);
+ return normalizedTarget.startsWith(normalizedQuery);
+ });
+ };
+
+ return (
+
+
+
+
setSearchQuery(e.target.value)}
+ onKeyDown={handleKeyPress}
+ onFocus={() => setShowDropdown(true)}
+ placeholder="유저 검색..."
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+ {/* Dropdown */}
+ {showDropdown && recentSearches.length > 0 && getFilteredSearches().length > 0 && (
+
+
+
+ 최근 검색
+
+ {getFilteredSearches().map((search, index) => (
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/kkuko/profile/components/ProfileStats.tsx b/app/kkuko/profile/components/ProfileStats.tsx
new file mode 100644
index 0000000..b577b81
--- /dev/null
+++ b/app/kkuko/profile/components/ProfileStats.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { ItemInfo } from '@/types/kkuko.types';
+import { calculateTotalOptions, getOptionName, formatNumber } from '../utils/profileHelper';
+
+interface ProfileStatsProps {
+ itemsData: ItemInfo[];
+ onShowDetail: () => void;
+}
+
+export default function ProfileStats({ itemsData, onShowDetail }: ProfileStatsProps) {
+ const totalOptions = calculateTotalOptions(itemsData);
+
+ return (
+
+
+
착용 아이템 정보
+
+
+
+
+ {Object.entries(totalOptions).map(([key, value]) => (
+
+
{getOptionName(key)}
+
{value > 0 ? '+' : ''}{formatNumber(value)}{key[0] === 'g' ? '%p' : ''}
+
+ ))}
+
+
+ );
+}
diff --git a/app/kkuko/profile/hooks/useKkukoProfile.ts b/app/kkuko/profile/hooks/useKkukoProfile.ts
new file mode 100644
index 0000000..a333708
--- /dev/null
+++ b/app/kkuko/profile/hooks/useKkukoProfile.ts
@@ -0,0 +1,147 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ fetchModes as fetchModesApi,
+ fetchTotalUsers as fetchTotalUsersApi,
+ fetchProfile as fetchProfileApi,
+ fetchItems as fetchItemsApi,
+ fetchExpRank as fetchExpRankApi
+} from '../../shared/lib/api';
+import { Equipment, ItemInfo, Mode, ProfileData } from '@/types/kkuko.types';
+import { useRecentSearches } from './useRecentSearches';
+
+export const useKkukoProfile = () => {
+ const [profileData, setProfileData] = useState(null);
+ const [itemsData, setItemsData] = useState([]);
+ const [modesData, setModesData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [detailedError, setDetailedError] = useState(null);
+ const [totalUserCount, setTotalUserCount] = useState(0);
+ const [expRank, setExpRank] = useState(null);
+
+ // Recent searches hook integration
+ const { recentSearches, saveToRecentSearches, removeFromRecentSearches } = useRecentSearches();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handleError = useCallback((err: any, inputValue: string, location: string) => {
+ const errorMsg: ErrorMessage = {
+ ErrName: err.name || "Error",
+ ErrMessage: err.message || "Unknown error",
+ ErrStackRace: err.stack || null,
+ inputValue: inputValue,
+ location: location
+ };
+ setDetailedError(errorMsg);
+ console.error(`Failed at ${location}:`, err);
+ }, []);
+
+ const fetchModes = useCallback(async () => {
+ try {
+ const response = await fetchModesApi();
+ const result = await response.data;
+ if (result.status === 200) {
+ setModesData(result.data);
+ }
+ } catch (err) {
+ handleError(err, 'fetchModes', 'fetchModes');
+ }
+ }, [handleError]);
+
+ const fetchTotalUsers = useCallback(async () => {
+ try {
+ const response = await fetchTotalUsersApi();
+ const result = await response.data;
+ if (result.status === 200) {
+ setTotalUserCount(result.data.totalUsers);
+ }
+ } catch (err) {
+ handleError(err, 'fetchTotalUsers', 'fetchTotalUsers');
+ }
+ }, [handleError]);
+
+
+ const fetchItems = useCallback(async (itemIds: string) => {
+ try {
+ const response = await fetchItemsApi(itemIds);
+ const result = await response.data;
+ if (result.status === 200) {
+ const newItems = Array.isArray(result.data) ? result.data : [result.data];
+ setItemsData(newItems);
+ }
+ } catch (err) {
+ handleError(err, itemIds, 'fetchItems');
+ }
+ }, [handleError]);
+
+ const fetchExpRank = useCallback(async (userId: string) => {
+ try {
+ const response = await fetchExpRankApi(userId);
+ setExpRank(response.data.rank);
+ } catch (err) {
+ handleError(err, userId, 'fetchExpRank');
+ }
+ }, [handleError]);
+
+ const fetchProfile = useCallback(async (query: string, type: 'nick' | 'id') => {
+ setLoading(true);
+ setError(null);
+ setDetailedError(null);
+ setProfileData(null);
+ setItemsData([]);
+ setExpRank(null);
+
+ try {
+ const response = await fetchProfileApi(query, type);
+
+ if (response.status === 404) {
+ setError('등록된 유저가 아닙니다.');
+ setLoading(false);
+ return;
+ }
+
+ const result = await response.data;
+
+ if (result.status === 200) {
+ setProfileData(result.data);
+
+ // Fetch items data
+ if (result.data.equipment.length > 0) {
+ const itemIds = result.data.equipment.map((eq: Equipment) => eq.itemId).join(',');
+ fetchItems(itemIds);
+ }
+
+ // Fetch exp rank
+ fetchExpRank(result.data.user.id);
+
+ // Save to recent searches
+ saveToRecentSearches(query, type);
+ }
+ } catch (err) {
+ setError('프로필을 불러오는데 실패했습니다.');
+ handleError(err, query, 'fetchProfile');
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchItems, fetchExpRank, saveToRecentSearches, handleError]);
+
+ // Initial load
+ useEffect(() => {
+ fetchModes();
+ fetchTotalUsers();
+ }, [fetchModes, fetchTotalUsers]);
+
+ return {
+ profileData,
+ itemsData,
+ modesData,
+ loading,
+ error,
+ detailedError,
+ setDetailedError,
+ totalUserCount,
+ expRank,
+ recentSearches,
+ fetchProfile,
+ removeFromRecentSearches
+ };
+};
diff --git a/app/kkuko/profile/hooks/useRecentSearches.ts b/app/kkuko/profile/hooks/useRecentSearches.ts
new file mode 100644
index 0000000..66cde87
--- /dev/null
+++ b/app/kkuko/profile/hooks/useRecentSearches.ts
@@ -0,0 +1,49 @@
+import { useState, useEffect, useCallback } from 'react';
+
+export type SearchHistoryItem = {
+ query: string;
+ type: 'nick' | 'id';
+};
+
+export const useRecentSearches = () => {
+ const [recentSearches, setRecentSearches] = useState([]);
+
+ useEffect(() => {
+ const saved = localStorage.getItem('kkuko-recent-searches');
+ if (saved) {
+ try {
+ setRecentSearches(JSON.parse(saved));
+ } catch (e) {
+ console.error('Failed to parse recent searches:', e);
+ }
+ }
+ }, []);
+
+ const saveToRecentSearches = useCallback((query: string, type: 'nick' | 'id') => {
+ setRecentSearches(prev => {
+ const newSearch = { query, type };
+ const filtered = prev.filter(
+ s => !(s.query === query && s.type === type)
+ );
+ const updated = [newSearch, ...filtered].slice(0, 7);
+ localStorage.setItem('kkuko-recent-searches', JSON.stringify(updated));
+ return updated;
+ });
+ }, []);
+
+ const removeFromRecentSearches = useCallback((query: string, type: 'nick' | 'id') => {
+ setRecentSearches(prev => {
+ const updated = prev.filter(
+ s => !(s.query === query && s.type === type)
+ );
+ localStorage.setItem('kkuko-recent-searches', JSON.stringify(updated));
+ return updated;
+ });
+ }, []);
+
+ return {
+ recentSearches,
+ saveToRecentSearches,
+ removeFromRecentSearches
+ };
+};
diff --git a/app/kkuko/profile/page.tsx b/app/kkuko/profile/page.tsx
new file mode 100644
index 0000000..0736c69
--- /dev/null
+++ b/app/kkuko/profile/page.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from 'react'
+import KkukoProfile from './KkukoProfile'
+
+export async function generateMetadata() {
+ return {
+ title: "끄코 유틸리티 - 끄코 유저 정보",
+ description: '끄코 유틸리티 - 끄코 유저 정보',
+ openGraph: {
+ title: "끄코 유틸리티 - 끄코 유저 정보",
+ description: "끄코 유틸리티 - 끄코 유저 정보",
+ type: "website",
+ url: "https://kkuko-utils.vercel.app/kkuko/profile",
+ siteName: "끄코 유틸리티",
+ locale: "ko_KR",
+ },
+ };
+}
+
+export default function KkukoProfilePage() {
+ return (
+ Loading... }>
+