Upgrades:

- Profile Editor
- Categories
- FAQ
- Search
This commit is contained in:
2024-03-05 12:19:47 +00:00
parent 2e43b4ef8b
commit 5b718c5dcf
51 changed files with 1199 additions and 6591 deletions

75
src/pages/category.tsx Normal file
View File

@ -0,0 +1,75 @@
import { StreamState } from "@/const";
import CategoryLink from "@/element/category-link";
import VideoGridSorted from "@/element/video-grid-sorted";
import { extractStreamInfo } from "@/utils";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
export const AllCategories = [
{
id: "irl",
name: <FormattedMessage defaultMessage="IRL" />,
icon: "face",
tags: ["irl"],
priority: 0,
},
{
id: "gaming",
name: <FormattedMessage defaultMessage="Gaming" />,
icon: "gaming-pad",
tags: ["gaming"],
priority: 0,
},
{
id: "music",
name: <FormattedMessage defaultMessage="Music" />,
icon: "music",
tags: ["music"],
priority: 0,
},
{
id: "talk",
name: <FormattedMessage defaultMessage="Talk" />,
icon: "mic",
tags: ["talk"],
priority: 0,
},
{
id: "art",
name: <FormattedMessage defaultMessage="Art" />,
icon: "art",
tags: ["art"],
priority: 0,
},
];
export default function Category() {
const { id } = useParams();
const cat = AllCategories.find(a => a.id === id);
const sub = useMemo(() => {
if (!cat) return;
const rb = new RequestBuilder(`category:${cat.id}`);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat.tags);
return rb;
}, [cat]);
const results = useRequestBuilder(sub).filter(a => {
const { status } = extractStreamInfo(a);
return status === StreamState.Live;
});
return (
<div>
<div className="flex gap-4">
{AllCategories.map(a => (
<CategoryLink key={a.id} {...a} />
))}
</div>
<h1 className="uppercase my-4">{id}</h1>
<VideoGridSorted evs={results} />
</div>
);
}

129
src/pages/faq.tsx Normal file
View File

@ -0,0 +1,129 @@
import { FormattedMessage } from "react-intl";
export default function FaqPage() {
return (
<div className="flex flex-col gap-4 w-[35rem] mx-auto">
<h1>
<FormattedMessage defaultMessage="FAQ" description="Title: FAQ page" />
</h1>
<h2>
<FormattedMessage defaultMessage="How do i stream on zap.stream?" />
</h2>
<p>
<FormattedMessage defaultMessage="To start streaming on zap.stream, follow these steps:" />
</p>
<ol className="leading-6 list-inside list-decimal">
<li>
<FormattedMessage defaultMessage="Click on Log In" />
</li>
<li>
<FormattedMessage defaultMessage="Create a new account if you don't have one already." />
</li>
<li>
<FormattedMessage defaultMessage="If you already have an account, you can use a nostr extension to log in. If you already use a nostr extension, you will be automatically logged in. If you don't have a nostr extension set up, you can use nos2x or Alby." />
</li>
<li>
<FormattedMessage defaultMessage="Click the “Stream” button in the top right corner" />
</li>
<li>
<FormattedMessage defaultMessage="Here you have a few options, using our in-house hosting, or your own (such as Cloudflare)." />
</li>
<ol className="leading-6 list-inside list-decimal ml-6">
<li>
<FormattedMessage defaultMessage="For manual hosting all you need is the HLS URL for the Stream URL field. You should be ale to find this in your hosting setup." />
</li>
<li>
<FormattedMessage defaultMessage="If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go." />
</li>
</ol>
</ol>
<h2>
<FormattedMessage defaultMessage="What is OBS?" />
</h2>
<p>
<FormattedMessage defaultMessage="OBS (Open Broadcaster Software) is a free and open source software for video recording and live streaming on Windows, Mac and Linux. It is a popular choice with streamers. You'll need to install this to capture your video, audio and anything else you'd like to add to your stream. Once installed and configured to preference, add your Stream URL and Stream Key from the Stream settings to OBS to form a connection with zap.stream." />
</p>
<h2>
<FormattedMessage defaultMessage="What does it cost to stream?" />
</h2>
<p>
<FormattedMessage defaultMessage="zap.stream is free up to 2 hours of hosting at high quality and up to 8 hours of “source” quality. After that you have an option to stream for 5 sats per minute or 10 sats per minute depending on your chosen quality. If you do not use zap.stream to host, pricing will depend on your chosen streaming provider." />
</p>
<h2>
<FormattedMessage defaultMessage="What are sats?" />
</h2>
<p>
<FormattedMessage defaultMessage="Sats are small units of Bitcoin. Sending sats on zap.stream is referred to as “zapping” or zaps." />
</p>
<h2>
<FormattedMessage defaultMessage="How do i get more sats?" />
</h2>
<p>
<FormattedMessage defaultMessage="We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice." />
</p>
<h2>
<FormattedMessage defaultMessage="What are zaps?" />
</h2>
<p>
<FormattedMessage defaultMessage="Zaps are lightning payments, which are published on nostr as receipts." />
</p>
<h2>
<FormattedMessage defaultMessage="What is a nostr extension?" />
</h2>
<p>
<FormattedMessage defaultMessage="A nostr extension simply saves your keys so you can safely log in without having to re-enter them every time. ZapStream uses the extension to authorize actions on your behalf without ever seeing your key information. This has a significant advantage over having to trust that websites handle your credentials safely." />
</p>
<h2>
<FormattedMessage defaultMessage="Recommended Stream Settings" />
</h2>
<table className="table-auto">
<thead>
<tr>
<th>
<FormattedMessage defaultMessage="Name" description="Config name column header" />
</th>
<th>
<FormattedMessage defaultMessage="Value" description="Config value column header" />
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<FormattedMessage defaultMessage="Video Codec" />
</td>
<td>h264</td>
</tr>
<tr>
<td>
<FormattedMessage defaultMessage="Audio Codec" />
</td>
<td>AAC</td>
</tr>
<tr>
<td>
<FormattedMessage defaultMessage="Max Video Bitrate" />
</td>
<td>7000k</td>
</tr>
<tr>
<td>
<FormattedMessage defaultMessage="Max Audio Bitrate" />
</td>
<td>320k</td>
</tr>
<tr>
<td>
<FormattedMessage defaultMessage="Keyframe Interval" />
</td>
<td>2s</td>
</tr>
</tbody>
</table>
<h3>
<FormattedMessage defaultMessage="Example settings in OBS (Apple M1 Mac)" />
</h3>
<img src="https://void.cat/d/VQQ75R6tmbVQJ9eqiwJhoj.webp" alt="OBS Mac settings" />
</div>
);
}

View File

@ -99,3 +99,18 @@
.fi-ru {
background-image: url("flag-icons/flags/1x1/ru.svg");
}
.fi-sa {
background-image: url("flag-icons/flags/1x1/sa.svg");
}
.fi-it {
background-image: url("flag-icons/flags/1x1/it.svg");
}
.fi-kr {
background-image: url("flag-icons/flags/1x1/kr.svg");
}
.fi-dk {
background-image: url("flag-icons/flags/1x1/dk.svg");
}
.fi-hu {
background-image: url("flag-icons/flags/1x1/hu.svg");
}

View File

@ -1,9 +1,9 @@
import "./layout.css";
import { CSSProperties, useEffect, useState, useSyncExternalStore } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { CSSProperties, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet";
import { FormattedMessage } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { hexToBech32 } from "@snort/shared";
@ -16,7 +16,7 @@ import { Login } from "@/login";
import { useLang } from "@/hooks/lang";
import { AllLocales } from "@/intl";
import { trackEvent } from "@/utils";
import { BorderButton, DefaultButton } from "@/element/buttons";
import { BorderButton } from "@/element/buttons";
import Modal from "@/element/modal";
import Logo from "@/element/logo";
@ -139,10 +139,19 @@ export function LayoutPage() {
<title>Home - zap.stream</title>
</Helmet>
<div className="flex justify-between mb-4">
<div
className="bg-white text-black flex items-center cursor-pointer rounded-2xl aspect-square px-1"
onClick={() => navigate("/")}>
<Logo width={40} height={40} />
<div className="flex gap-6 items-center">
<div
className="bg-white text-black flex items-center cursor-pointer rounded-2xl aspect-square px-1"
onClick={() => navigate("/")}>
<Logo width={40} height={40} />
</div>
<SearchBar />
<Link to="/category">
<FormattedMessage defaultMessage="Categories" id="VKb1MS" />
</Link>
<Link to="/faq">
<FormattedMessage defaultMessage="FAQ" id="W8nHSd" />
</Link>
</div>
<div className="flex items-center gap-3">
<Link
@ -162,26 +171,29 @@ export function LayoutPage() {
);
}
function NewVersionBanner() {
const newVersion = useSyncExternalStore(
c => NewVersion.hook(c),
() => NewVersion.snapshot()
);
if (!newVersion) return;
function SearchBar() {
const { term } = useParams();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const [search, setSearch] = useState(term ?? "");
return (
<div className="fixed top-0 left-0 w-max flex bg-slate-800 py-2 px-4 opacity-95">
<div className="grow">
<h1>
<FormattedMessage defaultMessage="A new version has been detected" id="RJ2VxG" />
</h1>
<p>
<FormattedMessage defaultMessage="Refresh the page to use the latest version" id="Gmiwnd" />
</p>
</div>
<DefaultButton onClick={() => window.location.reload()} className="btn">
<FormattedMessage defaultMessage="Refresh" id="rELDbB" />
</DefaultButton>
<div className="bg-layer-2 rounded-xl pr-4 py-1 flex items-center">
<input
type="text"
placeholder={formatMessage({
defaultMessage: "Search",
id: "xmcVZ0",
})}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
navigate(`/search/${encodeURIComponent(search)}`);
}
}}
/>
<Icon name="search" className="text-layer-4" size={16} />
</div>
);
}

View File

@ -1,31 +0,0 @@
.stream-providers-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stream-providers-grid > div {
display: flex;
flex-direction: column;
gap: 16px;
}
.stream-providers-grid > div img {
height: 64px;
}
.owncast-config {
display: flex;
gap: 16px;
padding: 40px;
}
.owncast-config > div {
flex: 1;
}
.owncast-config > div:nth-child(2) {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -1,70 +0,0 @@
import "./index.css";
import { useNavigate, useParams } from "react-router-dom";
import { StreamProviders } from "@/providers";
import Owncast from "@/owncast.png";
import Cloudflare from "@/cloudflare.png";
import { ConfigureOwncast } from "./owncast";
import { ConfigureNostrType } from "./nostr";
import { DefaultButton } from "@/element/buttons";
export function StreamProvidersPage() {
const navigate = useNavigate();
const { id } = useParams();
function mapName(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast:
return "Owncast";
case StreamProviders.Cloudflare:
return "Cloudflare";
case StreamProviders.NostrType:
return "Nostr Native";
}
return "Unknown";
}
function mapLogo(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast:
return <img src={Owncast} />;
case StreamProviders.Cloudflare:
return <img src={Cloudflare} />;
}
}
function providerLink(p: StreamProviders) {
return (
<div className="paper">
<h3>{mapName(p)}</h3>
{mapLogo(p)}
<DefaultButton onClick={() => navigate(p)}>+ Configure</DefaultButton>
</div>
);
}
function index() {
return (
<div className="stream-providers-page">
<h1>Providers</h1>
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
<div className="stream-providers-grid">
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div>
</div>
);
}
if (!id) {
return index();
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />;
}
case StreamProviders.NostrType: {
return <ConfigureNostrType />;
}
}
}
}

View File

@ -1,86 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { StatePill } from "@/element/state-pill";
import { StreamProviderInfo, StreamProviderStore } from "@/providers";
import { NostrStreamProvider } from "@/providers/zsz";
import { StreamState } from "@/const";
import { DefaultButton } from "@/element/buttons";
export function ConfigureNostrType() {
const [url, setUrl] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() {
try {
const api = new NostrStreamProvider(new URL(url).host, url);
const inf = await api.info();
setInfo(inf);
} catch (e) {
console.error(e);
}
}
function status() {
if (!info) return;
return (
<>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">{info?.name}</div>
</div>
{info?.summary && (
<div>
<p>Summary</p>
<div className="paper">{info?.summary}</div>
</div>
)}
{info?.viewers && (
<div>
<p>Viewers</p>
<div className="paper">{info?.viewers}</div>
</div>
)}
{info?.version && (
<div>
<p>Version</p>
<div className="paper">{info?.version}</div>
</div>
)}
<div>
<DefaultButton
onClick={() => {
StreamProviderStore.add(new NostrStreamProvider(new URL(url).host, url));
navigate("/");
}}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</DefaultButton>
</div>
</>
);
}
return (
<div className="owncast-config">
<div className="flex flex-col gap-3">
<div>
<p>Nostr streaming provider URL</p>
<div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
</div>
</div>
<DefaultButton onClick={tryConnect}>
<FormattedMessage defaultMessage="Connect" id="+vVZ/G" />
</DefaultButton>
</div>
<div>{status()}</div>
</div>
);
}

View File

@ -1,90 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { StatePill } from "@/element/state-pill";
import { StreamProviderInfo, StreamProviderStore } from "@/providers";
import { OwncastProvider } from "@/providers/owncast";
import { StreamState } from "@/const";
import { DefaultButton } from "@/element/buttons";
export function ConfigureOwncast() {
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() {
try {
const api = new OwncastProvider(url, token);
const i = await api.info();
setInfo(i);
} catch (e) {
console.debug(e);
}
}
function status() {
if (!info) return;
return (
<>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">{info?.name}</div>
</div>
{info?.summary && (
<div>
<p>Summary</p>
<div className="paper">{info?.summary}</div>
</div>
)}
{info?.viewers && (
<div>
<p>Viewers</p>
<div className="paper">{info?.viewers}</div>
</div>
)}
{info?.version && (
<div>
<p>Version</p>
<div className="paper">{info?.version}</div>
</div>
)}
<div>
<DefaultButton
onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/");
}}>
Save
</DefaultButton>
</div>
</>
);
}
return (
<div className="owncast-config">
<div className="flex flex-col gap-3">
<div>
<p>Owncast instance url</p>
<div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
</div>
</div>
<div>
<p>API token</p>
<div className="paper">
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
</div>
</div>
<DefaultButton onClick={tryConnect}>Connect</DefaultButton>
</div>
<div>{status()}</div>
</div>
);
}

View File

@ -1,89 +1,19 @@
import { FormattedMessage } from "react-intl";
import { ReactNode, useCallback, useMemo } from "react";
import type { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { VideoTile } from "@/element/video-tile";
import { useLogin } from "@/hooks/login";
import { getHost, getTagValues } from "@/utils";
import { useStreamsFeed } from "@/hooks/live-streams";
import VideoGrid from "@/element/video-grid";
import CategoryLink from "@/element/category-link";
import VideoGridSorted from "@/element/video-grid-sorted";
import { AllCategories } from "./category";
export function RootPage() {
const login = useLogin();
const { live, planned, ended } = useStreamsFeed();
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
const tags = login?.follows.tags ?? [];
const followsHost = useCallback(
(ev: NostrEvent) => {
return tags.find(t => t.at(1) === getHost(ev));
},
[tags]
);
const hashtags = getTagValues(tags, "t");
const following = live.filter(followsHost);
const liveNow = live.filter(e => !following.includes(e));
const hasFollowingLive = following.length > 0;
const plannedEvents = planned.filter(e => !mutedHosts.has(getHost(e))).filter(followsHost);
const endedEvents = ended.filter(e => !mutedHosts.has(getHost(e)));
const liveByHashtag = useMemo(() => {
return hashtags
.map(t => ({
tag: t,
live: live
.filter(e => !mutedHosts.has(getHost(e)))
.filter(e => {
const evTags = getTagValues(e.tags, "t");
return evTags.includes(t);
}),
}))
.filter(t => t.live.length > 0);
}, [live, hashtags]);
const streams = useStreamsFeed();
return (
<div className="flex flex-col gap-6">
{hasFollowingLive && (
<RootSection header={<FormattedMessage defaultMessage="Following" id="cPIKU2" />} items={following} />
)}
{!hasFollowingLive && (
<VideoGrid>
{live
.filter(e => !mutedHosts.has(getHost(e)))
.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</VideoGrid>
)}
{liveByHashtag.map(t => (
<RootSection header={`#${t.tag}`} items={t.live} />
))}
{hasFollowingLive && liveNow.length > 0 && (
<RootSection header={<FormattedMessage defaultMessage="Live" id="Dn82AL" />} items={liveNow} />
)}
{plannedEvents.length > 0 && (
<RootSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} />
)}
{endedEvents.length > 0 && (
<RootSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} />
)}
<div className="flex gap-4">
{AllCategories.filter(a => a.priority === 0).map(a => (
<CategoryLink key={a.id} {...a} />
))}
</div>
<VideoGridSorted evs={streams} />
</div>
);
}
function RootSection({ header, items }: { header: ReactNode; items: Array<TaggedNostrEvent> }) {
return (
<>
<h2 className="flex items-center gap-4">
{header}
<span className="h-[1px] bg-layer-1 w-full" />
</h2>
<VideoGrid>
{items.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</VideoGrid>
</>
);
}

47
src/pages/search.tsx Normal file
View File

@ -0,0 +1,47 @@
import VideoGrid from "@/element/video-grid";
import { VideoTile } from "@/element/video-tile";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
export const SearchRelays = [
"wss://relay.nostr.band",
"wss://search.nos.today",
"wss://relay.noswhere.com",
"wss://saltivka.org",
];
export default function SearchPage() {
const { term } = useParams();
const sub = useMemo(() => {
if (!term) return;
const rb = new RequestBuilder(`search:${term}`);
rb.withOptions({
skipDiff: true,
});
rb.withFilter().relay(SearchRelays).kinds([EventKind.LiveEvent]).search(term).limit(50);
return rb;
}, [term]);
const results = useRequestBuilder(sub);
return (
<div>
<h2 className="mb-4">
<FormattedMessage
defaultMessage="Search results: {term}"
id="A1zT+z"
values={{
term,
}}
/>
</h2>
<VideoGrid>
{results.map(a => (
<VideoTile ev={a} key={a.id} />
))}
</VideoGrid>
</div>
);
}

View File

@ -4,13 +4,17 @@ import { Outlet, useNavigate } from "react-router-dom";
const Tabs = [
{
name: <FormattedMessage defaultMessage="Account" id="TwyMau" />,
name: <FormattedMessage defaultMessage="Account" />,
path: "",
} as const,
},
{
name: <FormattedMessage defaultMessage="Stream" id="uYw2LD" />,
name: <FormattedMessage defaultMessage="Profile" />,
path: "profile",
},
{
name: <FormattedMessage defaultMessage="Stream" />,
path: "stream",
} as const,
},
];
export default function SettingsPage() {
const naviage = useNavigate();
@ -19,7 +23,7 @@ export default function SettingsPage() {
<div className="rounded-2xl p-3 md:w-[700px] mx-auto w-full">
<div className="flex flex-col gap-2">
<h1>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
<FormattedMessage defaultMessage="Settings" />
</h1>
<div className="flex flex-col gap-2">
<div className="flex gap-2">

View File

@ -0,0 +1,5 @@
import { ProfileEditor } from "@/element/profile-editor";
export default function ProfileSettings() {
return <ProfileEditor onClose={() => {}} />;
}

View File

@ -1,24 +1,19 @@
import { useParams } from "react-router-dom";
import { unwrap } from "@snort/shared";
import { VideoTile } from "@/element/video-tile";
import { FollowTagButton } from "@/element/follow-button";
import { useStreamsFeed } from "@/hooks/live-streams";
import VideoGridSorted from "@/element/video-grid-sorted";
export function TagPage() {
const { tag } = useParams();
const { live } = useStreamsFeed(tag);
const streams = useStreamsFeed(tag);
return (
<div className="tag-page">
<div className="tag-page-header">
<div>
<div className="flex items-center justify-between">
<h1>#{tag}</h1>
<FollowTagButton tag={unwrap(tag)} />
</div>
<div className="video-grid">
{live.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
<VideoGridSorted evs={streams} />
</div>
);
}