Updates:
- Game categories - Move stream setup/config to dashboard - Reorg files / cleanup - NSFW improvements
This commit is contained in:
parent
0a9bd35f43
commit
a385ca3271
@ -19,7 +19,7 @@ export default function CategoryLink({
|
||||
to={`/category/${id}`}
|
||||
key={id}
|
||||
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
|
||||
)}>
|
||||
{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 { EmojiPack } from "./emoji-pack";
|
||||
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 LiveStreamClip from "./clip";
|
||||
import { ExternalLink } from "./external-link";
|
||||
@ -48,7 +48,12 @@ export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
|
||||
}
|
||||
case EventKind.LiveEvent: {
|
||||
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: {
|
||||
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 { MediaURL } from "./collapsible";
|
||||
import { ExternalLink } from "./external-link";
|
||||
import { EventEmbed } from "./event-embed";
|
||||
import { parseNostrLink } from "@snort/system";
|
||||
|
||||
const FileExtensionRegex = /\.([\w]+)$/i;
|
||||
|
||||
@ -25,18 +22,14 @@ export function HyperText({ link, children }: HyperTextProps) {
|
||||
case "bmp":
|
||||
case "webp": {
|
||||
return (
|
||||
<MediaURL url={url}>
|
||||
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
||||
</MediaURL>
|
||||
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
||||
);
|
||||
}
|
||||
case "wav":
|
||||
case "mp3":
|
||||
case "ogg": {
|
||||
return (
|
||||
<MediaURL url={url}>
|
||||
<audio key={url.toString()} src={url.toString()} controls />;
|
||||
</MediaURL>
|
||||
<audio key={url.toString()} src={url.toString()} controls />
|
||||
);
|
||||
}
|
||||
case "mp4":
|
||||
@ -46,16 +39,12 @@ export function HyperText({ link, children }: HyperTextProps) {
|
||||
case "m4v":
|
||||
case "webm": {
|
||||
return (
|
||||
<MediaURL url={url}>
|
||||
<video key={url.toString()} src={url.toString()} controls />
|
||||
</MediaURL>
|
||||
<video key={url.toString()} src={url.toString()} controls />
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>;
|
||||
}
|
||||
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
|
||||
return <EventEmbed link={parseNostrLink(link)} />;
|
||||
} else {
|
||||
<ExternalLink href={link}>{children}</ExternalLink>;
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ export enum VideoStatus {
|
||||
type VideoPlayerProps = {
|
||||
title?: string;
|
||||
stream?: string;
|
||||
status?: string;
|
||||
status?: StreamState;
|
||||
poster?: string;
|
||||
muted?: boolean;
|
||||
} & HTMLProps<HTMLVideoElement>;
|
||||
|
@ -132,12 +132,14 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
const lnurl = new LNURL(lnAddress);
|
||||
await lnurl.load();
|
||||
} catch {
|
||||
throw new Error(
|
||||
formatMessage({
|
||||
defaultMessage: "Hmm, your lightning address looks wrong",
|
||||
id: "4l69eO",
|
||||
})
|
||||
);
|
||||
if (!lnAddress.includes("localhost") && import.meta.env.DEV) {
|
||||
throw new Error(
|
||||
formatMessage({
|
||||
defaultMessage: "Hmm, your lightning address looks wrong",
|
||||
id: "4l69eO",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
const pub = EventPublisher.privateKey(key);
|
||||
const profile = {
|
||||
|
@ -75,7 +75,7 @@ export default function Modal(props: ModalProps) {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<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()}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
@ -88,7 +88,7 @@ export default function Modal(props: ModalProps) {
|
||||
e.stopPropagation();
|
||||
props.onClose?.(e);
|
||||
}}
|
||||
className="rounded-full aspect-square"
|
||||
className="rounded-full aspect-square bg-layer-2 p-3"
|
||||
iconSize={10}
|
||||
/>
|
||||
</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 { unwrap } from "@snort/shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
|
||||
import { Icon } from "./icon";
|
||||
import { useStreamProvider } from "@/hooks/stream-provider";
|
||||
import { getCurrentStreamProvider, useStreamProvider } from "@/hooks/stream-provider";
|
||||
import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/providers";
|
||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||
import { eventLink } from "@/utils";
|
||||
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
||||
import NostrProviderDialog from "@/element/provider/nostr";
|
||||
import { DefaultButton } from "./buttons";
|
||||
import Pill from "./pill";
|
||||
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 providers = useStreamProvider();
|
||||
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
|
||||
@ -22,11 +21,9 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProvider) {
|
||||
setCurrentProvider(
|
||||
ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
|
||||
);
|
||||
setCurrentProvider(getCurrentStreamProvider(ev));
|
||||
}
|
||||
}, [providers, currentProvider]);
|
||||
}, [ev, providers, currentProvider]);
|
||||
|
||||
function providerDialog() {
|
||||
if (!currentProvider) return;
|
||||
@ -52,23 +49,14 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
||||
}
|
||||
case StreamProviders.NostrType: {
|
||||
return (
|
||||
<>
|
||||
<DefaultButton
|
||||
onClick={() => {
|
||||
navigate("/settings/stream");
|
||||
onFinish?.();
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />
|
||||
</DefaultButton>
|
||||
<NostrProviderDialog
|
||||
provider={currentProvider as NostrStreamProvider}
|
||||
onFinish={onFinish}
|
||||
ev={ev}
|
||||
showEndpoints={false}
|
||||
showEditor={true}
|
||||
showForwards={false}
|
||||
/>
|
||||
</>
|
||||
<NostrProviderDialog
|
||||
provider={currentProvider as NostrStreamProvider}
|
||||
onFinish={onFinish}
|
||||
ev={ev}
|
||||
showEndpoints={false}
|
||||
showEditor={true}
|
||||
showForwards={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case StreamProviders.Owncast: {
|
||||
@ -79,23 +67,23 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{!ev && <>
|
||||
<FormattedMessage defaultMessage="Stream Providers" id="6Z2pvJ" />
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{providers.map(v => (
|
||||
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
|
||||
{v.name}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{providers.map(v => (
|
||||
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
|
||||
{v.name}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
<div className="flex flex-col gap-4">{providerDialog()}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NewStreamDialogProps {
|
||||
text?: string;
|
||||
text?: ReactNode;
|
||||
btnClassName?: string;
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
const Markdown = lazy(() => import("./markdown"));
|
||||
import { ExternalIconLink } from "./external-link";
|
||||
import { Profile } from "./profile";
|
||||
import EventReactions from "./event-reactions";
|
||||
import { Text } from "@/element/text";
|
||||
|
||||
export function Note({ ev }: { ev: TaggedNostrEvent }) {
|
||||
return (
|
||||
@ -13,9 +12,7 @@ export function Note({ ev }: { ev: TaggedNostrEvent }) {
|
||||
<Profile pubkey={ev.pubkey} avatarSize={30} />
|
||||
<ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} />
|
||||
</div>
|
||||
<Suspense>
|
||||
<Markdown tags={ev.tags} content={ev.content} />
|
||||
</Suspense>
|
||||
<Text tags={ev.tags} content={ev.content} className="whitespace-pre-line overflow-wrap" />
|
||||
<EventReactions ev={ev} />
|
||||
</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 { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
|
||||
import { SendZaps } from "./send-zap";
|
||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||
import Spinner from "./spinner";
|
||||
import { SendZaps } from "@/element/send-zap";
|
||||
import { StreamEditor, StreamEditorProps } from "@/element/stream-editor";
|
||||
import Spinner from "@/element/spinner";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { useRates } from "@/hooks/rates";
|
||||
import { DefaultButton } from "./buttons";
|
||||
import Pill from "./pill";
|
||||
import { DefaultButton } from "@/element/buttons";
|
||||
import Pill from "@/element/pill";
|
||||
|
||||
export function NostrProviderDialog({
|
||||
export default function NostrProviderDialog({
|
||||
provider,
|
||||
showEndpoints,
|
||||
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 { NostrEvent } from "@snort/system";
|
||||
import { unixNow } from "@snort/shared";
|
||||
@ -7,11 +7,16 @@ import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { extractStreamInfo, findTag } from "@/utils";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { NewGoalDialog } from "./new-goal";
|
||||
import { useGoals } from "@/hooks/goals";
|
||||
import { StreamState } from "@/const";
|
||||
import { DefaultButton } from "./buttons";
|
||||
import Pill from "./pill";
|
||||
import { DefaultButton, IconButton } from "@/element/buttons";
|
||||
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 {
|
||||
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) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
@ -60,11 +44,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
const [contentWarning, setContentWarning] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [goal, setGoal] = useState<string>();
|
||||
const [game, setGame] = useState<GameInfo>();
|
||||
const [gameId, setGameId] = useState<string>();
|
||||
const login = useLogin();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
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);
|
||||
setTitle(title ?? "");
|
||||
setSummary(summary ?? "");
|
||||
@ -76,6 +62,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
setTags(tags ?? []);
|
||||
setContentWarning(contentWarning !== undefined);
|
||||
setGoal(goal);
|
||||
setGameId(gameId)
|
||||
if (gameInfo) {
|
||||
setGame(gameInfo);
|
||||
} else if (gameId) {
|
||||
new GameDatabase().getGame(gameId).then(setGame);
|
||||
}
|
||||
}, [ev?.id]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
@ -128,6 +120,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
if (goal && goal.length > 0) {
|
||||
eb.tag(["goal", goal]);
|
||||
}
|
||||
if (gameId) {
|
||||
eb.tag(["t", gameId]);
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
console.debug(evNew);
|
||||
@ -147,64 +142,46 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
<>
|
||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||
{(options?.canSetTitle ?? true) && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ defaultMessage: "What are we steaming today?", id: "QRHNuF" })}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Title" />}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</StreamInput>
|
||||
)}
|
||||
{(options?.canSetSummary ?? true) && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Summary" id="RrCui3" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ defaultMessage: "A short description of the content", id: "mtNGwh" })}
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Summary" />}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
/>
|
||||
</StreamInput>
|
||||
)}
|
||||
{(options?.canSetImage ?? true) && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Cover Image" id="Gq6x9o" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
|
||||
<DefaultButton>
|
||||
<FormattedMessage defaultMessage="Upload" />
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
</StreamInput>
|
||||
)}
|
||||
{(options?.canSetStream ?? true) && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream URL" id="QRRCp0" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Stream URL" />}>
|
||||
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="Stream type should be HLS" id="oZrFyI" />
|
||||
<FormattedMessage defaultMessage="Stream type should be HLS" />
|
||||
</small>
|
||||
</div>
|
||||
</StreamInput>
|
||||
)}
|
||||
{(options?.canSetStatus ?? true) && (
|
||||
<>
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Status" id="tzMNF3" />
|
||||
</p>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Status" />}>
|
||||
<div className="flex gap-2">
|
||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
|
||||
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}>
|
||||
@ -212,55 +189,54 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</StreamInput>
|
||||
{status === StreamState.Planned && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Start Time" id="5QYdPU" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toDateTimeString(Number(start ?? "0"))}
|
||||
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Start Time" />}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toDateTimeString(Number(start ?? "0"))}
|
||||
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
||||
/>
|
||||
</StreamInput>
|
||||
)}
|
||||
{status === StreamState.Ended && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Recording URL" id="Y0DXJb" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Recording URL" />}>
|
||||
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
|
||||
</StreamInput>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(options?.canSetTags ?? true) && (
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Tags" id="1EYCdR" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Category" />}>
|
||||
{!game && <SearchCategory onSelect={g => {
|
||||
setGame(g);
|
||||
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", ","]} />
|
||||
</div>
|
||||
</div>
|
||||
</StreamInput>
|
||||
</>
|
||||
)}
|
||||
{login?.pubkey && (
|
||||
<>
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Goal" id="0VV/sK" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
|
||||
</div>
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
|
||||
<NewGoalDialog />
|
||||
</div>
|
||||
<NewGoalDialog />
|
||||
</>
|
||||
</StreamInput>
|
||||
)}
|
||||
{(options?.canSetContentWarning ?? true) && (
|
||||
<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 className="text-warning">
|
||||
<FormattedMessage defaultMessage="NSFW Content" id="Atr2p4" />
|
||||
<FormattedMessage defaultMessage="NSFW Content" />
|
||||
</div>
|
||||
<FormattedMessage
|
||||
defaultMessage="Check here if this stream contains nudity or pornographic content."
|
||||
@ -280,13 +256,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
)}
|
||||
<div>
|
||||
<DefaultButton disabled={!isValid} onClick={publishStream}>
|
||||
{ev ? (
|
||||
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Start Stream" id="TaTRKo" />
|
||||
)}
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</DefaultButton>
|
||||
</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 { useContext, useState } from "react";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
|
||||
import { Icon } from "./icon";
|
||||
import { Icon } from "../icon";
|
||||
import { GOAL } from "@/const";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { defaultRelays } from "@/const";
|
||||
import { DefaultButton } from "./buttons";
|
||||
import Modal from "./modal";
|
||||
import { DefaultButton } from "../buttons";
|
||||
import Modal from "../modal";
|
||||
|
||||
export function NewGoalDialog() {
|
||||
const system = useContext(SnortContext);
|
||||
@ -42,7 +41,7 @@ export function NewGoalDialog() {
|
||||
<DefaultButton onClick={() => setOpen(true)}>
|
||||
<Icon name="zap-filled" size={12} />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" />
|
||||
<FormattedMessage defaultMessage="New Goal" />
|
||||
</span>
|
||||
</DefaultButton>
|
||||
{open && (
|
@ -2,23 +2,20 @@ import type { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { findTag, getTagValues } from "@/utils";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { StreamState } from "@/const";
|
||||
import Pill from "./pill";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
||||
const status = findTag(ev, "status");
|
||||
const hashtags = getTagValues(ev.tags, "t");
|
||||
const tags = max ? hashtags.slice(0, max) : hashtags;
|
||||
|
||||
const { status, tags } = extractStreamInfo(ev);
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{status === StreamState.Planned && (
|
||||
<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}>
|
||||
<Pill>{a}</Pill>
|
||||
</Link>
|
||||
|
@ -43,9 +43,7 @@ export function Text({ content, tags, eventComponent, className }: TextProps) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="text">
|
||||
<HyperText link={f.content}>{f.content}</HyperText>
|
||||
</span>
|
||||
<HyperText link={f.content}>{f.content}</HyperText>
|
||||
);
|
||||
}
|
||||
case "mention":
|
||||
|
@ -11,6 +11,7 @@ import { StreamState } from "@/const";
|
||||
import Pill from "./pill";
|
||||
import classNames from "classnames";
|
||||
import Logo from "./logo";
|
||||
import { useContentWarning } from "./nsfw";
|
||||
|
||||
export function VideoTile({
|
||||
ev,
|
||||
@ -23,12 +24,16 @@ export function VideoTile({
|
||||
}) {
|
||||
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
|
||||
const host = getHost(ev);
|
||||
const isGrownUp = useContentWarning();
|
||||
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const hasImg = (image?.length ?? 0) > 0;
|
||||
return (
|
||||
<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">
|
||||
{hasImg ? (
|
||||
<img loading="lazy" className="aspect-video object-cover rounded-xl" src={image} />
|
||||
|
@ -1,11 +1,11 @@
|
||||
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 { useMemo } from "react";
|
||||
|
||||
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 sub = useMemo(() => {
|
||||
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">
|
||||
<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 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>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@ -1,5 +1,7 @@
|
||||
import CategoryLink from "@/element/category-link";
|
||||
import Pill from "@/element/pill";
|
||||
import VideoGridSorted from "@/element/video-grid-sorted";
|
||||
import useGameInfo from "@/hooks/game-info";
|
||||
import { EventKind, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { useMemo } from "react";
|
||||
@ -55,18 +57,29 @@ export const AllCategories = [
|
||||
priority: 1,
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const game = useGameInfo(id);
|
||||
|
||||
const cat = AllCategories.find(a => a.id === id);
|
||||
const sub = useMemo(() => {
|
||||
if (!cat) return;
|
||||
const rb = new RequestBuilder(`category:${cat.id}`);
|
||||
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat.tags);
|
||||
if (!id) return;
|
||||
|
||||
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;
|
||||
}, [cat]);
|
||||
}, [id]);
|
||||
|
||||
const results = useRequestBuilder(sub);
|
||||
return (
|
||||
<div>
|
||||
@ -75,7 +88,17 @@ export default function Category() {
|
||||
<CategoryLink key={a.id} {...a} />
|
||||
))}
|
||||
</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} />
|
||||
</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 { dedupe, unwrap } from "@snort/shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Profile } from "./profile";
|
||||
import { Profile } from "../../element/profile";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useContext, useState } from "react";
|
||||
import { NostrLink, parseNostrLink } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { LIVE_STREAM_RAID } from "@/const";
|
||||
import { DefaultButton } from "./buttons";
|
||||
import { DefaultButton } from "../../element/buttons";
|
||||
import { useSortedStreams } from "@/hooks/useLiveStreams";
|
||||
|
||||
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 { useLogin, useLoginEvents } from "@/hooks/login";
|
||||
import { Profile } from "@/element/profile";
|
||||
import { NewStreamDialog } from "@/element/new-stream";
|
||||
import { LoginSignup } from "@/element/login-signup";
|
||||
import { Login } from "@/login";
|
||||
import { useLang } from "@/hooks/lang";
|
||||
import { AllLocales } from "@/intl";
|
||||
import { trackEvent } from "@/utils";
|
||||
import { BorderButton } from "@/element/buttons";
|
||||
import { BorderButton, DefaultButton } from "@/element/buttons";
|
||||
import Modal from "@/element/modal";
|
||||
import Logo from "@/element/logo";
|
||||
|
||||
@ -64,7 +63,14 @@ export function LayoutPage() {
|
||||
return (
|
||||
<>
|
||||
{(!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
|
||||
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 { NostrStreamProvider } from "@/providers";
|
||||
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 { Helmet } from "react-helmet";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { SnortContext, useUserProfile } from "@snort/system-react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Suspense, lazy, useContext } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
|
||||
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
|
||||
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
|
||||
@ -20,7 +20,7 @@ import { StreamCards } from "@/element/stream-cards";
|
||||
import { formatSats } from "@/number";
|
||||
import { StreamTimer } from "@/element/stream-time";
|
||||
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 { useStreamLink } from "@/hooks/stream-link";
|
||||
import { FollowButton } from "@/element/follow-button";
|
||||
@ -29,8 +29,8 @@ import { StreamState } from "@/const";
|
||||
import { NotificationsButton } from "@/element/notifications-button";
|
||||
import { WarningButton } from "@/element/buttons";
|
||||
import Pill from "@/element/pill";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
|
||||
import GameInfoCard from "@/element/game-info";
|
||||
|
||||
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
|
||||
const system = useContext(SnortContext);
|
||||
@ -40,7 +40,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
|
||||
const profile = useUserProfile(host);
|
||||
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;
|
||||
|
||||
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">
|
||||
<h1>{title}</h1>
|
||||
<p>{summary}</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<StatePill state={status as StreamState} />
|
||||
<Pill>
|
||||
@ -70,13 +71,16 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
|
||||
<StreamTimer ev={ev} />
|
||||
</Pill>
|
||||
)}
|
||||
<Pill>
|
||||
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
|
||||
</Pill>
|
||||
{ev && <Tags ev={ev} />}
|
||||
</div>
|
||||
{isMine && (
|
||||
<div className="flex gap-4">
|
||||
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
|
||||
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
|
||||
<WarningButton onClick={deleteStream}>
|
||||
<FormattedMessage defaultMessage="Delete" id="K3r6DQ" />
|
||||
<FormattedMessage defaultMessage="Delete" />
|
||||
</WarningButton>
|
||||
</div>
|
||||
)}
|
||||
@ -115,18 +119,18 @@ export function StreamPageHandler() {
|
||||
|
||||
if (!link) return;
|
||||
|
||||
if (link.kind === EventKind.LiveEvent) {
|
||||
return <StreamPage link={link} evPreload={evPreload} />;
|
||||
} else {
|
||||
if (link.type === NostrPrefix.Event) {
|
||||
return (
|
||||
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
|
||||
<NostrEventElement link={link} />
|
||||
</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 host = getHost(ev);
|
||||
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
|
||||
@ -143,8 +147,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
||||
} = extractStreamInfo(ev);
|
||||
const goal = useZapGoal(goalTag);
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
const isGrownUp = useContentWarning();
|
||||
|
||||
if (contentWarning && !isContentWarningAccepted()) {
|
||||
if (contentWarning && !isGrownUp) {
|
||||
return <ContentWarningOverlay />;
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { Login } from "@/login";
|
||||
import { getPublisher } from "@/login";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { StreamState } from "@/const";
|
||||
import { appendDedupe } from "@snort/shared";
|
||||
|
||||
export class NostrStreamProvider implements StreamProvider {
|
||||
#publisher?: EventPublisher;
|
||||
@ -59,12 +60,12 @@ export class NostrStreamProvider implements StreamProvider {
|
||||
}
|
||||
|
||||
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", {
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
tags,
|
||||
tags: appendDedupe(tags, gameId ? [gameId] : undefined),
|
||||
content_warning: contentWarning,
|
||||
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 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 {
|
||||
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) {
|
||||
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
|
||||
? (state as NostrEvent)
|
||||
? (state as TaggedNostrEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@ -81,22 +83,24 @@ export function debounce(time: number, fn: () => void): () => void {
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
|
||||
interface StreamInfo {
|
||||
export interface StreamInfo {
|
||||
id?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
image?: string;
|
||||
status?: string;
|
||||
status?: StreamState;
|
||||
stream?: string;
|
||||
recording?: string;
|
||||
contentWarning?: string;
|
||||
tags?: Array<string>;
|
||||
tags: Array<string>;
|
||||
goal?: string;
|
||||
participants?: string;
|
||||
starts?: string;
|
||||
ends?: string;
|
||||
service?: string;
|
||||
host?: string;
|
||||
gameId?: string;
|
||||
gameInfo?: GameInfo;
|
||||
}
|
||||
|
||||
export function extractStreamInfo(ev?: NostrEvent) {
|
||||
@ -114,7 +118,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
|
||||
matchTag(t, "title", v => (ret.title = v));
|
||||
matchTag(t, "summary", v => (ret.summary = 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")) {
|
||||
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, "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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user