Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions app/features/music/pages/music.user.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
55 changes: 50 additions & 5 deletions app/features/music/pages/music.user.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<fetcher.Form method="post">
<button style={{ background: like ? 'red' : 'gray' }} name="intent" value={like ? 'unlike' : 'like'}>
Like - {likes}
<fetcher.Form method="post" className={styles.likeForm}>
<button
type="submit"
name="intent"
value={nextIntent}
className={[styles.likeButton, optimisticLiked ? styles.liked : '', submitting ? styles.loading : ''].join(' ')}
aria-pressed={optimisticLiked}
aria-label={optimisticLiked ? '좋아요 취소' : '좋아요'}
disabled={submitting}
>
<svg
className={styles.heart}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.8 4.6c-1.7-1.7-4.4-1.7-6.1 0L12 7.3l-2.7-2.7c-1.7-1.7-4.4-1.7-6.1 0s-1.7 4.4 0 6.1l8.8 8.8 8.8-8.8c1.7-1.7 1.7-4.4 0-6.1z" />
</svg>

<span className={styles.likeCount} aria-live="polite">
{optimisticLikes}
</span>
</button>
</fetcher.Form>
);
Expand Down