refactor: profile & other styles
This commit is contained in:
parent
51905c4b7f
commit
30907927d1
@ -23,6 +23,7 @@
|
||||
"@void-cat/api": "^1.0.7",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.2",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"flag-icons": "^6.11.0",
|
||||
"hls.js": "^1.4.6",
|
||||
|
@ -1,5 +1,20 @@
|
||||
import { MetadataCache } from "@snort/system";
|
||||
import { HTMLProps, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { getPlaceholder } from "@/utils";
|
||||
|
||||
export function Avatar({ user, avatarClassname }: { user: MetadataCache; avatarClassname: string }) {
|
||||
return <img className={avatarClassname} alt={user?.name || user?.pubkey} src={user?.picture ?? ""} />;
|
||||
type AvatarProps = HTMLProps<HTMLImageElement> & { size?: number, pubkey: string, user?: MetadataCache };
|
||||
export function Avatar({ pubkey, size, user, ...props }: AvatarProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = user?.picture && !failed ? user.picture : getPlaceholder(pubkey);
|
||||
return <img
|
||||
{...props}
|
||||
className={classNames("aspect-square rounded-full bg-gray-1", props.className)}
|
||||
alt={user?.name || user?.pubkey}
|
||||
src={src}
|
||||
onError={() => setFailed(true)}
|
||||
style={{
|
||||
width: `${size ?? 40}px`,
|
||||
height: `${size ?? 40}px`
|
||||
}} />;
|
||||
}
|
||||
|
@ -129,6 +129,7 @@ export function ChatMessage({
|
||||
<>
|
||||
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
|
||||
<Profile
|
||||
className="text-secondary"
|
||||
icon={
|
||||
ev.pubkey === streamer ? (
|
||||
<Icon name="signal" size={16} />
|
||||
@ -141,14 +142,14 @@ export function ChatMessage({
|
||||
)
|
||||
}
|
||||
pubkey={ev.pubkey}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
<Text tags={ev.tags} content={ev.content} eventComponent={CollapsibleEvent} />
|
||||
{(hasReactions || hasZaps) && (
|
||||
<div className="message-reactions">
|
||||
{hasZaps && (
|
||||
<div className="zap-pill">
|
||||
<Icon name="zap-filled" className="zap-pill-icon" />
|
||||
<Icon name="zap-filled" className="text-zap" size={12} />
|
||||
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
|
||||
</div>
|
||||
)}
|
||||
@ -176,15 +177,15 @@ export function ChatMessage({
|
||||
style={
|
||||
isTablet
|
||||
? {
|
||||
display: showZapDialog || isHovering ? "flex" : "none",
|
||||
}
|
||||
display: showZapDialog || isHovering ? "flex" : "none",
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
top: topOffset ? topOffset - 12 : 0,
|
||||
left: leftOffset ? leftOffset - 32 : 0,
|
||||
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
|
||||
}
|
||||
position: "fixed",
|
||||
top: topOffset ? topOffset - 12 : 0,
|
||||
left: leftOffset ? leftOffset - 32 : 0,
|
||||
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
|
||||
}
|
||||
}>
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
|
@ -52,7 +52,6 @@
|
||||
}
|
||||
|
||||
.live-chat > .write-message > div:nth-child(1) {
|
||||
height: 40px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@ -72,26 +71,12 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.live-chat .message .profile {
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
float: left;
|
||||
color: #34d2fe;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.live-chat .message.streamer .profile {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.live-chat .message a {
|
||||
color: var(--primary);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.live-chat .profile img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.live-chat .message .text a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.live-chat .messages {
|
||||
@ -105,10 +90,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-chat .zap-content a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.top-zappers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -144,28 +125,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.top-zapper {
|
||||
display: flex;
|
||||
padding: 4px 8px 4px 4px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 49px;
|
||||
border: 1px solid var(--border, #171717);
|
||||
}
|
||||
|
||||
.top-zapper .top-zapper-amount {
|
||||
font-size: 15px;
|
||||
font-family: Outfit;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-zapper .top-zapper-name {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.zap-container {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
@ -188,14 +147,6 @@
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.zap-container .profile {
|
||||
color: #ff8d2b;
|
||||
}
|
||||
|
||||
.zap-container .zap-amount {
|
||||
color: #ff8d2b;
|
||||
}
|
||||
|
||||
.zap-container.big-zap:before {
|
||||
background: linear-gradient(60deg, #2bd9ff, #8c8ded, #f838d9, #f83838, #ff902b, #ddf838);
|
||||
animation: animatedgradient 3s ease alternate infinite;
|
||||
@ -216,10 +167,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.zap-content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.zap-pill {
|
||||
border-radius: 100px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@ -231,12 +178,6 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.zap-pill-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #ff8d2b;
|
||||
}
|
||||
|
||||
.message-zap-container {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./live-chat.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { EventKind, NostrEvent, NostrLink, ParsedZap } from "@snort/system";
|
||||
import { EventKind, NostrEvent, NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions } from "@snort/system-react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { useMemo } from "react";
|
||||
@ -80,8 +80,16 @@ export function LiveChat({
|
||||
|
||||
const reactions = useEventReactions(link, feed.reactions);
|
||||
const events = useMemo(() => {
|
||||
return [...feed.messages, ...feed.reactions, ...awards]
|
||||
.filter(a => a.created_at > started)
|
||||
const extra = [];
|
||||
const starts = findTag(ev, "starts");
|
||||
if (starts) {
|
||||
extra.push({ kind: -1, created_at: Number(starts) } as TaggedNostrEvent);
|
||||
}
|
||||
const ends = findTag(ev, "ends");
|
||||
if (ends) {
|
||||
extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent);
|
||||
}
|
||||
return [...feed.messages, ...feed.reactions, ...awards, ...extra]
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}, [feed.messages, feed.reactions, awards]);
|
||||
|
||||
@ -118,6 +126,13 @@ export function LiveChat({
|
||||
<div className="messages">
|
||||
{filteredEvents.map(a => {
|
||||
switch (a.kind) {
|
||||
case -1:
|
||||
case -2: {
|
||||
return <b className="border px-3 py-2 text-center border-gray-2 rounded-xl bg-primary uppercase">
|
||||
{a.kind === -1 ? <FormattedMessage defaultMessage="Stream Started" id="5tM0VD" />
|
||||
: <FormattedMessage defaultMessage="Stream Ended" id="jkAQj5" />}
|
||||
</b>;
|
||||
}
|
||||
case EventKind.BadgeAward: {
|
||||
return <BadgeAward ev={a} />;
|
||||
}
|
||||
@ -169,18 +184,18 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
|
||||
|
||||
return (
|
||||
<div className={`zap-container ${isBig ? "big-zap" : ""}`}>
|
||||
<div className="zap">
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<div className="flex gap-1 items-center">
|
||||
<Icon name="zap-filled" className="text-zap" />
|
||||
<FormattedMessage
|
||||
defaultMessage="{person} zapped {amount} sats"
|
||||
id="AIHaPH"
|
||||
defaultMessage="<s>{person}</s> zapped <s>{amount}</s> sats"
|
||||
id="q+zTWM"
|
||||
values={{
|
||||
s: (c) => <span className="text-zap">{c}</span>,
|
||||
person: (
|
||||
<Profile
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? ""}
|
||||
options={{
|
||||
showAvatar: !zap.anonZap,
|
||||
overrideName: zap.anonZap ? "Anon" : undefined,
|
||||
showAvatar: !zap.anonZap
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@ -188,11 +203,7 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{zap.content && (
|
||||
<div className="zap-content">
|
||||
<Text content={zap.content} tags={[]} />
|
||||
</div>
|
||||
)}
|
||||
{zap.content && <Text content={zap.content} tags={[]} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -12,16 +12,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note .note-header .profile {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note .note-header .note-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.note .note-header .note-link-icon {
|
||||
color: #909090;
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export function Note({ ev }: { ev: NostrEvent }) {
|
||||
return (
|
||||
<div className="surface note">
|
||||
<div className="note-header">
|
||||
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
|
||||
<Profile pubkey={ev.pubkey} />
|
||||
<ExternalIconLink
|
||||
size={24}
|
||||
className="note-link-icon"
|
||||
|
@ -1,19 +0,0 @@
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.profile img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 100%;
|
||||
background: #a7a7a7;
|
||||
border: unset;
|
||||
outline: unset;
|
||||
object-fit: cover;
|
||||
overflow: hidden;
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
import "./profile.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { UserMetadata } from "@snort/system";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { Icon } from "./icon";
|
||||
import usePlaceholder from "@/hooks/placeholders";
|
||||
import { Avatar } from "./avatar";
|
||||
import classNames from "classnames";
|
||||
|
||||
export interface ProfileOptions {
|
||||
showName?: boolean;
|
||||
@ -31,47 +29,40 @@ export function getName(pk: string, user?: UserMetadata) {
|
||||
export function Profile({
|
||||
pubkey,
|
||||
icon,
|
||||
className,
|
||||
avatarClassname,
|
||||
options,
|
||||
profile,
|
||||
linkToProfile,
|
||||
avatarSize,
|
||||
}: {
|
||||
pubkey: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
avatarClassname?: string;
|
||||
options?: ProfileOptions;
|
||||
profile?: UserMetadata;
|
||||
linkToProfile?: boolean;
|
||||
avatarSize?: number;
|
||||
}) {
|
||||
const { inView, ref } = useInView();
|
||||
const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) || profile;
|
||||
const { inView, ref } = useInView({ triggerOnce: true });
|
||||
const pLoaded = useUserProfile(inView ? pubkey : undefined);
|
||||
const showAvatar = options?.showAvatar ?? true;
|
||||
const showName = options?.showName ?? true;
|
||||
const placeholder = usePlaceholder(pubkey);
|
||||
|
||||
const isAnon = pubkey === "anon";
|
||||
const content = (
|
||||
<>
|
||||
{showAvatar &&
|
||||
(pubkey === "anon" ? (
|
||||
<Icon size={40} name="zap-filled" />
|
||||
) : (
|
||||
<img
|
||||
alt={pLoaded?.name || pubkey}
|
||||
className={avatarClassname ? avatarClassname : ""}
|
||||
src={pLoaded?.picture ?? placeholder}
|
||||
/>
|
||||
))}
|
||||
{showAvatar && <Avatar user={pLoaded} pubkey={pubkey} className={avatarClassname} size={avatarSize ?? 24} />}
|
||||
{icon}
|
||||
{showName && <span>{options?.overrideName ?? pubkey === "anon" ? "Anon" : getName(pubkey, pLoaded)}</span>}
|
||||
{showName && <span>{isAnon ? (options?.overrideName ?? "Anon") : getName(pubkey, pLoaded)}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return pubkey === "anon" || linkToProfile === false ? (
|
||||
<div className="profile" ref={ref}>
|
||||
const cls = classNames("flex gap-1 items-center align-bottom font-medium", className);
|
||||
return isAnon || linkToProfile === false ? (
|
||||
<div className={cls} ref={ref}>
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile" ref={ref}>
|
||||
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className={cls} ref={ref}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { HTMLProps } from "react";
|
||||
import "./state-pill.css";
|
||||
import { StreamState } from "@/index";
|
||||
import classNames from "classnames";
|
||||
|
||||
type StatePillProps = { state: StreamState } & HTMLProps<HTMLSpanElement>;
|
||||
|
||||
export function StatePill({ state, ...props }: StatePillProps) {
|
||||
return (
|
||||
<span {...props} className={`uppercase font-white pill ${state === StreamState.Live ? "bg-primary" : "bg-gray-1"}`}>
|
||||
<span {...props} className={classNames("uppercase font-white pill", state === StreamState.Live ? "bg-primary" : "bg-gray-1", props.className)}>
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ const UserItem = (metadata: MetadataCache) => {
|
||||
const { pubkey, display_name, ...rest } = metadata;
|
||||
return (
|
||||
<div key={pubkey} className="user-item">
|
||||
<Avatar avatarClassname="user-image" user={metadata} />
|
||||
<Avatar className="user-image" user={metadata} pubkey={pubkey} />
|
||||
<div className="user-details">{display_name || rest.name}</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,27 +1,10 @@
|
||||
import { ParsedZap } from "@snort/system";
|
||||
import useTopZappers from "@/hooks/top-zappers";
|
||||
import { formatSats } from "@/number";
|
||||
import { Icon } from "./icon";
|
||||
import { Profile } from "./profile";
|
||||
import { ZapperRow } from "./zapper-row";
|
||||
|
||||
export function TopZappers({ zaps, limit }: { zaps: ParsedZap[]; limit?: number }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
|
||||
return (
|
||||
<>
|
||||
{zappers.slice(0, limit ?? 10).map(({ pubkey, total }) => {
|
||||
return (
|
||||
<div className="top-zapper" key={pubkey}>
|
||||
{pubkey === "anon" ? (
|
||||
<p className="top-zapper-name">Anon</p>
|
||||
) : (
|
||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||
)}
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return zappers.slice(0, limit ?? 10).map(({ pubkey, total }) => <div className="border rounded-full px-2 py-1 border-gray-1 grow-0 shrink-0 basis-auto font-bold">
|
||||
<ZapperRow pubkey={pubkey} total={total} key={pubkey} showName={false} />
|
||||
</div>);
|
||||
}
|
||||
|
@ -83,7 +83,7 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
|
||||
return (
|
||||
<>
|
||||
<div className="paper" ref={ref}>
|
||||
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} />
|
||||
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} rows={2} />
|
||||
<div onClick={pickEmoji}>
|
||||
<Icon name="face" className="write-emoji-button" />
|
||||
</div>
|
||||
|
19
src/element/zapper-row.tsx
Normal file
19
src/element/zapper-row.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { formatSats } from "@/number";
|
||||
import { Icon } from "./icon";
|
||||
import { Profile } from "./profile";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function ZapperRow({ pubkey, total, showName }: { pubkey: string; total: number, showName?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-1 justify-between items-center">
|
||||
{pubkey === "anon" ? <span>
|
||||
<FormattedMessage defaultMessage="Anon" id="bfvyfs" />
|
||||
</span> :
|
||||
<Profile pubkey={pubkey} options={{ showName }} />}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="zap-filled" className="text-zap" />
|
||||
<span>{formatSats(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { getPlaceholder } from "@/utils";
|
||||
|
||||
export default function usePlaceholder(pubkey: string) {
|
||||
const url = useMemo(() => `https://robohash.v0l.io/${pubkey}.png?set=2`, [pubkey]);
|
||||
return url;
|
||||
return getPlaceholder(pubkey);
|
||||
}
|
||||
|
@ -18,6 +18,8 @@ body {
|
||||
--header-height: 48px;
|
||||
--text-muted: #797979;
|
||||
--primary: #f838d9;
|
||||
--secondary: #34d2fe;
|
||||
--zap: #ff8d2b;
|
||||
--text-danger: #ff563f;
|
||||
--surface: #222;
|
||||
--border: #171717;
|
||||
|
@ -9,20 +9,11 @@
|
||||
gap: var(--gap-s);
|
||||
}
|
||||
|
||||
.page.stream {
|
||||
height: calc(100vh - var(--header-page-padding));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.page {
|
||||
--page-pad-tb: 8px;
|
||||
--page-pad-lr: 0;
|
||||
}
|
||||
.page.stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
@ -109,10 +100,6 @@ header .profile img {
|
||||
}
|
||||
}
|
||||
|
||||
.zap-icon {
|
||||
color: #ff8d2b;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -65,7 +65,7 @@ export function LayoutPage() {
|
||||
menuButton={
|
||||
<div className="profile-menu">
|
||||
<Profile
|
||||
avatarClassname="mb-squared"
|
||||
avatarSize={48}
|
||||
pubkey={login.pubkey}
|
||||
options={{
|
||||
showName: false,
|
||||
@ -125,7 +125,7 @@ export function LayoutPage() {
|
||||
(styles as Record<string, string>)["--primary"] = login.color;
|
||||
}
|
||||
return (
|
||||
<div className={`page${location.pathname.startsWith("/naddr1") ? " stream" : ""}`} style={styles}>
|
||||
<div className="page" style={styles}>
|
||||
<Helmet>
|
||||
<title>Home - zap.stream</title>
|
||||
</Helmet>
|
||||
|
@ -1,124 +1,3 @@
|
||||
.profile-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.profile-page .profile-container {
|
||||
width: 620px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-page .profile-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-page .banner {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.profile-page .banner {
|
||||
height: 348.75px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-page .avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 88px;
|
||||
border: 3px solid #fff;
|
||||
object-fit: cover;
|
||||
margin-left: 16px;
|
||||
margin-top: -40px;
|
||||
}
|
||||
|
||||
.profile-page .status-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 120px;
|
||||
}
|
||||
|
||||
.profile-page .status-indicator .offline {
|
||||
margin-top: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.profile-page .status-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-page .profile-actions {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.profile-page .profile-actions {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-page .profile-information {
|
||||
margin: 12px;
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-page .name {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 21px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.profile-page .bio {
|
||||
margin: 0;
|
||||
color: #adadad;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.profile-page .zap-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-page .zap-button-icon {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.profile-page .pill.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-page .pill.offline {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
flex-shrink: 0;
|
||||
@ -165,51 +44,10 @@
|
||||
background: linear-gradient(94.73deg, #2bd9ff 0%, #8c8ded 47.4%, #f838d9 100%);
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
flex-grow: 1;
|
||||
padding: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
.tabs-content:focus {
|
||||
box-shadow: 0 0 0 2px black;
|
||||
}
|
||||
|
||||
.profile-page .profile-top-zappers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profile-page .zapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-page .zapper .zapper-amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.profile-page .stream-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-page .stream-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stream-item .video-tile h3 {
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
|
@ -2,12 +2,11 @@ import "./profile-page.css";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { NostrPrefix, ParsedZap, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import { NostrPrefix, ParsedZap, TaggedNostrEvent, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { Profile } from "@/element/profile";
|
||||
import { Icon } from "@/element/icon";
|
||||
import { SendZapsDialog } from "@/element/send-zap";
|
||||
import { VideoTile } from "@/element/video-tile";
|
||||
@ -15,31 +14,20 @@ import { FollowButton } from "@/element/follow-button";
|
||||
import { MuteButton } from "@/element/mute-button";
|
||||
import { useProfile } from "@/hooks/profile";
|
||||
import useTopZappers from "@/hooks/top-zappers";
|
||||
import usePlaceholder from "@/hooks/placeholders";
|
||||
import { Text } from "@/element/text";
|
||||
import { StreamState } from "@/index";
|
||||
import { findTag } from "@/utils";
|
||||
import { formatSats } from "@/number";
|
||||
import { StatePill } from "@/element/state-pill";
|
||||
import { Avatar } from "@/element/avatar";
|
||||
import { ZapperRow } from "@/element/zapper-row";
|
||||
|
||||
function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
|
||||
return (
|
||||
<div className="zapper">
|
||||
<Profile pubkey={pubkey} />
|
||||
<div className="zapper-amount">
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
return (
|
||||
<section className="profile-top-zappers">
|
||||
<section className="flex flex-col gap-2">
|
||||
{zappers.map(z => (
|
||||
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
|
||||
<ZapperRow key={z.pubkey} pubkey={z.pubkey} total={z.total} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
@ -51,7 +39,6 @@ export function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const link = parseNostrLink(unwrap(params.npub));
|
||||
const placeholder = usePlaceholder(link.id);
|
||||
const profile = useUserProfile(link.id);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const { streams, zaps } = useProfile(link, true);
|
||||
@ -75,106 +62,89 @@ export function ProfilePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-container">
|
||||
<img
|
||||
className="banner"
|
||||
alt={profile?.name || link.id}
|
||||
src={profile?.banner ? profile?.banner : defaultBanner}
|
||||
/>
|
||||
<div className="profile-content">
|
||||
{profile?.picture ? (
|
||||
<img className="avatar" alt={profile.name || link.id} src={profile.picture} />
|
||||
) : (
|
||||
<img className="avatar" alt={profile?.name || link.id} src={placeholder} />
|
||||
)}
|
||||
<div className="status-indicator">{isLive && <StatePill state={StreamState.Live} onClick={goToLive} />}</div>
|
||||
<div className="profile-actions">
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
<div className="zap-button">
|
||||
<Icon name="zap-filled" className="zap-button-icon" />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
targetName={profile?.name || link.id}
|
||||
/>
|
||||
)}
|
||||
<FollowButton pubkey={link.id} />
|
||||
<MuteButton pubkey={link.id} />
|
||||
<div className="flex flex-col gap-3 max-sm:px-4">
|
||||
<img
|
||||
className="rounded-xl object-cover h-[360px]"
|
||||
alt={profile?.name || link.id}
|
||||
src={profile?.banner ? profile?.banner : defaultBanner}
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<Avatar user={profile} pubkey={link.id} size={88} className="border border-4" />
|
||||
{isLive && <StatePill state={StreamState.Live} onClick={goToLive} className="absolute bottom-0 -mb-2" />}
|
||||
</div>
|
||||
<div className="profile-information">
|
||||
<div className="flex flex-col gap-1">
|
||||
{profile?.name && <h1 className="name">{profile.name}</h1>}
|
||||
{profile?.about && (
|
||||
<p className="bio">
|
||||
<p className="text-neutral-400">
|
||||
<Text content={profile.about} tags={[]} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
|
||||
<Tabs.List className="tabs-list" aria-label={`Information about ${profile ? profile.name : link.id}`}>
|
||||
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
||||
<FormattedMessage defaultMessage="Top Zappers" id="dVD/AR" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||
<FormattedMessage defaultMessage="Past Streams" id="UfSot5" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||
<FormattedMessage defaultMessage="Schedule" id="hGQqkW" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content className="tabs-content" value="top-zappers">
|
||||
<TopZappers zaps={zaps} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="past-streams">
|
||||
<div className="stream-list">
|
||||
{pastStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
<FormattedMessage
|
||||
defaultMessage="Streamed on {date}"
|
||||
id="cvAsEh"
|
||||
values={{
|
||||
date: new Date(ev.created_at * 1000).toLocaleDateString(),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="schedule">
|
||||
<div className="stream-list">
|
||||
{futureStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
<FormattedMessage
|
||||
defaultMessage="Scheduled for {date}"
|
||||
id="pO/lPX"
|
||||
values={{
|
||||
date: new Date(ev.created_at * 1000).toLocaleDateString(),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
<Icon name="zap-filled" className="zap-button-icon" />
|
||||
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
||||
</button>
|
||||
}
|
||||
targetName={profile?.name || link.id}
|
||||
/>
|
||||
)}
|
||||
<FollowButton pubkey={link.id} />
|
||||
<MuteButton pubkey={link.id} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
|
||||
<Tabs.List className="tabs-list" aria-label={`Information about ${profile ? profile.name : link.id}`}>
|
||||
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
||||
<FormattedMessage defaultMessage="Top Zappers" id="dVD/AR" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||
<FormattedMessage defaultMessage="Past Streams" id="UfSot5" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||
<FormattedMessage defaultMessage="Schedule" id="hGQqkW" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content className="tabs-content" value="top-zappers">
|
||||
<TopZappers zaps={zaps} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="past-streams">
|
||||
<ProfileStreamList streams={pastStreams} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="schedule">
|
||||
<ProfileStreamList streams={futureStreams} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStreamList({ streams }: { streams: Array<TaggedNostrEvent> }) {
|
||||
return <div className="flex gap-3 flex-wrap justify-center">
|
||||
{streams.map(ev => (
|
||||
<div key={ev.id} className="flex flex-col gap-1 sm:w-64 w-full">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="text-neutral-500">
|
||||
<FormattedMessage
|
||||
defaultMessage="Streamed on {date}"
|
||||
id="cvAsEh"
|
||||
values={{
|
||||
date: new Date(ev.created_at * 1000).toLocaleDateString(),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>;
|
||||
}
|
@ -151,7 +151,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
||||
<ProfileInfo ev={ev} goal={goal} />
|
||||
<StreamCards host={host} />
|
||||
</div>
|
||||
<LiveChat link={evLink ?? link} ev={ev} goal={goal} />
|
||||
<LiveChat link={evLink ?? link} ev={ev} goal={goal} options={{
|
||||
canWrite: status === StreamState.Live
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -78,3 +78,7 @@ export function uniqBy<T>(vals: Array<T>, key: (x: T) => string) {
|
||||
}, {} as Record<string, T>)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlaceholder(id: string) {
|
||||
return `https://robohash.v0l.io/${id}.png`;
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ module.exports = {
|
||||
"gray-1": "#171717",
|
||||
"gray-2": "#222",
|
||||
primary: "var(--primary)",
|
||||
secondary: "var(--secondary)",
|
||||
zap: "var(--zap)"
|
||||
},
|
||||
animation: {
|
||||
"ping-once": "ping 1s cubic-bezier(0, 0, 0.2, 1);",
|
||||
|
@ -10,6 +10,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
|
@ -4062,7 +4062,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"classnames@npm:^2.2.5":
|
||||
"classnames@npm:^2.2.5, classnames@npm:^2.3.2":
|
||||
version: 2.3.2
|
||||
resolution: "classnames@npm:2.3.2"
|
||||
checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e
|
||||
@ -7827,6 +7827,7 @@ __metadata:
|
||||
"@webscopeio/react-textarea-autocomplete": ^4.9.2
|
||||
autoprefixer: ^10.4.16
|
||||
buffer: ^6.0.3
|
||||
classnames: ^2.3.2
|
||||
emoji-mart: ^5.5.2
|
||||
eslint: ^8.48.0
|
||||
eslint-plugin-formatjs: ^4.11.3
|
||||
|
Loading…
x
Reference in New Issue
Block a user