diff --git a/app/features/search/components/MemoItem.module.scss b/app/features/search/components/MemoItem.module.scss new file mode 100644 index 0000000..c2cc01e --- /dev/null +++ b/app/features/search/components/MemoItem.module.scss @@ -0,0 +1,162 @@ +@use 'styles/layout' as *; + +.memoLink { + display: block; + text-decoration: none; + color: inherit; +} + +.memoItem { + background: #fff; + border: 1px solid #eef0f3; + border-radius: 12px; + overflow: hidden; +} + +.media { + position: relative; + width: 100%; + height: 180px; + overflow: hidden; + background: #f6f7f9; + + @include mobile { + height: 140px; + } +} + +.cover { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + align-items: center; +} + +.overlay { + position: absolute; + left: 12px; + right: 12px; + top: 12px; + display: flex; + justify-content: flex-start; + align-items: center; + pointer-events: none; +} + +.userRow { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 18px 6px 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.5); +} + +.avatar { + width: 36px; + height: 36px; + border-radius: 999px; + object-fit: cover; + background: #f3f4f6; + flex: 0 0 40px; + border: 2px solid rgba(255, 255, 255, 0.9); + + @include mobile { + width: 36px; + height: 36px; + } +} + +.userInfo { + display: flex; + flex-direction: column; + line-height: 1; + pointer-events: none; +} + +.handle { + font-weight: 700; + color: #0f172a; + font-size: 15px; +} + +.timeShort { + font-size: 12px; + color: #6b7280; +} + +.timeTop { + pointer-events: none; + display: flex; + align-items: center; + min-width: 0; + color: #6b7280; + font-size: 12px; + background: rgba(255, 255, 255, 0.6); + padding: 6px 8px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(2, 6, 23, 0.04); +} + +.body { + padding: 12px 14px 16px; +} + +.songMeta { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.titleBlock { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeInBody { + color: #6b7280; + font-size: 12px; + white-space: nowrap; + pointer-events: none; + margin-left: 8px; + flex: 0 0 auto; +} + +.songTitle { + font-weight: 700; + color: #0f172a; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @include mobile { + font-size: 15px; + } +} + +.artist { + font-size: 13px; + color: #6b7280; +} + +.content { + margin-top: 10px; + color: #374151; + font-size: 14px; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + + @include mobile { + -webkit-line-clamp: 3; + line-clamp: 3; + } +} diff --git a/app/features/search/components/MemoItem.tsx b/app/features/search/components/MemoItem.tsx new file mode 100644 index 0000000..78e8aeb --- /dev/null +++ b/app/features/search/components/MemoItem.tsx @@ -0,0 +1,48 @@ +import { Link } from '@remix-run/react'; + +import type { UserMusicMemoWithRelations } from 'app/features/search/services'; + +import styles from './MemoItem.module.scss'; + +const DEFAULT_AVATAR = '/images/features/profile/profile_default.png'; +const DEFAULT_COVER = '/images/features/profile/album_default2.png'; + +export default function MemoItem({ memo }: { memo: UserMusicMemoWithRelations }) { + const cover = memo.song?.thumbnailUrl ?? DEFAULT_COVER; + const avatar = memo.user?.avatarUrl ?? DEFAULT_AVATAR; + const excerpt = memo.content.length > 120 ? `${memo.content.slice(0, 120)}…` : memo.content; + const createdISO = new Date(memo.createdAt).toISOString(); + const created = new Date(memo.createdAt).toLocaleString(); + + return ( + +
+
+ {memo.song?.title +
+
+ {memo.user?.handle +
+ @{memo.user?.handle} +
+
+
+
+ +
+
+
+
{memo.song?.title ?? '제목 없음'}
+
{memo.song?.artist}
+
+
+ +
+
+ + {excerpt ?

{excerpt}

: null} +
+
+ + ); +} diff --git a/app/features/search/loader.ts b/app/features/search/loader.ts index 7d106d8..9c92934 100644 --- a/app/features/search/loader.ts +++ b/app/features/search/loader.ts @@ -1,20 +1,22 @@ import createLoader from 'app/utils/createLoader'; import { searchSongInputLoader } from './components/SearchSongInputloader'; -import { findUserByHandleSim } from './services'; +import { findMemosByContent, findUserByHandleSim } from './services'; export const searchLoader = createLoader(async ({ request, db }) => { const url = new URL(request.url); const query = url.searchParams.get('search') ?? ''; - const type = url.searchParams.get('tab') ?? 'music'; + const tab = url.searchParams.get('tab') ?? 'music'; const results = await (async () => { - if (type === 'music') { + if (tab === 'music') { return await searchSongInputLoader({ searchQuery: query }); - } - if (query) { + } else if (tab === 'handle') { return await findUserByHandleSim(db)({ handle: query }); + } else if (tab === 'memo') { + return await findMemosByContent(db)({ content: query }); } + return null; })(); diff --git a/app/features/search/pages/search.module.scss b/app/features/search/pages/search.module.scss index c4fc80e..6d73cdc 100644 --- a/app/features/search/pages/search.module.scss +++ b/app/features/search/pages/search.module.scss @@ -85,6 +85,7 @@ gap: 12px; padding: 0; margin: 0; + list-style: none; } .empty { @@ -198,12 +199,11 @@ } .profileGrid { + list-style: none; display: grid; gap: 8px; width: 100%; margin: 0; grid-auto-rows: 1fr; - /* reset ul default inset so items align with content */ padding: 0; - list-style: none; } diff --git a/app/features/search/pages/search.tsx b/app/features/search/pages/search.tsx index bb53715..b789c24 100644 --- a/app/features/search/pages/search.tsx +++ b/app/features/search/pages/search.tsx @@ -4,24 +4,27 @@ import { Suspense } from 'react'; import { SearchBar } from 'app/components/SearchBar'; import { UserItem } from 'app/components/UserItem'; import { MusicInfo } from 'app/external/music/IMusicSearchAPI'; +import MemoItem from 'app/features/search/components/MemoItem'; import SongItem, { SimpleSong } from 'app/features/search/components/SongItem'; import styles from './search.module.scss'; import { searchLoader } from '../loader'; - -import type { UserWithRecommendedSong } from '../services'; +import { UserMusicMemoWithRelations, UserWithRecommendedSong } from '../services'; type SearchUser = UserWithRecommendedSong & { isFollowing?: boolean | null }; +type Tab = 'music' | 'memo' | 'handle'; const PLACEHOLDER = '/images/features/profile/album_default2.png'; +type Results = MusicInfo[] | SearchUser[] | UserMusicMemoWithRelations[]; + export default function SearchPage() { const { query, results } = useLoaderData(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const tab = searchParams.get('tab') ?? 'music'; + const tab = (searchParams.get('tab') ?? 'music') as Tab; - const switchTab = (t: 'music' | 'handle') => { + const switchTab = (t: Tab) => { const next = new URLSearchParams(searchParams); next.set('tab', t); setSearchParams(next); @@ -36,43 +39,113 @@ export default function SearchPage() { < 이전 +
-
- -
- - -
-
- - {isResultsEmpty ? ( -

{`"${query}"에 대한 검색 결과가 없습니다.`}

- ) : tab === 'music' ? ( - - ) : ( -
    - {((results as SearchUser[]) || []).map((user) => ( - - ))} -
- )} + + +
); } +function SearchHeader({ + defaultQuery, + tab, + onSwitchTab, +}: { + defaultQuery: string; + tab: Tab; + onSwitchTab: (t: Tab) => void; +}) { + return ( +
+ +
+ + + +
+
+ ); +} + +function ResultsArea({ + tab, + results, + query, + isEmpty, +}: { + tab: Tab; + results: Results | null; + query: string; + isEmpty: boolean; +}) { + if (isEmpty) { + return

{`"${query}"에 대한 검색 결과가 없습니다.`}

; + } + + const isMusicResults = (r: Results | null): r is MusicInfo[] => + Array.isArray(r) && + r.length > 0 && + typeof (r[0] as MusicInfo).title === 'string' && + typeof (r[0] as MusicInfo).artist === 'string'; + + const isUserResults = (r: Results | null): r is SearchUser[] => + Array.isArray(r) && r.length > 0 && typeof (r[0] as SearchUser).handle === 'string'; + + const isMemoResults = (r: Results | null): r is UserMusicMemoWithRelations[] => + Array.isArray(r) && r.length > 0 && ('content' in r[0] || 'songId' in r[0]); + + if (tab === 'music' && isMusicResults(results)) return ; + if (tab === 'handle' && isUserResults(results)) return ; + if (tab === 'memo' && isMemoResults(results)) return ; + + if (tab === 'music') return ; + if (tab === 'handle') return ; + return ; +} + +function UserList({ users }: { users: SearchUser[] }) { + return ( +
    + {users.map((user) => ( + + ))} +
+ ); +} + +function MemoList({ memos }: { memos: UserMusicMemoWithRelations[] }) { + return ( +
    + {memos.map((m) => ( +
  • + +
  • + ))} +
+ ); +} + const SearchedSongList = ({ songs }: { songs: MusicInfo[] }) => { return (
    diff --git a/app/features/search/services.ts b/app/features/search/services.ts index d7adb09..43bff78 100644 --- a/app/features/search/services.ts +++ b/app/features/search/services.ts @@ -6,6 +6,10 @@ export type UserWithRecommendedSong = Prisma.UserGetPayload<{ include: { todayRecommendedSong: true }; }>; +export type UserMusicMemoWithRelations = Prisma.UserMusicMemoGetPayload<{ + include: { user: true; song: true }; +}>; + export const findUserByHandleSim = createService<{ handle: string }, UserWithRecommendedSong[]>(async (db, args) => { const users = await db.user.findMany({ where: { @@ -36,3 +40,25 @@ export const getRecommendedUsers = createService, UserWith })); } ); + +export const findMemosByContent = createService<{ content: string }, UserMusicMemoWithRelations[]>( + async (docToBlocks, args) => { + const content = (args.content ?? '').trim(); + if (!content) return []; + const memos = await docToBlocks.userMusicMemo.findMany({ + where: { + content: { + contains: content, + mode: 'insensitive', + }, + }, + include: { + user: true, + song: true, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + return memos; + } +);