diff --git a/app/features/music/pages/music.user.module.scss b/app/features/music/pages/music.user.module.scss index 9672699..b4a7e02 100644 --- a/app/features/music/pages/music.user.module.scss +++ b/app/features/music/pages/music.user.module.scss @@ -134,3 +134,100 @@ 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); + } + 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 9862fca..61f32b5 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,58 @@ 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 ( - - );