feat: dashboard intro
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2024-03-12 12:35:42 +00:00
parent 4d77882114
commit f7b80c0b51
37 changed files with 1204 additions and 454 deletions

View File

@ -0,0 +1,44 @@
import { useRates } from "@/hooks/rates";
import { useCallback, useEffect, useState } from "react";
export default function AmountInput({ onChange }: { onChange: (n: number) => void }) {
const [type, setType] = useState<"sats" | "usd">("sats");
const [value, setValue] = useState(0);
const rates = useRates("BTCUSD");
const satsValue = useCallback(
() => (type === "usd" ? Math.round(value * 1e-6 * rates.ask) / 100 : value),
[value, type]
);
useEffect(() => {
onChange(satsValue());
}, [satsValue]);
return (
<div className="flex bg-layer-2 rounded-xl">
<input
type="number"
className="!pr-0 !pl-4"
value={value}
onChange={e => {
setValue(e.target.valueAsNumber);
}}
/>
<select
value={type}
className="px-1 text-center w-fit"
onChange={e => {
if (type === "sats" && e.target.value === "usd") {
setValue(Math.round(value * 1e-6 * rates.ask) / 100);
} else if (type === "usd" && e.target.value === "sats") {
setValue(Math.round((value / rates.ask) * 1e8));
}
setType(e.target.value as "sats" | "usd");
}}>
<option value="sats">SATS</option>
<option value="usd">USD</option>
</select>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { StreamProviderEndpoint } from "@/providers";
import { FormattedMessage, FormattedNumber } from "react-intl";
export default function BalanceTimeEstimate({
balance,
endpoint,
}: {
balance: number;
endpoint: StreamProviderEndpoint;
}) {
const rate = (endpoint.unit === "min" ? (endpoint.rate ?? 0) * 60 : endpoint.rate) ?? 0;
return (
<FormattedMessage
defaultMessage="{n} hours"
values={{
n: <FormattedNumber value={balance / rate} maximumFractionDigits={1} />,
}}
/>
);
}

View File

@ -7,7 +7,10 @@ export const DefaultButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((pr
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-white text-black")}
className={classNames(
props.className,
"px-3 py-2 font-semibold rounded-xl bg-white text-black disabled:opacity-20"
)}
ref={ref}
/>
);
@ -16,7 +19,7 @@ export const PrimaryButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((pr
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-primary")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-primary disabled:opacity-20")}
ref={ref}
/>
);
@ -25,7 +28,7 @@ export const Layer1Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((pro
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-1")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-1 disabled:opacity-20")}
ref={ref}
/>
);
@ -34,7 +37,7 @@ export const Layer2Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((pro
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-2")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-2 disabled:opacity-20")}
ref={ref}
/>
);
@ -43,7 +46,7 @@ export const Layer3Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((pro
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-3")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-3 disabled:opacity-20")}
ref={ref}
/>
);
@ -52,7 +55,7 @@ export const WarningButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((pr
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-warning")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-warning disabled:opacity-20")}
ref={ref}
/>
);
@ -70,7 +73,7 @@ export const BorderButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((pro
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl btn-border")}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl btn-border disabled:opacity-20")}
ref={ref}
/>
);

View File

@ -1,33 +0,0 @@
.file-uploader-container {
display: flex;
justify-content: space-between;
}
.file-uploader input[type="file"] {
display: none;
}
.file-uploader {
align-self: flex-start;
background: white;
color: black;
max-width: 100px;
border-radius: 10px;
padding: 6px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview {
width: 82px;
height: 60px;
border-radius: 10px;
}
.file-uploader-preview {
display: flex;
align-items: flex-start;
gap: 12px;
}

View File

@ -1,9 +1,8 @@
import "./file-uploader.css";
import type { ChangeEvent } from "react";
import { VoidApi } from "@void-cat/api";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { DefaultButton } from "./buttons";
import { Layer2Button } from "./buttons";
import { openFile } from "@/utils";
import { Icon } from "./icon";
const voidCatHost = "https://void.cat";
const fileExtensionRegex = /\.([\w]{1,7})$/i;
@ -40,59 +39,31 @@ async function voidCatUpload(file: File): Promise<UploadResult> {
}
interface FileUploaderProps {
defaultImage?: string;
onClear(): void;
onFileUpload(url: string): void;
onResult(url: string | undefined): void;
}
export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUploaderProps) {
const [img, setImg] = useState<string>(defaultImage ?? "");
const [isUploading, setIsUploading] = useState(false);
async function onFileChange(ev: ChangeEvent<HTMLInputElement>) {
const file = ev.target.files && ev.target.files[0];
export function FileUploader({ onResult }: FileUploaderProps) {
async function uploadFile() {
const file = await openFile();
if (file) {
try {
setIsUploading(true);
const upload = await voidCatUpload(file);
if (upload.url) {
setImg(upload.url);
onFileUpload(upload.url);
onResult(upload.url);
}
if (upload.error) {
console.error(upload.error);
}
} catch (error) {
console.error(error);
} finally {
setIsUploading(false);
}
}
}
function clearImage() {
setImg("");
onClear();
}
return (
<div className="file-uploader-container">
<label className="file-uploader">
<input type="file" onChange={onFileChange} />
{isUploading ? (
<FormattedMessage defaultMessage="Uploading..." id="JEsxDw" />
) : (
<FormattedMessage defaultMessage="Add File" id="fc2iho" />
)}
</label>
<div className="file-uploader-preview">
{img?.length > 0 && (
<DefaultButton onClick={clearImage}>
<FormattedMessage defaultMessage="Clear" id="/GCoTA" />
</DefaultButton>
)}
{img && <img className="image-preview" src={img} />}
</div>
</div>
<Layer2Button onClick={uploadFile}>
<FormattedMessage defaultMessage="Upload" />
<Icon name="upload" size={14} />
</Layer2Button>
);
}

View File

@ -165,15 +165,7 @@ export function LiveChat({
return <BadgeAward ev={a} key={a.id} />;
}
case LIVE_STREAM_CHAT: {
return (
<ChatMessage
badges={badges}
emojiPacks={allEmojiPacks}
streamer={host}
ev={a}
key={a.id}
/>
);
return <ChatMessage badges={badges} emojiPacks={allEmojiPacks} streamer={host} ev={a} key={a.id} />;
}
case LIVE_STREAM_RAID: {
return <ChatRaid ev={a} link={link} key={a.id} autoRaid={autoRaid} />;

View File

@ -166,7 +166,7 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.Login: {
return (
<>
<img src={LoginHeader as string} srcSet={`${LoginHeader2x} 2x`} className="header-image" />
<img src={LoginHeader as string} srcSet={`${LoginHeader2x} 2x`} className="w-full" />
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Create an Account" id="u6uD94" />
@ -197,19 +197,18 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.LoginInput: {
return (
<>
<img src={LoginVault as string} srcSet={`${LoginVault2x} 2x`} className="header-image" />
<img src={LoginVault as string} srcSet={`${LoginVault2x} 2x`} className="w-full" />
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Login with private key" id="3df560" />
<FormattedMessage defaultMessage="Login with private key" />
</h2>
<p>
<FormattedMessage
defaultMessage="This method is insecure. We recommend using a {nostrlink}"
id="Z8ZOEY"
values={{
nostrlink: (
<ExternalLink href="">
<FormattedMessage defaultMessage="nostr signer extension" id="/EvlqN" />
<FormattedMessage defaultMessage="nostr signer extension" />
</ExternalLink>
),
}}
@ -219,7 +218,7 @@ export function LoginSignup({ close }: { close: () => void }) {
type="text"
value={key}
onChange={e => setNewKey(e.target.value)}
placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz", id: "yzKwBQ" })}
placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz" })}
/>
<div className="flex justify-between">
<div></div>
@ -244,10 +243,10 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.Details: {
return (
<>
<img src={LoginProfile as string} srcSet={`${LoginProfile2x} 2x`} className="header-image" />
<img src={LoginProfile as string} srcSet={`${LoginProfile2x} 2x`} className="w-full" />
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Setup Profile" id="nOaArs" />
<FormattedMessage defaultMessage="Setup Profile" />
</h2>
<div className="relative mx-auto w-[100px] h-[100px] rounded-full overflow-hidden">
{avatar && <img className="absolute object-fit w-full h-full" src={avatar} />}
@ -261,16 +260,15 @@ export function LoginSignup({ close }: { close: () => void }) {
type="text"
placeholder={formatMessage({
defaultMessage: "Username",
id: "JCIgkj",
})}
value={username}
onChange={e => setUsername(e.target.value)}
/>
<small className="text-neutral-300">
<FormattedMessage defaultMessage="You can change this later" id="ZmqxZs" />
<FormattedMessage defaultMessage="You can change this later" />
</small>
<DefaultButton onClick={setupProfile}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
<FormattedMessage defaultMessage="Save" />
</DefaultButton>
{error && <b className="error">{error}</b>}
</div>
@ -280,22 +278,18 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.LnAddress: {
return (
<>
<img src={LoginWallet as string} srcSet={`${LoginWallet2x} 2x`} className="header-image" />
<img src={LoginWallet as string} srcSet={`${LoginWallet2x} 2x`} className="w-full" />
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Get paid by viewers" id="Fodi9+" />
<FormattedMessage defaultMessage="Get paid by viewers" />
</h2>
<p>
<FormattedMessage
defaultMessage="We hooked you up with a lightning wallet so you can get paid by viewers right away!"
id="Oxqtyf"
/>
<FormattedMessage defaultMessage="We hooked you up with a lightning wallet so you can get paid by viewers right away!" />
</p>
{providerInfo?.balance && (
<p>
<FormattedMessage
defaultMessage="Oh, and you have {n} sats of free streaming on us! 💜"
id="f6biFA"
values={{
n: <FormattedNumber value={providerInfo.balance} />,
}}
@ -304,16 +298,16 @@ export function LoginSignup({ close }: { close: () => void }) {
)}
<input
type="text"
placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com", id: "1qsXCO" })}
placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com" })}
value={lnAddress}
onChange={e => setLnAddress(e.target.value)}
/>
<small>
<FormattedMessage defaultMessage="You can always replace it with your own address later." id="FjDlus" />
<FormattedMessage defaultMessage="You can always replace it with your own address later." />
</small>
{error && <b className="error">{error}</b>}
<DefaultButton onClick={saveProfile}>
<FormattedMessage defaultMessage="Amazing! Continue.." id="tM6fNW" />
<FormattedMessage defaultMessage="Amazing! Continue.." />
</DefaultButton>
</div>
</>
@ -322,7 +316,7 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.SaveKey: {
return (
<>
<img src={LoginKey as string} srcSet={`${LoginKey2x} 2x`} className="header-image" />
<img src={LoginKey as string} srcSet={`${LoginKey2x} 2x`} className="w-full" />
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Save Key" id="04lmFi" />

View File

@ -10,6 +10,7 @@ export interface ModalProps {
onClose?: (e: React.MouseEvent | KeyboardEvent) => void;
onClick?: (e: React.MouseEvent) => void;
children: ReactNode;
showClose?: boolean;
}
let scrollbarWidth: number | null = null;
@ -81,17 +82,19 @@ export default function Modal(props: ModalProps) {
e.stopPropagation();
props.onClick?.(e);
}}>
<div className="absolute right-4 top-4">
<IconButton
iconName="x"
onClick={e => {
e.stopPropagation();
props.onClose?.(e);
}}
className="rounded-full aspect-square bg-layer-2 p-3"
iconSize={10}
/>
</div>
{(props.showClose ?? true) && (
<div className="absolute right-4 top-4">
<IconButton
iconName="x"
onClick={e => {
e.stopPropagation();
props.onClose?.(e);
}}
className="rounded-full aspect-square bg-layer-2 p-3"
iconSize={10}
/>
</div>
)}
{props.children}
</div>
</div>,

View File

@ -0,0 +1,169 @@
import { DefaultButton } from "@/element/buttons";
import { NostrStreamProvider } from "@/providers";
import { unwrap } from "@snort/shared";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
enum ForwardService {
Custom = "custom",
Twitch = "twitch",
Youtube = "youtube",
Facebook = "facebook",
Kick = "kick",
Trovo = "trovo",
}
export function AddForwardInputs({
provider,
onAdd,
}: {
provider: NostrStreamProvider;
onAdd: (name: string, target: string) => void;
}) {
const [name, setName] = useState("");
const [target, setTarget] = useState("");
const [svc, setService] = useState(ForwardService.Twitch);
const [error, setError] = useState("");
const { formatMessage } = useIntl();
async function getTargetFull() {
if (svc === ForwardService.Custom) {
return target;
}
if (svc === ForwardService.Twitch) {
const urls = (await (await fetch("https://ingest.twitch.tv/ingests")).json()) as {
ingests: Array<{
availability: number;
name: string;
url_template: string;
}>;
};
const ingestsEurope = urls.ingests.filter(
a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1
);
const random = ingestsEurope.at(ingestsEurope.length * Math.random());
return unwrap(random).url_template.replace("{stream_key}", target);
}
if (svc === ForwardService.Youtube) {
return `rtmp://a.rtmp.youtube.com:1935/live2/${target}`;
}
if (svc === ForwardService.Facebook) {
return `rtmps://live-api-s.facebook.com:443/rtmp/${target}`;
}
if (svc === ForwardService.Trovo) {
return `rtmp://livepush.trovo.live:1935/live/${target}`;
}
if (svc === ForwardService.Kick) {
return `rtmps://fa723fc1b171.global-contribute.live-video.net:443/app/${target}`;
}
}
async function doAdd() {
if (svc === ForwardService.Custom) {
if (!target.startsWith("rtmp://")) {
setError(
formatMessage({
defaultMessage: "Stream url must start with rtmp://",
id: "7+bCC1",
})
);
return;
}
try {
// stupid URL parser doesnt work for non-http protocols
const u = new URL(target.replace("rtmp://", "http://"));
console.debug(u);
if (u.host.length < 1) {
throw new Error();
}
if (u.pathname === "/") {
throw new Error();
}
} catch {
setError(
formatMessage({
defaultMessage: "Not a valid URL",
id: "1q4BO/",
})
);
return;
}
} else {
if (target.length < 2) {
setError(
formatMessage({
defaultMessage: "Stream Key is required",
id: "50+/JW",
})
);
return;
}
}
if (name.length < 2) {
setError(
formatMessage({
defaultMessage: "Name is required",
id: "Gvxoji",
})
);
return;
}
try {
const t = await getTargetFull();
if (!t)
throw new Error(
formatMessage({
defaultMessage: "Could not create stream URL",
id: "E9APoR",
})
);
await provider.addForward(name, t);
} catch (e) {
setError((e as Error).message);
}
setName("");
setTarget("");
onAdd(name, target);
}
return (
<div className="flex flex-col p-4 gap-2 bg-layer-3 rounded-xl">
<div className="flex gap-2">
<select value={svc} onChange={e => setService(e.target.value as ForwardService)} className="flex-1">
<option value="twitch">Twitch</option>
<option value="youtube">Youtube</option>
<option value="facebook">Facebook Gaming</option>
<option value="kick">Kick</option>
<option value="trovo">Trovo</option>
<option value="custom">Custom</option>
</select>
<input
type="text"
className="flex-1"
placeholder={formatMessage({ defaultMessage: "Display name", id: "dOQCL8" })}
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<input
type="password"
placeholder={
svc === ForwardService.Custom ? "rtmp://" : formatMessage({ defaultMessage: "Stream key", id: "QWlMq9" })
}
value={target}
onChange={e => setTarget(e.target.value)}
/>
<DefaultButton onClick={doAdd}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</DefaultButton>
{error && <b className="warning">{error}</b>}
</div>
);
}

View File

@ -1,16 +1,18 @@
import { NostrEvent } from "@snort/system";
import { useContext, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
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 "@/element/buttons";
import Pill from "@/element/pill";
import { AddForwardInputs } from "./fowards";
import StreamKey from "./stream-key";
import AccountTopup from "./topup";
export default function NostrProviderDialog({
provider,
@ -164,23 +166,7 @@ export default function NostrProviderDialog({
</div>
</div>
)}
<div>
<p>
<FormattedMessage defaultMessage="Server Url" id="5kx+2v" />
</p>
<input type="text" value={ep?.url} disabled />
</div>
<div>
<p>
<FormattedMessage defaultMessage="Stream Key" id="LknBsU" />
</p>
<div className="flex gap-2">
<input type="password" value={ep?.key} disabled />
<DefaultButton onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
<FormattedMessage defaultMessage="Copy" id="4l6vz1" />
</DefaultButton>
</div>
</div>
{ep && <StreamKey ep={ep} />}
<div>
<p>
<FormattedMessage defaultMessage="Balance" id="H5+NAX" />
@ -193,9 +179,12 @@ export default function NostrProviderDialog({
values={{ amount: info.balance?.toLocaleString() }}
/>
</div>
<DefaultButton onClick={() => setTopup(true)}>
<FormattedMessage defaultMessage="Topup" id="nBCvvJ" />
</DefaultButton>
<AccountTopup
provider={provider}
onFinish={async () => {
loadInfo();
}}
/>
</div>
<small>
<FormattedMessage defaultMessage="About {estimate}" id="Q3au2v" values={{ estimate: calcEstimate() }} />
@ -283,167 +272,3 @@ export default function NostrProviderDialog({
</>
);
}
enum ForwardService {
Custom = "custom",
Twitch = "twitch",
Youtube = "youtube",
Facebook = "facebook",
Kick = "kick",
Trovo = "trovo",
}
function AddForwardInputs({
provider,
onAdd,
}: {
provider: NostrStreamProvider;
onAdd: (name: string, target: string) => void;
}) {
const [name, setName] = useState("");
const [target, setTarget] = useState("");
const [svc, setService] = useState(ForwardService.Twitch);
const [error, setError] = useState("");
const { formatMessage } = useIntl();
async function getTargetFull() {
if (svc === ForwardService.Custom) {
return target;
}
if (svc === ForwardService.Twitch) {
const urls = (await (await fetch("https://ingest.twitch.tv/ingests")).json()) as {
ingests: Array<{
availability: number;
name: string;
url_template: string;
}>;
};
const ingestsEurope = urls.ingests.filter(
a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1
);
const random = ingestsEurope.at(ingestsEurope.length * Math.random());
return unwrap(random).url_template.replace("{stream_key}", target);
}
if (svc === ForwardService.Youtube) {
return `rtmp://a.rtmp.youtube.com:1935/live2/${target}`;
}
if (svc === ForwardService.Facebook) {
return `rtmps://live-api-s.facebook.com:443/rtmp/${target}`;
}
if (svc === ForwardService.Trovo) {
return `rtmp://livepush.trovo.live:1935/live/${target}`;
}
if (svc === ForwardService.Kick) {
return `rtmps://fa723fc1b171.global-contribute.live-video.net:443/app/${target}`;
}
}
async function doAdd() {
if (svc === ForwardService.Custom) {
if (!target.startsWith("rtmp://")) {
setError(
formatMessage({
defaultMessage: "Stream url must start with rtmp://",
id: "7+bCC1",
})
);
return;
}
try {
// stupid URL parser doesnt work for non-http protocols
const u = new URL(target.replace("rtmp://", "http://"));
console.debug(u);
if (u.host.length < 1) {
throw new Error();
}
if (u.pathname === "/") {
throw new Error();
}
} catch {
setError(
formatMessage({
defaultMessage: "Not a valid URL",
id: "1q4BO/",
})
);
return;
}
} else {
if (target.length < 2) {
setError(
formatMessage({
defaultMessage: "Stream Key is required",
id: "50+/JW",
})
);
return;
}
}
if (name.length < 2) {
setError(
formatMessage({
defaultMessage: "Name is required",
id: "Gvxoji",
})
);
return;
}
try {
const t = await getTargetFull();
if (!t)
throw new Error(
formatMessage({
defaultMessage: "Could not create stream URL",
id: "E9APoR",
})
);
await provider.addForward(name, t);
} catch (e) {
setError((e as Error).message);
}
setName("");
setTarget("");
onAdd(name, target);
}
return (
<div className="flex flex-col p-4 gap-2 bg-layer-3 rounded-xl">
<div className="flex gap-2">
<select value={svc} onChange={e => setService(e.target.value as ForwardService)} className="flex-1">
<option value="twitch">Twitch</option>
<option value="youtube">Youtube</option>
<option value="facebook">Facebook Gaming</option>
<option value="kick">Kick</option>
<option value="trovo">Trovo</option>
<option value="custom">Custom</option>
</select>
<input
type="text"
className="flex-1"
placeholder={formatMessage({ defaultMessage: "Display name", id: "dOQCL8" })}
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<input
type="text"
placeholder={
svc === ForwardService.Custom ? "rtmp://" : formatMessage({ defaultMessage: "Stream key", id: "QWlMq9" })
}
value={target}
onChange={e => setTarget(e.target.value)}
/>
<DefaultButton onClick={doAdd}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</DefaultButton>
{error && <b className="warning">{error}</b>}
</div>
);
}

View File

@ -0,0 +1,28 @@
import Copy from "@/element/copy";
import { StreamProviderEndpoint } from "@/providers";
import { FormattedMessage } from "react-intl";
export default function StreamKey({ ep }: { ep: StreamProviderEndpoint }) {
return (
<div className="flex flex-col gap-2">
<div>
<p className="mb-2">
<FormattedMessage defaultMessage="Server Url" />
</p>
<div className="flex bg-layer-2 rounded-xl pr-4">
<input type="text" value={ep.url} disabled />
<Copy text={ep.url} hideText={true} />
</div>
</div>
<div>
<p className="mb-2">
<FormattedMessage defaultMessage="Stream Key" />
</p>
<div className="flex bg-layer-2 rounded-xl pr-4">
<input type="password" value={ep?.key} disabled />
<Copy text={ep.key} hideText={true} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
import { SendZaps } from "@/element/send-zap";
import { NostrStreamProvider } from "@/providers";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
export default function AccountTopup({ provider, onFinish }: { provider: NostrStreamProvider; onFinish: () => void }) {
const [topup, setTopup] = useState(false);
return (
<>
<DefaultButton onClick={() => setTopup(true)}>
<FormattedMessage defaultMessage="Topup" />
</DefaultButton>
{topup && (
<Modal id="topup" onClose={() => setTopup(false)}>
<SendZaps
lnurl={{
name: provider.name,
canZap: false,
maxCommentLength: 0,
getInvoice: async amount => {
const pr = await provider.topup(amount);
return { pr };
},
}}
onFinish={onFinish}
/>
</Modal>
)}
</>
);
}

View File

@ -16,35 +16,43 @@ interface CardDialogProps {
export function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDialogProps) {
const [title, setTitle] = useState(card?.title ?? "");
const [image, setImage] = useState(card?.image ?? "");
const [image, setImage] = useState<string | undefined>(card?.image);
const [content, setContent] = useState(card?.content ?? "");
const [link, setLink] = useState(card?.link ?? "");
const { formatMessage } = useIntl();
return (
<div className="flex flex-col gap-2">
<h3>{header || <FormattedMessage defaultMessage="Add card" id="nwA8Os" />}</h3>
<h3>{header || <FormattedMessage defaultMessage="Add card" />}</h3>
{/* TITLE */}
<label htmlFor="card-title">
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
<FormattedMessage defaultMessage="Title" />
</label>
<input
id="card-title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={formatMessage({ defaultMessage: "e.g. about me", id: "k21gTS" })}
placeholder={formatMessage({ defaultMessage: "e.g. about me" })}
/>
{/* IMAGE */}
<label htmlFor="card-image">
<FormattedMessage defaultMessage="Image" id="+0zv6g" />
<FormattedMessage defaultMessage="Image" />
</label>
<FileUploader defaultImage={image} onFileUpload={setImage} onClear={() => setImage("")} />
{image.length > 0 && (
{image && (
<>
<img src={image} />
<WarningButton onClick={() => setImage(undefined)}>
<FormattedMessage defaultMessage="Remove Image" />
</WarningButton>
</>
)}
<FileUploader defaultImage={image} onResult={setImage} />
{image && (
<>
{/* IMAGE LINK */}
<label htmlFor="card-image-link">
<FormattedMessage defaultMessage="Image Link" id="s5ksS7" />
<FormattedMessage defaultMessage="Image Link" />
</label>
<input
id="card-image-link"
@ -57,10 +65,10 @@ export function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: C
)}
{/* CONTENT */}
<label htmlFor="card-content">
<FormattedMessage defaultMessage="Content" id="Jq3FDz" />
<FormattedMessage defaultMessage="Content" />
</label>
<textarea
placeholder={formatMessage({ defaultMessage: "Start typing", id: "w0Xm2F" })}
placeholder={formatMessage({ defaultMessage: "Start typing" })}
value={content}
rows={5}
onChange={e => setContent(e.target.value)}
@ -68,7 +76,6 @@ export function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: C
<span className="help-text">
<FormattedMessage
defaultMessage="Supports {markdown}"
id="I1kjHI"
values={{
markdown: (
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
@ -79,12 +86,10 @@ export function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: C
/>
</span>
<div className="flex justify-between">
<WarningButton onClick={onCancel}>
{cancelCta || <FormattedMessage defaultMessage="Cancel" id="47FYwb" />}
</WarningButton>
<WarningButton onClick={onCancel}>{cancelCta || <FormattedMessage defaultMessage="Cancel" />}</WarningButton>
<DefaultButton onClick={() => onSave({ title, image, content, link })}>
{cta || <FormattedMessage defaultMessage="Add Card" id="UJBFYK" />}
{cta || <FormattedMessage defaultMessage="Add Card" />}
</DefaultButton>
</div>
</div>

View File

@ -0,0 +1,55 @@
import { GameInfo } from "@/service/game-database";
import { FormattedMessage } from "react-intl";
import { IconButton } from "../buttons";
import GameInfoCard from "../game-info";
import { SearchCategory } from "./category-search";
import { StreamInput } from "./input";
import { TagsInput } from "react-tag-input-component";
export default function CategoryInput({
tags,
game,
gameId,
setTags,
setGame,
setGameId,
}: {
tags: Array<string>;
game: GameInfo | undefined;
gameId: string | undefined;
setTags: (v: Array<string>) => void;
setGame: (g: GameInfo | undefined) => void;
setGameId: (id: string | undefined) => void;
}) {
return (
<>
<StreamInput label={<FormattedMessage defaultMessage="Category" />}>
{!gameId && (
<SearchCategory
onSelect={g => {
setGame(g);
setGameId(g.id);
}}
/>
)}
{gameId && (
<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", ","]} />
</StreamInput>
</>
);
}

View File

@ -1,18 +1,19 @@
import { useIntl } from "react-intl";
import { useGoals } from "@/hooks/goals";
import { useLogin } from "@/hooks/login";
interface GoalSelectorProps {
goal?: string;
pubkey: string;
onGoalSelect: (g: string) => void;
}
export function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
const goals = useGoals(pubkey, true);
export function GoalSelector({ goal, onGoalSelect }: GoalSelectorProps) {
const login = useLogin();
const goals = useGoals(login?.pubkey, true);
const { formatMessage } = useIntl();
return (
<select value={goal} onChange={ev => onGoalSelect(ev.target.value)}>
<option>{formatMessage({ defaultMessage: "Select a goal..." })}</option>
<option value={""}>{formatMessage({ defaultMessage: "New Goal" })}</option>
{goals?.map(x => (
<option key={x.id} value={x.id}>
{x.content}

View File

@ -2,21 +2,19 @@ import "./index.css";
import { useCallback, useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { TagsInput } from "react-tag-input-component";
import { FormattedMessage, useIntl } from "react-intl";
import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login";
import { StreamState } from "@/const";
import { DefaultButton, IconButton } from "@/element/buttons";
import { DefaultButton } 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";
import CategoryInput from "./category-input";
export interface StreamEditorProps {
ev?: NostrEvent;
@ -207,40 +205,19 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
</>
)}
{(options?.canSetTags ?? true) && (
<>
<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", ","]} />
</StreamInput>
</>
<CategoryInput
tags={tags}
game={game}
gameId={gameId}
setTags={setTags}
setGame={setGame}
setGameId={setGameId}
/>
)}
{login?.pubkey && (
<StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
<div className="flex flex-col gap-2">
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
<GoalSelector goal={goal} onGoalSelect={setGoal} />
<NewGoalDialog />
</div>
</StreamInput>

View File

@ -8,6 +8,7 @@ import { useLogin } from "@/hooks/login";
import { defaultRelays } from "@/const";
import { DefaultButton } from "../buttons";
import Modal from "../modal";
import { StreamInput } from "./input";
export function NewGoalDialog() {
const system = useContext(SnortContext);
@ -46,28 +47,22 @@ export function NewGoalDialog() {
</DefaultButton>
{open && (
<Modal id="new-goal" onClose={() => setOpen(false)}>
<div className="new-goal content-inner">
<div className="zap-goals">
<div className="flex flex-col gap-4">
<div className="flex gap-2 items-center">
<Icon name="zap-filled" className="stream-zap-goals-icon" size={16} />
<h3>
<FormattedMessage defaultMessage="Stream Zap Goals" id="0GfNiL" />
<FormattedMessage defaultMessage="New Stream Goal" />
</h3>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Name" id="HAlOn1" />
</p>
<StreamInput label={<FormattedMessage defaultMessage="Name" />}>
<input
type="text"
value={goalName}
placeholder="e.g. New Laptop"
onChange={e => setGoalName(e.target.value)}
/>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Amount" id="/0TOL5" />
</p>
</StreamInput>
<StreamInput label={<FormattedMessage defaultMessage="Amount" />}>
<input
type="number"
placeholder="21"
@ -76,12 +71,10 @@ export function NewGoalDialog() {
value={goalAmount}
onChange={e => setGoalAmount(e.target.value)}
/>
</div>
<div className="create-goal">
<DefaultButton disabled={!isValid} onClick={publishGoal}>
<FormattedMessage defaultMessage="Create Goal" id="X2PZ7D" />
</DefaultButton>
</div>
</StreamInput>
<DefaultButton disabled={!isValid} onClick={publishGoal}>
<FormattedMessage defaultMessage="Create Goal" id="X2PZ7D" />
</DefaultButton>
</div>
</Modal>
)}

View File

@ -153,6 +153,9 @@
<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>
<symbol id="upload" viewBox="0 0 14 14" fill="none">
<path d="M13 9V9.8C13 10.9201 13 11.4802 12.782 11.908C12.5903 12.2843 12.2843 12.5903 11.908 12.782C11.4802 13 10.9201 13 9.8 13H4.2C3.07989 13 2.51984 13 2.09202 12.782C1.71569 12.5903 1.40973 12.2843 1.21799 11.908C1 11.4802 1 10.9201 1 9.8V9M10.3333 4.33333L7 1M7 1L3.66667 4.33333M7 1V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -142,3 +142,10 @@ table th {
table th {
@apply bg-layer-1;
}
.zap-glow {
border: 0.83px solid rgba(94, 31, 11, 1);
background: rgba(46, 23, 16, 1);
box-shadow: 0px 0px 5px 5px rgba(255, 91, 39, 0.22);
color: rgba(255, 91, 39, 1);
}

View File

@ -34,6 +34,11 @@ import CategoryPage from "./pages/category";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
import FaqPage from "./pages/faq";
import DashboardIntroStep1 from "./pages/dashboard/intro/step1";
import DashboardIntroStep2 from "./pages/dashboard/intro/step2";
import DashboardIntroStep3 from "./pages/dashboard/intro/step3";
import DashboardIntroStep4 from "./pages/dashboard/intro/step4";
import DashboardIntroFinal from "./pages/dashboard/intro/final";
const hasWasm = "WebAssembly" in globalThis;
const workerRelay = new WorkerRelayInterface(
@ -131,6 +136,26 @@ const router = createBrowserRouter([
</Suspense>
),
},
{
path: "/dashboard/step-1",
element: <DashboardIntroStep1 />,
},
{
path: "/dashboard/step-2",
element: <DashboardIntroStep2 />,
},
{
path: "/dashboard/step-3",
element: <DashboardIntroStep3 />,
},
{
path: "/dashboard/step-4",
element: <DashboardIntroStep4 />,
},
{
path: "/dashboard/final",
element: <DashboardIntroFinal />,
},
{
path: "/search/:term?",
element: <SearchPage />,

View File

@ -3,7 +3,7 @@ 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)}>
<div {...props} className={classNames("px-4 py-6 rounded-3xl border border-layer-2", props.className)}>
{props.children}
</div>
);

View File

@ -2,29 +2,65 @@ 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 { FormattedMessage, FormattedNumber } from "react-intl";
import { DashboardCard } from "./card";
import { DashboardHighlightZap } from "./zap-highlight";
import ZapGlow from "./zap-glow";
import { ShareMenu } from "@/element/share-menu";
export function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Array<TaggedNostrEvent> }) {
export function DashboardZapColumn({
ev,
link,
feed,
}: {
ev: TaggedNostrEvent;
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);
const zapSum = sortedZaps.reduce((acc, v) => acc + v.amount, 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>
<div className="flex flex-col gap-4">
<DashboardCard className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<ZapGlow />
<h3>
<FormattedMessage defaultMessage="Stream Earnings" />
</h3>
</div>
<ShareMenu ev={ev} />
</div>
<div>
<FormattedMessage
defaultMessage="{n} sats"
values={{
n: (
<span className="text-3xl">
<FormattedNumber value={zapSum} />
</span>
),
}}
/>
</div>
</DashboardCard>
<DashboardCard className="flex flex-col gap-4 grow">
<h3>
<FormattedMessage defaultMessage="Zaps" />
</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>
</div>
);
}

View File

@ -4,10 +4,10 @@ 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 { useEffect, useMemo, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { StreamTimer } from "@/element/stream-time";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP } from "@/const";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP, StreamState } from "@/const";
import { DashboardRaidButton } from "./button-raid";
import { DashboardZapColumn } from "./column-zaps";
import { DashboardChatList } from "./chat-list";
@ -16,11 +16,37 @@ import { DashboardCard } from "./card";
import { NewStreamDialog } from "@/element/new-stream";
import { DashboardSettingsButton } from "./button-settings";
import DashboardIntro from "./intro";
import { useLocation } from "react-router-dom";
import StreamKey from "@/element/provider/nostr/stream-key";
import { DefaultProvider, NostrStreamProvider, StreamProviderInfo } from "@/providers";
import { ExternalLink } from "@/element/external-link";
import BalanceTimeEstimate from "@/element/balance-time-estimate";
import { DefaultButton } from "@/element/buttons";
import { useLogin } from "@/hooks/login";
import AccountTopup from "@/element/provider/nostr/topup";
export function DashboardForLink({ link }: { link: NostrLink }) {
const streamEvent = useCurrentStreamFeed(link, true);
const location = useLocation();
const login = useLogin();
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
const { stream, status, image, participants } = extractStreamInfo(streamEvent);
const { stream, status, image, participants, service } = extractStreamInfo(streamEvent);
const [info, setInfo] = useState<StreamProviderInfo>();
const provider = useMemo(() => (service ? new NostrStreamProvider("", service) : DefaultProvider), [service]);
const defaultEndpoint = useMemo(() => {
return info?.endpoints.find(a => a.name == "Good");
}, [info]);
useEffect(() => {
provider.info().then(setInfo);
const t = setInterval(() => {
provider.info().then(setInfo);
}, 1000 * 60);
return () => {
clearInterval(t);
};
}, [provider]);
const [maxParticipants, setMaxParticipants] = useState(0);
useEffect(() => {
@ -39,44 +65,123 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
},
true
);
if (!streamLink) return <DashboardIntro />;
if (!streamLink && !location.search.includes("setupComplete=true")) 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 className="flex justify-between items-center">
<h3>
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</h3>
<div className="uppercase font-semibold flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
status === StreamState.Live ? "animate-pulse bg-green-500" : "bg-red-500"
}`}></div>
{status === StreamState.Live ? (
<FormattedMessage defaultMessage="Started" />
) : (
<FormattedMessage defaultMessage="Stopped" />
)}
</div>
</div>
{streamLink && (
<>
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
<div className="flex gap-4">
<DashboardStatsCard
name={<FormattedMessage defaultMessage="Stream Time" />}
value={<StreamTimer ev={streamEvent} />}
/>
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" />} value={participants} />
<DashboardStatsCard name={<FormattedMessage defaultMessage="Top Viewers" />} value={maxParticipants} />
</div>
{defaultEndpoint && (
<div className="bg-layer-1 rounded-xl px-4 py-3 flex justify-between items-center text-layer-5">
<div>
<FormattedMessage
defaultMessage="{estimate} remaining ({balance} sats @ {rate} sats / {unit})"
values={{
estimate: (
<span className="text-white">
<BalanceTimeEstimate balance={info?.balance ?? 0} endpoint={defaultEndpoint} />
</span>
),
balance: <FormattedNumber value={info?.balance ?? 0} />,
rate: defaultEndpoint.rate ?? 0,
unit: defaultEndpoint.unit ?? "min",
}}
/>
</div>
<AccountTopup
provider={provider}
onFinish={() => {
provider.info().then(setInfo);
}}
/>
</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>
{streamEvent?.pubkey === login?.pubkey && (
<DefaultButton>
<FormattedMessage defaultMessage="End Stream" />
</DefaultButton>
)}
</>
)}
{!streamLink && (
<>
<div className="bg-layer-1 rounded-xl aspect-video flex items-center justify-center uppercase text-warning font-semibold">
<FormattedMessage defaultMessage="Offline" />
</div>
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
<div className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Stream Setup" />
</h3>
<p className="text-layer-5">
<FormattedMessage
defaultMessage="To go live, copy and paste your Server URL and Stream Key below into your streaming software settings and press 'Start Streaming'. We recommend <a>OBS</a>."
values={{
a: c => <ExternalLink href="https://obsproject.com/">{c}</ExternalLink>,
}}
/>
</p>
{defaultEndpoint && <StreamKey ep={defaultEndpoint} />}
</div>
</>
)}
</DashboardCard>
{streamLink && (
<DashboardCard className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Chat Users" />
</h3>
<div className="h-[calc(100%-4rem)] overflow-y-auto">
<DashboardChatList feed={feed} />
</div>
</DashboardCard>
)}
</div>
<DashboardZapColumn link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
{streamLink && (
<>
<DashboardZapColumn ev={streamEvent!} link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
</>
)}
{!streamLink && (
<>
<DashboardCard></DashboardCard>
<DashboardCard></DashboardCard>
</>
)}
</div>
);
}

View File

@ -1,11 +0,0 @@
import { FormattedMessage } from "react-intl";
export default function DashboardIntro() {
return (
<>
<h1>
<FormattedMessage defaultMessage="Welcome to zap.stream!" />
</h1>
</>
);
}

View File

@ -0,0 +1,53 @@
import { FormattedMessage } from "react-intl";
import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import StreamKey from "@/element/provider/nostr/stream-key";
import { ExternalLink } from "@/element/external-link";
export default function DashboardIntroFinal() {
const navigate = useNavigate();
const [info, setInfo] = useState<StreamProviderInfo>();
const defaultEndpoint = useMemo(() => {
return info?.endpoints.find(a => a.name == "Good");
}, [info]);
async function loadInfo() {
DefaultProvider.info().then(i => {
setInfo(i);
});
}
useEffect(() => {
loadInfo();
}, []);
return (
<div className="flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
<FormattedMessage defaultMessage="Configure your streaming software" />
</h2>
<p className="text-center text-layer-5">
<FormattedMessage
defaultMessage="To go live, copy and paste your Server URL and Stream Key below into your streaming software settings and press 'Start Streaming'. We recommend <a>OBS</a>."
values={{
a: c => <ExternalLink href="https://obsproject.com/">{c}</ExternalLink>,
}}
/>
</p>
{defaultEndpoint && <StreamKey ep={defaultEndpoint} />}
<DefaultButton
onClick={async () => {
navigate("/dashboard?setupComplete=true");
}}>
<FormattedMessage defaultMessage="Go to Dashboard" />
</DefaultButton>
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import BalanceTimeEstimate from "@/element/balance-time-estimate";
import { DefaultButton } from "@/element/buttons";
import { Icon } from "@/element/icon";
import { useRates } from "@/hooks/rates";
import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { useNavigate } from "react-router-dom";
import ZapGlow from "../zap-glow";
export default function DashboardIntro() {
const navigate = useNavigate();
const [info, setInfo] = useState<StreamProviderInfo>();
const [tos, setTos] = useState<boolean>(false);
const defaultSatsBalance = 1000;
const exampleHours = 4;
const defaultEndpoint = useMemo(() => {
return info?.endpoints.find(a => a.name == "Good");
}, [info]);
const rate = useRates("BTCUSD");
const exampleCost = rate.ask * (exampleHours * (defaultEndpoint?.rate ?? 0) * 60) * 1e-8;
useEffect(() => {
DefaultProvider.info().then(i => {
setInfo(i);
setTos(Boolean(i.tosAccepted));
});
}, []);
if (!defaultEndpoint) return;
return (
<div className="flex flex-col gap-4 mx-auto w-1/3 bg-layer-1 rounded-xl border border-layer-2 p-6">
<h1>
<FormattedMessage defaultMessage="Welcome to zap.stream!" />
</h1>
<div className="flex gap-4">
<div>
<ZapGlow />
</div>
<p className="text-layer-5">
<FormattedMessage defaultMessage="ZapStream is a new kind of streaming platform that allows you to earn bitcoin (sats) the moment you start streaming! Viewers can tip streamers any amount they choose. The tips are instantly deposited to your bitcoin (lightning) wallet. zap.stream never touches your earnings!" />
</p>
</div>
<h3>
<FormattedMessage defaultMessage="Pricing" />
</h3>
<p className="text-layer-5">
<FormattedMessage defaultMessage="zap.stream is an open source platform powered by the nostr protocol. There are no giant corporations or giant funds available to provide free streaming." />
</p>
<p className="text-layer-5">
<FormattedMessage
defaultMessage="Streamers pay a small fee to cover our running costs. We give new streamers a credit of {amount} sats (about {time_estimate} of streaming) to get started!"
values={{
amount: <FormattedNumber value={defaultSatsBalance} />,
time_estimate: <BalanceTimeEstimate balance={defaultSatsBalance} endpoint={defaultEndpoint} />,
}}
/>
</p>
<p>
<FormattedMessage
defaultMessage="Current stream cost: {amount} sats/{unit} (about {usd}/day for a {x}hr stream)"
values={{
amount: defaultEndpoint.rate,
unit: defaultEndpoint.unit,
x: exampleHours,
usd: <FormattedNumber value={exampleCost} style="currency" currency="USD" />,
}}
/>
</p>
{!info?.tosAccepted && (
<div>
<div className="flex gap-2">
<input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
<p>
<FormattedMessage
defaultMessage="I have read and agree with {provider}'s {terms}."
values={{
provider: info?.name,
terms: (
<span
className="text-primary"
onClick={() => window.open(info?.tosLink, "popup", "width=400,height=800")}>
<FormattedMessage defaultMessage="terms and conditions" />
</span>
),
}}
/>
</p>
</div>
</div>
)}
<DefaultButton
disabled={!tos}
onClick={async () => {
if (!info?.tosAccepted) {
await DefaultProvider.acceptTos();
}
navigate("/dashboard/step-1");
}}>
<FormattedMessage defaultMessage="Create Stream" />
<Icon name="signal" />
</DefaultButton>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { FormattedMessage } from "react-intl";
import { Link, useLocation } from "react-router-dom";
export default function StepHeader() {
const location = useLocation();
const onStep = Number(location.pathname.split("/").slice(-1)[0].split("-")[1]);
return (
<div className="flex mb-[10vh] justify-between lg:w-[35rem] max-lg:w-full max-lg:px-6">
<Link to="/dashboard/step-1" className={onStep < 1 ? "opacity-20" : undefined}>
<FormattedMessage defaultMessage="Info" />
</Link>
<Link to="/dashboard/step-2" className={onStep < 2 ? "opacity-20" : undefined}>
<FormattedMessage defaultMessage="Category" />
</Link>
<Link to="/dashboard/step-3" className={onStep < 3 ? "opacity-20" : undefined}>
<FormattedMessage defaultMessage="Forwarding" />
</Link>
<Link to="/dashboard/step-4" className={onStep < 4 ? "opacity-20" : undefined}>
<FormattedMessage defaultMessage="Goal" />
</Link>
</div>
);
}

View File

@ -0,0 +1,73 @@
import { FormattedMessage, useIntl } from "react-intl";
import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider } from "@/providers";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileUploader } from "@/element/file-uploader";
export default function DashboardIntroStep1() {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const [title, setTitle] = useState<string>();
const [summary, setDescription] = useState<string>();
const [image, setImage] = useState<string>();
useEffect(() => {
DefaultProvider.info().then(i => {
setTitle(i.streamInfo?.title ?? "");
setDescription(i.streamInfo?.summary ?? "");
setImage(i.streamInfo?.image ?? "");
});
}, []);
return (
<div className="mx-auto flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
<FormattedMessage defaultMessage="Create Stream" />
</h2>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Stream Title" })}
/>
<input
type="text"
value={summary}
onChange={e => setDescription(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Description" })}
/>
{image && <img src={image} className="aspect-video rounded-xl object-cover" />}
<div className="flex gap-2">
<input
type="text"
value={image}
onChange={e => setImage(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Cover image URL (optional)" })}
/>
<FileUploader onResult={setImage} />
</div>
<small className="text-layer-4">
<FormattedMessage defaultMessage="Recommended size: 1920x1080 (16:9)" />
</small>
<DefaultButton
onClick={async () => {
const newState = {
title,
summary,
image,
};
await DefaultProvider.updateStream(newState);
navigate("/dashboard/step-2", {
state: newState,
});
}}>
<FormattedMessage defaultMessage="Continue" />
</DefaultButton>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { FormattedMessage } from "react-intl";
import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider } from "@/providers";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import CategoryInput from "@/element/stream-editor/category-input";
import { GameInfo } from "@/service/game-database";
import { extractGameTag, sortStreamTags } from "@/utils";
import { appendDedupe } from "@snort/shared";
export default function DashboardIntroStep2() {
const navigate = useNavigate();
const location = useLocation();
const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>();
const [tags, setTags] = useState<Array<string>>([]);
useEffect(() => {
DefaultProvider.info().then(i => {
const { regularTags, prefixedTags } = sortStreamTags(i.streamInfo?.tags ?? []);
const { gameInfo, gameId } = extractGameTag(prefixedTags);
setGame(gameInfo);
setGameId(gameId);
setTags(regularTags);
});
}, []);
return (
<div className="mx-auto flex flex-col items-center ">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
<FormattedMessage defaultMessage="Choose a category" />
</h2>
<CategoryInput
tags={tags}
game={game}
gameId={gameId}
setTags={setTags}
setGame={setGame}
setGameId={setGameId}
/>
<DefaultButton
onClick={async () => {
const newState = {
...location.state,
tags: appendDedupe(tags, gameId ? [gameId] : undefined),
};
await DefaultProvider.updateStream(newState);
navigate("/dashboard/step-3", {
state: newState,
});
}}>
<FormattedMessage defaultMessage="Continue" />
</DefaultButton>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { FormattedMessage } from "react-intl";
import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider, StreamProviderForward } from "@/providers";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AddForwardInputs } from "@/element/provider/nostr/fowards";
export default function DashboardIntroStep3() {
const navigate = useNavigate();
const [forwards, setForwards] = useState<Array<StreamProviderForward>>([]);
async function loadInfo() {
DefaultProvider.info().then(i => {
setForwards(i.forwards ?? []);
});
}
useEffect(() => {
loadInfo();
}, []);
return (
<div className="flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
<FormattedMessage defaultMessage="Stream Forwarding (optional)" />
</h2>
<p className="text-center text-layer-5">
<FormattedMessage defaultMessage="This allows you to forward your stream to other platforms to reach a wider audience." />
<br />
<FormattedMessage defaultMessage="To get started, grab your stream key from the platform you wish to forward to." />
</p>
<div className="grid grid-cols-2 gap-2">
{forwards?.map(a => (
<>
<div className="bg-layer-2 rounded-xl px-3 flex items-center">{a.name}</div>
<DefaultButton
onClick={async () => {
await DefaultProvider.removeForward(a.id);
await loadInfo();
}}>
<FormattedMessage defaultMessage="Remove" id="G/yZLu" />
</DefaultButton>
</>
))}
</div>
<AddForwardInputs provider={DefaultProvider} onAdd={loadInfo} />
<DefaultButton
onClick={async () => {
navigate("/dashboard/step-4");
}}>
<FormattedMessage defaultMessage="Continue" />
</DefaultButton>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { FormattedMessage, useIntl } from "react-intl";
import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider } from "@/providers";
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { GoalSelector } from "@/element/stream-editor/goal-selector";
import AmountInput from "@/element/amount-input";
import { useLogin } from "@/hooks/login";
import { GOAL, defaultRelays } from "@/const";
import { SnortContext } from "@snort/system-react";
export default function DashboardIntroStep4() {
const navigate = useNavigate();
const [goalName, setGoalName] = useState("");
const [goalAmount, setGoalMount] = useState(0);
const [goal, setGoal] = useState<string>();
const { formatMessage } = useIntl();
const login = useLogin();
const system = useContext(SnortContext);
async function loadInfo() {
DefaultProvider.info().then(i => {
setGoal(i.streamInfo?.goal);
});
}
useEffect(() => {
loadInfo();
}, []);
return (
<div className="flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
<FormattedMessage defaultMessage="Stream Goal (optional)" />
</h2>
<p className="text-center text-layer-5">
<FormattedMessage defaultMessage="Stream goals encourage viewers to support streamers via donations." />
<FormattedMessage defaultMessage="Leave blank if you do not wish to set up any goals." />
</p>
<GoalSelector goal={goal} onGoalSelect={setGoal} />
{!goal && (
<div className="flex gap-2">
<input
type="text"
placeholder={formatMessage({
defaultMessage: "Goal Name",
})}
value={goalName}
onChange={e => setGoalName(e.target.value)}
/>
<AmountInput onChange={setGoalMount} />
</div>
)}
<DefaultButton
onClick={async () => {
const pub = login?.publisher();
if (!goal && pub) {
const goalEvent = await pub.generic(eb => {
return eb
.kind(GOAL)
.tag(["amount", String(goalAmount * 1000)])
.tag(["relays", ...Object.keys(defaultRelays)])
.content(goalName);
});
await system.BroadcastEvent(goalEvent);
await DefaultProvider.updateStream({
goal: goalEvent.id,
});
navigate("/dashboard/final");
} else if (goal) {
await DefaultProvider.updateStream({
goal,
});
navigate("/dashboard/final");
}
}}>
<FormattedMessage defaultMessage="Continue" />
</DefaultButton>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { Icon } from "@/element/icon";
export default function ZapGlow() {
return (
<div className="rounded-xl p-2 zap-glow inline-block">
<Icon name="zap-filled" size={30} />
</div>
);
}

View File

@ -125,10 +125,9 @@ export function LayoutPage() {
<Modal
id="login"
onClose={() => setShowLogin(false)}
bodyClassName="my-auto bg-layer-1 rounded-xl overflow-hidden">
<div className="w-full">
<LoginSignup close={() => setShowLogin(false)} />
</div>
bodyClassName="relative bg-layer-1 rounded-3xl overflow-hidden my-auto lg:w-[500px] max-lg:w-full"
showClose={false}>
<LoginSignup close={() => setShowLogin(false)} />
</Modal>
)}
</>

View File

@ -56,7 +56,12 @@ export interface StreamProviderInfo {
endpoints: Array<StreamProviderEndpoint>;
tosAccepted?: boolean;
tosLink?: string;
forwards?: Array<{ id: string; name: string }>;
forwards?: Array<StreamProviderForward>;
}
export interface StreamProviderForward {
id: string;
name?: string;
}
export interface StreamProviderEndpoint {

View File

@ -71,6 +71,17 @@ export class NostrStreamProvider implements StreamProvider {
});
}
async updateStream(props: {
title?: string;
summary?: string;
image?: string;
tags?: Array<string>;
content_warning?: string;
goal?: string;
}): Promise<void> {
await this.#getJson("PATCH", "event", props);
}
async topup(amount: number): Promise<string> {
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
return rsp.pr;

View File

@ -103,6 +103,7 @@ export interface StreamInfo {
gameInfo?: GameInfo;
}
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
export function extractStreamInfo(ev?: NostrEvent) {
const ret = {
host: getHost(ev),
@ -130,24 +131,39 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "ends", v => (ret.ends = v));
matchTag(t, "service", v => (ret.service = v));
}
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 { regularTags, prefixedTags } = sortStreamTags(ev?.tags ?? []);
ret.tags = regularTags;
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}`);
const { gameInfo, gameId } = extractGameTag(prefixedTags);
ret.gameId = gameId;
ret.gameInfo = gameInfo;
return ret;
}
export function sortStreamTags(tags: Array<string | Array<string>>) {
const plainTags = tags.filter(a => (Array.isArray(a) ? a[0] === "t" : true)).map(a => (Array.isArray(a) ? a[1] : a));
const regularTags = plainTags.filter(a => !a.match(gameTagFormat)) ?? [];
const prefixedTags = plainTags.filter(a => !regularTags.includes(a));
return { regularTags, prefixedTags };
}
export function extractGameTag(tags: Array<string>) {
let gameInfo: GameInfo | undefined = undefined;
const gameId = tags.find(a => a.match(gameTagFormat));
if (gameId?.startsWith("internal:")) {
const internal = AllCategories.find(a => gameId === `internal:${a.id}`);
if (internal) {
ret.gameInfo = {
gameInfo = {
id: internal?.id,
name: internal.name,
genres: internal.tags,
className: internal.className,
};
}
} else {
ret.gameId = game;
}
return ret;
return { gameInfo, gameId };
}
export function trackEvent(

View File

@ -12,6 +12,7 @@ module.exports = {
"layer-2": "rgb(34 34 34 / <alpha-value>)",
"layer-3": "rgb(50 50 50 / <alpha-value>)",
"layer-4": "rgb(121 121 121 / <alpha-value>)",
"layer-5": "rgb(173 173 173 / <alpha-value>)",
primary: "var(--primary)",
secondary: "var(--secondary)",
zap: "var(--zap)",