React to a friend's current song or any of their recent plays: tap an emoji to add yours (or take it back), "☺+" for the quick six, and a full picker with search and categories (Unicode emoji up to 13.1, generated into the shared package so web and mobile match). One reaction per person per play; a new one replaces the old. Chips show counts and, on hover/ long label, who reacted; yours is highlighted. Listeners and followers get the update live. Not on your own plays, not across a block (blocking also clears reactions between the two people). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -102,6 +102,32 @@ input:focus, textarea:focus, select:focus {
|
||||
.share-link { font: inherit; cursor: pointer; color: var(--text); }
|
||||
.song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; }
|
||||
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
|
||||
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
|
||||
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
|
||||
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.07); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
|
||||
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.13); }
|
||||
.reaction:active:not(:disabled) { transform: scale(0.9); }
|
||||
.reaction:disabled { cursor: default; }
|
||||
.reaction.mine { border-color: rgba(123, 47, 247, 0.7); background: rgba(123, 47, 247, 0.28); }
|
||||
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
||||
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||
.reaction-add { color: var(--muted); font-size: 1rem; }
|
||||
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
||||
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
|
||||
.reaction-quick button:hover { transform: scale(1.25); }
|
||||
.reaction-quick button.mine { background: rgba(123, 47, 247, 0.35); }
|
||||
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
|
||||
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
|
||||
.emoji-tabs { display: flex; gap: 0.15rem; margin-bottom: 0.5rem; overflow-x: auto; }
|
||||
.emoji-tabs button { all: unset; cursor: pointer; font-size: 1.25rem; padding: 0.25rem 0.4rem; border-radius: 0.5rem; opacity: 0.6; }
|
||||
.emoji-tabs button[aria-selected='true'] { opacity: 1; background: rgba(255, 255, 255, 0.1); }
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr)); gap: 0.1rem; max-height: 18rem; overflow-y: auto; }
|
||||
.emoji-grid button { all: unset; cursor: pointer; font-size: 1.55rem; text-align: center; padding: 0.2rem; border-radius: 0.5rem; }
|
||||
.emoji-grid button:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
@media (prefers-reduced-motion: reduce) { .reaction-quick { animation: none; } }
|
||||
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
|
||||
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -138,7 +138,7 @@ export function ProfileClient() {
|
||||
<p className="muted">Nothing playing right now.</p>
|
||||
)}
|
||||
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} ownerId={user.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } fr
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Reactions } from './Reactions';
|
||||
import { TrackCard } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
@@ -85,6 +86,7 @@ export function NowPlayingCard({
|
||||
>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
<ProgressBar np={np} />
|
||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
import { Modal } from './ProfileMenu';
|
||||
|
||||
/**
|
||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||
* offers the quick six, and "More" opens the full picker. One reaction per person.
|
||||
*/
|
||||
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
|
||||
const { me } = useMe();
|
||||
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
|
||||
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
|
||||
const shown = local && local.from === reactions ? local.value : reactions;
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickOpen) return;
|
||||
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setQuickOpen(false);
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [quickOpen]);
|
||||
|
||||
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
||||
if (!listenId || (!canReact && shown.length === 0)) return null;
|
||||
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
||||
|
||||
const choose = async (emoji: string) => {
|
||||
setQuickOpen(false);
|
||||
setPickerOpen(false);
|
||||
setError(null);
|
||||
try {
|
||||
const value = emoji === mine ? await client.unreact(listenId) : await client.react(listenId, emoji);
|
||||
setLocal({ from: reactions, value });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reactions" ref={ref}>
|
||||
{shown.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
className={`reaction ${r.emoji === mine ? 'mine' : ''}`}
|
||||
disabled={!canReact}
|
||||
onClick={() => choose(r.emoji)}
|
||||
title={names(r)}
|
||||
aria-label={`${r.emoji} ${r.count}: ${names(r)}`}
|
||||
>
|
||||
<span className="reaction-emoji">{r.emoji}</span>
|
||||
{r.count > 1 && <span className="reaction-count">{r.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
{canReact && (
|
||||
<span className="reaction-add-wrap">
|
||||
<button type="button" className="reaction reaction-add" onClick={() => setQuickOpen((v) => !v)} aria-label="Add a reaction" aria-expanded={quickOpen}>
|
||||
☺<span aria-hidden>+</span>
|
||||
</button>
|
||||
{quickOpen && (
|
||||
<div className="reaction-quick" role="menu">
|
||||
{QUICK_REACTIONS.map((e) => (
|
||||
<button key={e} type="button" role="menuitem" className={e === mine ? 'mine' : ''} onClick={() => choose(e)}>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" role="menuitem" className="reaction-more" onClick={() => (setQuickOpen(false), setPickerOpen(true))}>
|
||||
More…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="error">{error}</span>}
|
||||
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function names(r: ReactionDTO) {
|
||||
const list = r.users.map((u) => u.displayName ?? u.username);
|
||||
const extra = r.count - list.length;
|
||||
return extra > 0 ? `${list.join(', ')} and ${extra} more` : list.join(', ');
|
||||
}
|
||||
|
||||
/** Every emoji, by category, with search. */
|
||||
export function EmojiPicker({ onPick, onClose }: { onPick: (emoji: string) => void; onClose: () => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [group, setGroup] = useState(0);
|
||||
const results = query.trim() ? searchEmoji(query) : EMOJI_PICKER_GROUPS[group]!.emojis;
|
||||
return (
|
||||
<Modal title="React" onClose={onClose}>
|
||||
<input className="emoji-search" placeholder="Search emoji" value={query} onChange={(e) => setQuery(e.target.value)} autoFocus />
|
||||
{!query.trim() && (
|
||||
<div className="emoji-tabs" role="tablist">
|
||||
{EMOJI_PICKER_GROUPS.map((g, i) => (
|
||||
<button key={g.name} type="button" role="tab" aria-selected={i === group} title={g.name} onClick={() => setGroup(i)}>
|
||||
{g.emojis[0]?.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="emoji-grid">
|
||||
{results.map((e) => (
|
||||
<button key={e.emoji} type="button" title={e.name} aria-label={e.name} onClick={() => onPick(e.emoji)}>
|
||||
{e.emoji}
|
||||
</button>
|
||||
))}
|
||||
{results.length === 0 && <p className="muted">No emoji match “{query}”.</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { Reactions } from './Reactions';
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
|
||||
export function RecentPlays({
|
||||
plays,
|
||||
preferred,
|
||||
attribution = {},
|
||||
ownerId,
|
||||
}: {
|
||||
plays: RecentPlayDTO[];
|
||||
/** Whose plays these are (they can't react to their own). */
|
||||
ownerId: string;
|
||||
preferred: StreamingService | null | undefined;
|
||||
attribution?: ProfileDTO['attribution'];
|
||||
}) {
|
||||
@@ -30,7 +34,7 @@ export function RecentPlays({
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={`${p.playedAt}-${i}`}>
|
||||
<li key={p.listenId ?? `${p.playedAt}-${i}`}>
|
||||
{target ? (
|
||||
<a
|
||||
className="recent-row"
|
||||
@@ -44,6 +48,7 @@ export function RecentPlays({
|
||||
) : (
|
||||
<div className="recent-row">{row}</div>
|
||||
)}
|
||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user