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) => (
-//
{
-// 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}
+
+
+
+
+
+
);
}
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) => (
+//
{
+// 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}
+
+
+ {showFollow && !isCurrentUser && (
+
+
+
+
+
+ )}
+
+
+ {user.todayRecommendedSong ? (
+
+
+
+ {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}
-
- {!isCurrentUser && (
-
-
-
-
-
- )}
-
+// return (
+//
+//
+//
+//

+//
@{user.handle}
+//
+// {!isCurrentUser && (
+//
+//
+//
+//
+//
+// )}
+//
- {user.todayRecommendedSong ? (
-
-
-
- {user.todayRecommendedSong.title}
- {user.todayRecommendedSong.artist}
-
-
- ) : (
-
- )}
-
- );
-}
+// {user.todayRecommendedSong ? (
+//
+//
+//
+// {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 ;
+ }}
+
+
+
+ ))}
);
};