From 8a48b6572b859bc18c0eafe254e2ff1c2a939306 Mon Sep 17 00:00:00 2001 From: mokjakA Date: Thu, 30 Oct 2025 19:07:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(search):=20=EB=85=B8=EB=9E=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/features/profile/loader.ts | 64 +++++++++++++++++----------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/app/features/profile/loader.ts b/app/features/profile/loader.ts index 800add3..6689313 100644 --- a/app/features/profile/loader.ts +++ b/app/features/profile/loader.ts @@ -55,38 +55,38 @@ export const profileRedirectLoader = createLoader(async ({ request }) => { return redirect(`/profile/${user.id}/feed`); }); -export const addTodaySongLoader = createLoader(async ({ db, params, request }) => { - await requireUserOwnership(request, { userId: params.userId }); - - const userId = Number(params.userId); - if (isNaN(userId)) { - throw new Response('잘못된 사용자입니다.', { status: 400 }); - } - - const user = await db.user.findUnique({ - where: { id: userId }, - select: { - todayRecommendedSong: { - select: { - id: true, - title: true, - artist: true, - album: true, - thumbnailUrl: true, - spotifyId: true, - }, - }, - }, - }); - - if (!user) { - throw new Response('유저가 존재하지 않습니다.', { status: 404 }); - } - - const songs = searchSongInputLoader({ request }); - - return { initialSong: user.todayRecommendedSong, songs }; -}); +// export const addTodaySongLoader = createLoader(async ({ db, params, request }) => { +// await requireUserOwnership(request, { userId: params.userId }); + +// const userId = Number(params.userId); +// if (isNaN(userId)) { +// throw new Response('잘못된 사용자입니다.', { status: 400 }); +// } + +// const user = await db.user.findUnique({ +// where: { id: userId }, +// select: { +// todayRecommendedSong: { +// select: { +// id: true, +// title: true, +// artist: true, +// album: true, +// thumbnailUrl: true, +// spotifyId: true, +// }, +// }, +// }, +// }); + +// if (!user) { +// throw new Response('유저가 존재하지 않습니다.', { status: 404 }); +// } + +// const songs = searchSongInputLoader({ request }); + +// return { initialSong: user.todayRecommendedSong, songs }; +// }); export const editHandleLoader = createLoader(async ({ request, params }) => { await requireUserOwnership(request, { userId: params.userId }); From 3a5b0e6e863dfccf070a6e995ae16d4fdbe9e396 Mon Sep 17 00:00:00 2001 From: mokjakA Date: Fri, 31 Oct 2025 00:26:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(search):=20=EB=85=B8=EB=9E=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=9B=84=20=EC=98=A4=EB=8A=98=EC=9D=98?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EA=B3=A1&=EB=A9=94=EB=AA=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/features/profile/action.ts | 40 --------- app/features/profile/loader.ts | 33 ------- .../profile/pages/addTodaySong.module.scss | 48 ---------- app/features/profile/pages/addTodaySong.tsx | 60 ------------- app/features/search/action.ts | 74 +++++++++++++++ .../search/components/SearchSongInput.tsx | 73 --------------- .../search/components/SongItem.module.scss | 74 +++++++++++++-- app/features/search/components/SongItem.tsx | 53 ++++++++++- .../search/components/searchSong.module.scss | 90 ------------------- app/features/search/pages/search.module.scss | 4 +- app/features/search/pages/search.tsx | 5 +- app/routes/_index.tsx | 3 - app/routes/profile.$userId.addTodaySong.tsx | 12 --- app/routes/search.tsx | 2 + 14 files changed, 198 insertions(+), 373 deletions(-) delete mode 100644 app/features/profile/pages/addTodaySong.module.scss delete mode 100644 app/features/profile/pages/addTodaySong.tsx delete mode 100644 app/features/search/components/SearchSongInput.tsx delete mode 100644 app/features/search/components/searchSong.module.scss delete mode 100644 app/routes/profile.$userId.addTodaySong.tsx diff --git a/app/features/profile/action.ts b/app/features/profile/action.ts index 29166d9..adc843c 100644 --- a/app/features/profile/action.ts +++ b/app/features/profile/action.ts @@ -7,46 +7,6 @@ import createAction from 'app/utils/createAction'; import { HandleSchema, FollowsActionSchema } from './schema'; -export const addTodaySongAction = createAction(async ({ request, db, params }) => { - await requireUserOwnership(request, { userId: params.userId }); - const formData = await request.formData(); - - const userId = Number(params.userId); - if (isNaN(userId)) { - return new Response(JSON.stringify('잘못된 사용자입니다.'), { status: 400 }); - } - - const title = (formData.get('title') as string | null)?.trim() ?? ''; - const artist = (formData.get('artist') as string | null)?.trim() ?? ''; - const album = (formData.get('album') as string | null)?.trim() || null; - const thumbnailUrl = (formData.get('thumbnailUrl') as string | null)?.trim() || null; - const spotifyId = (formData.get('spotifyId') as string | null)?.trim() || null; - - if (!title || !artist) { - return new Response('제목과 아티스트는 필수입니다.', { status: 400 }); - } - - const existingSong = - (spotifyId ? await db.song.findFirst({ where: { spotifyId } }) : null) ?? - (await db.song.findFirst({ where: { title, artist } })); - - const song = existingSong - ? await db.song.update({ - where: { id: existingSong.id }, - data: { album, thumbnailUrl, spotifyId }, - }) - : await db.song.create({ - data: { title, artist, album, thumbnailUrl, spotifyId }, - }); - - await db.user.update({ - where: { id: userId }, - data: { todayRecommendedSongId: song.id }, - }); - - return redirect('../show'); -}); - export const editHandleAction = createAction(async ({ request, db, params }) => { await requireUserOwnership(request, { userId: params.userId }); diff --git a/app/features/profile/loader.ts b/app/features/profile/loader.ts index 6689313..05579e4 100644 --- a/app/features/profile/loader.ts +++ b/app/features/profile/loader.ts @@ -55,39 +55,6 @@ export const profileRedirectLoader = createLoader(async ({ request }) => { return redirect(`/profile/${user.id}/feed`); }); -// export const addTodaySongLoader = createLoader(async ({ db, params, request }) => { -// await requireUserOwnership(request, { userId: params.userId }); - -// const userId = Number(params.userId); -// if (isNaN(userId)) { -// throw new Response('잘못된 사용자입니다.', { status: 400 }); -// } - -// const user = await db.user.findUnique({ -// where: { id: userId }, -// select: { -// todayRecommendedSong: { -// select: { -// id: true, -// title: true, -// artist: true, -// album: true, -// thumbnailUrl: true, -// spotifyId: true, -// }, -// }, -// }, -// }); - -// if (!user) { -// throw new Response('유저가 존재하지 않습니다.', { status: 404 }); -// } - -// const songs = searchSongInputLoader({ request }); - -// return { initialSong: user.todayRecommendedSong, songs }; -// }); - export const editHandleLoader = createLoader(async ({ request, params }) => { await requireUserOwnership(request, { userId: params.userId }); diff --git a/app/features/profile/pages/addTodaySong.module.scss b/app/features/profile/pages/addTodaySong.module.scss deleted file mode 100644 index 14c8106..0000000 --- a/app/features/profile/pages/addTodaySong.module.scss +++ /dev/null @@ -1,48 +0,0 @@ -// @use 'styles/layout' as *; - -// .add-today-song-container { -// display: flex; -// padding: 36px 16px; -// flex-direction: column; -// border-radius: 8px; -// } - -// .title-box { -// display: flex; -// align-items: center; -// .back-button { -// color: $black-500; -// font-size: 24px; -// font-weight: 600; -// margin-left: 16px; -// margin: 0; -// text-decoration: none; -// } -// .title { -// flex: 1 0 auto; -// font-size: 24px; -// font-weight: 600; -// margin-left: 16px; -// margin: 0; -// text-align: center; -// } -// } - -// .search-section { -// margin-top: 24px; -// h2 { -// font-size: 20px; -// font-weight: 600; -// margin-bottom: 16px; -// } -// } - -// .initial-song-container { -// margin-top: 24px; -// h3 { -// margin: 0; -// font-size: 20px; -// font-weight: 600; -// margin-bottom: 16px; -// } -// } diff --git a/app/features/profile/pages/addTodaySong.tsx b/app/features/profile/pages/addTodaySong.tsx deleted file mode 100644 index 3eaf4b9..0000000 --- a/app/features/profile/pages/addTodaySong.tsx +++ /dev/null @@ -1,60 +0,0 @@ -// import { Link, useLoaderData, useSubmit } from '@remix-run/react'; - -// import type { MusicInfo } from 'app/external/music/IMusicSearchAPI'; -// import styles from 'app/features/profile/pages/addTodaySong.module.scss'; -// import SongItem, { type SimpleSong } from 'app/features/search/components/SongItem'; - -// import SearchSongInput from '../../search/components/SearchSongInput'; -// import { addTodaySongLoader } from '../loader'; - -// export default function AddTodaySongPage() { -// const { initialSong, songs } = useLoaderData(); -// const savePickedSong = useSavePickedSong(); - -// return ( -//
-//
-// -// {'<'} -// -//

오늘의 추천곡 수정

-//
- -//
-// -//
- -// {initialSong && ( -//
-//

현재 추천곡

-// -//
-// )} -//
-// ); -// } - -// const useSavePickedSong = () => { -// const submit = useSubmit(); - -// return async (song: MusicInfo) => { -// const ok = window.confirm(`"${song.title}" – ${song.artist}\n오늘의 추천곡으로 등록하시겠습니까?`); -// if (!ok) return; - -// const simple: SimpleSong = { -// id: 0, -// title: song.title, -// artist: song.artist, -// album: song.album ?? null, -// thumbnailUrl: song.albumCover ? await song.albumCover : null, -// }; - -// const fd = new FormData(); -// fd.append('title', simple.title); -// fd.append('artist', simple.artist); -// fd.append('album', simple.album ?? ''); -// fd.append('thumbnailUrl', simple.thumbnailUrl ?? ''); -// fd.append('spotifyId', song.spotifyId ?? ''); -// submit(fd, { method: 'post' }); -// }; -// }; diff --git a/app/features/search/action.ts b/app/features/search/action.ts index e69de29..dd8197a 100644 --- a/app/features/search/action.ts +++ b/app/features/search/action.ts @@ -0,0 +1,74 @@ +import { redirect } from '@remix-run/node'; + +import { getCurrentUser } from 'app/external/auth/jwt.server'; +import createAction from 'app/utils/createAction'; + +export const searchAction = createAction(async ({ request, db }) => { + const formData = await request.formData(); + const actionType = String(formData.get('_action') ?? ''); + + const currentUser = await getCurrentUser(request); + if (!currentUser) { + return redirect('/login/error'); + } + const userId = currentUser.id; + if (Number.isNaN(userId)) { + return new Response('잘못된 사용자입니다.', { status: 400 }); + } + + const songId = formData.get('songId'); + const title = (formData.get('title') as string | null)?.trim() ?? ''; + const artist = (formData.get('artist') as string | null)?.trim() ?? ''; + const album = (formData.get('album') as string | null)?.trim() || null; + const thumbnailUrl = (formData.get('thumbnailUrl') as string | null)?.trim() || null; + const spotifyId = (formData.get('spotifyId') as string | null)?.trim() || null; + + const maybeSongOrResponse = await (async () => { + if (songId) { + const s = await db.song.findUnique({ where: { id: Number(songId) } }); + if (!s) return new Response('노래를 찾을 수 없습니다.', { status: 404 }); + return s; + } + + if (!title || !artist) { + return new Response('제목과 아티스트는 필수입니다.', { status: 400 }); + } + + const existingSong = + (spotifyId ? await db.song.findFirst({ where: { spotifyId } }) : null) ?? + (await db.song.findFirst({ where: { title, artist } })); + + if (existingSong) { + const updated = await db.song.update({ + where: { id: existingSong.id }, + data: { album, thumbnailUrl, spotifyId }, + }); + return updated; + } + + const created = await db.song.create({ + data: { title, artist, album, thumbnailUrl, spotifyId }, + }); + return created; + })(); + + if (maybeSongOrResponse instanceof Response) { + return maybeSongOrResponse; + } + + const song = maybeSongOrResponse; + + if (actionType === 'addToday') { + await db.user.update({ + where: { id: userId }, + data: { todayRecommendedSongId: song.id }, + }); + return redirect(`/profile/${userId}/show`); + } + + if (actionType === 'createMemo') { + return redirect(`/music/${song.id}/user/${userId}/create`); + } + + return new Response('unknown action', { status: 400 }); +}); diff --git a/app/features/search/components/SearchSongInput.tsx b/app/features/search/components/SearchSongInput.tsx deleted file mode 100644 index f0badd2..0000000 --- a/app/features/search/components/SearchSongInput.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// import { Await, Form } from '@remix-run/react'; -// import { Suspense } from 'react'; - -// import type { MusicInfo } from 'app/external/music/IMusicSearchAPI'; -// import styles from 'app/features/profile/pages/searchSong.module.scss'; - -// const PLACEHOLDER = '/images/features/profile/album_default2.png'; - -// interface Props { -// songs: Promise | undefined; -// onSelect: (song: MusicInfo, index: number) => void; -// } - -// export default function SearchSongInput({ onSelect, songs }: Props) { -// return ( -//
-//
-// -//
- -// -//
-// ); -// } - -// const SearchedSongList = ({ -// songs, -// onSelect, -// }: { -// songs: Promise | undefined; -// onSelect: (song: MusicInfo, index: number) => void; -// }) => { -// return ( -//
    -// 검색 중...

    }> -// -// {(resolvedSongs) => ( -// <> -// {resolvedSongs?.length === 0 &&

    검색 결과가 없습니다.

    } -// {resolvedSongs?.map((song, i) => ( -//
  • onSelect(song, i)} -// > -// }> -// -// {(albumCover) => ( -// {song.album} { -// e.currentTarget.src = PLACEHOLDER; -// }} -// /> -// )} -// -// -//
    -//
    {song.title}
    -//
    {song.artist}
    -//
    {song.album}
    -//
    -//
  • -// ))} -// -// )} -//
    -//
    -//
-// ); -// }; diff --git a/app/features/search/components/SongItem.module.scss b/app/features/search/components/SongItem.module.scss index b27d498..7099578 100644 --- a/app/features/search/components/SongItem.module.scss +++ b/app/features/search/components/SongItem.module.scss @@ -1,26 +1,84 @@ @use 'styles/layout' as *; -.song-item { +.songItem { display: flex; align-items: center; gap: 12px; - .item-image { - height: 60px; - border-radius: 8px; + width: 100%; + padding-right: 8px; + position: relative; + + @include mobile { + height: 100px; + } + + .itemImage { + height: 85px; + width: 85px; + object-fit: cover; + border-radius: 5px; + + @include mobile { + height: 100%; + } } - .item-text-box { + + .itemTextBox { display: flex; flex-direction: column; gap: 4px; - .item-title { + flex: 1; + min-width: 0; + + @include mobile { + padding-bottom: 35px; + } + + .itemTitle { font-size: 1rem; font-weight: 600; color: #7b7b7b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .item-artist { + + .itemArtist { font-size: 0.875rem; font-weight: 400; - color: #a0a0a0; + color: inherit; + } + } + .actions { + display: flex; + flex-direction: row; + gap: 6px; + margin-left: auto; + margin-right: 0; + + @include mobile { + position: absolute; + right: 8px; + bottom: 8px; + margin-left: 0; + gap: 6px; } } + + .memoButton, + .addTodayButton { + padding: 6px 10px; + font-size: 13px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + background: white; + cursor: pointer; + white-space: inherit; + } + + .addTodayButton { + background: $primary-500; + color: white; + border-color: transparent; + } } diff --git a/app/features/search/components/SongItem.tsx b/app/features/search/components/SongItem.tsx index 7023fe7..d092393 100644 --- a/app/features/search/components/SongItem.tsx +++ b/app/features/search/components/SongItem.tsx @@ -1,11 +1,14 @@ +import { Form } from '@remix-run/react'; + import styles from './SongItem.module.scss'; export interface SimpleSong { - id: number; + id?: number; title: string; artist: string; - thumbnailUrl: string | null; - album: string | null; + thumbnailUrl?: string | null; + album?: string | null; + spotifyId?: string | null; } interface Props { @@ -13,6 +16,32 @@ interface Props { } export default function SongItem({ song }: Props) { + const renderHiddenFieldsForSong = () => { + if (song.id && song.id > 0) { + return ; + } + + return ( + <> + + + + + + + ); + }; + + const confirmAddToday = (e: React.FormEvent) => { + const ok = window.confirm(`"${song.title}" – ${song.artist}\n오늘의 추천곡으로 등록하시겠습니까?`); + if (!ok) e.preventDefault(); + }; + + const confirmCreateMemo = (e: React.FormEvent) => { + const ok = window.confirm(`"${song.title}" – ${song.artist}\n이 곡을 메모로 추가하시겠습니까?`); + if (!ok) e.preventDefault(); + }; + return (
{song.title}

{song.artist}

+ +
+
+ + {renderHiddenFieldsForSong()} + +
+ +
+ + {renderHiddenFieldsForSong()} + +
+
); } diff --git a/app/features/search/components/searchSong.module.scss b/app/features/search/components/searchSong.module.scss deleted file mode 100644 index 608bbf1..0000000 --- a/app/features/search/components/searchSong.module.scss +++ /dev/null @@ -1,90 +0,0 @@ -// .searchWrapper { -// background-color: #f2f4f5; -// padding: 24px; -// border-radius: 8px; - -// margin: 16px 24px; -// } - -// .formRow { -// display: flex; -// gap: 8px; -// } - -// .input { -// flex: 1; -// padding: 8px 12px; -// border: 1px solid #d1d5db; -// border-radius: 100px; -// font-size: 14px; -// } - -// .button { -// padding: 8px 16px; -// border: none; -// background-color: #2563eb; -// color: #fff; -// border-radius: 4px; -// cursor: pointer; - -// &:disabled { -// opacity: 0.6; -// cursor: default; -// } -// } - -// .songList { -// margin-top: 16px; -// display: flex; -// flex-direction: column; -// gap: 12px; -// padding: 0; -// } - -// .songItem { -// display: flex; -// gap: 16px; -// align-items: center; -// padding: 12px; -// background-color: #fff; -// border: 1px solid #e5e7eb; -// border-radius: 6px; -// cursor: pointer; - -// &:hover { -// background-color: #f9fafb; -// } -// } - -// .cover { -// width: 64px; -// height: 64px; -// object-fit: cover; -// border-radius: 4px; -// background-color: #f3f4f6; -// } - -// .texts { -// overflow: hidden; -// } - -// .title { -// font-weight: 600; -// white-space: nowrap; -// overflow: hidden; -// text-overflow: ellipsis; -// margin-bottom: 2px; -// } - -// .artist, -// .album { -// font-size: 12px; -// color: #6b7280; -// white-space: nowrap; -// overflow: hidden; -// text-overflow: ellipsis; -// } - -// .empty { -// color: #9ca3af; -// } diff --git a/app/features/search/pages/search.module.scss b/app/features/search/pages/search.module.scss index 5fecb89..b1d1770 100644 --- a/app/features/search/pages/search.module.scss +++ b/app/features/search/pages/search.module.scss @@ -14,11 +14,13 @@ display: flex; justify-content: center; align-items: flex-start; - padding: 14px 10px; position: sticky; align-self: flex-start; + top: 15px; + height: fit-content; + @include tablet { display: none; } diff --git a/app/features/search/pages/search.tsx b/app/features/search/pages/search.tsx index 725e86f..5810341 100644 --- a/app/features/search/pages/search.tsx +++ b/app/features/search/pages/search.tsx @@ -3,7 +3,7 @@ import { Suspense } from 'react'; import { SearchBar } from 'app/components/SearchBar'; import { MusicInfo } from 'app/external/music/IMusicSearchAPI'; -import SongItem, { type SimpleSong } from 'app/features/search/components/SongItem'; +import SongItem, { SimpleSong } from 'app/features/search/components/SongItem'; import styles from './search.module.scss'; import { searchLoader } from '../loader'; @@ -43,11 +43,12 @@ const SearchedSongList = ({ songs }: { songs: Promise | undefined } {(albumCover) => { const simple: SimpleSong = { - id: idx, + id: 0, title: song.title, artist: song.artist, thumbnailUrl: albumCover ?? null, album: song.album ?? null, + spotifyId: song.spotifyId ?? null, }; return ; }} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index ed87fec..d25457c 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,6 +1,3 @@ import Home from 'app/features/home'; -import { addTodaySongAction } from 'app/features/profile/action'; - -export { addTodaySongAction as action }; export default Home; diff --git a/app/routes/profile.$userId.addTodaySong.tsx b/app/routes/profile.$userId.addTodaySong.tsx deleted file mode 100644 index 3307fa9..0000000 --- a/app/routes/profile.$userId.addTodaySong.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// import type { MetaFunction } from '@remix-run/node'; - -// import { addTodaySongAction } from 'app/features/profile/action'; -// import { addTodaySongLoader } from 'app/features/profile/loader'; -// import AddTodaySongPage from 'app/features/profile/pages/addTodaySong'; - -// export const meta: MetaFunction = () => [{ title: '오늘의 추천곡 수정' }]; - -// export const loader = addTodaySongLoader; -// export const action = addTodaySongAction; - -// export default AddTodaySongPage; diff --git a/app/routes/search.tsx b/app/routes/search.tsx index 7ecbf25..897c357 100644 --- a/app/routes/search.tsx +++ b/app/routes/search.tsx @@ -1,8 +1,10 @@ +import { searchAction } from 'app/features/search/action'; import { searchLoader } from 'app/features/search/loader'; import Search from 'app/features/search/pages/search'; export const meta = () => [{ title: '유저 검색' }]; export const loader = searchLoader; +export const action = searchAction; export default Search; From 108cfa3c6395fedca32a4d9f933188f0e718e8ba Mon Sep 17 00:00:00 2001 From: mokjakA Date: Thu, 30 Oct 2025 19:07:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(search):=20=EB=85=B8=EB=9E=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/components/SearchSongInput.tsx | 73 +++++++++++++++ .../search/components/searchSong.module.scss | 90 +++++++++++++++++++ app/features/search/pages/search.module.scss | 1 + 3 files changed, 164 insertions(+) create mode 100644 app/features/search/components/SearchSongInput.tsx create mode 100644 app/features/search/components/searchSong.module.scss diff --git a/app/features/search/components/SearchSongInput.tsx b/app/features/search/components/SearchSongInput.tsx new file mode 100644 index 0000000..f0badd2 --- /dev/null +++ b/app/features/search/components/SearchSongInput.tsx @@ -0,0 +1,73 @@ +// import { Await, Form } from '@remix-run/react'; +// import { Suspense } from 'react'; + +// import type { MusicInfo } from 'app/external/music/IMusicSearchAPI'; +// import styles from 'app/features/profile/pages/searchSong.module.scss'; + +// const PLACEHOLDER = '/images/features/profile/album_default2.png'; + +// interface Props { +// songs: Promise | undefined; +// onSelect: (song: MusicInfo, index: number) => void; +// } + +// export default function SearchSongInput({ onSelect, songs }: Props) { +// return ( +//
+//
+// +//
+ +// +//
+// ); +// } + +// const SearchedSongList = ({ +// songs, +// onSelect, +// }: { +// songs: Promise | undefined; +// onSelect: (song: MusicInfo, index: number) => void; +// }) => { +// return ( +//
    +// 검색 중...

    }> +// +// {(resolvedSongs) => ( +// <> +// {resolvedSongs?.length === 0 &&

    검색 결과가 없습니다.

    } +// {resolvedSongs?.map((song, i) => ( +//
  • onSelect(song, i)} +// > +// }> +// +// {(albumCover) => ( +// {song.album} { +// e.currentTarget.src = PLACEHOLDER; +// }} +// /> +// )} +// +// +//
    +//
    {song.title}
    +//
    {song.artist}
    +//
    {song.album}
    +//
    +//
  • +// ))} +// +// )} +//
    +//
    +//
+// ); +// }; diff --git a/app/features/search/components/searchSong.module.scss b/app/features/search/components/searchSong.module.scss new file mode 100644 index 0000000..608bbf1 --- /dev/null +++ b/app/features/search/components/searchSong.module.scss @@ -0,0 +1,90 @@ +// .searchWrapper { +// background-color: #f2f4f5; +// padding: 24px; +// border-radius: 8px; + +// margin: 16px 24px; +// } + +// .formRow { +// display: flex; +// gap: 8px; +// } + +// .input { +// flex: 1; +// padding: 8px 12px; +// border: 1px solid #d1d5db; +// border-radius: 100px; +// font-size: 14px; +// } + +// .button { +// padding: 8px 16px; +// border: none; +// background-color: #2563eb; +// color: #fff; +// border-radius: 4px; +// cursor: pointer; + +// &:disabled { +// opacity: 0.6; +// cursor: default; +// } +// } + +// .songList { +// margin-top: 16px; +// display: flex; +// flex-direction: column; +// gap: 12px; +// padding: 0; +// } + +// .songItem { +// display: flex; +// gap: 16px; +// align-items: center; +// padding: 12px; +// background-color: #fff; +// border: 1px solid #e5e7eb; +// border-radius: 6px; +// cursor: pointer; + +// &:hover { +// background-color: #f9fafb; +// } +// } + +// .cover { +// width: 64px; +// height: 64px; +// object-fit: cover; +// border-radius: 4px; +// background-color: #f3f4f6; +// } + +// .texts { +// overflow: hidden; +// } + +// .title { +// font-weight: 600; +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// margin-bottom: 2px; +// } + +// .artist, +// .album { +// font-size: 12px; +// color: #6b7280; +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// } + +// .empty { +// color: #9ca3af; +// } diff --git a/app/features/search/pages/search.module.scss b/app/features/search/pages/search.module.scss index b1d1770..859e46b 100644 --- a/app/features/search/pages/search.module.scss +++ b/app/features/search/pages/search.module.scss @@ -14,6 +14,7 @@ display: flex; justify-content: center; align-items: flex-start; + padding: 14px 10px; position: sticky; align-self: flex-start; From 55283a9104d45dfaca7251a032b2999a04c61292 Mon Sep 17 00:00:00 2001 From: mokjakA Date: Fri, 31 Oct 2025 18:22:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(search):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20&=20=ED=83=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/SearchBar.tsx | 13 +- app/components/UserItem.module.scss | 169 ++++++++++++++++++ app/components/UserItem.tsx | 81 +++++++++ .../profile/pages/follows.module.scss | 157 ---------------- app/features/profile/pages/follows.tsx | 109 +++++------ app/features/profile/pages/show.tsx | 6 +- .../search/components/ProfileCard.module.scss | 129 +++++++++---- .../search/components/SongItem.module.scss | 7 +- app/features/search/loader.ts | 21 ++- app/features/search/pages/search.module.scss | 110 ++++++++++-- app/features/search/pages/search.tsx | 103 +++++++---- 11 files changed, 584 insertions(+), 321 deletions(-) create mode 100644 app/components/UserItem.module.scss create mode 100644 app/components/UserItem.tsx diff --git a/app/components/SearchBar.tsx b/app/components/SearchBar.tsx index d325338..f5beb1f 100644 --- a/app/components/SearchBar.tsx +++ b/app/components/SearchBar.tsx @@ -2,9 +2,17 @@ import { Form } from '@remix-run/react'; import styles from './SearchBar.module.scss'; -export const SearchBar = ({ defaultQuery = '' }: { defaultQuery?: string }) => { +export const SearchBar = ({ + defaultQuery = '', + actionUrl = '/search', + tab, +}: { + defaultQuery?: string; + actionUrl?: string; + tab?: string; +}) => { return ( -
+ ); diff --git a/app/components/UserItem.module.scss b/app/components/UserItem.module.scss new file mode 100644 index 0000000..b657291 --- /dev/null +++ b/app/components/UserItem.module.scss @@ -0,0 +1,169 @@ +@use 'styles/layout' as *; + +.userItem { + display: flex; + flex-direction: column; + padding: 1rem; + border-radius: 12px; + background-color: #fff; + border: 1px solid #eee; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } + + box-sizing: border-box; + justify-content: flex-start; +} + +.userInfo { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 0.8rem; + + @include mobile { + margin-bottom: 0; + } +} + +.userLink { + display: flex; + align-items: center; + gap: 0.8rem; + text-decoration: none; + color: inherit; + flex: 1; + min-width: 0; +} + +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + background-color: #f0f0f0; +} + +.handle { + font-weight: 600; + font-size: 0.95rem; +} + +.followButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + min-width: 90px; + flex-shrink: 0; + + &.follow { + background-color: $primary-500; + color: white; + &:hover { + opacity: 0.85; + } + } + + &.unfollow { + background-color: #f0f0f0; + color: #555; + border: 1px solid #ddd; + &:hover { + background-color: #e0e0e0; + } + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.recommendedSong { + display: flex; + align-items: center; + gap: 0.8rem; + background-color: #f8f9fa; + border-radius: 8px; + padding: 0.8rem; + text-decoration: none; + color: inherit; + transition: background-color 0.2s; + min-width: 0; + overflow: hidden; + cursor: pointer; + + &:hover { + background-color: #f1f3f5; + } + + &__thumbnail { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + } + + &__details { + display: flex; + flex-direction: column; + gap: 0.2rem; + flex: 1; + } + + &__title { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; + } + + &__artist { + font-size: 0.85rem; + color: #666; + line-height: 1.2; + } + + min-height: 64px; + height: 64px; + box-sizing: border-box; + + @include mobile { + display: none; + } +} + +.noSongPlaceholder { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background-color: #f8f9fa; + border-radius: 8px; + padding: 1rem; + color: #888; + font-size: 0.9rem; + + p { + margin: 0; + } + + svg { + width: 20px; + height: 20px; + } + + min-height: 64px; + height: 64px; + box-sizing: border-box; + + @include mobile { + display: none; + } +} diff --git a/app/components/UserItem.tsx b/app/components/UserItem.tsx new file mode 100644 index 0000000..6bb5f15 --- /dev/null +++ b/app/components/UserItem.tsx @@ -0,0 +1,81 @@ +import { Link, useFetcher } from '@remix-run/react'; + +import { MusicNoteIcon } from 'app/features/search/components/Icons'; + +import styles from './UserItem.module.scss'; + +interface RecommendedSong { + id: number; + title: string; + artist: string; + thumbnailUrl?: string | null; +} + +export interface UserItemUser { + id: number; + handle: string; + avatarUrl?: string | null; + isFollowing?: boolean | null; + todayRecommendedSong?: RecommendedSong | null; +} + +export interface UserItemProps { + user: UserItemUser; + currentUserId?: number | null; + showFollow?: boolean; +} + +export function UserItem({ user, currentUserId = null, showFollow = true }: UserItemProps) { + const fetcher = useFetcher(); + const isSubmitting = fetcher.state === 'submitting'; + const isFollowing = fetcher.formData ? fetcher.formData.get('intent') === 'follow' : !!user.isFollowing; + const isCurrentUser = currentUserId === user.id; + + return ( +
  • +
    + + {`${user.handle}의 + @{user.handle} + + + {showFollow && !isCurrentUser && ( + + + + + + )} +
    + + {user.todayRecommendedSong ? ( + + {user.todayRecommendedSong.title} +
    + {user.todayRecommendedSong.title} + {user.todayRecommendedSong.artist} +
    + + ) : ( +
    + +

    추천곡 없음

    +
    + )} +
  • + ); +} diff --git a/app/features/profile/pages/follows.module.scss b/app/features/profile/pages/follows.module.scss index 18842a7..a40124a 100644 --- a/app/features/profile/pages/follows.module.scss +++ b/app/features/profile/pages/follows.module.scss @@ -50,160 +50,3 @@ color: #888; padding: 3rem 0; } - -.userItem { - display: flex; - flex-direction: column; - padding: 1rem; - border-radius: 12px; - background-color: #fff; - border: 1px solid #eee; - transition: box-shadow 0.2s; - - &:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - } -} - -.userInfo { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - margin-bottom: 0.8rem; - - @include mobile { - margin-bottom: 0; - } -} - -.userLink { - display: flex; - align-items: center; - gap: 0.8rem; - text-decoration: none; - color: inherit; - flex: 1; - min-width: 0; -} - -.avatar { - width: 48px; - height: 48px; - border-radius: 50%; - object-fit: cover; - background-color: #f0f0f0; -} - -.handle { - font-weight: 600; - font-size: 0.95rem; -} - -.recommendedSong { - display: flex; - align-items: center; - gap: 0.8rem; - background-color: #f8f9fa; - border-radius: 8px; - padding: 0.8rem; - text-decoration: none; - color: inherit; - transition: background-color 0.2s; - min-width: 0; - overflow: hidden; - cursor: pointer; - - &:hover { - background-color: #f1f3f5; - } - - &__thumbnail { - width: 48px; - height: 48px; - border-radius: 8px; - object-fit: cover; - flex-shrink: 0; - } - - &__details { - display: flex; - flex-direction: column; - gap: 0.2rem; - flex: 1; - } - - &__title { - font-weight: 600; - font-size: 0.95rem; - line-height: 1.2; - } - - &__artist { - font-size: 0.85rem; - color: #666; - line-height: 1.2; - } - - @include mobile { - display: none; - } -} - -.noSongPlaceholder { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - background-color: #f8f9fa; - border-radius: 8px; - padding: 1rem; - color: #888; - font-size: 0.9rem; - - p { - margin: 0; - } - - svg { - width: 20px; - height: 20px; - } - - @include mobile { - display: none; - } -} - -.followButton { - padding: 0.5rem 1rem; - border: none; - border-radius: 6px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - min-width: 90px; - flex-shrink: 0; - - &.follow { - background-color: $primary-500; - color: white; - &:hover { - opacity: 0.85; - } - } - - &.unfollow { - background-color: #f0f0f0; - color: #555; - border: 1px solid #ddd; - &:hover { - background-color: #e0e0e0; - } - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } -} diff --git a/app/features/profile/pages/follows.tsx b/app/features/profile/pages/follows.tsx index 415f9ed..aa700d5 100644 --- a/app/features/profile/pages/follows.tsx +++ b/app/features/profile/pages/follows.tsx @@ -1,10 +1,11 @@ -import { Link, useLoaderData, useSearchParams, useFetcher } from '@remix-run/react'; +import { Link, useLoaderData, useSearchParams } from '@remix-run/react'; +import { UserItem } from 'app/components/UserItem'; import type { followsLoader } from 'app/features/profile/loader'; import styles from './follows.module.scss'; -import { MusicNoteIcon } from '../../search/components/Icons'; -import { UserForFollowList } from '../services'; +// import { MusicNoteIcon } from '../../search/components/Icons'; +// import { UserForFollowList } from '../services'; export default function FollowsPage() { const { list, tab, currentUserId } = useLoaderData(); @@ -44,58 +45,58 @@ export default function FollowsPage() { ); } -function UserItem({ user, currentUserId }: { user: UserForFollowList; currentUserId: number | null }) { - const fetcher = useFetcher(); - const isSubmitting = fetcher.state === 'submitting'; +// function UserItem({ user, currentUserId }: { user: UserForFollowList; currentUserId: number | null }) { +// const fetcher = useFetcher(); +// const isSubmitting = fetcher.state === 'submitting'; - const isFollowing = fetcher.formData ? fetcher.formData.get('intent') === 'follow' : user.isFollowing; +// const isFollowing = fetcher.formData ? fetcher.formData.get('intent') === 'follow' : user.isFollowing; - const isCurrentUser = currentUserId === user.id; +// const isCurrentUser = currentUserId === user.id; - return ( -
  • -
    - - {`${user.handle}의 - @{user.handle} - - {!isCurrentUser && ( - - - - - - )} -
    +// return ( +//
  • +//
    +// +// {`${user.handle}의 +// @{user.handle} +// +// {!isCurrentUser && ( +// +// +// +// +// +// )} +//
    - {user.todayRecommendedSong ? ( - - {user.todayRecommendedSong.title} -
    - {user.todayRecommendedSong.title} - {user.todayRecommendedSong.artist} -
    - - ) : ( -
    - -

    추천곡 없음

    -
    - )} -
  • - ); -} +// {user.todayRecommendedSong ? ( +// +// {user.todayRecommendedSong.title} +//
    +// {user.todayRecommendedSong.title} +// {user.todayRecommendedSong.artist} +//
    +// +// ) : ( +//
    +// +//

    추천곡 없음

    +//
    +// )} +// +// ); +// } diff --git a/app/features/profile/pages/show.tsx b/app/features/profile/pages/show.tsx index 20e2bcb..c85e5de 100644 --- a/app/features/profile/pages/show.tsx +++ b/app/features/profile/pages/show.tsx @@ -100,11 +100,7 @@ export function TodaySongSection({ song, isCurrentUserProfile, userId }: TodaySo

    {song.title}

    {song.artist}

    - {isCurrentUserProfile && ( - - 편집 - - )} + {isCurrentUserProfile &&

    메모하기

    } diff --git a/app/features/search/components/ProfileCard.module.scss b/app/features/search/components/ProfileCard.module.scss index 82e4ec3..f0e94d1 100644 --- a/app/features/search/components/ProfileCard.module.scss +++ b/app/features/search/components/ProfileCard.module.scss @@ -1,20 +1,32 @@ +@use 'styles/layout' as *; + .card { background-color: #fff; border-radius: 12px; - padding: 1.25rem; - display: block; + padding: 1rem; + display: flex; + flex-direction: row; border: 1px solid #f1f3f5; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.04); - transition: - box-shadow 0.3s ease, - transform 0.3s ease; + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); text-decoration: none; color: inherit; overflow: hidden; + min-width: 0; + width: 100%; + box-sizing: border-box; + gap: 10px; &:hover { - transform: translateY(-5px); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + transform: translateY(-6px); + } + + @include tablet { + min-width: 200px; + padding: 0.9rem; + } + + @include mobile { + flex-direction: column; } } @@ -22,6 +34,7 @@ display: flex; align-items: center; justify-content: space-between; + gap: 12px; } .profileInfo { @@ -32,48 +45,87 @@ } .avatar { - width: 50px; - height: 50px; + width: 56px; + height: 56px; border-radius: 50%; object-fit: cover; flex-shrink: 0; + border: 2px solid #f1f3f5; + background: linear-gradient(180deg, #ffffff, #fbfbfb); + + @include tablet { + width: 52px; + height: 52px; + } } .userInfo { + display: flex; + flex-direction: column; + min-width: 0; + .handle { - font-size: 0.9rem; - font-weight: 600; - color: #212529; + font-size: 0.95rem; + font-weight: 700; + color: #222; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .meta { + font-size: 0.8rem; + color: #8b9197; + margin-top: 4px; } } .chevron { - color: #ced4da; - transition: transform 0.2s; + color: #aab0b6; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(16, 24, 40, 0.02); + transition: + transform 160ms ease, + background 160ms ease; +} - .card:hover & { - transform: translateX(3px); - } +.card:hover .chevron { + transform: translateX(4px); + background: rgba(16, 24, 40, 0.04); + color: #7b7b7b; } .todaysPick { - margin-top: 1rem; - padding: 0.75rem; - border-radius: 8px; - background-color: #f8f9fa; + margin-top: 0.25rem; + padding: 0.6rem; + border-radius: 10px; + background: linear-gradient(180deg, #fbfcfd, #f7f9fb); display: flex; align-items: center; gap: 0.75rem; min-width: 0; box-sizing: border-box; + border: 1px solid #f1f4f6; } .songThumbnail { - width: 36px; - height: 36px; - border-radius: 4px; + width: 44px; + height: 44px; + border-radius: 6px; object-fit: cover; flex-shrink: 0; + // box-shadow: 0 2px 8px rgba(10, 10, 10, 0.04); + + @include tablet { + width: 40px; + height: 40px; + } } .songText { @@ -82,14 +134,12 @@ gap: 2px; flex: 1 1 0; min-width: 0; - max-width: 100%; .song, .artist { margin: 0; display: block; max-width: 100%; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -97,32 +147,35 @@ .song { font-weight: 600; - font-size: 0.85rem; - color: #495057; + font-size: 0.9rem; + color: #2b2f36; } .artist { - font-size: 0.75rem; - color: #adb5bd; + font-size: 0.78rem; + color: #9aa0a6; } } .noSongPlaceholder { - margin-top: 1rem; - padding: 0.75rem; - border-radius: 8px; - background-color: #f8f9fa; + margin-top: 0.25rem; + padding: 0.6rem; + border-radius: 10px; + background-color: #fbfbfd; display: flex; align-items: center; justify-content: center; gap: 0.5rem; min-height: 52px; + border: 1px dashed #eef2f5; + svg { width: 18px; height: 18px; - color: #ced4da; + color: #d0d6db; } p { - font-size: 0.8rem; - color: #adb5bd; + font-size: 0.85rem; + color: #9aa0a6; + margin: 0; } } diff --git a/app/features/search/components/SongItem.module.scss b/app/features/search/components/SongItem.module.scss index 7099578..794ed0b 100644 --- a/app/features/search/components/SongItem.module.scss +++ b/app/features/search/components/SongItem.module.scss @@ -70,15 +70,14 @@ padding: 6px 10px; font-size: 13px; border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.08); + border: 1px solid #7b7b7b; background: white; cursor: pointer; white-space: inherit; } .addTodayButton { - background: $primary-500; - color: white; - border-color: transparent; + border-color: $primary-500; + color: $primary-500; } } diff --git a/app/features/search/loader.ts b/app/features/search/loader.ts index 7717a2e..7d106d8 100644 --- a/app/features/search/loader.ts +++ b/app/features/search/loader.ts @@ -1,17 +1,22 @@ -import { data } from '@remix-run/node'; - import createLoader from 'app/utils/createLoader'; import { searchSongInputLoader } from './components/SearchSongInputloader'; +import { findUserByHandleSim } from './services'; -export const searchLoader = createLoader(async ({ request }) => { +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 songs = searchSongInputLoader({ searchQuery: query }); + const results = await (async () => { + if (type === 'music') { + return await searchSongInputLoader({ searchQuery: query }); + } + if (query) { + return await findUserByHandleSim(db)({ handle: query }); + } + return null; + })(); - return data({ - query, - songs, - }); + return { query, results }; }); diff --git a/app/features/search/pages/search.module.scss b/app/features/search/pages/search.module.scss index 859e46b..1f327ce 100644 --- a/app/features/search/pages/search.module.scss +++ b/app/features/search/pages/search.module.scss @@ -31,10 +31,23 @@ } .content { - display: flex; - flex-direction: column; + box-sizing: border-box; width: 100%; max-width: 800px; + padding: 0 16px; + position: relative; +} + +.searchHeader { + position: sticky; + top: 0; + padding: 12px 0; + z-index: 60; + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + background: white; } .backButton { @@ -62,32 +75,17 @@ margin-right: auto; } -.profileGrid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1.5rem; - max-width: 900px; - margin: 0 auto; - - @include tablet { - grid-template-columns: repeat(2, 1fr); - } - @include mobile { - grid-template-columns: 1fr; - } -} - .formRow { display: flex; gap: 8px; } -.songList { - margin: 10px 16px; +.listContainer { display: flex; flex-direction: column; gap: 12px; padding: 0; + margin: 0; } .empty { @@ -136,3 +134,77 @@ overflow: hidden; text-overflow: ellipsis; } + +.tabs { + display: flex; + gap: 24px; + margin: 0 16px; + align-items: center; + border-bottom: 1px solid #e6edf3; + padding-bottom: 6px; +} + +.content form[role='search'], +.searchHeader > form[role='search'] { + width: 100%; + box-sizing: border-box; + background: transparent; + padding: 0; +} + +.tab { + background: transparent; + border: 0; + padding: 10px 0; + margin: 0; + font-weight: 600; + color: #6b7280; + font-size: 0.95rem; + cursor: pointer; + position: relative; + white-space: nowrap; + -webkit-tap-highlight-color: transparent; + transition: color 140ms ease; +} + +.tab:hover, +.tab:focus { + color: #111827; + outline: none; +} + +.tab::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -7px; + height: 3px; + background: transparent; + transform: scaleX(0); + transform-origin: center; + transition: + transform 160ms ease, + background 160ms ease; + border-radius: 3px; +} + +.active { + color: $primary-500; +} + +.active::after { + background: $primary-500; + transform: scaleX(1); +} + +.profileGrid { + 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 5810341..bb53715 100644 --- a/app/features/search/pages/search.tsx +++ b/app/features/search/pages/search.tsx @@ -1,18 +1,33 @@ -import { Await, useNavigate, useLoaderData } from '@remix-run/react'; +import { Await, useNavigate, useLoaderData, useSearchParams } from '@remix-run/react'; import { Suspense } from 'react'; import { SearchBar } from 'app/components/SearchBar'; +import { UserItem } from 'app/components/UserItem'; import { MusicInfo } from 'app/external/music/IMusicSearchAPI'; import SongItem, { SimpleSong } from 'app/features/search/components/SongItem'; import styles from './search.module.scss'; import { searchLoader } from '../loader'; +import type { UserWithRecommendedSong } from '../services'; + +type SearchUser = UserWithRecommendedSong & { isFollowing?: boolean | null }; + const PLACEHOLDER = '/images/features/profile/album_default2.png'; export default function SearchPage() { - const { query, songs } = useLoaderData(); + const { query, results } = useLoaderData(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = searchParams.get('tab') ?? 'music'; + + const switchTab = (t: 'music' | 'handle') => { + const next = new URLSearchParams(searchParams); + next.set('tab', t); + setSearchParams(next); + }; + + const isResultsEmpty = !results || (Array.isArray(results) && results.length === 0); return (
    @@ -22,44 +37,64 @@ export default function SearchPage() {
    - - +
    + +
    + + +
    +
    + + {isResultsEmpty ? ( +

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

    + ) : tab === 'music' ? ( + + ) : ( +
      + {((results as SearchUser[]) || []).map((user) => ( + + ))} +
    + )}
    ); } -const SearchedSongList = ({ songs }: { songs: Promise | undefined }) => { +const SearchedSongList = ({ songs }: { songs: MusicInfo[] }) => { return ( -
      - - - {(resolvedSongs) => ( - <> - {resolvedSongs?.length === 0 &&

      검색 결과가 없습니다.

      } - {resolvedSongs?.map((song, idx) => ( -
    • - }> - - {(albumCover) => { - const simple: SimpleSong = { - id: 0, - title: song.title, - artist: song.artist, - thumbnailUrl: albumCover ?? null, - album: song.album ?? null, - spotifyId: song.spotifyId ?? null, - }; - return ; - }} - - -
    • - ))} - - )} -
      -
      +
        + {songs.map((song, idx) => ( +
      • + }> + + {(albumCover) => { + const simple: SimpleSong = { + id: 0, + title: song.title, + artist: song.artist, + thumbnailUrl: albumCover ?? null, + album: song.album ?? null, + spotifyId: song.spotifyId ?? null, + }; + return ; + }} + + +
      • + ))}
      ); };