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 ( +추천곡 없음
+추천곡 없음
-추천곡 없음
+//{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 b1d1770..c4fc80e 100644 --- a/app/features/search/pages/search.module.scss +++ b/app/features/search/pages/search.module.scss @@ -30,10 +30,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 { @@ -61,32 +74,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 { @@ -135,3 +133,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{`"${query}"에 대한 검색 결과가 없습니다.`}
+ ) : tab === 'music' ? ( +검색 결과가 없습니다.
} - {resolvedSongs?.map((song, idx) => ( -