forked from Kieran/zap.stream
feat: profile page
This commit is contained in:
parent
9a08dd6768
commit
111eea3d14
@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@react-hook/resize-observer": "^1.2.6",
|
"@react-hook/resize-observer": "^1.2.6",
|
||||||
"@snort/system-react": "^1.0.8",
|
"@snort/system-react": "^1.0.8",
|
||||||
"@testing-library/jest-dom": "^5.14.1",
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
|
4
src/const.ts
Normal file
4
src/const.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { EventKind } from "@snort/system";
|
||||||
|
|
||||||
|
export const LIVE_STREAM = 30_311 as EventKind;
|
||||||
|
export const LIVE_STREAM_CHAT = 1_311 as EventKind;
|
4
src/element/follow-button.tsx
Normal file
4
src/element/follow-button.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// todo
|
||||||
|
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||||
|
return <button className="btn btn-primary">Follow</button>;
|
||||||
|
}
|
@ -187,10 +187,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-icon {
|
|
||||||
color: #FFCB44;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zap-container {
|
.zap-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -211,11 +207,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.zap-container .profile {
|
.zap-container .profile {
|
||||||
color: #FFCB44;
|
color: #FF8D2B;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-container .zap-amount {
|
.zap-container .zap-amount {
|
||||||
color: #FFCB44;
|
color: #FF8D2B;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-content {
|
.zap-content {
|
||||||
|
@ -9,7 +9,6 @@ import {
|
|||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
@ -27,41 +26,27 @@ import Spinner from "./spinner";
|
|||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { formatSats } from "number";
|
import { formatSats } from "number";
|
||||||
|
import useTopZappers from "hooks/top-zappers";
|
||||||
|
|
||||||
export interface LiveChatOptions {
|
export interface LiveChatOptions {
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
|
|
||||||
return zaps
|
|
||||||
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
|
|
||||||
.reduce((acc, z) => acc + z.amount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||||
const zappers = zaps
|
const zappers = useTopZappers(zaps).slice(0, 3);
|
||||||
.map((z) => (z.anonZap ? "anon" : z.sender))
|
|
||||||
.map((p) => p as string);
|
|
||||||
|
|
||||||
const sortedZappers = useMemo(() => {
|
|
||||||
const sorted = [...new Set([...zappers])];
|
|
||||||
sorted.sort((a, b) => totalZapped(b, zaps) - totalZapped(a, zaps));
|
|
||||||
return sorted.slice(0, 3);
|
|
||||||
}, [zaps, zappers]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>Top zappers</h3>
|
<h3>Top zappers</h3>
|
||||||
<div className="top-zappers-container">
|
<div className="top-zappers-container">
|
||||||
{sortedZappers.map((pk, idx) => {
|
{zappers.map(({ pubkey, total }, idx) => {
|
||||||
const total = totalZapped(pk, zaps);
|
|
||||||
return (
|
return (
|
||||||
<div className="top-zapper" key={pk}>
|
<div className="top-zapper" key={pubkey}>
|
||||||
{pk === "anon" ? (
|
{pubkey === "anon" ? (
|
||||||
<p className="top-zapper-name">Anon</p>
|
<p className="top-zapper-name">Anon</p>
|
||||||
) : (
|
) : (
|
||||||
<Profile pubkey={pk} options={{ showName: false }} />
|
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||||
)}
|
)}
|
||||||
<Icon name="zap-filled" className="zap-icon" />
|
<Icon name="zap-filled" className="zap-icon" />
|
||||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||||
@ -132,7 +117,7 @@ function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
|
|||||||
return (
|
return (
|
||||||
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
|
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
|
||||||
<Profile pubkey={ev.pubkey} />
|
<Profile pubkey={ev.pubkey} />
|
||||||
<Text ev={ev} />
|
<Text content={ev.content} tags={ev.tags} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
|
import { hexToBech32 } from "utils";
|
||||||
|
|
||||||
interface MentionProps {
|
interface MentionProps {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
@ -8,13 +10,6 @@ interface MentionProps {
|
|||||||
|
|
||||||
export function Mention({ pubkey, relays }: MentionProps) {
|
export function Mention({ pubkey, relays }: MentionProps) {
|
||||||
const user = useUserProfile(System, pubkey);
|
const user = useUserProfile(System, pubkey);
|
||||||
return (
|
const npub = hexToBech32("npub", pubkey);
|
||||||
<a
|
return <Link to={`/p/${npub}`}>{user?.name || pubkey}</Link>;
|
||||||
href={`https://snort.social/p/${pubkey}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{user?.name || pubkey}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import "./profile.css";
|
import "./profile.css";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { UserMetadata } from "@snort/system";
|
import { UserMetadata } from "@snort/system";
|
||||||
import { hexToBech32 } from "@snort/shared";
|
import { hexToBech32 } from "@snort/shared";
|
||||||
|
import { Icon } from "element/icon";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
|
|
||||||
export interface ProfileOptions {
|
export interface ProfileOptions {
|
||||||
@ -12,13 +14,14 @@ export interface ProfileOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getName(pk: string, user?: UserMetadata) {
|
export function getName(pk: string, user?: UserMetadata) {
|
||||||
const shortPubkey = hexToBech32("npub", pk).slice(0, 12);
|
const npub = hexToBech32("npub", pk);
|
||||||
if ((user?.display_name?.length ?? 0) > 0) {
|
const shortPubkey = npub.slice(0, 12);
|
||||||
return user?.display_name;
|
|
||||||
}
|
|
||||||
if ((user?.name?.length ?? 0) > 0) {
|
if ((user?.name?.length ?? 0) > 0) {
|
||||||
return user?.name;
|
return user?.name;
|
||||||
}
|
}
|
||||||
|
if ((user?.display_name?.length ?? 0) > 0) {
|
||||||
|
return user?.display_name;
|
||||||
|
}
|
||||||
return shortPubkey;
|
return shortPubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,17 +36,32 @@ export function Profile({
|
|||||||
}) {
|
}) {
|
||||||
const profile = useUserProfile(System, pubkey);
|
const profile = useUserProfile(System, pubkey);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className="profile">
|
<>
|
||||||
{(options?.showAvatar ?? true) && (
|
{(options?.showAvatar ?? true) && pubkey === "anon" ? (
|
||||||
|
<Icon size={40} name="zap-filled" />
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
alt={profile?.name || pubkey}
|
alt={profile?.name || pubkey}
|
||||||
className={avatarClassname ? avatarClassname : ""}
|
className={avatarClassname ? avatarClassname : ""}
|
||||||
src={profile?.picture ?? ""}
|
src={profile?.picture ?? ""}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(options?.showName ?? true) &&
|
{(options?.showName ?? true) && (
|
||||||
(options?.overrideName ?? getName(pubkey, profile))}
|
<span>
|
||||||
</div>
|
{options?.overrideName ?? pubkey === "anon"
|
||||||
|
? "Anon"
|
||||||
|
: getName(pubkey, profile)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return pubkey === "anon" ? (
|
||||||
|
<div className="profile">{content}</div>
|
||||||
|
) : (
|
||||||
|
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import "./send-zap.css";
|
import "./send-zap.css";
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, ReactNode } from "react";
|
||||||
import { LNURL } from "@snort/shared";
|
import { LNURL } from "@snort/shared";
|
||||||
import { NostrEvent, EventPublisher } from "@snort/system";
|
import { NostrEvent, EventPublisher } from "@snort/system";
|
||||||
import { formatSats } from "../number";
|
import { formatSats } from "../number";
|
||||||
@ -15,6 +15,7 @@ interface SendZapsProps {
|
|||||||
ev?: NostrEvent;
|
ev?: NostrEvent;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
|
button?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
||||||
@ -154,15 +155,20 @@ export function SendZapsDialog({
|
|||||||
lnurl,
|
lnurl,
|
||||||
ev,
|
ev,
|
||||||
targetName,
|
targetName,
|
||||||
|
button,
|
||||||
}: Omit<SendZapsProps, "onFinish">) {
|
}: Omit<SendZapsProps, "onFinish">) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Trigger asChild>
|
<Dialog.Trigger asChild>
|
||||||
<button className="btn btn-primary zap">
|
{button ? (
|
||||||
<span className="hide-on-mobile">Zap</span>
|
button
|
||||||
<Icon name="zap" size={16} />
|
) : (
|
||||||
</button>
|
<button className="btn btn-primary zap">
|
||||||
|
<span className="hide-on-mobile">Zap</span>
|
||||||
|
<Icon name="zap" size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-overlay" />
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
26
src/element/tags.tsx
Normal file
26
src/element/tags.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import moment from "moment";
|
||||||
|
import { TaggedRawEvent } from "@snort/system";
|
||||||
|
import { StreamState } from "index";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
|
||||||
|
export function Tags({ ev }: { ev: TaggedRawEvent }) {
|
||||||
|
const status = findTag(ev, "status");
|
||||||
|
const start = findTag(ev, "starts");
|
||||||
|
return (
|
||||||
|
<div className="tags">
|
||||||
|
{status === StreamState.Planned && (
|
||||||
|
<span className="pill">
|
||||||
|
Starts {moment(Number(start) * 1000).fromNow()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ev.tags
|
||||||
|
.filter((a) => a[0] === "t")
|
||||||
|
.map((a) => a[1])
|
||||||
|
.map((a) => (
|
||||||
|
<span className="pill" key={a}>
|
||||||
|
{a}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { TaggedRawEvent, validateNostrLink } from "@snort/system";
|
import { validateNostrLink } from "@snort/system";
|
||||||
import { splitByUrl } from "utils";
|
import { splitByUrl } from "utils";
|
||||||
import { Emoji } from "./emoji";
|
import { Emoji } from "./emoji";
|
||||||
import { HyperText } from "./hypertext";
|
import { HyperText } from "./hypertext";
|
||||||
@ -74,11 +74,11 @@ function extractLinks(fragments: Fragment[]) {
|
|||||||
.flat();
|
.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Text({ ev }: { ev: TaggedRawEvent }) {
|
export function Text({ content, tags }: { content: string; tags: string[][] }) {
|
||||||
// todo: RTL langugage support
|
// todo: RTL langugage support
|
||||||
const element = useMemo(() => {
|
const element = useMemo(() => {
|
||||||
return <span>{transformText([ev.content], ev.tags)}</span>;
|
return <span>{transformText([content], tags)}</span>;
|
||||||
}, [ev]);
|
}, [content, tags]);
|
||||||
|
|
||||||
return <>{element}</>;
|
return <>{element}</>;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import { NostrLink, RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
|
import {
|
||||||
|
NostrLink,
|
||||||
|
RequestBuilder,
|
||||||
|
EventKind,
|
||||||
|
FlatNoteStore,
|
||||||
|
} from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { LIVE_STREAM_CHAT } from "const";
|
||||||
|
|
||||||
export function useLiveChatFeed(link: NostrLink) {
|
export function useLiveChatFeed(link: NostrLink) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
@ -10,11 +16,11 @@ export function useLiveChatFeed(link: NostrLink) {
|
|||||||
leaveOpen: true,
|
leaveOpen: true,
|
||||||
});
|
});
|
||||||
rb.withFilter()
|
rb.withFilter()
|
||||||
.kinds([EventKind.ZapReceipt, 1311 as EventKind])
|
.kinds([EventKind.ZapReceipt, LIVE_STREAM_CHAT])
|
||||||
.tag("a", [`${link.kind}:${link.author}:${link.id}`])
|
.tag("a", [`${link.kind}:${link.author}:${link.id}`])
|
||||||
.limit(100);
|
.limit(100);
|
||||||
return rb;
|
return rb;
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
||||||
}
|
}
|
||||||
|
@ -2,5 +2,8 @@ import { Login } from "index";
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
return useSyncExternalStore(c => Login.hook(c), () => Login.snapshot());
|
return useSyncExternalStore(
|
||||||
}
|
(c) => Login.hook(c),
|
||||||
|
() => Login.snapshot()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
67
src/hooks/profile.ts
Normal file
67
src/hooks/profile.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
RequestBuilder,
|
||||||
|
ReplaceableNoteStore,
|
||||||
|
FlatNoteStore,
|
||||||
|
NostrLink,
|
||||||
|
EventKind,
|
||||||
|
parseZap,
|
||||||
|
} from "@snort/system";
|
||||||
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
|
import { LIVE_STREAM } from "const";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
import { System } from "index";
|
||||||
|
|
||||||
|
export function useProfile(link: NostrLink, leaveOpen = false) {
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`);
|
||||||
|
b.withOptions({
|
||||||
|
leaveOpen,
|
||||||
|
})
|
||||||
|
.withFilter()
|
||||||
|
.kinds([LIVE_STREAM])
|
||||||
|
.authors([link.id]);
|
||||||
|
return b;
|
||||||
|
}, [link, leaveOpen]);
|
||||||
|
|
||||||
|
const { data: streamsData } = useRequestBuilder<ReplaceableNoteStore>(
|
||||||
|
System,
|
||||||
|
ReplaceableNoteStore,
|
||||||
|
sub
|
||||||
|
);
|
||||||
|
|
||||||
|
const streams = Array.isArray(streamsData)
|
||||||
|
? streamsData
|
||||||
|
: streamsData
|
||||||
|
? [streamsData]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const addresses = useMemo(() => {
|
||||||
|
return streams.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
|
||||||
|
}, [streamsData]);
|
||||||
|
|
||||||
|
const zapsSub = useMemo(() => {
|
||||||
|
const b = new RequestBuilder(`profile-zaps:${link.id.slice(0, 12)}`);
|
||||||
|
b.withOptions({
|
||||||
|
leaveOpen,
|
||||||
|
})
|
||||||
|
.withFilter()
|
||||||
|
.kinds([EventKind.ZapReceipt])
|
||||||
|
.tag("a", addresses);
|
||||||
|
return b;
|
||||||
|
}, [link, addresses, leaveOpen]);
|
||||||
|
|
||||||
|
const { data: zapsData } = useRequestBuilder<FlatNoteStore>(
|
||||||
|
System,
|
||||||
|
FlatNoteStore,
|
||||||
|
zapsSub
|
||||||
|
);
|
||||||
|
const zaps = (zapsData ?? [])
|
||||||
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||||
|
.filter((z) => z && z.valid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
streams,
|
||||||
|
zaps,
|
||||||
|
};
|
||||||
|
}
|
25
src/hooks/top-zappers.ts
Normal file
25
src/hooks/top-zappers.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ParsedZap } from "@snort/system";
|
||||||
|
|
||||||
|
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
|
||||||
|
return zaps
|
||||||
|
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
|
||||||
|
.reduce((acc, z) => acc + z.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useTopZappers(zaps: ParsedZap[]) {
|
||||||
|
const zappers = zaps
|
||||||
|
.map((z) => (z.anonZap ? "anon" : z.sender))
|
||||||
|
.map((p) => p as string);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const pubkeys = [...new Set([...zappers])];
|
||||||
|
const result = pubkeys.map((pubkey) => {
|
||||||
|
return { pubkey, total: totalZapped(pubkey, zaps) };
|
||||||
|
});
|
||||||
|
result.sort((a, b) => b.total - a.total);
|
||||||
|
return result;
|
||||||
|
}, [zaps, zappers]);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
@ -7,6 +7,7 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
|||||||
|
|
||||||
import { RootPage } from "./pages/root";
|
import { RootPage } from "./pages/root";
|
||||||
import { LayoutPage } from "pages/layout";
|
import { LayoutPage } from "pages/layout";
|
||||||
|
import { ProfilePage } from "pages/profile-page";
|
||||||
import { StreamPage } from "pages/stream-page";
|
import { StreamPage } from "pages/stream-page";
|
||||||
import { ChatPopout } from "pages/chat-popout";
|
import { ChatPopout } from "pages/chat-popout";
|
||||||
import { LoginStore } from "login";
|
import { LoginStore } from "login";
|
||||||
@ -14,7 +15,7 @@ import { LoginStore } from "login";
|
|||||||
export enum StreamState {
|
export enum StreamState {
|
||||||
Live = "live",
|
Live = "live",
|
||||||
Ended = "ended",
|
Ended = "ended",
|
||||||
Planned = "planned"
|
Planned = "planned",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const System = new NostrSystem({});
|
export const System = new NostrSystem({});
|
||||||
@ -42,7 +43,11 @@ const router = createBrowserRouter([
|
|||||||
element: <RootPage />,
|
element: <RootPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/live/:id",
|
path: "/p/:npub",
|
||||||
|
element: <ProfilePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:id",
|
||||||
element: <StreamPage />,
|
element: <StreamPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
40
src/login.ts
40
src/login.ts
@ -1,29 +1,31 @@
|
|||||||
import { ExternalStore } from "@snort/shared";
|
import { ExternalStore } from "@snort/shared";
|
||||||
|
|
||||||
export interface LoginSession {
|
export interface LoginSession {
|
||||||
pubkey: string
|
pubkey: string;
|
||||||
|
follows: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||||
#session?: LoginSession;
|
#session?: LoginSession;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const json = window.localStorage.getItem("session");
|
const json = window.localStorage.getItem("session");
|
||||||
if (json) {
|
if (json) {
|
||||||
this.#session = JSON.parse(json);
|
this.#session = JSON.parse(json);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loginWithPubkey(pk: string) {
|
loginWithPubkey(pk: string) {
|
||||||
this.#session = {
|
this.#session = {
|
||||||
pubkey: pk
|
pubkey: pk,
|
||||||
};
|
follows: [],
|
||||||
window.localStorage.setItem("session", JSON.stringify(this.#session));
|
};
|
||||||
this.notifyChange();
|
window.localStorage.setItem("session", JSON.stringify(this.#session));
|
||||||
}
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
takeSnapshot() {
|
takeSnapshot() {
|
||||||
return this.#session ? { ...this.#session } : undefined;
|
return this.#session ? { ...this.#session } : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,8 +11,7 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page.only-content {
|
||||||
.page.home {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
@ -84,6 +83,10 @@ header {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 24px 0 32px 0;
|
padding: 24px 0 32px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page.only-content {
|
||||||
|
grid-template-rows: 88px 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header .logo {
|
header .logo {
|
||||||
@ -172,3 +175,12 @@ button span.hide-on-mobile {
|
|||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zap-icon {
|
||||||
|
color: #FF8D2B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
@ -70,8 +70,8 @@ export function LayoutPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
location.pathname === "/"
|
location.pathname === "/" || location.pathname.startsWith("/p/")
|
||||||
? "page home"
|
? "page only-content"
|
||||||
: location.pathname.startsWith("/chat/")
|
: location.pathname.startsWith("/chat/")
|
||||||
? "page chat"
|
? "page chat"
|
||||||
: "page"
|
: "page"
|
||||||
|
199
src/pages/profile-page.css
Normal file
199
src/pages/profile-page.css
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
.profile-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .profile-container {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .profile-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .banner {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (min-width: 768px){
|
||||||
|
.profile-page .banner {
|
||||||
|
max-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 .profile-actions {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .profile-information{
|
||||||
|
margin-left: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .icon-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .icon-button span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 420px) {
|
||||||
|
.profile-page .icon-button span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-tab {
|
||||||
|
background: black;
|
||||||
|
background-clip: padding-box;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 52px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: Outfit;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 400px){
|
||||||
|
.tabs-tab {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabs-tab[data-state='active']:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; right: 0; bottom: 0; left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
margin: -1px;
|
||||||
|
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.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-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page .zapper .zapper-amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
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;
|
||||||
|
}
|
181
src/pages/profile-page.tsx
Normal file
181
src/pages/profile-page.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import "./profile-page.css";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import * as Tabs from "@radix-ui/react-tabs";
|
||||||
|
import {
|
||||||
|
parseNostrLink,
|
||||||
|
NostrPrefix,
|
||||||
|
ParsedZap,
|
||||||
|
encodeTLV,
|
||||||
|
} from "@snort/system";
|
||||||
|
import { useUserProfile } from "@snort/system-react";
|
||||||
|
import { Profile } from "element/profile";
|
||||||
|
import { Icon } from "element/icon";
|
||||||
|
import { SendZapsDialog } from "element/send-zap";
|
||||||
|
import { VideoTile } from "element/video-tile";
|
||||||
|
import { useProfile } from "hooks/profile";
|
||||||
|
import useTopZappers from "hooks/top-zappers";
|
||||||
|
import { Text } from "element/text";
|
||||||
|
import { Tags } from "element/tags";
|
||||||
|
import { StreamState, System } from "index";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
import { formatSats } from "number";
|
||||||
|
|
||||||
|
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">
|
||||||
|
{zappers.map((z) => (
|
||||||
|
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams();
|
||||||
|
const link = parseNostrLink(params.npub!);
|
||||||
|
const profile = useUserProfile(System, link.id);
|
||||||
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||||
|
const { streams, zaps } = useProfile(link, true);
|
||||||
|
const liveEvent = useMemo(() => {
|
||||||
|
return streams.find((ev) => findTag(ev, "status") === StreamState.Live);
|
||||||
|
}, [streams]);
|
||||||
|
const pastStreams = useMemo(() => {
|
||||||
|
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended);
|
||||||
|
}, [streams]);
|
||||||
|
const futureStreams = useMemo(() => {
|
||||||
|
return streams.filter(
|
||||||
|
(ev) => findTag(ev, "status") === StreamState.Planned
|
||||||
|
);
|
||||||
|
}, [streams]);
|
||||||
|
const isLive = Boolean(liveEvent);
|
||||||
|
|
||||||
|
function goToLive() {
|
||||||
|
if (liveEvent) {
|
||||||
|
const d =
|
||||||
|
liveEvent.tags?.find((t: string[]) => t?.at(0) === "d")?.at(1) || "";
|
||||||
|
const naddr = encodeTLV(
|
||||||
|
NostrPrefix.Address,
|
||||||
|
d,
|
||||||
|
undefined,
|
||||||
|
liveEvent.kind,
|
||||||
|
liveEvent.pubkey
|
||||||
|
);
|
||||||
|
navigate(`/${naddr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: follow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page">
|
||||||
|
<div className="profile-container">
|
||||||
|
<img
|
||||||
|
className="banner"
|
||||||
|
alt={profile?.name || link.id}
|
||||||
|
src={profile?.banner || defaultBanner}
|
||||||
|
/>
|
||||||
|
<div className="profile-content">
|
||||||
|
{profile?.picture && (
|
||||||
|
<img
|
||||||
|
className="avatar"
|
||||||
|
alt={profile.name || link.id}
|
||||||
|
src={profile.picture}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="status-indicator">
|
||||||
|
{isLive ? (
|
||||||
|
<div className="icon-button pill live" onClick={goToLive}>
|
||||||
|
<Icon name="signal" />
|
||||||
|
<span>live</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="pill offline">offline</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile-actions">
|
||||||
|
{zapTarget && (
|
||||||
|
<SendZapsDialog
|
||||||
|
lnurl={zapTarget}
|
||||||
|
button={
|
||||||
|
<button className="btn">
|
||||||
|
<div className="icon-button">
|
||||||
|
<span>Zap</span>
|
||||||
|
<Icon name="zap-filled" className="zap-button-icon" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
targetName={profile?.name || link.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile-information">
|
||||||
|
{profile?.name && <h1 className="name">{profile.name}</h1>}
|
||||||
|
{profile?.about && (
|
||||||
|
<p className="bio">
|
||||||
|
<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">
|
||||||
|
Top Zappers
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||||
|
Past Streams
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||||
|
Schedule
|
||||||
|
</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} />
|
||||||
|
<Tags ev={ev} />
|
||||||
|
</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} />
|
||||||
|
<Tags ev={ev} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -2,53 +2,84 @@ import "./root.css";
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { unixNow } from "@snort/shared";
|
import { unixNow } from "@snort/shared";
|
||||||
import { EventKind, ParameterizedReplaceableNoteStore, RequestBuilder } from "@snort/system";
|
import {
|
||||||
|
ParameterizedReplaceableNoteStore,
|
||||||
|
RequestBuilder,
|
||||||
|
} from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { StreamState, System } from "..";
|
import { StreamState, System } from "..";
|
||||||
import { VideoTile } from "../element/video-tile";
|
import { VideoTile } from "../element/video-tile";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
import { LIVE_STREAM } from "const";
|
||||||
|
|
||||||
export function RootPage() {
|
export function RootPage() {
|
||||||
const rb = useMemo(() => {
|
const rb = useMemo(() => {
|
||||||
const rb = new RequestBuilder("root");
|
const rb = new RequestBuilder("root");
|
||||||
rb.withOptions({
|
rb.withOptions({
|
||||||
leaveOpen: true
|
leaveOpen: true,
|
||||||
}).withFilter()
|
})
|
||||||
.kinds([30_311 as EventKind])
|
.withFilter()
|
||||||
.since(unixNow() - 86400);
|
.kinds([LIVE_STREAM])
|
||||||
return rb;
|
.since(unixNow() - 86400);
|
||||||
}, []);
|
return rb;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const feed = useRequestBuilder<ParameterizedReplaceableNoteStore>(System, ParameterizedReplaceableNoteStore, rb);
|
const feed = useRequestBuilder<ParameterizedReplaceableNoteStore>(
|
||||||
const feedSorted = useMemo(() => {
|
System,
|
||||||
if (feed.data) {
|
ParameterizedReplaceableNoteStore,
|
||||||
return [...feed.data].sort((a, b) => {
|
rb
|
||||||
const aStatus = findTag(a, "status")!;
|
);
|
||||||
const bStatus = findTag(b, "status")!;
|
const feedSorted = useMemo(() => {
|
||||||
if (aStatus === bStatus) {
|
if (feed.data) {
|
||||||
return b.created_at > a.created_at ? 1 : -1;
|
return [...feed.data].sort((a, b) => {
|
||||||
} else {
|
const aStatus = findTag(a, "status")!;
|
||||||
return aStatus === "live" ? -1 : 1;
|
const bStatus = findTag(b, "status")!;
|
||||||
}
|
if (aStatus === bStatus) {
|
||||||
});
|
return b.created_at > a.created_at ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aStatus === "live" ? -1 : 1;
|
||||||
}
|
}
|
||||||
return [];
|
});
|
||||||
}, [feed.data])
|
}
|
||||||
|
return [];
|
||||||
|
}, [feed.data]);
|
||||||
|
|
||||||
const live = feedSorted.filter(a => findTag(a, "status") === StreamState.Live);
|
const live = feedSorted.filter(
|
||||||
const planned = feedSorted.filter(a => findTag(a, "status") === StreamState.Planned);
|
(a) => findTag(a, "status") === StreamState.Live
|
||||||
const ended = feedSorted.filter(a => findTag(a, "status") === StreamState.Ended);
|
);
|
||||||
return <div className="homepage">
|
const planned = feedSorted.filter(
|
||||||
<div className="video-grid">
|
(a) => findTag(a, "status") === StreamState.Planned
|
||||||
{live.map(e => <VideoTile ev={e} key={e.id} />)}
|
);
|
||||||
</div>
|
const ended = feedSorted.filter(
|
||||||
{planned.length > 0 && <><h2>Planned</h2>
|
(a) => findTag(a, "status") === StreamState.Ended
|
||||||
<div className="video-grid">
|
);
|
||||||
{planned.map(e => <VideoTile ev={e} key={e.id} />)}
|
return (
|
||||||
</div></>}
|
<div className="homepage">
|
||||||
{ended.length > 0 && <><h2>Ended</h2>
|
<div className="video-grid">
|
||||||
<div className="video-grid">
|
{live.map((e) => (
|
||||||
{ended.map(e => <VideoTile ev={e} key={e.id} />)}
|
<VideoTile ev={e} key={e.id} />
|
||||||
</div></>}
|
))}
|
||||||
|
</div>
|
||||||
|
{planned.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>Planned</h2>
|
||||||
|
<div className="video-grid">
|
||||||
|
{planned.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{ended.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>Ended</h2>
|
||||||
|
<div className="video-grid">
|
||||||
|
{ended.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
@ -43,6 +43,7 @@
|
|||||||
|
|
||||||
.pill.live {
|
.pill.live {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1020px) {
|
@media (min-width: 1020px) {
|
||||||
@ -103,11 +104,6 @@
|
|||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin: 8px 0 0 0;
|
margin: 8px 0 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -2,7 +2,6 @@ import "./stream-page.css";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { parseNostrLink, EventPublisher } from "@snort/system";
|
import { parseNostrLink, EventPublisher } from "@snort/system";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import moment from "moment";
|
|
||||||
|
|
||||||
import useEventFeed from "hooks/event-feed";
|
import useEventFeed from "hooks/event-feed";
|
||||||
import { LiveVideoPlayer } from "element/live-video-player";
|
import { LiveVideoPlayer } from "element/live-video-player";
|
||||||
@ -11,11 +10,12 @@ import { Profile, getName } from "element/profile";
|
|||||||
import { LiveChat } from "element/live-chat";
|
import { LiveChat } from "element/live-chat";
|
||||||
import AsyncButton from "element/async-button";
|
import AsyncButton from "element/async-button";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
import { StreamState, System } from "index";
|
import { System } from "index";
|
||||||
import { SendZapsDialog } from "element/send-zap";
|
import { SendZapsDialog } from "element/send-zap";
|
||||||
import type { NostrLink } from "@snort/system";
|
import type { NostrLink } from "@snort/system";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { NewStreamDialog } from "element/new-stream";
|
import { NewStreamDialog } from "element/new-stream";
|
||||||
|
import { Tags } from "element/tags";
|
||||||
|
|
||||||
function ProfileInfo({ link }: { link: NostrLink }) {
|
function ProfileInfo({ link }: { link: NostrLink }) {
|
||||||
const thisEvent = useEventFeed(link, true);
|
const thisEvent = useEventFeed(link, true);
|
||||||
@ -24,9 +24,6 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
|||||||
const profile = useUserProfile(System, thisEvent.data?.pubkey);
|
const profile = useUserProfile(System, thisEvent.data?.pubkey);
|
||||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||||
|
|
||||||
const status = findTag(thisEvent.data, "status");
|
|
||||||
const start = findTag(thisEvent.data, "starts");
|
|
||||||
const isLive = status === "live";
|
|
||||||
const isMine = link.author === login?.pubkey;
|
const isMine = link.author === login?.pubkey;
|
||||||
|
|
||||||
async function deleteStream() {
|
async function deleteStream() {
|
||||||
@ -45,22 +42,7 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
|||||||
<div className="f-grow stream-info">
|
<div className="f-grow stream-info">
|
||||||
<h1>{findTag(thisEvent.data, "title")}</h1>
|
<h1>{findTag(thisEvent.data, "title")}</h1>
|
||||||
<p>{findTag(thisEvent.data, "summary")}</p>
|
<p>{findTag(thisEvent.data, "summary")}</p>
|
||||||
<div className="tags">
|
{thisEvent?.data && <Tags ev={thisEvent.data} />}
|
||||||
<span className={`pill${isLive ? " live" : ""}`}>{status}</span>
|
|
||||||
{status === StreamState.Planned && (
|
|
||||||
<span className="pill">
|
|
||||||
Starts {moment(Number(start) * 1000).fromNow()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{thisEvent.data?.tags
|
|
||||||
.filter((a) => a[0] === "t")
|
|
||||||
.map((a) => a[1])
|
|
||||||
.map((a) => (
|
|
||||||
<span className="pill" key={a}>
|
|
||||||
{a}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{isMine && (
|
{isMine && (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{thisEvent.data && (
|
{thisEvent.data && (
|
||||||
|
49
yarn.lock
49
yarn.lock
@ -1706,6 +1706,17 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.13.10"
|
"@babel/runtime" "^7.13.10"
|
||||||
|
|
||||||
|
"@radix-ui/react-collection@1.0.3":
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
|
||||||
|
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
"@radix-ui/react-compose-refs" "1.0.1"
|
||||||
|
"@radix-ui/react-context" "1.0.1"
|
||||||
|
"@radix-ui/react-primitive" "1.0.3"
|
||||||
|
"@radix-ui/react-slot" "1.0.2"
|
||||||
|
|
||||||
"@radix-ui/react-compose-refs@1.0.1":
|
"@radix-ui/react-compose-refs@1.0.1":
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||||
@ -1741,6 +1752,13 @@
|
|||||||
aria-hidden "^1.1.1"
|
aria-hidden "^1.1.1"
|
||||||
react-remove-scroll "2.5.5"
|
react-remove-scroll "2.5.5"
|
||||||
|
|
||||||
|
"@radix-ui/react-direction@1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
|
||||||
|
integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer@1.0.4":
|
"@radix-ui/react-dismissable-layer@1.0.4":
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
|
||||||
@ -1803,6 +1821,22 @@
|
|||||||
"@babel/runtime" "^7.13.10"
|
"@babel/runtime" "^7.13.10"
|
||||||
"@radix-ui/react-slot" "1.0.2"
|
"@radix-ui/react-slot" "1.0.2"
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus@1.0.4":
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
|
||||||
|
integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
"@radix-ui/primitive" "1.0.1"
|
||||||
|
"@radix-ui/react-collection" "1.0.3"
|
||||||
|
"@radix-ui/react-compose-refs" "1.0.1"
|
||||||
|
"@radix-ui/react-context" "1.0.1"
|
||||||
|
"@radix-ui/react-direction" "1.0.1"
|
||||||
|
"@radix-ui/react-id" "1.0.1"
|
||||||
|
"@radix-ui/react-primitive" "1.0.3"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||||
|
|
||||||
"@radix-ui/react-slot@1.0.2":
|
"@radix-ui/react-slot@1.0.2":
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
|
||||||
@ -1811,6 +1845,21 @@
|
|||||||
"@babel/runtime" "^7.13.10"
|
"@babel/runtime" "^7.13.10"
|
||||||
"@radix-ui/react-compose-refs" "1.0.1"
|
"@radix-ui/react-compose-refs" "1.0.1"
|
||||||
|
|
||||||
|
"@radix-ui/react-tabs@^1.0.4":
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
|
||||||
|
integrity sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
"@radix-ui/primitive" "1.0.1"
|
||||||
|
"@radix-ui/react-context" "1.0.1"
|
||||||
|
"@radix-ui/react-direction" "1.0.1"
|
||||||
|
"@radix-ui/react-id" "1.0.1"
|
||||||
|
"@radix-ui/react-presence" "1.0.1"
|
||||||
|
"@radix-ui/react-primitive" "1.0.3"
|
||||||
|
"@radix-ui/react-roving-focus" "1.0.4"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||||
|
|
||||||
"@radix-ui/react-use-callback-ref@1.0.1":
|
"@radix-ui/react-use-callback-ref@1.0.1":
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
||||||
|
Loading…
Reference in New Issue
Block a user