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";