From 6830889ec6b51fa3b3e63b02e74a9d879a94a910 Mon Sep 17 00:00:00 2001 From: alstj0127 Date: Tue, 28 Oct 2025 00:23:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20ui=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../music/pages/music.user.module.scss | 80 +++++++++++++++++++ app/features/music/pages/music.user.tsx | 47 +++++++++-- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/app/features/music/pages/music.user.module.scss b/app/features/music/pages/music.user.module.scss index 9672699..065ad51 100644 --- a/app/features/music/pages/music.user.module.scss +++ b/app/features/music/pages/music.user.module.scss @@ -134,3 +134,83 @@ cursor: pointer; } } + +.like-form { + display: inline-block; +} + +.like-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid #e5e7eb; + background: #f9fafb; + color: #6b7280; + cursor: pointer; + transition: transform 120ms ease, background 160ms ease, color 160ms ease, border-color 160ms ease; + will-change: transform; + + &:hover:not(:disabled) { + transform: translateY(-1px); + background: #f3f4f6; + border-color: #d1d5db; + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: default; + } +} + +.liked { + background: #fee2e2; + color: #ef4444; + border-color: #fecaca; + + .heart { + animation: pulse 280ms ease; + } +} + +.loading { + animation: breathe 800ms ease-in-out infinite; +} + +.heart { + width: 18px; + height: 18px; + flex: 0 0 18px; + transition: fill 0.2s ease, transform 0.2s ease; +} + +.liked .heart { + fill: currentColor; +} + +.like-count { + font-weight: 700; + font-size: 14px; + line-height: 1; + padding-left: 4px; + min-width: 1ch; + text-align: right; +} + +@keyframes pulse { + 0% { transform: scale(0.9); } + 60% { transform: scale(1.08); } + 100% { transform: scale(1); } +} + +@keyframes breathe { + 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); } + 50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.06); } + 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); } +} + diff --git a/app/features/music/pages/music.user.tsx b/app/features/music/pages/music.user.tsx index 9862fca..aef77cb 100644 --- a/app/features/music/pages/music.user.tsx +++ b/app/features/music/pages/music.user.tsx @@ -1,5 +1,6 @@ import { Link, Outlet, useFetcher, useLoaderData } from '@remix-run/react'; import dayjs from 'dayjs'; +import { useMemo } from 'react'; import { musicUserLoader } from '../loader'; import styles from './music.user.module.scss'; @@ -66,14 +67,50 @@ const MemoHeader = ({ spotifyEmbed, UserMusicMemo, song }: MemoHeaderProps) => { ); }; -const LikeButton = ({ likes, isChecked: isMemoLiked }: { likes: number; memoId: number; isChecked: boolean }) => { +const LikeButton = ({likes, memoId: _memoId, isChecked: isMemoLiked }: { likes: number; memoId: number; isChecked: boolean; }) => { const fetcher = useFetcher(); - const like = fetcher.formData ? fetcher.formData.get('like') === 'true' : isMemoLiked; + const submitting = fetcher.state !== 'idle'; + const intentInFlight = fetcher.formData?.get('intent') as 'like' | 'unlike' | undefined; + + const optimisticLiked = useMemo(() => { + if (!intentInFlight) return isMemoLiked; + return intentInFlight === 'like'; + }, [intentInFlight, isMemoLiked]); + + const optimisticLikes = useMemo(() => { + if (!intentInFlight) return likes; + return intentInFlight === 'like' ? likes + 1 : likes - 1; + }, [intentInFlight, likes]); + + const nextIntent = optimisticLiked ? 'unlike' : 'like'; return ( - - ); From a01f1bdb6f1a89be7058db6f0ad587bec9136b2f Mon Sep 17 00:00:00 2001 From: alstj0127 Date: Tue, 28 Oct 2025 00:40:32 +0900 Subject: [PATCH 2/2] apply prettier --- .../music/pages/music.user.module.scss | 47 +++++++++++++------ app/features/music/pages/music.user.tsx | 10 +++- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/features/music/pages/music.user.module.scss b/app/features/music/pages/music.user.module.scss index 065ad51..b4a7e02 100644 --- a/app/features/music/pages/music.user.module.scss +++ b/app/features/music/pages/music.user.module.scss @@ -145,11 +145,15 @@ gap: 8px; padding: 8px 12px; border-radius: 999px; - border: 1px solid #e5e7eb; - background: #f9fafb; - color: #6b7280; + border: 1px solid #e5e7eb; + background: #f9fafb; + color: #6b7280; cursor: pointer; - transition: transform 120ms ease, background 160ms ease, color 160ms ease, border-color 160ms ease; + transition: + transform 120ms ease, + background 160ms ease, + color 160ms ease, + border-color 160ms ease; will-change: transform; &:hover:not(:disabled) { @@ -169,9 +173,9 @@ } .liked { - background: #fee2e2; - color: #ef4444; - border-color: #fecaca; + background: #fee2e2; + color: #ef4444; + border-color: #fecaca; .heart { animation: pulse 280ms ease; @@ -186,7 +190,9 @@ width: 18px; height: 18px; flex: 0 0 18px; - transition: fill 0.2s ease, transform 0.2s ease; + transition: + fill 0.2s ease, + transform 0.2s ease; } .liked .heart { @@ -203,14 +209,25 @@ } @keyframes pulse { - 0% { transform: scale(0.9); } - 60% { transform: scale(1.08); } - 100% { transform: scale(1); } + 0% { + transform: scale(0.9); + } + 60% { + transform: scale(1.08); + } + 100% { + transform: scale(1); + } } @keyframes breathe { - 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); } - 50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.06); } - 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); } + 0% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } + 50% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.06); + } + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } } - diff --git a/app/features/music/pages/music.user.tsx b/app/features/music/pages/music.user.tsx index aef77cb..61f32b5 100644 --- a/app/features/music/pages/music.user.tsx +++ b/app/features/music/pages/music.user.tsx @@ -67,7 +67,15 @@ const MemoHeader = ({ spotifyEmbed, UserMusicMemo, song }: MemoHeaderProps) => { ); }; -const LikeButton = ({likes, memoId: _memoId, isChecked: isMemoLiked }: { likes: number; memoId: number; isChecked: boolean; }) => { +const LikeButton = ({ + likes, + memoId: _memoId, + isChecked: isMemoLiked, +}: { + likes: number; + memoId: number; + isChecked: boolean; +}) => { const fetcher = useFetcher(); const submitting = fetcher.state !== 'idle'; const intentInFlight = fetcher.formData?.get('intent') as 'like' | 'unlike' | undefined;