feat(Search): Redis 기반 bi-gram 추천 검색어 시스템 구현
- MariaDB 테이블 추가 (search_queries, word_pairs) - Redis 컨테이너 추가 (Sorted Set 캐싱) - 백엔드 suggestions 서비스 및 API 구현 - 검색 실행 시 검색어 저장 (bi-gram 학습) - PC Schedule 프론트엔드 연동 완료
This commit is contained in:
parent
3d2a6555f8
commit
727b05f0f5
8 changed files with 389 additions and 31 deletions
19
backend/lib/redis.js
Normal file
19
backend/lib/redis.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Redis from "ioredis";
|
||||
|
||||
// Redis 클라이언트 초기화
|
||||
const redis = new Redis({
|
||||
host: "fromis9-redis",
|
||||
port: 6379,
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redis.on("connect", () => {
|
||||
console.log("[Redis] 연결 성공");
|
||||
});
|
||||
|
||||
redis.on("error", (err) => {
|
||||
console.error("[Redis] 연결 오류:", err.message);
|
||||
});
|
||||
|
||||
export default redis;
|
||||
102
backend/package-lock.json
generated
102
backend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"dayjs": "^1.11.19",
|
||||
"express": "^4.18.2",
|
||||
"inko": "^1.1.1",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"meilisearch": "^0.55.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
@ -1291,6 +1292,12 @@
|
|||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@smithy/abort-controller": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz",
|
||||
|
|
@ -2184,6 +2191,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
|
@ -2759,6 +2775,53 @@
|
|||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.1.tgz",
|
||||
"integrity": "sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -2835,12 +2898,24 @@
|
|||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
|
|
@ -3316,6 +3391,27 @@
|
|||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/rss-parser": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
|
||||
|
|
@ -3555,6 +3651,12 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"dayjs": "^1.11.19",
|
||||
"express": "^4.18.2",
|
||||
"inko": "^1.1.1",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"meilisearch": "^0.55.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
import { searchSchedules } from "../services/meilisearch.js";
|
||||
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 검색어 추천 API (Bi-gram 기반)
|
||||
router.get("/suggestions", async (req, res) => {
|
||||
try {
|
||||
const { q, limit } = req.query;
|
||||
|
||||
if (!q || q.trim().length === 0) {
|
||||
return res.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const suggestions = await getSuggestions(q, parseInt(limit) || 10);
|
||||
res.json({ suggestions });
|
||||
} catch (error) {
|
||||
console.error("추천 검색어 오류:", error);
|
||||
res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 공개 일정 목록 조회 (검색 포함)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
|
|
@ -14,6 +32,13 @@ router.get("/", async (req, res) => {
|
|||
const offset = parseInt(req.query.offset) || 0;
|
||||
const pageLimit = parseInt(req.query.limit) || 100;
|
||||
|
||||
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
|
||||
if (offset === 0) {
|
||||
saveSearchQuery(search.trim()).catch((err) =>
|
||||
console.error("검색어 저장 실패:", err.message)
|
||||
);
|
||||
}
|
||||
|
||||
// Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징)
|
||||
const results = await searchSchedules(search.trim(), {
|
||||
limit: 1000, // 내부적으로 1000개까지 검색
|
||||
|
|
|
|||
178
backend/services/suggestions.js
Normal file
178
backend/services/suggestions.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import pool from "../lib/db.js";
|
||||
import redis from "../lib/redis.js";
|
||||
import Inko from "inko";
|
||||
|
||||
const inko = new Inko();
|
||||
|
||||
// Redis 키 prefix
|
||||
const SUGGESTION_PREFIX = "suggestions:";
|
||||
const CACHE_TTL = 86400; // 24시간
|
||||
|
||||
/**
|
||||
* 영문 자판으로 입력된 검색어인지 확인
|
||||
*/
|
||||
function isEnglishKeyboard(text) {
|
||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||
return englishChars.length > 0 && koreanChars.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 저장 (검색 실행 시 호출)
|
||||
* - search_queries 테이블에 Unigram 저장
|
||||
* - word_pairs 테이블에 Bi-gram 저장
|
||||
* - Redis 캐시 업데이트
|
||||
*/
|
||||
export async function saveSearchQuery(query) {
|
||||
if (!query || query.trim().length === 0) return;
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
// 1. Unigram 저장 (인기도)
|
||||
await pool.query(
|
||||
`INSERT INTO search_queries (query, count)
|
||||
VALUES (?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
|
||||
[normalizedQuery]
|
||||
);
|
||||
|
||||
// 2. Bi-gram 저장 (다음 단어 예측)
|
||||
const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0);
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const word1 = words[i];
|
||||
const word2 = words[i + 1];
|
||||
|
||||
// DB 저장
|
||||
await pool.query(
|
||||
`INSERT INTO word_pairs (word1, word2, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||
[word1, word2]
|
||||
);
|
||||
|
||||
// Redis 캐시 업데이트 (Sorted Set)
|
||||
await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2);
|
||||
}
|
||||
|
||||
console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 검색어 저장 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 검색어 조회
|
||||
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
|
||||
* - 그 외: prefix 매칭 (인기순)
|
||||
*/
|
||||
export async function getSuggestions(query, limit = 10) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let searchQuery = query.toLowerCase();
|
||||
|
||||
// 영문 자판 -> 한글 변환 시도
|
||||
if (isEnglishKeyboard(searchQuery)) {
|
||||
const koreanQuery = inko.en2ko(searchQuery);
|
||||
if (koreanQuery !== searchQuery) {
|
||||
searchQuery = koreanQuery;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const endsWithSpace = query.endsWith(" ");
|
||||
const words = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0);
|
||||
|
||||
if (endsWithSpace && words.length > 0) {
|
||||
// 다음 단어 예측 (Bi-gram)
|
||||
const lastWord = words[words.length - 1];
|
||||
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
||||
} else {
|
||||
// prefix 매칭 (인기순)
|
||||
return await getPrefixSuggestions(searchQuery.trim(), limit);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 단어 예측 (Bi-gram 기반)
|
||||
*/
|
||||
async function getNextWordSuggestions(lastWord, prefix, limit) {
|
||||
try {
|
||||
// 1. Redis 캐시 확인
|
||||
const cacheKey = `${SUGGESTION_PREFIX}${lastWord}`;
|
||||
let nextWords = await redis.zrevrange(cacheKey, 0, limit - 1);
|
||||
|
||||
// 2. 캐시 미스 시 DB 조회 후 Redis 채우기
|
||||
if (nextWords.length === 0) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT word2, count FROM word_pairs
|
||||
WHERE word1 = ?
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[lastWord, limit * 2] // 여유있게 가져오기
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Redis에 캐싱
|
||||
const multi = redis.multi();
|
||||
for (const row of rows) {
|
||||
multi.zadd(cacheKey, row.count, row.word2);
|
||||
}
|
||||
multi.expire(cacheKey, CACHE_TTL);
|
||||
await multi.exec();
|
||||
|
||||
nextWords = rows.map((r) => r.word2);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. prefix + 다음 단어 조합으로 반환
|
||||
return nextWords.slice(0, limit).map((word) => `${prefix} ${word}`);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix 매칭 (인기순)
|
||||
*/
|
||||
async function getPrefixSuggestions(prefix, limit) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT query FROM search_queries
|
||||
WHERE query LIKE ?
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, limit]
|
||||
);
|
||||
|
||||
return rows.map((r) => r.query);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] Prefix 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 캐시 초기화 (필요시)
|
||||
*/
|
||||
export async function clearSuggestionCache() {
|
||||
try {
|
||||
const keys = await redis.keys(`${SUGGESTION_PREFIX}*`);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,16 @@ services:
|
|||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis - 추천 검색어 캐시
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: fromis9-redis
|
||||
volumes:
|
||||
- ./redis_data:/data
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
app:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ function AdminSchedule() {
|
|||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리
|
||||
const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
|
||||
const SEARCH_LIMIT = 20; // 페이지당 20개
|
||||
const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ function Schedule() {
|
|||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const SEARCH_LIMIT = 20; // 페이지당 20개
|
||||
const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정)
|
||||
|
||||
|
|
@ -102,6 +104,34 @@ function Schedule() {
|
|||
prevInViewRef.current = inView;
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
|
||||
|
||||
// 검색어 자동완성 API 호출 (debounce 적용)
|
||||
useEffect(() => {
|
||||
// 검색어가 비어있으면 초기화
|
||||
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce: 200ms 후에 API 호출
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data.suggestions || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('추천 검색어 API 오류:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [originalSearchQuery]);
|
||||
|
||||
// 데이터 로드
|
||||
// 초기 데이터 로드 (카테고리만)
|
||||
useEffect(() => {
|
||||
|
|
@ -752,33 +782,28 @@ function Schedule() {
|
|||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
// 필터링은 원본 쿼리 기준으로 유지
|
||||
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
|
||||
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
|
||||
).slice(0, 7);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const newIndex = selectedSuggestionIndex < dummySuggestions.length - 1
|
||||
const newIndex = selectedSuggestionIndex < suggestions.length - 1
|
||||
? selectedSuggestionIndex + 1
|
||||
: 0;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (dummySuggestions[newIndex]) {
|
||||
setSearchInput(dummySuggestions[newIndex]);
|
||||
if (suggestions[newIndex]) {
|
||||
setSearchInput(suggestions[newIndex]);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const newIndex = selectedSuggestionIndex > 0
|
||||
? selectedSuggestionIndex - 1
|
||||
: dummySuggestions.length - 1;
|
||||
: suggestions.length - 1;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (dummySuggestions[newIndex]) {
|
||||
setSearchInput(dummySuggestions[newIndex]);
|
||||
if (suggestions[newIndex]) {
|
||||
setSearchInput(suggestions[newIndex]);
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
if (selectedSuggestionIndex >= 0 && dummySuggestions[selectedSuggestionIndex]) {
|
||||
setSearchInput(dummySuggestions[selectedSuggestionIndex]);
|
||||
setSearchTerm(dummySuggestions[selectedSuggestionIndex]);
|
||||
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
|
||||
setSearchInput(suggestions[selectedSuggestionIndex]);
|
||||
setSearchTerm(suggestions[selectedSuggestionIndex]);
|
||||
} else if (searchInput.trim()) {
|
||||
setSearchTerm(searchInput);
|
||||
}
|
||||
|
|
@ -831,20 +856,16 @@ function Schedule() {
|
|||
{/* 검색어 추천 드롭다운 */}
|
||||
{showSuggestions && originalSearchQuery.length > 0 && (
|
||||
<div className="absolute top-full mt-2 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 overflow-hidden" style={{ left: '44px', right: '66px' }}>
|
||||
{(() => {
|
||||
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
|
||||
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
|
||||
).slice(0, 7);
|
||||
|
||||
if (dummySuggestions.length === 0) {
|
||||
return (
|
||||
{isLoadingSuggestions ? (
|
||||
<div className="px-4 py-3 text-gray-400 text-sm text-center">
|
||||
검색 중...
|
||||
</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-gray-400 text-sm text-center">
|
||||
추천 검색어가 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return dummySuggestions.map((suggestion, index) => (
|
||||
) : (
|
||||
suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
|
|
@ -863,8 +884,8 @@ function Schedule() {
|
|||
<Search size={15} className={selectedSuggestionIndex === index ? 'text-primary' : 'text-gray-400'} />
|
||||
<span className="text-sm">{suggestion}</span>
|
||||
</button>
|
||||
));
|
||||
})()}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue