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 800add3..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;