This commit is contained in:
parent
4d77882114
commit
f7b80c0b51
44
src/element/amount-input.tsx
Normal file
44
src/element/amount-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
21
src/element/balance-time-estimate.tsx
Normal file
21
src/element/balance-time-estimate.tsx
Normal 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} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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} />;
|
||||
|
@ -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" />
|
||||
|
@ -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,6 +82,7 @@ export default function Modal(props: ModalProps) {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}>
|
||||
{(props.showClose ?? true) && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<IconButton
|
||||
iconName="x"
|
||||
@ -92,6 +94,7 @@ export default function Modal(props: ModalProps) {
|
||||
iconSize={10}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
|
169
src/element/provider/nostr/fowards.tsx
Normal file
169
src/element/provider/nostr/fowards.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
28
src/element/provider/nostr/stream-key.tsx
Normal file
28
src/element/provider/nostr/stream-key.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
src/element/provider/nostr/topup.tsx
Normal file
33
src/element/provider/nostr/topup.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
55
src/element/stream-editor/category-input.tsx
Normal file
55
src/element/stream-editor/category-input.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
@ -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);
|
||||
}}
|
||||
<CategoryInput
|
||||
tags={tags}
|
||||
game={game}
|
||||
gameId={gameId}
|
||||
setTags={setTags}
|
||||
setGame={setGame}
|
||||
setGameId={setGameId}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
@ -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,13 +71,11 @@ export function NewGoalDialog() {
|
||||
value={goalAmount}
|
||||
onChange={e => setGoalAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="create-goal">
|
||||
</StreamInput>
|
||||
<DefaultButton disabled={!isValid} onClick={publishGoal}>
|
||||
<FormattedMessage defaultMessage="Create Goal" id="X2PZ7D" />
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
|
@ -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 |
@ -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);
|
||||
}
|
||||
|
@ -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 />,
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -2,22 +2,57 @@ 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">
|
||||
<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="Zaps" id="OEW7yJ" />
|
||||
<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} />}
|
||||
@ -26,5 +61,6 @@ export function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Arra
|
||||
))}
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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">
|
||||
<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" id="miQKuZ" />}
|
||||
name={<FormattedMessage defaultMessage="Stream Time" />}
|
||||
value={<StreamTimer ev={streamEvent} />}
|
||||
/>
|
||||
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" id="37mth/" />} value={participants} />
|
||||
<DashboardStatsCard
|
||||
name={<FormattedMessage defaultMessage="Highest Viewers" id="jctiUc" />}
|
||||
value={maxParticipants}
|
||||
<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" id="RtYNX5" />
|
||||
<FormattedMessage defaultMessage="Chat Users" />
|
||||
</h3>
|
||||
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
|
||||
<div className="h-[calc(100%-4rem)] overflow-y-auto">
|
||||
<DashboardChatList feed={feed} />
|
||||
</div>
|
||||
</DashboardCard>
|
||||
)}
|
||||
</div>
|
||||
<DashboardZapColumn link={streamLink} feed={feed} />
|
||||
{streamLink && (
|
||||
<>
|
||||
<DashboardZapColumn ev={streamEvent!} link={streamLink} feed={feed} />
|
||||
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
|
||||
</>
|
||||
)}
|
||||
{!streamLink && (
|
||||
<>
|
||||
<DashboardCard></DashboardCard>
|
||||
<DashboardCard></DashboardCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export default function DashboardIntro() {
|
||||
return (
|
||||
<>
|
||||
<h1>
|
||||
<FormattedMessage defaultMessage="Welcome to zap.stream!" />
|
||||
</h1>
|
||||
</>
|
||||
);
|
||||
}
|
53
src/pages/dashboard/intro/final.tsx
Normal file
53
src/pages/dashboard/intro/final.tsx
Normal 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>
|
||||
);
|
||||
}
|
107
src/pages/dashboard/intro/index.tsx
Normal file
107
src/pages/dashboard/intro/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
src/pages/dashboard/intro/step-header.tsx
Normal file
24
src/pages/dashboard/intro/step-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/pages/dashboard/intro/step1.tsx
Normal file
73
src/pages/dashboard/intro/step1.tsx
Normal 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>
|
||||
);
|
||||
}
|
60
src/pages/dashboard/intro/step2.tsx
Normal file
60
src/pages/dashboard/intro/step2.tsx
Normal 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>
|
||||
);
|
||||
}
|
60
src/pages/dashboard/intro/step3.tsx
Normal file
60
src/pages/dashboard/intro/step3.tsx
Normal 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>
|
||||
);
|
||||
}
|
86
src/pages/dashboard/intro/step4.tsx
Normal file
86
src/pages/dashboard/intro/step4.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
src/pages/dashboard/zap-glow.tsx
Normal file
9
src/pages/dashboard/zap-glow.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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">
|
||||
bodyClassName="relative bg-layer-1 rounded-3xl overflow-hidden my-auto lg:w-[500px] max-lg:w-full"
|
||||
showClose={false}>
|
||||
<LoginSignup close={() => setShowLogin(false)} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
34
src/utils.ts
34
src/utils.ts
@ -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(
|
||||
|
@ -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)",
|
||||
|
Loading…
Reference in New Issue
Block a user