refactor: profile & other styles

This commit is contained in:
Kieran 2023-12-07 12:35:46 +00:00
parent 51905c4b7f
commit 30907927d1
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
25 changed files with 197 additions and 457 deletions

View File

@ -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",

View File

@ -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`
}} />;
}

View File

@ -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}
/>
&nbsp;
<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

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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"

View File

@ -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;
}

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>);
}

View File

@ -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>

View 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>
);
}

View File

@ -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);
}

View File

@ -18,6 +18,8 @@ body {
--header-height: 48px;
--text-muted: #797979;
--primary: #f838d9;
--secondary: #34d2fe;
--zap: #ff8d2b;
--text-danger: #ff563f;
--surface: #222;
--border: #171717;

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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`;
}

View File

@ -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);",

View File

@ -10,6 +10,7 @@
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"jsx": "react-jsx",

View File

@ -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