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 ( -
+
Sonnets @@ -17,6 +25,7 @@ export const SearchBar = ({ defaultQuery = '' }: { defaultQuery?: string }) => { defaultValue={defaultQuery} autoComplete="on" /> + {tab && }
); 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 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(); + 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 ; + }} + + +
      • + ))}
      ); };