Updates:
- Game categories - Move stream setup/config to dashboard - Reorg files / cleanup - NSFW improvements
This commit is contained in:
@ -19,7 +19,7 @@ export default function CategoryLink({
|
|||||||
to={`/category/${id}`}
|
to={`/category/${id}`}
|
||||||
key={id}
|
key={id}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"min-w-[12rem] flex items-center justify-between px-6 py-2 text-xl font-semibold rounded-xl",
|
"min-w-[12rem] flex items-center justify-between gap-4 px-6 py-2 text-xl font-semibold rounded-xl",
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
{name}
|
{name}
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Layer1Button, WarningButton } from "./buttons";
|
|
||||||
|
|
||||||
export function isContentWarningAccepted() {
|
|
||||||
return Boolean(window.localStorage.getItem("accepted-content-warning"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentWarningOverlay() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [is18Plus, setIs18Plus] = useState(isContentWarningAccepted());
|
|
||||||
if (is18Plus) return null;
|
|
||||||
|
|
||||||
function grownUp() {
|
|
||||||
window.localStorage.setItem("accepted-content-warning", "true");
|
|
||||||
setIs18Plus(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fullscreen-exclusive age-check">
|
|
||||||
<h1>
|
|
||||||
<FormattedMessage defaultMessage="Sexually explicit material ahead!" id="rWBFZA" />
|
|
||||||
</h1>
|
|
||||||
<h2>
|
|
||||||
<FormattedMessage defaultMessage="Confirm your age" id="s7V+5p" />
|
|
||||||
</h2>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<WarningButton onClick={grownUp}>
|
|
||||||
<FormattedMessage defaultMessage="Yes, I am over 18" id="O2Cy6m" />
|
|
||||||
</WarningButton>
|
|
||||||
<Layer1Button onClick={() => navigate("/")}>
|
|
||||||
<FormattedMessage defaultMessage="No, I am under 18" id="KkIL3s" />
|
|
||||||
</Layer1Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -5,7 +5,7 @@ import { Goal } from "./goal";
|
|||||||
import { Note } from "./note";
|
import { Note } from "./note";
|
||||||
import { EmojiPack } from "./emoji-pack";
|
import { EmojiPack } from "./emoji-pack";
|
||||||
import { Badge } from "./badge";
|
import { Badge } from "./badge";
|
||||||
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP } from "@/const";
|
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const";
|
||||||
import { useEventFeed } from "@snort/system-react";
|
import { useEventFeed } from "@snort/system-react";
|
||||||
import LiveStreamClip from "./clip";
|
import LiveStreamClip from "./clip";
|
||||||
import { ExternalLink } from "./external-link";
|
import { ExternalLink } from "./external-link";
|
||||||
@ -48,7 +48,12 @@ export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
|
|||||||
}
|
}
|
||||||
case EventKind.LiveEvent: {
|
case EventKind.LiveEvent: {
|
||||||
const info = extractStreamInfo(ev);
|
const info = extractStreamInfo(ev);
|
||||||
return <LiveVideoPlayer {...info} />;
|
return <LiveVideoPlayer
|
||||||
|
title={info.title}
|
||||||
|
status={info.status}
|
||||||
|
stream={info.status === StreamState.Live ? info.stream : info.recording}
|
||||||
|
poster={info.image}
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const link = NostrLink.fromEvent(ev);
|
const link = NostrLink.fromEvent(ev);
|
||||||
|
36
src/element/game-info.tsx
Normal file
36
src/element/game-info.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import useGameInfo from "@/hooks/game-info";
|
||||||
|
import { GameInfo } from "@/service/game-database";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface GameInfoCardProps {
|
||||||
|
gameId?: string,
|
||||||
|
gameInfo?: GameInfo,
|
||||||
|
imageSize?: number,
|
||||||
|
showImage?: boolean,
|
||||||
|
link?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameInfoCard({
|
||||||
|
gameId,
|
||||||
|
gameInfo,
|
||||||
|
imageSize,
|
||||||
|
showImage,
|
||||||
|
link
|
||||||
|
}: GameInfoCardProps) {
|
||||||
|
const game = useGameInfo(gameId, gameInfo);
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const inner = <div className="flex gap-2 items-center">
|
||||||
|
{(showImage ?? true) && <img src={game.cover} style={{ height: imageSize ?? 20 }} className={classNames("object-contain", game.className)} />}
|
||||||
|
{game.name}
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return <Link to={`/category/${gameId}`} className="text-primary">
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
} else {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { MediaURL } from "./collapsible";
|
|
||||||
import { ExternalLink } from "./external-link";
|
import { ExternalLink } from "./external-link";
|
||||||
import { EventEmbed } from "./event-embed";
|
|
||||||
import { parseNostrLink } from "@snort/system";
|
|
||||||
|
|
||||||
const FileExtensionRegex = /\.([\w]+)$/i;
|
const FileExtensionRegex = /\.([\w]+)$/i;
|
||||||
|
|
||||||
@ -25,18 +22,14 @@ export function HyperText({ link, children }: HyperTextProps) {
|
|||||||
case "bmp":
|
case "bmp":
|
||||||
case "webp": {
|
case "webp": {
|
||||||
return (
|
return (
|
||||||
<MediaURL url={url}>
|
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
||||||
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
|
||||||
</MediaURL>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "wav":
|
case "wav":
|
||||||
case "mp3":
|
case "mp3":
|
||||||
case "ogg": {
|
case "ogg": {
|
||||||
return (
|
return (
|
||||||
<MediaURL url={url}>
|
<audio key={url.toString()} src={url.toString()} controls />
|
||||||
<audio key={url.toString()} src={url.toString()} controls />;
|
|
||||||
</MediaURL>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "mp4":
|
case "mp4":
|
||||||
@ -46,16 +39,12 @@ export function HyperText({ link, children }: HyperTextProps) {
|
|||||||
case "m4v":
|
case "m4v":
|
||||||
case "webm": {
|
case "webm": {
|
||||||
return (
|
return (
|
||||||
<MediaURL url={url}>
|
<video key={url.toString()} src={url.toString()} controls />
|
||||||
<video key={url.toString()} src={url.toString()} controls />
|
|
||||||
</MediaURL>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>;
|
return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>;
|
||||||
}
|
}
|
||||||
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
|
|
||||||
return <EventEmbed link={parseNostrLink(link)} />;
|
|
||||||
} else {
|
} else {
|
||||||
<ExternalLink href={link}>{children}</ExternalLink>;
|
<ExternalLink href={link}>{children}</ExternalLink>;
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ export enum VideoStatus {
|
|||||||
type VideoPlayerProps = {
|
type VideoPlayerProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
stream?: string;
|
stream?: string;
|
||||||
status?: string;
|
status?: StreamState;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
} & HTMLProps<HTMLVideoElement>;
|
} & HTMLProps<HTMLVideoElement>;
|
||||||
|
@ -132,12 +132,14 @@ export function LoginSignup({ close }: { close: () => void }) {
|
|||||||
const lnurl = new LNURL(lnAddress);
|
const lnurl = new LNURL(lnAddress);
|
||||||
await lnurl.load();
|
await lnurl.load();
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
if (!lnAddress.includes("localhost") && import.meta.env.DEV) {
|
||||||
formatMessage({
|
throw new Error(
|
||||||
defaultMessage: "Hmm, your lightning address looks wrong",
|
formatMessage({
|
||||||
id: "4l69eO",
|
defaultMessage: "Hmm, your lightning address looks wrong",
|
||||||
})
|
id: "4l69eO",
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const pub = EventPublisher.privateKey(key);
|
const pub = EventPublisher.privateKey(key);
|
||||||
const profile = {
|
const profile = {
|
||||||
|
@ -75,7 +75,7 @@ export default function Modal(props: ModalProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}>
|
}}>
|
||||||
<div
|
<div
|
||||||
className={props.bodyClassName ?? "bg-layer-1 p-8 rounded-3xl my-auto lg:w-[500px] max-lg:w-full"}
|
className={props.bodyClassName ?? "relative bg-layer-1 p-8 rounded-3xl my-auto lg:w-[500px] max-lg:w-full"}
|
||||||
onMouseDown={e => e.stopPropagation()}
|
onMouseDown={e => e.stopPropagation()}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -88,7 +88,7 @@ export default function Modal(props: ModalProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
props.onClose?.(e);
|
props.onClose?.(e);
|
||||||
}}
|
}}
|
||||||
className="rounded-full aspect-square"
|
className="rounded-full aspect-square bg-layer-2 p-3"
|
||||||
iconSize={10}
|
iconSize={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
.new-goal .h3 {
|
|
||||||
font-size: 24px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-goal .zap-goals {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-goal .btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-goal .create-goal {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
@ -1,20 +1,19 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { ReactNode, useContext, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { unwrap } from "@snort/shared";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { SnortContext } from "@snort/system-react";
|
import { SnortContext } from "@snort/system-react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { useStreamProvider } from "@/hooks/stream-provider";
|
import { getCurrentStreamProvider, useStreamProvider } from "@/hooks/stream-provider";
|
||||||
import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/providers";
|
import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/providers";
|
||||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||||
import { eventLink } from "@/utils";
|
import { eventLink } from "@/utils";
|
||||||
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
import NostrProviderDialog from "@/element/provider/nostr";
|
||||||
import { DefaultButton } from "./buttons";
|
import { DefaultButton } from "./buttons";
|
||||||
import Pill from "./pill";
|
import Pill from "./pill";
|
||||||
import Modal from "./modal";
|
import Modal from "./modal";
|
||||||
|
|
||||||
function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
|
export function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
|
||||||
const system = useContext(SnortContext);
|
const system = useContext(SnortContext);
|
||||||
const providers = useStreamProvider();
|
const providers = useStreamProvider();
|
||||||
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
|
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
|
||||||
@ -22,11 +21,9 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProvider) {
|
if (!currentProvider) {
|
||||||
setCurrentProvider(
|
setCurrentProvider(getCurrentStreamProvider(ev));
|
||||||
ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [providers, currentProvider]);
|
}, [ev, providers, currentProvider]);
|
||||||
|
|
||||||
function providerDialog() {
|
function providerDialog() {
|
||||||
if (!currentProvider) return;
|
if (!currentProvider) return;
|
||||||
@ -52,23 +49,14 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
|||||||
}
|
}
|
||||||
case StreamProviders.NostrType: {
|
case StreamProviders.NostrType: {
|
||||||
return (
|
return (
|
||||||
<>
|
<NostrProviderDialog
|
||||||
<DefaultButton
|
provider={currentProvider as NostrStreamProvider}
|
||||||
onClick={() => {
|
onFinish={onFinish}
|
||||||
navigate("/settings/stream");
|
ev={ev}
|
||||||
onFinish?.();
|
showEndpoints={false}
|
||||||
}}>
|
showEditor={true}
|
||||||
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />
|
showForwards={false}
|
||||||
</DefaultButton>
|
/>
|
||||||
<NostrProviderDialog
|
|
||||||
provider={currentProvider as NostrStreamProvider}
|
|
||||||
onFinish={onFinish}
|
|
||||||
ev={ev}
|
|
||||||
showEndpoints={false}
|
|
||||||
showEditor={true}
|
|
||||||
showForwards={false}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case StreamProviders.Owncast: {
|
case StreamProviders.Owncast: {
|
||||||
@ -79,23 +67,23 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>
|
{!ev && <>
|
||||||
<FormattedMessage defaultMessage="Stream Providers" id="6Z2pvJ" />
|
<FormattedMessage defaultMessage="Stream Providers" id="6Z2pvJ" />
|
||||||
</p>
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
{providers.map(v => (
|
||||||
{providers.map(v => (
|
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
|
||||||
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
|
{v.name}
|
||||||
{v.name}
|
</Pill>
|
||||||
</Pill>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</>}
|
||||||
<div className="flex flex-col gap-4">{providerDialog()}</div>
|
<div className="flex flex-col gap-4">{providerDialog()}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewStreamDialogProps {
|
interface NewStreamDialogProps {
|
||||||
text?: string;
|
text?: ReactNode;
|
||||||
btnClassName?: string;
|
btnClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { Suspense, lazy } from "react";
|
|
||||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||||
|
|
||||||
const Markdown = lazy(() => import("./markdown"));
|
|
||||||
import { ExternalIconLink } from "./external-link";
|
import { ExternalIconLink } from "./external-link";
|
||||||
import { Profile } from "./profile";
|
import { Profile } from "./profile";
|
||||||
import EventReactions from "./event-reactions";
|
import EventReactions from "./event-reactions";
|
||||||
|
import { Text } from "@/element/text";
|
||||||
|
|
||||||
export function Note({ ev }: { ev: TaggedNostrEvent }) {
|
export function Note({ ev }: { ev: TaggedNostrEvent }) {
|
||||||
return (
|
return (
|
||||||
@ -13,9 +12,7 @@ export function Note({ ev }: { ev: TaggedNostrEvent }) {
|
|||||||
<Profile pubkey={ev.pubkey} avatarSize={30} />
|
<Profile pubkey={ev.pubkey} avatarSize={30} />
|
||||||
<ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} />
|
<ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} />
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Text tags={ev.tags} content={ev.content} className="whitespace-pre-line overflow-wrap" />
|
||||||
<Markdown tags={ev.tags} content={ev.content} />
|
|
||||||
</Suspense>
|
|
||||||
<EventReactions ev={ev} />
|
<EventReactions ev={ev} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
7
src/element/nsfw/hook.tsx
Normal file
7
src/element/nsfw/hook.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { NSFWStore } from "./store";
|
||||||
|
|
||||||
|
export function useContentWarning() {
|
||||||
|
const v = useSyncExternalStore(c => NSFWStore.hook(c), () => NSFWStore.snapshot());
|
||||||
|
return v;
|
||||||
|
}
|
39
src/element/nsfw/index.tsx
Normal file
39
src/element/nsfw/index.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Layer1Button, WarningButton } from "@/element/buttons";
|
||||||
|
import Logo from "@/element/logo";
|
||||||
|
import { NSFWStore } from "./store";
|
||||||
|
import { useContentWarning } from "./hook.tsx";
|
||||||
|
|
||||||
|
export function ContentWarningOverlay() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const is18Plus = useContentWarning();
|
||||||
|
if (is18Plus) return null;
|
||||||
|
|
||||||
|
function grownUp() {
|
||||||
|
NSFWStore.setValue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 bg-layer-0 w-screen h-screen absolute top-0 left-0 flex flex-col gap-4 justify-center items-center">
|
||||||
|
<Logo width={50} />
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage defaultMessage="Sexually explicit material ahead!" />
|
||||||
|
</h1>
|
||||||
|
<h2>
|
||||||
|
<FormattedMessage defaultMessage="Confirm your age" />
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<WarningButton onClick={grownUp}>
|
||||||
|
<FormattedMessage defaultMessage="Yes, I am over 18" />
|
||||||
|
</WarningButton>
|
||||||
|
<Layer1Button onClick={() => navigate("/")}>
|
||||||
|
<FormattedMessage defaultMessage="No, I am under 18" />
|
||||||
|
</Layer1Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useContentWarning }
|
23
src/element/nsfw/store.tsx
Normal file
23
src/element/nsfw/store.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ExternalStore } from "@snort/shared";
|
||||||
|
|
||||||
|
class Store extends ExternalStore<boolean> {
|
||||||
|
#value: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#value = Boolean(window.localStorage.getItem("accepted-content-warning"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(v: boolean) {
|
||||||
|
this.#value = v;
|
||||||
|
window.localStorage.setItem("accepted-content-warning", String(v));
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeSnapshot(): boolean {
|
||||||
|
return this.#value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NSFWStore = new Store();
|
@ -4,15 +4,15 @@ import { FormattedMessage, useIntl } from "react-intl";
|
|||||||
import { SnortContext } from "@snort/system-react";
|
import { SnortContext } from "@snort/system-react";
|
||||||
|
|
||||||
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
|
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
|
||||||
import { SendZaps } from "./send-zap";
|
import { SendZaps } from "@/element/send-zap";
|
||||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
import { StreamEditor, StreamEditorProps } from "@/element/stream-editor";
|
||||||
import Spinner from "./spinner";
|
import Spinner from "@/element/spinner";
|
||||||
import { unwrap } from "@snort/shared";
|
import { unwrap } from "@snort/shared";
|
||||||
import { useRates } from "@/hooks/rates";
|
import { useRates } from "@/hooks/rates";
|
||||||
import { DefaultButton } from "./buttons";
|
import { DefaultButton } from "@/element/buttons";
|
||||||
import Pill from "./pill";
|
import Pill from "@/element/pill";
|
||||||
|
|
||||||
export function NostrProviderDialog({
|
export default function NostrProviderDialog({
|
||||||
provider,
|
provider,
|
||||||
showEndpoints,
|
showEndpoints,
|
||||||
showEditor,
|
showEditor,
|
@ -1,14 +0,0 @@
|
|||||||
.rti--container {
|
|
||||||
background-color: unset !important;
|
|
||||||
border: 0 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
box-shadow: unset !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rti--tag {
|
|
||||||
color: black !important;
|
|
||||||
padding: 4px 10px !important;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
display: unset !important;
|
|
||||||
}
|
|
70
src/element/stream-editor/category-search.tsx
Normal file
70
src/element/stream-editor/category-search.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { ControlledMenu, MenuItem } from "@szhsin/react-menu";
|
||||||
|
import { debounce } from "@/utils";
|
||||||
|
import { AllCategories } from "@/pages/category";
|
||||||
|
import GameDatabase, { GameInfo } from "@/service/game-database";
|
||||||
|
import GameInfoCard from "@/element/game-info";
|
||||||
|
|
||||||
|
export function SearchCategory({ onSelect }: { onSelect?: (game: GameInfo) => void; }) {
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [categoryResults, setCategoryResults] = useState<Array<GameInfo>>([]);
|
||||||
|
|
||||||
|
function searchNonGames(s: string) {
|
||||||
|
return AllCategories.filter(a => {
|
||||||
|
if (a.id.toLowerCase().includes(s.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a.tags.some(b => b.toLowerCase().includes(s.toLowerCase()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).map(a => ({
|
||||||
|
id: `internal:${a.id}`,
|
||||||
|
name: a.name,
|
||||||
|
genres: a.tags,
|
||||||
|
className: a.className
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new GameDatabase();
|
||||||
|
useEffect(() => {
|
||||||
|
if (search) {
|
||||||
|
return debounce(500, async () => {
|
||||||
|
setCategoryResults([]);
|
||||||
|
const games = await db.searchGames(search);
|
||||||
|
setCategoryResults([...searchNonGames(search), ...games]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCategoryResults([]);
|
||||||
|
}
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder={formatMessage({
|
||||||
|
defaultMessage: "Gaming"
|
||||||
|
})} />
|
||||||
|
<ControlledMenu
|
||||||
|
gap={2}
|
||||||
|
menuClassName="ctx-menu gap-1"
|
||||||
|
state={categoryResults.length > 0 ? "open" : "closed"}
|
||||||
|
anchorRef={ref}
|
||||||
|
captureFocus={false}
|
||||||
|
>
|
||||||
|
{categoryResults.map(a => <MenuItem className="!px-2 !py-0" onClick={() => {
|
||||||
|
setCategoryResults([]);
|
||||||
|
setSearch("");
|
||||||
|
onSelect?.(a);
|
||||||
|
}}>
|
||||||
|
<GameInfoCard gameInfo={a} imageSize={40} />
|
||||||
|
</MenuItem>)}
|
||||||
|
</ControlledMenu>
|
||||||
|
</>;
|
||||||
|
}
|
23
src/element/stream-editor/goal-selector.tsx
Normal file
23
src/element/stream-editor/goal-selector.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { useGoals } from "@/hooks/goals";
|
||||||
|
|
||||||
|
interface GoalSelectorProps {
|
||||||
|
goal?: string;
|
||||||
|
pubkey: string;
|
||||||
|
onGoalSelect: (g: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
|
||||||
|
const goals = useGoals(pubkey, true);
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
return (
|
||||||
|
<select value={goal} onChange={ev => onGoalSelect(ev.target.value)}>
|
||||||
|
<option >{formatMessage({ defaultMessage: "Select a goal..." })}</option>
|
||||||
|
{goals?.map(x => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.content}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
15
src/element/stream-editor/index.css
Normal file
15
src/element/stream-editor/index.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.rti--container {
|
||||||
|
background-color: unset !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti--tag {
|
||||||
|
color: black !important;
|
||||||
|
padding: 4px 10px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
display: unset !important;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
import "./stream-editor.css";
|
import "./index.css";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
import { unixNow } from "@snort/shared";
|
import { unixNow } from "@snort/shared";
|
||||||
@ -7,11 +7,16 @@ import { FormattedMessage, useIntl } from "react-intl";
|
|||||||
|
|
||||||
import { extractStreamInfo, findTag } from "@/utils";
|
import { extractStreamInfo, findTag } from "@/utils";
|
||||||
import { useLogin } from "@/hooks/login";
|
import { useLogin } from "@/hooks/login";
|
||||||
import { NewGoalDialog } from "./new-goal";
|
|
||||||
import { useGoals } from "@/hooks/goals";
|
|
||||||
import { StreamState } from "@/const";
|
import { StreamState } from "@/const";
|
||||||
import { DefaultButton } from "./buttons";
|
import { DefaultButton, IconButton } from "@/element/buttons";
|
||||||
import Pill from "./pill";
|
import Pill from "@/element/pill";
|
||||||
|
|
||||||
|
import { NewGoalDialog } from "./new-goal";
|
||||||
|
import { StreamInput } from "./input";
|
||||||
|
import { SearchCategory } from "./category-search";
|
||||||
|
import { GoalSelector } from "./goal-selector";
|
||||||
|
import GameDatabase, { GameInfo } from "@/service/game-database";
|
||||||
|
import GameInfoCard from "../game-info";
|
||||||
|
|
||||||
export interface StreamEditorProps {
|
export interface StreamEditorProps {
|
||||||
ev?: NostrEvent;
|
ev?: NostrEvent;
|
||||||
@ -27,27 +32,6 @@ export interface StreamEditorProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GoalSelectorProps {
|
|
||||||
goal?: string;
|
|
||||||
pubkey: string;
|
|
||||||
onGoalSelect: (g: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
|
|
||||||
const goals = useGoals(pubkey, true);
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
return (
|
|
||||||
<select onChange={ev => onGoalSelect(ev.target.value)}>
|
|
||||||
<option value={goal}>{formatMessage({ defaultMessage: "Select a goal...", id: "I/TubD" })}</option>
|
|
||||||
{goals?.map(x => (
|
|
||||||
<option key={x.id} value={x.id}>
|
|
||||||
{x.content}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("");
|
||||||
@ -60,11 +44,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
const [contentWarning, setContentWarning] = useState(false);
|
const [contentWarning, setContentWarning] = useState(false);
|
||||||
const [isValid, setIsValid] = useState(false);
|
const [isValid, setIsValid] = useState(false);
|
||||||
const [goal, setGoal] = useState<string>();
|
const [goal, setGoal] = useState<string>();
|
||||||
|
const [game, setGame] = useState<GameInfo>();
|
||||||
|
const [gameId, setGameId] = useState<string>();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
|
const { gameInfo, gameId, title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
|
||||||
extractStreamInfo(ev);
|
extractStreamInfo(ev);
|
||||||
setTitle(title ?? "");
|
setTitle(title ?? "");
|
||||||
setSummary(summary ?? "");
|
setSummary(summary ?? "");
|
||||||
@ -76,6 +62,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
setTags(tags ?? []);
|
setTags(tags ?? []);
|
||||||
setContentWarning(contentWarning !== undefined);
|
setContentWarning(contentWarning !== undefined);
|
||||||
setGoal(goal);
|
setGoal(goal);
|
||||||
|
setGameId(gameId)
|
||||||
|
if (gameInfo) {
|
||||||
|
setGame(gameInfo);
|
||||||
|
} else if (gameId) {
|
||||||
|
new GameDatabase().getGame(gameId).then(setGame);
|
||||||
|
}
|
||||||
}, [ev?.id]);
|
}, [ev?.id]);
|
||||||
|
|
||||||
const validate = useCallback(() => {
|
const validate = useCallback(() => {
|
||||||
@ -128,6 +120,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
if (goal && goal.length > 0) {
|
if (goal && goal.length > 0) {
|
||||||
eb.tag(["goal", goal]);
|
eb.tag(["goal", goal]);
|
||||||
}
|
}
|
||||||
|
if (gameId) {
|
||||||
|
eb.tag(["t", gameId]);
|
||||||
|
}
|
||||||
return eb;
|
return eb;
|
||||||
});
|
});
|
||||||
console.debug(evNew);
|
console.debug(evNew);
|
||||||
@ -147,64 +142,46 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
<>
|
<>
|
||||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||||
{(options?.canSetTitle ?? true) && (
|
{(options?.canSetTitle ?? true) && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Title" />}>
|
||||||
<p>
|
<input
|
||||||
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
|
type="text"
|
||||||
</p>
|
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
|
||||||
<div className="paper">
|
value={title}
|
||||||
<input
|
onChange={e => setTitle(e.target.value)}
|
||||||
type="text"
|
/>
|
||||||
placeholder={formatMessage({ defaultMessage: "What are we steaming today?", id: "QRHNuF" })}
|
</StreamInput>
|
||||||
value={title}
|
|
||||||
onChange={e => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{(options?.canSetSummary ?? true) && (
|
{(options?.canSetSummary ?? true) && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Summary" />}>
|
||||||
<p>
|
<input
|
||||||
<FormattedMessage defaultMessage="Summary" id="RrCui3" />
|
type="text"
|
||||||
</p>
|
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
|
||||||
<div className="paper">
|
value={summary}
|
||||||
<input
|
onChange={e => setSummary(e.target.value)}
|
||||||
type="text"
|
/>
|
||||||
placeholder={formatMessage({ defaultMessage: "A short description of the content", id: "mtNGwh" })}
|
</StreamInput>
|
||||||
value={summary}
|
|
||||||
onChange={e => setSummary(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{(options?.canSetImage ?? true) && (
|
{(options?.canSetImage ?? true) && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}>
|
||||||
<p>
|
<div className="flex gap-2">
|
||||||
<FormattedMessage defaultMessage="Cover Image" id="Gq6x9o" />
|
|
||||||
</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
|
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
|
||||||
|
<DefaultButton>
|
||||||
|
<FormattedMessage defaultMessage="Upload" />
|
||||||
|
</DefaultButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StreamInput>
|
||||||
)}
|
)}
|
||||||
{(options?.canSetStream ?? true) && (
|
{(options?.canSetStream ?? true) && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Stream URL" />}>
|
||||||
<p>
|
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
||||||
<FormattedMessage defaultMessage="Stream URL" id="QRRCp0" />
|
|
||||||
</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<small>
|
<small>
|
||||||
<FormattedMessage defaultMessage="Stream type should be HLS" id="oZrFyI" />
|
<FormattedMessage defaultMessage="Stream type should be HLS" />
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</StreamInput>
|
||||||
)}
|
)}
|
||||||
{(options?.canSetStatus ?? true) && (
|
{(options?.canSetStatus ?? true) && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Status" />}>
|
||||||
<p>
|
|
||||||
<FormattedMessage defaultMessage="Status" id="tzMNF3" />
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
|
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
|
||||||
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}>
|
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}>
|
||||||
@ -212,55 +189,54 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
</Pill>
|
</Pill>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StreamInput>
|
||||||
{status === StreamState.Planned && (
|
{status === StreamState.Planned && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Start Time" />}>
|
||||||
<p>
|
<input
|
||||||
<FormattedMessage defaultMessage="Start Time" id="5QYdPU" />
|
type="datetime-local"
|
||||||
</p>
|
value={toDateTimeString(Number(start ?? "0"))}
|
||||||
<div className="paper">
|
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
||||||
<input
|
/>
|
||||||
type="datetime-local"
|
</StreamInput>
|
||||||
value={toDateTimeString(Number(start ?? "0"))}
|
|
||||||
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{status === StreamState.Ended && (
|
{status === StreamState.Ended && (
|
||||||
<div>
|
<StreamInput label={<FormattedMessage defaultMessage="Recording URL" />}>
|
||||||
<p>
|
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
|
||||||
<FormattedMessage defaultMessage="Recording URL" id="Y0DXJb" />
|
</StreamInput>
|
||||||
</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(options?.canSetTags ?? true) && (
|
{(options?.canSetTags ?? true) && (
|
||||||
<div>
|
<>
|
||||||
<p>
|
<StreamInput label={<FormattedMessage defaultMessage="Category" />}>
|
||||||
<FormattedMessage defaultMessage="Tags" id="1EYCdR" />
|
{!game && <SearchCategory onSelect={g => {
|
||||||
</p>
|
setGame(g);
|
||||||
<div className="paper">
|
setGameId(g.id);
|
||||||
|
}} />}
|
||||||
|
{game && <div className="flex justify-between rounded-xl px-3 py-2 border border-layer-2">
|
||||||
|
<GameInfoCard gameInfo={game} gameId={gameId} imageSize={80} />
|
||||||
|
<IconButton iconName="x"
|
||||||
|
iconSize={12}
|
||||||
|
className="text-layer-4"
|
||||||
|
onClick={() => {
|
||||||
|
setGame(undefined);
|
||||||
|
setGameId(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>}
|
||||||
|
</StreamInput>
|
||||||
|
<StreamInput label={<FormattedMessage defaultMessage="Tags" />}>
|
||||||
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
|
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
|
||||||
</div>
|
</StreamInput>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
{login?.pubkey && (
|
{login?.pubkey && (
|
||||||
<>
|
<StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
|
||||||
<FormattedMessage defaultMessage="Goal" id="0VV/sK" />
|
<NewGoalDialog />
|
||||||
</p>
|
|
||||||
<div className="paper">
|
|
||||||
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<NewGoalDialog />
|
</StreamInput>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{(options?.canSetContentWarning ?? true) && (
|
{(options?.canSetContentWarning ?? true) && (
|
||||||
<div className="flex gap-2 rounded-xl border border-warning px-4 py-3">
|
<div className="flex gap-2 rounded-xl border border-warning px-4 py-3">
|
||||||
@ -269,7 +245,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-warning">
|
<div className="text-warning">
|
||||||
<FormattedMessage defaultMessage="NSFW Content" id="Atr2p4" />
|
<FormattedMessage defaultMessage="NSFW Content" />
|
||||||
</div>
|
</div>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
defaultMessage="Check here if this stream contains nudity or pornographic content."
|
defaultMessage="Check here if this stream contains nudity or pornographic content."
|
||||||
@ -280,13 +256,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<DefaultButton disabled={!isValid} onClick={publishStream}>
|
<DefaultButton disabled={!isValid} onClick={publishStream}>
|
||||||
{ev ? (
|
<FormattedMessage defaultMessage="Save" />
|
||||||
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage defaultMessage="Start Stream" id="TaTRKo" />
|
|
||||||
)}
|
|
||||||
</DefaultButton>
|
</DefaultButton>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
12
src/element/stream-editor/input.tsx
Normal file
12
src/element/stream-editor/input.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function StreamInput({ label, children }: { label: ReactNode; children?: ReactNode; }) {
|
||||||
|
return <div>
|
||||||
|
<div className="mb-1 text-layer-4 text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
@ -1,14 +1,13 @@
|
|||||||
import "./new-goal.css";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { SnortContext } from "@snort/system-react";
|
import { SnortContext } from "@snort/system-react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../icon";
|
||||||
import { GOAL } from "@/const";
|
import { GOAL } from "@/const";
|
||||||
import { useLogin } from "@/hooks/login";
|
import { useLogin } from "@/hooks/login";
|
||||||
import { defaultRelays } from "@/const";
|
import { defaultRelays } from "@/const";
|
||||||
import { DefaultButton } from "./buttons";
|
import { DefaultButton } from "../buttons";
|
||||||
import Modal from "./modal";
|
import Modal from "../modal";
|
||||||
|
|
||||||
export function NewGoalDialog() {
|
export function NewGoalDialog() {
|
||||||
const system = useContext(SnortContext);
|
const system = useContext(SnortContext);
|
||||||
@ -42,7 +41,7 @@ export function NewGoalDialog() {
|
|||||||
<DefaultButton onClick={() => setOpen(true)}>
|
<DefaultButton onClick={() => setOpen(true)}>
|
||||||
<Icon name="zap-filled" size={12} />
|
<Icon name="zap-filled" size={12} />
|
||||||
<span>
|
<span>
|
||||||
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" />
|
<FormattedMessage defaultMessage="New Goal" />
|
||||||
</span>
|
</span>
|
||||||
</DefaultButton>
|
</DefaultButton>
|
||||||
{open && (
|
{open && (
|
@ -2,23 +2,20 @@ import type { ReactNode } from "react";
|
|||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
import { findTag, getTagValues } from "@/utils";
|
import { extractStreamInfo } from "@/utils";
|
||||||
import { StreamState } from "@/const";
|
import { StreamState } from "@/const";
|
||||||
import Pill from "./pill";
|
import Pill from "./pill";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
||||||
const status = findTag(ev, "status");
|
const { status, tags } = extractStreamInfo(ev);
|
||||||
const hashtags = getTagValues(ev.tags, "t");
|
|
||||||
const tags = max ? hashtags.slice(0, max) : hashtags;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{status === StreamState.Planned && (
|
{status === StreamState.Planned && (
|
||||||
<Pill>{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " id="0hNxBy" /> : ""}</Pill>
|
<Pill>{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " id="0hNxBy" /> : ""}</Pill>
|
||||||
)}
|
)}
|
||||||
{tags.map(a => (
|
{tags.slice(0, max ? max : tags.length).map(a => (
|
||||||
<Link to={`/t/${encodeURIComponent(a)}`} key={a}>
|
<Link to={`/t/${encodeURIComponent(a)}`} key={a}>
|
||||||
<Pill>{a}</Pill>
|
<Pill>{a}</Pill>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -43,9 +43,7 @@ export function Text({ content, tags, eventComponent, className }: TextProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text">
|
<HyperText link={f.content}>{f.content}</HyperText>
|
||||||
<HyperText link={f.content}>{f.content}</HyperText>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "mention":
|
case "mention":
|
||||||
|
@ -11,6 +11,7 @@ import { StreamState } from "@/const";
|
|||||||
import Pill from "./pill";
|
import Pill from "./pill";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import Logo from "./logo";
|
import Logo from "./logo";
|
||||||
|
import { useContentWarning } from "./nsfw";
|
||||||
|
|
||||||
export function VideoTile({
|
export function VideoTile({
|
||||||
ev,
|
ev,
|
||||||
@ -23,12 +24,16 @@ export function VideoTile({
|
|||||||
}) {
|
}) {
|
||||||
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
|
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
|
||||||
const host = getHost(ev);
|
const host = getHost(ev);
|
||||||
|
const isGrownUp = useContentWarning();
|
||||||
|
|
||||||
const link = NostrLink.fromEvent(ev);
|
const link = NostrLink.fromEvent(ev);
|
||||||
const hasImg = (image?.length ?? 0) > 0;
|
const hasImg = (image?.length ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Link to={`/${link.encode()}`} className={classNames({ blur: contentWarning }, "h-full")} state={ev}>
|
<Link to={`/${link.encode()}`} className={classNames({
|
||||||
|
"blur transition": contentWarning,
|
||||||
|
"hover:blur-none": isGrownUp,
|
||||||
|
}, "h-full")} state={ev}>
|
||||||
<div className="relative mb-2 aspect-video">
|
<div className="relative mb-2 aspect-video">
|
||||||
{hasImg ? (
|
{hasImg ? (
|
||||||
<img loading="lazy" className="aspect-video object-cover rounded-xl" src={image} />
|
<img loading="lazy" className="aspect-video object-cover rounded-xl" src={image} />
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { unwrap } from "@snort/shared";
|
import { unwrap } from "@snort/shared";
|
||||||
import { NostrEvent, NostrLink, NostrPrefix, RequestBuilder } from "@snort/system";
|
import { NostrLink, NostrPrefix, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { LIVE_STREAM } from "@/const";
|
import { LIVE_STREAM } from "@/const";
|
||||||
|
|
||||||
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
|
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
|
||||||
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const b = new RequestBuilder(`current-event:${link.id}`);
|
const b = new RequestBuilder(`current-event:${link.id}`);
|
||||||
|
28
src/hooks/game-info.ts
Normal file
28
src/hooks/game-info.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { AllCategories } from "@/pages/category";
|
||||||
|
import GameDatabase, { GameInfo } from "@/service/game-database";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useGameInfo(gameId?: string, gameInfo?: GameInfo) {
|
||||||
|
const [game, setGame] = useState<GameInfo | undefined>(gameInfo);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameInfo && gameId) {
|
||||||
|
const [prefix, id] = gameId.split(":");
|
||||||
|
if (prefix === "internal" || !gameId.includes(":")) {
|
||||||
|
const ix = AllCategories.find(a => a.id === id || a.id === gameId);
|
||||||
|
if (ix) {
|
||||||
|
setGame({
|
||||||
|
id: `internal:${ix.id}`,
|
||||||
|
name: ix.name,
|
||||||
|
genres: ix.tags,
|
||||||
|
className: ix.className
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
new GameDatabase().getGame(gameId).then(setGame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gameInfo, gameId]);
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
25
src/hooks/stream-provider.ts
Normal file
25
src/hooks/stream-provider.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { NostrStreamProvider, StreamProviderStore } from "@/providers";
|
||||||
|
import { ManualProvider } from "@/providers/manual";
|
||||||
|
import { findTag } from "@/utils";
|
||||||
|
import { NostrEvent } from "@snort/system";
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export function useStreamProvider() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
c => StreamProviderStore.hook(c),
|
||||||
|
() => StreamProviderStore.snapshot()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentStreamProvider(ev?: NostrEvent) {
|
||||||
|
const providers = StreamProviderStore.snapshot();
|
||||||
|
if (ev) {
|
||||||
|
const service = findTag(ev, "service");
|
||||||
|
if (service) {
|
||||||
|
return new NostrStreamProvider("", service);
|
||||||
|
} else {
|
||||||
|
return new ManualProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providers.at(0);
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
import { StreamProviderStore } from "@/providers";
|
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
export function useStreamProvider() {
|
|
||||||
return useSyncExternalStore(
|
|
||||||
c => StreamProviderStore.hook(c),
|
|
||||||
() => StreamProviderStore.snapshot()
|
|
||||||
);
|
|
||||||
}
|
|
@ -150,6 +150,9 @@
|
|||||||
<symbol id="dice" viewBox="0 0 34 34" fill="none">
|
<symbol id="dice" viewBox="0 0 34 34" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4454 0.333009H6.98362C6.10506 0.332981 5.34708 0.332956 4.72281 0.383961C4.0639 0.437796 3.40854 0.556656 2.7779 0.877979C1.83709 1.35735 1.07219 2.12225 0.592823 3.06306C0.2715 3.69369 0.15264 4.34906 0.0988048 5.00797C0.0477998 5.63224 0.0478244 6.39015 0.0478529 7.26871V26.7306C0.0478244 27.6091 0.0477998 28.3671 0.0988048 28.9914C0.15264 29.6503 0.2715 30.3057 0.592823 30.9363C1.07219 31.8771 1.83709 32.642 2.7779 33.1214C3.40854 33.4427 4.0639 33.5616 4.72281 33.6154C5.34709 33.6664 6.10501 33.6664 6.98358 33.6663H26.4455C27.324 33.6664 28.082 33.6664 28.7062 33.6154C29.3651 33.5616 30.0205 33.4427 30.6511 33.1214C31.592 32.642 32.3569 31.8771 32.8362 30.9363C33.1575 30.3057 33.2764 29.6503 33.3302 28.9914C33.3812 28.3671 33.3812 27.6092 33.3812 26.7306V7.26873C33.3812 6.39017 33.3812 5.63224 33.3302 5.00797C33.2764 4.34906 33.1575 3.69369 32.8362 3.06306C32.3569 2.12225 31.592 1.35735 30.6511 0.877979C30.0205 0.556656 29.3651 0.437796 28.7062 0.383961C28.082 0.332956 27.324 0.332981 26.4454 0.333009ZM21.7145 9.49968C21.7145 8.11896 22.8338 6.99968 24.2145 6.99968C25.5952 6.99968 26.7145 8.11896 26.7145 9.49968C26.7145 10.8804 25.5952 11.9997 24.2145 11.9997C22.8338 11.9997 21.7145 10.8804 21.7145 9.49968ZM14.2145 16.9997C14.2145 15.619 15.3338 14.4997 16.7145 14.4997C18.0952 14.4997 19.2145 15.619 19.2145 16.9997C19.2145 18.3804 18.0952 19.4997 16.7145 19.4997C15.3338 19.4997 14.2145 18.3804 14.2145 16.9997ZM9.21452 21.9997C7.83381 21.9997 6.71452 23.119 6.71452 24.4997C6.71452 25.8804 7.83381 26.9997 9.21452 26.9997C10.5952 26.9997 11.7145 25.8804 11.7145 24.4997C11.7145 23.119 10.5952 21.9997 9.21452 21.9997ZM24.2145 21.9997C25.5952 21.9997 26.7145 23.119 26.7145 24.4997C26.7145 25.8804 25.5952 26.9997 24.2145 26.9997C22.8338 26.9997 21.7145 25.8804 21.7145 24.4997C21.7145 23.119 22.8338 21.9997 24.2145 21.9997ZM11.7145 9.49968C11.7145 8.11896 10.5952 6.99968 9.21452 6.99968C7.83381 6.99968 6.71452 8.11896 6.71452 9.49968C6.71452 10.8804 7.83381 11.9997 9.21452 11.9997C10.5952 11.9997 11.7145 10.8804 11.7145 9.49968Z" fill="currentColor"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4454 0.333009H6.98362C6.10506 0.332981 5.34708 0.332956 4.72281 0.383961C4.0639 0.437796 3.40854 0.556656 2.7779 0.877979C1.83709 1.35735 1.07219 2.12225 0.592823 3.06306C0.2715 3.69369 0.15264 4.34906 0.0988048 5.00797C0.0477998 5.63224 0.0478244 6.39015 0.0478529 7.26871V26.7306C0.0478244 27.6091 0.0477998 28.3671 0.0988048 28.9914C0.15264 29.6503 0.2715 30.3057 0.592823 30.9363C1.07219 31.8771 1.83709 32.642 2.7779 33.1214C3.40854 33.4427 4.0639 33.5616 4.72281 33.6154C5.34709 33.6664 6.10501 33.6664 6.98358 33.6663H26.4455C27.324 33.6664 28.082 33.6664 28.7062 33.6154C29.3651 33.5616 30.0205 33.4427 30.6511 33.1214C31.592 32.642 32.3569 31.8771 32.8362 30.9363C33.1575 30.3057 33.2764 29.6503 33.3302 28.9914C33.3812 28.3671 33.3812 27.6092 33.3812 26.7306V7.26873C33.3812 6.39017 33.3812 5.63224 33.3302 5.00797C33.2764 4.34906 33.1575 3.69369 32.8362 3.06306C32.3569 2.12225 31.592 1.35735 30.6511 0.877979C30.0205 0.556656 29.3651 0.437796 28.7062 0.383961C28.082 0.332956 27.324 0.332981 26.4454 0.333009ZM21.7145 9.49968C21.7145 8.11896 22.8338 6.99968 24.2145 6.99968C25.5952 6.99968 26.7145 8.11896 26.7145 9.49968C26.7145 10.8804 25.5952 11.9997 24.2145 11.9997C22.8338 11.9997 21.7145 10.8804 21.7145 9.49968ZM14.2145 16.9997C14.2145 15.619 15.3338 14.4997 16.7145 14.4997C18.0952 14.4997 19.2145 15.619 19.2145 16.9997C19.2145 18.3804 18.0952 19.4997 16.7145 19.4997C15.3338 19.4997 14.2145 18.3804 14.2145 16.9997ZM9.21452 21.9997C7.83381 21.9997 6.71452 23.119 6.71452 24.4997C6.71452 25.8804 7.83381 26.9997 9.21452 26.9997C10.5952 26.9997 11.7145 25.8804 11.7145 24.4997C11.7145 23.119 10.5952 21.9997 9.21452 21.9997ZM24.2145 21.9997C25.5952 21.9997 26.7145 23.119 26.7145 24.4997C26.7145 25.8804 25.5952 26.9997 24.2145 26.9997C22.8338 26.9997 21.7145 25.8804 21.7145 24.4997C21.7145 23.119 22.8338 21.9997 24.2145 21.9997ZM11.7145 9.49968C11.7145 8.11896 10.5952 6.99968 9.21452 6.99968C7.83381 6.99968 6.71452 8.11896 6.71452 9.49968C6.71452 10.8804 7.83381 11.9997 9.21452 11.9997C10.5952 11.9997 11.7145 10.8804 11.7145 9.49968Z" fill="currentColor"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="x" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M11.7071 1.70711C12.0976 1.31658 12.0976 0.68342 11.7071 0.292895C11.3166 -0.0976291 10.6834 -0.0976292 10.2929 0.292895L6 4.58579L1.70711 0.292894C1.31658 -0.0976309 0.683418 -0.097631 0.292893 0.292893C-0.0976312 0.683417 -0.0976313 1.31658 0.292893 1.70711L4.58579 6L0.292891 10.2929C-0.097633 10.6834 -0.0976331 11.3166 0.292891 11.7071C0.683415 12.0976 1.31658 12.0976 1.7071 11.7071L6 7.41421L10.2929 11.7071C10.6834 12.0976 11.3166 12.0976 11.7071 11.7071C12.0976 11.3166 12.0976 10.6834 11.7071 10.2929L7.41421 6L11.7071 1.70711Z" fill="currentColor"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@ -1,5 +1,7 @@
|
|||||||
import CategoryLink from "@/element/category-link";
|
import CategoryLink from "@/element/category-link";
|
||||||
|
import Pill from "@/element/pill";
|
||||||
import VideoGridSorted from "@/element/video-grid-sorted";
|
import VideoGridSorted from "@/element/video-grid-sorted";
|
||||||
|
import useGameInfo from "@/hooks/game-info";
|
||||||
import { EventKind, RequestBuilder } from "@snort/system";
|
import { EventKind, RequestBuilder } from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@ -55,18 +57,29 @@ export const AllCategories = [
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
className: "bg-category-gradient-6",
|
className: "bg-category-gradient-6",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "science-and-technology",
|
||||||
|
name: <FormattedMessage defaultMessage="Science & Technology" />,
|
||||||
|
icon: "dice",
|
||||||
|
tags: ["science", "technology"],
|
||||||
|
priority: 1,
|
||||||
|
className: "bg-category-gradient-7",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Category() {
|
export default function Category() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const game = useGameInfo(id);
|
||||||
|
|
||||||
const cat = AllCategories.find(a => a.id === id);
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!cat) return;
|
if (!id) return;
|
||||||
const rb = new RequestBuilder(`category:${cat.id}`);
|
|
||||||
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat.tags);
|
const cat = AllCategories.find(a => a.id === id);
|
||||||
|
const rb = new RequestBuilder(`category:${id}`);
|
||||||
|
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat?.tags ?? [id]);
|
||||||
return rb;
|
return rb;
|
||||||
}, [cat]);
|
}, [id]);
|
||||||
|
|
||||||
const results = useRequestBuilder(sub);
|
const results = useRequestBuilder(sub);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -75,7 +88,17 @@ export default function Category() {
|
|||||||
<CategoryLink key={a.id} {...a} />
|
<CategoryLink key={a.id} {...a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="uppercase my-4">{id}</h1>
|
<div className="flex gap-8 py-8">
|
||||||
|
{game?.cover && <img src={game?.cover} className="h-[250px]" />}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1>{game?.name}</h1>
|
||||||
|
{game?.genres && <div className="flex gap-2">
|
||||||
|
{game?.genres?.map(a => <Pill>
|
||||||
|
{a}
|
||||||
|
</Pill>)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<VideoGridSorted evs={results} showAll={true} />
|
<VideoGridSorted evs={results} showAll={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,194 +0,0 @@
|
|||||||
import { ChatZap, LiveChat } from "@/element/live-chat";
|
|
||||||
import LiveVideoPlayer from "@/element/live-video-player";
|
|
||||||
import { MuteButton } from "@/element/mute-button";
|
|
||||||
import { Profile } from "@/element/profile";
|
|
||||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
|
||||||
import { useLogin } from "@/hooks/login";
|
|
||||||
import { extractStreamInfo } from "@/utils";
|
|
||||||
import { dedupe } from "@snort/shared";
|
|
||||||
import { NostrLink, NostrPrefix, ParsedZap, TaggedNostrEvent } from "@snort/system";
|
|
||||||
import { useEventReactions, useReactions } from "@snort/system-react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { HTMLProps, ReactNode, useEffect, useMemo, useState } from "react";
|
|
||||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
|
||||||
import { Text } from "@/element/text";
|
|
||||||
import { StreamTimer } from "@/element/stream-time";
|
|
||||||
import { DashboardRaidMenu } from "@/element/raid-menu";
|
|
||||||
import { DefaultButton } from "@/element/buttons";
|
|
||||||
import Modal from "@/element/modal";
|
|
||||||
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP } from "@/const";
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const login = useLogin();
|
|
||||||
if (!login) return;
|
|
||||||
|
|
||||||
return <DashboardForLink link={new NostrLink(NostrPrefix.PublicKey, login.pubkey)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardForLink({ link }: { link: NostrLink }) {
|
|
||||||
const streamEvent = useCurrentStreamFeed(link, true);
|
|
||||||
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
|
|
||||||
const { stream, status, image, participants } = extractStreamInfo(streamEvent);
|
|
||||||
const [maxParticipants, setMaxParticipants] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (participants) {
|
|
||||||
setMaxParticipants(v => (v < Number(participants) ? Number(participants) : v));
|
|
||||||
}
|
|
||||||
}, [participants]);
|
|
||||||
|
|
||||||
const feed = useReactions(
|
|
||||||
`live:${link?.id}:${streamLink?.author}:reactions`,
|
|
||||||
streamLink ? [streamLink] : [],
|
|
||||||
rb => {
|
|
||||||
if (streamLink) {
|
|
||||||
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).replyToLink([streamLink]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (!streamLink) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-2 h-[calc(100%-48px-1rem)]">
|
|
||||||
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
|
|
||||||
<DashboardCard className="flex flex-col gap-4">
|
|
||||||
<h3>
|
|
||||||
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
|
|
||||||
</h3>
|
|
||||||
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<DashboardStatsCard
|
|
||||||
name={<FormattedMessage defaultMessage="Stream Time" id="miQKuZ" />}
|
|
||||||
value={<StreamTimer ev={streamEvent} />}
|
|
||||||
/>
|
|
||||||
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" id="37mth/" />} value={participants} />
|
|
||||||
<DashboardStatsCard
|
|
||||||
name={<FormattedMessage defaultMessage="Highest Viewers" id="jctiUc" />}
|
|
||||||
value={maxParticipants}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DashboardRaidButton link={streamLink} />
|
|
||||||
</DashboardCard>
|
|
||||||
<DashboardCard className="flex flex-col gap-4">
|
|
||||||
<h3>
|
|
||||||
<FormattedMessage defaultMessage="Chat Users" id="RtYNX5" />
|
|
||||||
</h3>
|
|
||||||
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
|
|
||||||
<DashboardChatList feed={feed} />
|
|
||||||
</div>
|
|
||||||
</DashboardCard>
|
|
||||||
</div>
|
|
||||||
<DashboardZapColumn link={streamLink} feed={feed} />
|
|
||||||
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardCard(props: HTMLProps<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div {...props} className={classNames("px-4 py-6 rounded-3xl border border-layer-1", props.className)}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardStatsCard({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: { name: ReactNode; value: ReactNode } & Omit<HTMLProps<HTMLDivElement>, "children" | "name" | "value">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
className={classNames("flex-1 bg-layer-1 flex flex-col gap-1 px-4 py-2 rounded-xl", props.className)}>
|
|
||||||
<div className="text-layer-4 font-medium">{name}</div>
|
|
||||||
<div>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardChatList({ feed }: { feed: Array<TaggedNostrEvent> }) {
|
|
||||||
const pubkeys = useMemo(() => {
|
|
||||||
return dedupe(feed.map(a => a.pubkey));
|
|
||||||
}, [feed]);
|
|
||||||
|
|
||||||
return pubkeys.map(a => (
|
|
||||||
<div className="flex justify-between items-center px-4 py-2 border-b border-layer-1">
|
|
||||||
<Profile pubkey={a} avatarSize={32} gap={4} />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<MuteButton pubkey={a} />
|
|
||||||
<DefaultButton onClick={() => {}} className="font-bold">
|
|
||||||
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
|
||||||
</DefaultButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Array<TaggedNostrEvent> }) {
|
|
||||||
const reactions = useEventReactions(link, feed);
|
|
||||||
|
|
||||||
const sortedZaps = useMemo(
|
|
||||||
() => reactions.zaps.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)),
|
|
||||||
[reactions.zaps]
|
|
||||||
);
|
|
||||||
const latestZap = sortedZaps.at(0);
|
|
||||||
return (
|
|
||||||
<DashboardCard className="min-h-0 h-full flex flex-col gap-4">
|
|
||||||
<h3>
|
|
||||||
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-2 overflow-y-scroll">
|
|
||||||
{latestZap && <DashboardHighlightZap zap={latestZap} />}
|
|
||||||
{sortedZaps.slice(1).map(a => (
|
|
||||||
<ChatZap zap={a} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardHighlightZap({ zap }: { zap: ParsedZap }) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-6 bg-layer-1 flex flex-col gap-4 rounded-xl animate-flash">
|
|
||||||
<div className="flex justify-between items-center text-zap text-2xl font-semibold">
|
|
||||||
<Profile
|
|
||||||
pubkey={zap.sender ?? "anon"}
|
|
||||||
options={{
|
|
||||||
showAvatar: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<FormattedMessage
|
|
||||||
defaultMessage="{n} sats"
|
|
||||||
id="CsCUYo"
|
|
||||||
values={{
|
|
||||||
n: <FormattedNumber value={zap.amount} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{zap.content && (
|
|
||||||
<div className="text-2xl">
|
|
||||||
<Text content={zap.content} tags={[]} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardRaidButton({ link }: { link: NostrLink }) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DefaultButton onClick={() => setShow(true)}>
|
|
||||||
<FormattedMessage defaultMessage="Raid" id="4iBdw1" />
|
|
||||||
</DefaultButton>
|
|
||||||
{show && (
|
|
||||||
<Modal id="raid-menu" onClose={() => setShow(false)}>
|
|
||||||
<DashboardRaidMenu link={link} onClose={() => setShow(false)} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
22
src/pages/dashboard/button-raid.tsx
Normal file
22
src/pages/dashboard/button-raid.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NostrLink } from "@snort/system";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { DashboardRaidMenu } from "./raid-menu";
|
||||||
|
import { DefaultButton } from "@/element/buttons";
|
||||||
|
import Modal from "@/element/modal";
|
||||||
|
|
||||||
|
export function DashboardRaidButton({ link }: { link: NostrLink; }) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DefaultButton onClick={() => setShow(true)}>
|
||||||
|
<FormattedMessage defaultMessage="Raid" id="4iBdw1" />
|
||||||
|
</DefaultButton>
|
||||||
|
{show && (
|
||||||
|
<Modal id="raid-menu" onClose={() => setShow(false)}>
|
||||||
|
<DashboardRaidMenu link={link} onClose={() => setShow(false)} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
33
src/pages/dashboard/button-settings.tsx
Normal file
33
src/pages/dashboard/button-settings.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { TaggedNostrEvent } from "@snort/system";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { DefaultButton } from "@/element/buttons";
|
||||||
|
import Modal from "@/element/modal";
|
||||||
|
import { getCurrentStreamProvider } from "@/hooks/stream-provider";
|
||||||
|
import NostrProviderDialog from "@/element/provider/nostr";
|
||||||
|
import { NostrStreamProvider } from "@/providers";
|
||||||
|
|
||||||
|
export function DashboardSettingsButton({ ev }: { ev?: TaggedNostrEvent }) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const provider = getCurrentStreamProvider(ev) as NostrStreamProvider;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DefaultButton onClick={() => setShow(true)}>
|
||||||
|
<FormattedMessage defaultMessage="Settings" />
|
||||||
|
</DefaultButton>
|
||||||
|
{show && (
|
||||||
|
<Modal id="dashboard-settings" onClose={() => setShow(false)}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<NostrProviderDialog
|
||||||
|
provider={provider}
|
||||||
|
ev={ev}
|
||||||
|
showEndpoints={true}
|
||||||
|
showForwards={true}
|
||||||
|
showEditor={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
10
src/pages/dashboard/card.tsx
Normal file
10
src/pages/dashboard/card.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { HTMLProps } from "react";
|
||||||
|
|
||||||
|
export function DashboardCard(props: HTMLProps<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div {...props} className={classNames("px-4 py-6 rounded-3xl border border-layer-1", props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
25
src/pages/dashboard/chat-list.tsx
Normal file
25
src/pages/dashboard/chat-list.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { MuteButton } from "@/element/mute-button";
|
||||||
|
import { Profile } from "@/element/profile";
|
||||||
|
import { dedupe } from "@snort/shared";
|
||||||
|
import { TaggedNostrEvent } from "@snort/system";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { DefaultButton } from "@/element/buttons";
|
||||||
|
|
||||||
|
export function DashboardChatList({ feed }: { feed: Array<TaggedNostrEvent>; }) {
|
||||||
|
const pubkeys = useMemo(() => {
|
||||||
|
return dedupe(feed.map(a => a.pubkey));
|
||||||
|
}, [feed]);
|
||||||
|
|
||||||
|
return pubkeys.map(a => (
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 border-b border-layer-1">
|
||||||
|
<Profile pubkey={a} avatarSize={32} gap={4} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<MuteButton pubkey={a} />
|
||||||
|
<DefaultButton onClick={() => { }} className="font-bold">
|
||||||
|
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
||||||
|
</DefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
30
src/pages/dashboard/column-zaps.tsx
Normal file
30
src/pages/dashboard/column-zaps.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ChatZap } from "@/element/live-chat";
|
||||||
|
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||||
|
import { useEventReactions } from "@snort/system-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { DashboardCard } from "./card";
|
||||||
|
import { DashboardHighlightZap } from "./zap-highlight";
|
||||||
|
|
||||||
|
export function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Array<TaggedNostrEvent>; }) {
|
||||||
|
const reactions = useEventReactions(link, feed);
|
||||||
|
|
||||||
|
const sortedZaps = useMemo(
|
||||||
|
() => reactions.zaps.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)),
|
||||||
|
[reactions.zaps]
|
||||||
|
);
|
||||||
|
const latestZap = sortedZaps.at(0);
|
||||||
|
return (
|
||||||
|
<DashboardCard className="min-h-0 h-full flex flex-col gap-4">
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-2 overflow-y-scroll">
|
||||||
|
{latestZap && <DashboardHighlightZap zap={latestZap} />}
|
||||||
|
{sortedZaps.slice(1).map(a => (
|
||||||
|
<ChatZap zap={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
}
|
82
src/pages/dashboard/dashboard.tsx
Normal file
82
src/pages/dashboard/dashboard.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { LiveChat } from "@/element/live-chat";
|
||||||
|
import LiveVideoPlayer from "@/element/live-video-player";
|
||||||
|
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||||
|
import { extractStreamInfo } from "@/utils";
|
||||||
|
import { NostrLink } from "@snort/system";
|
||||||
|
import { useReactions } from "@snort/system-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { StreamTimer } from "@/element/stream-time";
|
||||||
|
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP } from "@/const";
|
||||||
|
import { DashboardRaidButton } from "./button-raid";
|
||||||
|
import { DashboardZapColumn } from "./column-zaps";
|
||||||
|
import { DashboardChatList } from "./chat-list";
|
||||||
|
import { DashboardStatsCard } from "./stats-card";
|
||||||
|
import { DashboardCard } from "./card";
|
||||||
|
import { NewStreamDialog } from "@/element/new-stream";
|
||||||
|
import { DashboardSettingsButton } from "./button-settings";
|
||||||
|
import DashboardIntro from "./intro";
|
||||||
|
|
||||||
|
export function DashboardForLink({ link }: { link: NostrLink; }) {
|
||||||
|
const streamEvent = useCurrentStreamFeed(link, true);
|
||||||
|
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
|
||||||
|
const { stream, status, image, participants } = extractStreamInfo(streamEvent);
|
||||||
|
|
||||||
|
const [maxParticipants, setMaxParticipants] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (participants) {
|
||||||
|
setMaxParticipants(v => (v < Number(participants) ? Number(participants) : v));
|
||||||
|
}
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
const feed = useReactions(
|
||||||
|
`live:${link?.id}:${streamLink?.author}:reactions`,
|
||||||
|
streamLink ? [streamLink] : [],
|
||||||
|
rb => {
|
||||||
|
if (streamLink) {
|
||||||
|
rb.withFilter()
|
||||||
|
.kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP])
|
||||||
|
.replyToLink([streamLink]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (!streamLink) return <DashboardIntro />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2 h-[calc(100%-48px-1rem)]">
|
||||||
|
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
|
||||||
|
<DashboardCard className="flex flex-col gap-4">
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
|
||||||
|
</h3>
|
||||||
|
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<DashboardStatsCard
|
||||||
|
name={<FormattedMessage defaultMessage="Stream Time" id="miQKuZ" />}
|
||||||
|
value={<StreamTimer ev={streamEvent} />} />
|
||||||
|
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" id="37mth/" />} value={participants} />
|
||||||
|
<DashboardStatsCard
|
||||||
|
name={<FormattedMessage defaultMessage="Highest Viewers" id="jctiUc" />}
|
||||||
|
value={maxParticipants} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 grid-cols-3">
|
||||||
|
<DashboardRaidButton link={streamLink} />
|
||||||
|
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
|
||||||
|
<DashboardSettingsButton ev={streamEvent} />
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
<DashboardCard className="flex flex-col gap-4">
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage defaultMessage="Chat Users" id="RtYNX5" />
|
||||||
|
</h3>
|
||||||
|
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
|
||||||
|
<DashboardChatList feed={feed} />
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
</div>
|
||||||
|
<DashboardZapColumn link={streamLink} feed={feed} />
|
||||||
|
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
10
src/pages/dashboard/index.tsx
Normal file
10
src/pages/dashboard/index.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useLogin } from "@/hooks/login";
|
||||||
|
import { NostrLink, NostrPrefix } from "@snort/system";
|
||||||
|
import { DashboardForLink } from "./dashboard";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const login = useLogin();
|
||||||
|
if (!login) return;
|
||||||
|
|
||||||
|
return <DashboardForLink link={new NostrLink(NostrPrefix.PublicKey, login.pubkey)} />;
|
||||||
|
}
|
9
src/pages/dashboard/intro.tsx
Normal file
9
src/pages/dashboard/intro.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
|
export default function DashboardIntro() {
|
||||||
|
return <>
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage defaultMessage="Welcome to zap.stream!" />
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
}
|
@ -2,13 +2,13 @@ import { useStreamsFeed } from "@/hooks/live-streams";
|
|||||||
import { getHost, getTagValues } from "@/utils";
|
import { getHost, getTagValues } from "@/utils";
|
||||||
import { dedupe, unwrap } from "@snort/shared";
|
import { dedupe, unwrap } from "@snort/shared";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Profile } from "./profile";
|
import { Profile } from "../../element/profile";
|
||||||
import { useLogin } from "@/hooks/login";
|
import { useLogin } from "@/hooks/login";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { NostrLink, parseNostrLink } from "@snort/system";
|
import { NostrLink, parseNostrLink } from "@snort/system";
|
||||||
import { SnortContext } from "@snort/system-react";
|
import { SnortContext } from "@snort/system-react";
|
||||||
import { LIVE_STREAM_RAID } from "@/const";
|
import { LIVE_STREAM_RAID } from "@/const";
|
||||||
import { DefaultButton } from "./buttons";
|
import { DefaultButton } from "../../element/buttons";
|
||||||
import { useSortedStreams } from "@/hooks/useLiveStreams";
|
import { useSortedStreams } from "@/hooks/useLiveStreams";
|
||||||
|
|
||||||
export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose: () => void }) {
|
export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose: () => void }) {
|
15
src/pages/dashboard/stats-card.tsx
Normal file
15
src/pages/dashboard/stats-card.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { HTMLProps, ReactNode } from "react";
|
||||||
|
|
||||||
|
export function DashboardStatsCard({
|
||||||
|
name, value, ...props
|
||||||
|
}: { name: ReactNode; value: ReactNode; } & Omit<HTMLProps<HTMLDivElement>, "children" | "name" | "value">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={classNames("flex-1 bg-layer-1 flex flex-col gap-1 px-4 py-2 rounded-xl", props.className)}>
|
||||||
|
<div className="text-layer-4 font-medium">{name}</div>
|
||||||
|
<div>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
31
src/pages/dashboard/zap-highlight.tsx
Normal file
31
src/pages/dashboard/zap-highlight.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Profile } from "@/element/profile";
|
||||||
|
import { ParsedZap } from "@snort/system";
|
||||||
|
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||||
|
import { Text } from "@/element/text";
|
||||||
|
|
||||||
|
export function DashboardHighlightZap({ zap }: { zap: ParsedZap; }) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 bg-layer-1 flex flex-col gap-4 rounded-xl animate-flash">
|
||||||
|
<div className="flex justify-between items-center text-zap text-2xl font-semibold">
|
||||||
|
<Profile
|
||||||
|
pubkey={zap.sender ?? "anon"}
|
||||||
|
options={{
|
||||||
|
showAvatar: false,
|
||||||
|
}} />
|
||||||
|
<span>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="{n} sats"
|
||||||
|
id="CsCUYo"
|
||||||
|
values={{
|
||||||
|
n: <FormattedNumber value={zap.amount} />,
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{zap.content && (
|
||||||
|
<div className="text-2xl">
|
||||||
|
<Text content={zap.content} tags={[]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -10,13 +10,12 @@ import { hexToBech32 } from "@snort/shared";
|
|||||||
import { Icon } from "@/element/icon";
|
import { Icon } from "@/element/icon";
|
||||||
import { useLogin, useLoginEvents } from "@/hooks/login";
|
import { useLogin, useLoginEvents } from "@/hooks/login";
|
||||||
import { Profile } from "@/element/profile";
|
import { Profile } from "@/element/profile";
|
||||||
import { NewStreamDialog } from "@/element/new-stream";
|
|
||||||
import { LoginSignup } from "@/element/login-signup";
|
import { LoginSignup } from "@/element/login-signup";
|
||||||
import { Login } from "@/login";
|
import { Login } from "@/login";
|
||||||
import { useLang } from "@/hooks/lang";
|
import { useLang } from "@/hooks/lang";
|
||||||
import { AllLocales } from "@/intl";
|
import { AllLocales } from "@/intl";
|
||||||
import { trackEvent } from "@/utils";
|
import { trackEvent } from "@/utils";
|
||||||
import { BorderButton } from "@/element/buttons";
|
import { BorderButton, DefaultButton } from "@/element/buttons";
|
||||||
import Modal from "@/element/modal";
|
import Modal from "@/element/modal";
|
||||||
import Logo from "@/element/logo";
|
import Logo from "@/element/logo";
|
||||||
|
|
||||||
@ -64,7 +63,14 @@ export function LayoutPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && (
|
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && (
|
||||||
<NewStreamDialog btnClassName="btn btn-primary" />
|
<Link to="/dashboard">
|
||||||
|
<DefaultButton>
|
||||||
|
<span className="max-lg:hidden">
|
||||||
|
<FormattedMessage defaultMessage="Stream" />
|
||||||
|
</span>
|
||||||
|
<Icon name="signal" />
|
||||||
|
</DefaultButton>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Menu
|
<Menu
|
||||||
menuClassName="ctx-menu"
|
menuClassName="ctx-menu"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { NostrProviderDialog } from "@/element/nostr-provider-dialog";
|
import NostrProviderDialog from "@/element/provider/nostr";
|
||||||
import { useStreamProvider } from "@/hooks/stream-provider";
|
import { useStreamProvider } from "@/hooks/stream-provider";
|
||||||
import { NostrStreamProvider } from "@/providers";
|
import { NostrStreamProvider } from "@/providers";
|
||||||
import { unwrap } from "@snort/shared";
|
import { unwrap } from "@snort/shared";
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
|
import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { NostrEvent } from "@snort/system";
|
|
||||||
import { SnortContext, useUserProfile } from "@snort/system-react";
|
import { SnortContext, useUserProfile } from "@snort/system-react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Suspense, lazy, useContext } from "react";
|
import { Suspense, lazy, useContext } from "react";
|
||||||
|
import { useMediaQuery } from "usehooks-ts";
|
||||||
|
|
||||||
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
|
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
|
||||||
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
|
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
|
||||||
@ -20,7 +20,7 @@ import { StreamCards } from "@/element/stream-cards";
|
|||||||
import { formatSats } from "@/number";
|
import { formatSats } from "@/number";
|
||||||
import { StreamTimer } from "@/element/stream-time";
|
import { StreamTimer } from "@/element/stream-time";
|
||||||
import { ShareMenu } from "@/element/share-menu";
|
import { ShareMenu } from "@/element/share-menu";
|
||||||
import { ContentWarningOverlay, isContentWarningAccepted } from "@/element/content-warning";
|
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
|
||||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||||
import { useStreamLink } from "@/hooks/stream-link";
|
import { useStreamLink } from "@/hooks/stream-link";
|
||||||
import { FollowButton } from "@/element/follow-button";
|
import { FollowButton } from "@/element/follow-button";
|
||||||
@ -29,8 +29,8 @@ import { StreamState } from "@/const";
|
|||||||
import { NotificationsButton } from "@/element/notifications-button";
|
import { NotificationsButton } from "@/element/notifications-button";
|
||||||
import { WarningButton } from "@/element/buttons";
|
import { WarningButton } from "@/element/buttons";
|
||||||
import Pill from "@/element/pill";
|
import Pill from "@/element/pill";
|
||||||
import { useMediaQuery } from "usehooks-ts";
|
|
||||||
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
|
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
|
||||||
|
import GameInfoCard from "@/element/game-info";
|
||||||
|
|
||||||
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
|
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
|
||||||
const system = useContext(SnortContext);
|
const system = useContext(SnortContext);
|
||||||
@ -40,7 +40,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
|
|||||||
const profile = useUserProfile(host);
|
const profile = useUserProfile(host);
|
||||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||||
|
|
||||||
const { status, participants, title, summary, service } = extractStreamInfo(ev);
|
const { status, participants, title, summary, service, gameId, gameInfo } = extractStreamInfo(ev);
|
||||||
const isMine = ev?.pubkey === login?.pubkey;
|
const isMine = ev?.pubkey === login?.pubkey;
|
||||||
|
|
||||||
async function deleteStream() {
|
async function deleteStream() {
|
||||||
@ -60,6 +60,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
|
|||||||
<div className="grow flex flex-col gap-2 max-xl:hidden">
|
<div className="grow flex flex-col gap-2 max-xl:hidden">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{summary}</p>
|
<p>{summary}</p>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<StatePill state={status as StreamState} />
|
<StatePill state={status as StreamState} />
|
||||||
<Pill>
|
<Pill>
|
||||||
@ -70,13 +71,16 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
|
|||||||
<StreamTimer ev={ev} />
|
<StreamTimer ev={ev} />
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
|
<Pill>
|
||||||
|
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
|
||||||
|
</Pill>
|
||||||
{ev && <Tags ev={ev} />}
|
{ev && <Tags ev={ev} />}
|
||||||
</div>
|
</div>
|
||||||
{isMine && (
|
{isMine && (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
|
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
|
||||||
<WarningButton onClick={deleteStream}>
|
<WarningButton onClick={deleteStream}>
|
||||||
<FormattedMessage defaultMessage="Delete" id="K3r6DQ" />
|
<FormattedMessage defaultMessage="Delete" />
|
||||||
</WarningButton>
|
</WarningButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -115,18 +119,18 @@ export function StreamPageHandler() {
|
|||||||
|
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|
||||||
if (link.kind === EventKind.LiveEvent) {
|
if (link.type === NostrPrefix.Event) {
|
||||||
return <StreamPage link={link} evPreload={evPreload} />;
|
|
||||||
} else {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
|
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
|
||||||
<NostrEventElement link={link} />
|
<NostrEventElement link={link} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return <StreamPage link={link} evPreload={evPreload} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link: NostrLink }) {
|
export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent; link: NostrLink }) {
|
||||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||||
const host = getHost(ev);
|
const host = getHost(ev);
|
||||||
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
|
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
|
||||||
@ -143,8 +147,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
|||||||
} = extractStreamInfo(ev);
|
} = extractStreamInfo(ev);
|
||||||
const goal = useZapGoal(goalTag);
|
const goal = useZapGoal(goalTag);
|
||||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||||
|
const isGrownUp = useContentWarning();
|
||||||
|
|
||||||
if (contentWarning && !isContentWarningAccepted()) {
|
if (contentWarning && !isGrownUp) {
|
||||||
return <ContentWarningOverlay />;
|
return <ContentWarningOverlay />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import { Login } from "@/login";
|
|||||||
import { getPublisher } from "@/login";
|
import { getPublisher } from "@/login";
|
||||||
import { extractStreamInfo } from "@/utils";
|
import { extractStreamInfo } from "@/utils";
|
||||||
import { StreamState } from "@/const";
|
import { StreamState } from "@/const";
|
||||||
|
import { appendDedupe } from "@snort/shared";
|
||||||
|
|
||||||
export class NostrStreamProvider implements StreamProvider {
|
export class NostrStreamProvider implements StreamProvider {
|
||||||
#publisher?: EventPublisher;
|
#publisher?: EventPublisher;
|
||||||
@ -59,12 +60,12 @@ export class NostrStreamProvider implements StreamProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateStreamInfo(_: SystemInterface, ev: NostrEvent): Promise<void> {
|
async updateStreamInfo(_: SystemInterface, ev: NostrEvent): Promise<void> {
|
||||||
const { title, summary, image, tags, contentWarning, goal } = extractStreamInfo(ev);
|
const { title, summary, image, tags, contentWarning, goal, gameId } = extractStreamInfo(ev);
|
||||||
await this.#getJson("PATCH", "event", {
|
await this.#getJson("PATCH", "event", {
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
image,
|
image,
|
||||||
tags,
|
tags: appendDedupe(tags, gameId ? [gameId] : undefined),
|
||||||
content_warning: contentWarning,
|
content_warning: contentWarning,
|
||||||
goal,
|
goal,
|
||||||
});
|
});
|
||||||
|
30
src/service/game-database.ts
Normal file
30
src/service/game-database.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export default class GameDatabase {
|
||||||
|
readonly url = "https://api.zap.stream/api/v1";
|
||||||
|
|
||||||
|
async searchGames(search: string, limit = 10) {
|
||||||
|
const rsp = await fetch(`${this.url}/games/search?q=${encodeURIComponent(search)}&limit=${limit}`);
|
||||||
|
if (rsp.ok) {
|
||||||
|
const games = await rsp.json() as Array<GameInfo>;
|
||||||
|
return games.map(a => ({
|
||||||
|
...a,
|
||||||
|
genres: [...a.genres, "gaming"]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGame(id: string) {
|
||||||
|
const rsp = await fetch(`${this.url}/games/${id}`);
|
||||||
|
if (rsp.ok) {
|
||||||
|
return await rsp.json() as GameInfo | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameInfo {
|
||||||
|
id: string;
|
||||||
|
name: string | JSX.Element;
|
||||||
|
cover?: string;
|
||||||
|
genres: Array<string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
33
src/utils.ts
33
src/utils.ts
@ -1,7 +1,9 @@
|
|||||||
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||||
|
|
||||||
import type { Tags } from "@/types";
|
import type { Tags } from "@/types";
|
||||||
import { LIVE_STREAM } from "@/const";
|
import { LIVE_STREAM, StreamState } from "@/const";
|
||||||
|
import { GameInfo } from "./service/game-database";
|
||||||
|
import { AllCategories } from "./pages/category";
|
||||||
|
|
||||||
export function toAddress(e: NostrEvent): string {
|
export function toAddress(e: NostrEvent): string {
|
||||||
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
||||||
@ -58,7 +60,7 @@ export function getTagValues(tags: Tags, tag: string): Array<string> {
|
|||||||
|
|
||||||
export function getEventFromLocationState(state: unknown | undefined | null) {
|
export function getEventFromLocationState(state: unknown | undefined | null) {
|
||||||
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
|
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
|
||||||
? (state as NostrEvent)
|
? (state as TaggedNostrEvent)
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,22 +83,24 @@ export function debounce(time: number, fn: () => void): () => void {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreamInfo {
|
export interface StreamInfo {
|
||||||
id?: string;
|
id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
status?: string;
|
status?: StreamState;
|
||||||
stream?: string;
|
stream?: string;
|
||||||
recording?: string;
|
recording?: string;
|
||||||
contentWarning?: string;
|
contentWarning?: string;
|
||||||
tags?: Array<string>;
|
tags: Array<string>;
|
||||||
goal?: string;
|
goal?: string;
|
||||||
participants?: string;
|
participants?: string;
|
||||||
starts?: string;
|
starts?: string;
|
||||||
ends?: string;
|
ends?: string;
|
||||||
service?: string;
|
service?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
gameId?: string;
|
||||||
|
gameInfo?: GameInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractStreamInfo(ev?: NostrEvent) {
|
export function extractStreamInfo(ev?: NostrEvent) {
|
||||||
@ -114,7 +118,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
|||||||
matchTag(t, "title", v => (ret.title = v));
|
matchTag(t, "title", v => (ret.title = v));
|
||||||
matchTag(t, "summary", v => (ret.summary = v));
|
matchTag(t, "summary", v => (ret.summary = v));
|
||||||
matchTag(t, "image", v => (ret.image = v));
|
matchTag(t, "image", v => (ret.image = v));
|
||||||
matchTag(t, "status", v => (ret.status = v));
|
matchTag(t, "status", v => (ret.status = v as StreamState));
|
||||||
if (t[0] === "streaming" && t[1].startsWith("http")) {
|
if (t[0] === "streaming" && t[1].startsWith("http")) {
|
||||||
matchTag(t, "streaming", v => (ret.stream = v));
|
matchTag(t, "streaming", v => (ret.stream = v));
|
||||||
}
|
}
|
||||||
@ -126,8 +130,23 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
|||||||
matchTag(t, "ends", v => (ret.ends = v));
|
matchTag(t, "ends", v => (ret.ends = v));
|
||||||
matchTag(t, "service", v => (ret.service = v));
|
matchTag(t, "service", v => (ret.service = v));
|
||||||
}
|
}
|
||||||
ret.tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
|
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
|
||||||
|
ret.tags = ev?.tags.filter(a => a[0] === "t" && !a[1].match(gameTagFormat)).map(a => a[1]) ?? [];
|
||||||
|
|
||||||
|
const game = ev?.tags.find(a => a[0] === "t" && a[1].match(gameTagFormat))?.[1];
|
||||||
|
if (game?.startsWith("internal:")) {
|
||||||
|
const internal = AllCategories.find(a => game === `internal:${a.id}`);
|
||||||
|
if (internal) {
|
||||||
|
ret.gameInfo = {
|
||||||
|
id: internal?.id,
|
||||||
|
name: internal.name,
|
||||||
|
genres: internal.tags,
|
||||||
|
className: internal.className
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ret.gameId = game;
|
||||||
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user