1
0
Fork 0
This commit is contained in:
Kieran 2023-06-21 13:27:52 +01:00
commit 9ca496b1f9
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
36 changed files with 10610 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `yarn build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "stream_ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@snort/system-react": "^1.0.2",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"hls.js": "^1.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.13.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"typescript": "^5.1.3"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

21
public/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Nostr live streaming" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Nostr stream</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

44
src/d.ts Normal file
View File

@ -0,0 +1,44 @@
declare module "*.jpg" {
const value: unknown;
export default value;
}
declare module "*.svg" {
const value: unknown;
export default value;
}
declare module "*.webp" {
const value: string;
export default value;
}
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.css" {
const stylesheet: CSSStyleSheet;
export default stylesheet;
}
declare module "translations/*.json" {
const value: Record<string, string>;
export default value;
}
declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
}
export interface Section {
name: string;
value: string | Uint8Array | number | undefined;
}
}

View File

@ -0,0 +1,14 @@
button {
position: relative;
}
.spinner-wrapper {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,40 @@
import "./async-button.css";
import { useState } from "react";
import Spinner from "element/spinner";
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
onClick(e: React.MouseEvent): Promise<void> | void;
children?: React.ReactNode;
}
export default function AsyncButton(props: AsyncButtonProps) {
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
e.stopPropagation();
if (loading || props.disabled) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
const f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
}
} finally {
setLoading(false);
}
}
return (
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
{loading && (
<span className="spinner-wrapper">
<Spinner />
</span>
)}
</button>
);
}

20
src/element/icon.tsx Normal file
View File

@ -0,0 +1,20 @@
import { MouseEventHandler } from "react";
import IconsSvg from "icons.svg";
type Props = {
name: string;
size?: number;
className?: string;
onClick?: MouseEventHandler<SVGSVGElement>;
};
export function Icon(props: Props) {
const size = props.size || 20;
const href = `${IconsSvg}#` + props.name;
return (
<svg width={size} height={size} className={props.className} onClick={props.onClick}>
<use href={href} />
</svg>
);
};

69
src/element/live-chat.css Normal file
View File

@ -0,0 +1,69 @@
.live-chat {
height: calc(100vh - 72px - 96px);
display: flex;
flex-direction: column;
padding: 24px 16px 8px 24px;
border: 1px solid #171717;
border-radius: 24px;
gap: 16px;
}
.live-chat>div:nth-child(1) {
font-weight: 600;
font-size: 24px;
line-height: 30px;
padding: 0px 0px 16px;
}
.live-chat>div:nth-child(2) {
flex-grow: 1;
display: flex;
gap: 12px;
flex-direction: column-reverse;
overflow-y: auto;
overflow-x: hidden;
}
.live-chat>div:nth-child(3) {
display: flex;
gap: 10px;
padding: 6px 0;
}
.live-chat .write-message {
background: #171717;
border-radius: 16px;
padding: 8px 16px;
display: flex;
align-items: center;
height: 32px;
flex-grow: 1;
}
.live-chat .write-message input {
background: unset;
border: unset;
outline: unset;
color: inherit;
font: inherit;
flex-grow: 1;
}
.live-chat .profile {
gap: 8px;
font-weight: 600;
font-size: 15px;
float: left;
}
.live-chat .profile img {
width: 24px;
height: 24px;
}
.live-chat .message>span {
font-weight: 400;
font-size: 15px;
line-height: 24px;
margin-left: 8px;
}

80
src/element/live-chat.tsx Normal file
View File

@ -0,0 +1,80 @@
import "./live-chat.css";
import { EventKind, NostrLink, TaggedRawEvent, EventPublisher } from "@snort/system";
import { useState } from "react";
import { System } from "index";
import { useLiveChatFeed } from "hooks/live-chat";
import AsyncButton from "./async-button";
import { Profile } from "./profile";
import { Icon } from "./icon";
import Spinner from "./spinner";
export function LiveChat({ link }: { link: NostrLink }) {
const [chat, setChat] = useState("");
const messages = useLiveChatFeed(link);
async function sendChatMessage() {
const pub = await EventPublisher.nip7();
if (chat.length > 1) {
const reply = await pub?.generic(eb => {
return eb
.kind(1311 as EventKind)
.content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
.processContent();
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
setChat("");
}
}
return (
<div className="live-chat">
<div>
Stream Chat
</div>
<div>
{[...(messages.data ?? [])]
.sort((a, b) => b.created_at - a.created_at)
.map(a => (
<ChatMessage ev={a} key={a.id} />
))}
{messages.data === undefined && <Spinner />}
</div>
<div>
<div className="write-message">
<input
type="text"
autoFocus={false}
onChange={v => setChat(v.target.value)}
value={chat}
placeholder="Message"
onKeyDown={async e => {
if (e.code === "Enter") {
e.preventDefault();
await sendChatMessage();
}
}}
/>
<Icon name="message" size={15} />
</div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send
</AsyncButton>
</div>
</div>
);
}
function ChatMessage({ ev }: { ev: TaggedRawEvent }) {
return (
<div className="message">
<Profile pubkey={ev.pubkey} />
<span>
{ev.content}
</span>
</div>
);
}

View File

@ -0,0 +1,19 @@
import Hls from "hls.js";
import { HTMLProps, useEffect, useRef } from "react";
export function LiveVideoPlayer(props: HTMLProps<HTMLVideoElement> & { stream?: string }) {
const video = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (props.stream && video.current && !video.current.src && Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(props.stream);
hls.attachMedia(video.current);
return () => hls.destroy();
}
}, [video, props]);
return (
<div>
<video ref={video} {...props} controls={true} />
</div>
);
}

17
src/element/profile.css Normal file
View File

@ -0,0 +1,17 @@
.profile {
display: flex;
align-items: center;
gap: 12px;
font-weight: 500;
font-size: 16px;
line-height: 20px;
}
.profile img {
width: 40px;
height: 40px;
border-radius: 100%;
background: #A7A7A7;
border: unset;
outline: unset;
}

12
src/element/profile.tsx Normal file
View File

@ -0,0 +1,12 @@
import "./profile.css";
import { useUserProfile } from "@snort/system-react";
import { System } from "index";
export function Profile({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(System, pubkey);
return <div className="profile">
<img src={profile?.picture} />
{profile?.display_name ?? profile?.name}
</div>
}

33
src/element/spinner.css Normal file
View File

@ -0,0 +1,33 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

17
src/element/spinner.tsx Normal file
View File

@ -0,0 +1,17 @@
import "./spinner.css";
export interface IconProps {
className?: string;
width?: number;
height?: number;
}
const Spinner = (props: IconProps) => (
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}>
<g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g>
</svg>
);
export default Spinner;

View File

@ -0,0 +1,21 @@
.video-tile {}
.video-tile>div:nth-child(1) {
border-radius: 16px;
width: 100%;
aspect-ratio: 16 / 10;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.video-tile h3 {
font-size: 20px;
line-height: 25px;
margin: 16px 0;
}
.video-tile .pill {
float: right;
margin: 16px 20px 0 0;
}

View File

@ -0,0 +1,27 @@
import { Link } from "react-router-dom";
import { Profile } from "./profile";
import "./video-tile.css";
import { NostrEvent, encodeTLV, NostrPrefix } from "@snort/system";
export function VideoTile({ ev }: { ev: NostrEvent }) {
const id = ev.tags.find(a => a[0] === "d")?.[1]!;
const title = ev.tags.find(a => a[0] === "title")?.[1];
const image = ev.tags.find(a => a[0] === "image")?.[1];
const status = ev.tags.find(a => a[0] === "status")?.[1];
const isLive = status === "live";
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
return <Link to={`/live/${link}`} className="video-tile">
<div style={{
backgroundImage: `url(${image})`
}}>
<span className={`pill${isLive ? " live" : ""}`}>
{status}
</span>
</div>
<h3>{title}</h3>
<div>
<Profile pubkey={ev.pubkey} />
</div>
</Link>
}

31
src/hooks/event-feed.ts Normal file
View File

@ -0,0 +1,31 @@
import { useMemo } from "react";
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export default function useEventFeed(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
if (link.type === NostrPrefix.Address) {
const f = b.withFilter().tag("d", [link.id]);
if (link.author) {
f.authors([link.author]);
}
if (link.kind) {
f.kinds([link.kind]);
}
} else {
const f = b.withFilter().ids([link.id]);
if (link.relays) {
link.relays.slice(0, 2).forEach(r => f.relay(r));
}
if (link.author) {
f.authors([link.author]);
}
}
return b;
}, [link]);
return useRequestBuilder<ReplaceableNoteStore>(System, ReplaceableNoteStore, sub);
}

20
src/hooks/live-chat.tsx Normal file
View File

@ -0,0 +1,20 @@
import { NostrLink, RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { useMemo } from "react";
export function useLiveChatFeed(link: NostrLink) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
rb.withOptions({
leaveOpen: true,
});
rb.withFilter()
.kinds([EventKind.ZapReceipt, 1311 as EventKind])
.tag("a", [`${link.kind}:${link.author}:${link.id}`])
.limit(100);
return rb;
}, [link]);
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
}

20
src/icons.svg Normal file
View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="zap" viewBox="0 0 16 20" fill="none">
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="search" viewBox="0 0 20 21" fill="none">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="logout" viewBox="0 0 22 20" fill="none">
<path d="M17 6L21 10M21 10L17 14M21 10H8M14 2.20404C12.7252 1.43827 11.2452 1 9.66667 1C4.8802 1 1 5.02944 1 10C1 14.9706 4.8802 19 9.66667 19C11.2452 19 12.7252 18.5617 14 17.796" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="message" viewBox="0 0 18 16" fill="none">
<path d="M7.75036 8.00004H3.16702M3.09648 8.24296L1.15071 14.0552C0.997847 14.5118 0.921417 14.7401 0.976267 14.8807C1.0239 15.0028 1.1262 15.0954 1.25244 15.1306C1.3978 15.1712 1.61736 15.0724 2.05647 14.8748L15.9827 8.60799C16.4113 8.41512 16.6256 8.31868 16.6918 8.18471C16.7494 8.06832 16.7494 7.93176 16.6918 7.81537C16.6256 7.6814 16.4113 7.58497 15.9827 7.39209L2.05161 1.12314C1.61383 0.926139 1.39493 0.827637 1.24971 0.868044C1.1236 0.903136 1.0213 0.995457 0.973507 1.11733C0.91847 1.25766 0.994084 1.48547 1.14531 1.9411L3.09702 7.82131C3.12299 7.89957 3.13598 7.9387 3.14111 7.97871C3.14565 8.01422 3.14561 8.05017 3.14097 8.08567C3.13574 8.12567 3.12265 8.16477 3.09648 8.24296Z" stroke="currentColor" stroke-opacity="0.5" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="login" viewBox="0 0 18 18" fill="none">
<path d="M4 13.1667C4 13.4594 4 13.6058 4.01306 13.7331C4.12146 14.7895 4.8855 15.6622 5.91838 15.9093C6.04279 15.939 6.18792 15.9584 6.47807 15.9971L11.9713 16.7295C13.535 16.938 14.3169 17.0423 14.9237 16.801C15.4565 16.5891 15.9002 16.2006 16.1806 15.7005C16.5 15.1309 16.5 14.3421 16.5 12.7646V5.23541C16.5 3.65787 16.5 2.8691 16.1806 2.2995C15.9002 1.7994 15.4565 1.41088 14.9237 1.19904C14.3169 0.957756 13.535 1.062 11.9713 1.2705L6.47807 2.00293C6.18788 2.04162 6.04279 2.06097 5.91838 2.09073C4.8855 2.33781 4.12145 3.21049 4.01306 4.26696C4 4.39421 4 4.54059 4 4.83334M9 5.66668L12.3333 9.00001M12.3333 9.00001L9 12.3333M12.3333 9.00001H1.5" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

61
src/index.css Normal file
View File

@ -0,0 +1,61 @@
body {
margin: 0;
font-family: 'Outfit', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0A0A0A;
color: white;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
color: unset;
text-decoration: unset;
}
.flex {
display: flex;
}
.f-grow {
flex-grow: 1;
}
.pill {
background: #171717;
padding: 4px 8px;
border-radius: 9px;
font-weight: 700;
font-size: 14px;
line-height: 18px;
text-transform: capitalize;
}
.pill.live {
background: #F838D9;
color: white;
}
.g24 {
gap: 24px;
}
.btn-border {
border: 1px solid transparent;
color: inherit;
background: linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #2BD9FF 0%, #F838D9 100%) border-box;
}
.btn {
cursor: pointer;
font-weight: 700;
font-size: 16px;
line-height: 20px;
padding: 8px 16px;
border-radius: 16px;
}

39
src/index.tsx Normal file
View File

@ -0,0 +1,39 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { NostrSystem } from "@snort/system";
import './index.css';
import { RootPage } from './pages/root';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { LayoutPage } from 'pages/layout';
import { StreamPage } from 'pages/stream-page';
export const System = new NostrSystem({
});
[
"wss://relay.snort.social",
"wss://nos.lol"
].forEach(r => System.ConnectToRelay(r, { read: true, write: true }));
const router = createBrowserRouter([
{
element: <LayoutPage />,
children: [
{
path: "/",
element: <RootPage />
},
{
path: "/live/:id",
element: <StreamPage />
}
]
}
])
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

55
src/pages/layout.css Normal file
View File

@ -0,0 +1,55 @@
header {
display: grid;
grid-template-columns: min-content min-content auto;
gap: 24px;
align-items: center;
padding: 24px 40px 0 40px;
}
header>div:nth-child(1) {
background: #171717;
border-radius: 16px;
width: 48px;
height: 48px;
font-weight: bold;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
header>div:nth-child(2) {
background: #171717;
border-radius: 16px;
padding: 8px 16px;
min-width: 300px;
display: flex;
align-items: center;
height: 32px;
}
header>div:nth-child(3) {
justify-self: end;
}
header input[type="text"] {
border: unset;
background-color: unset;
color: inherit;
width: 100%;
font-size: 16px;
font-weight: 500;
outline: none;
}
header input[type="text"]:active {
border: unset;
}
header button {
height: 48px;
display: flex;
align-items: center;
gap: 8px;
}

25
src/pages/layout.tsx Normal file
View File

@ -0,0 +1,25 @@
import { Icon } from "element/icon";
import "./layout.css";
import { Outlet, useNavigate } from "react-router-dom";
export function LayoutPage() {
const navigate = useNavigate();
return <>
<header>
<div onClick={() => navigate("/")}>
S
</div>
<div>
<input type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>
<div>
<button type="button" className="btn btn-border">
Login
<Icon name="login" />
</button>
</div>
</header>
<Outlet />
</>
}

6
src/pages/root.css Normal file
View File

@ -0,0 +1,6 @@
.video-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
padding: 40px;
}

33
src/pages/root.tsx Normal file
View File

@ -0,0 +1,33 @@
import "./root.css";
import { useMemo } from "react";
import { EventKind, ParameterizedReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "..";
import { VideoTile } from "../element/video-tile";
import { findTag } from "utils";
export function RootPage() {
const rb = new RequestBuilder("root");
rb.withFilter()
.kinds([30_311 as EventKind]);
const feed = useRequestBuilder<ParameterizedReplaceableNoteStore>(System, ParameterizedReplaceableNoteStore, rb);
const feedSorted = useMemo(() => {
if (feed.data) {
return [...feed.data].sort((a, b) => {
const aStatus = findTag(a, "status")!;
const bStatus = findTag(b, "status")!;
if (aStatus === bStatus) {
return b.created_at - a.created_at;
} else {
return aStatus === "live" ? -1 : 1;
}
});
}
return [];
}, [feed.data])
return <div className="video-grid">
{feedSorted.map(e => <VideoTile ev={e} key={e.id} />)}
</div>
}

72
src/pages/stream-page.css Normal file
View File

@ -0,0 +1,72 @@
.live-page {
display: grid;
height: calc(100vh - 72px - 32px - 32px);
padding: 32px 40px;
grid-template-columns: auto 376px;
gap: 32px;
}
@media (min-width: 2000px) {
.live-page {
grid-template-columns: auto 450px;
}
}
.live-page>div:nth-child(1) {
overflow-y: auto;
}
.live-page video {
width: 100%;
aspect-ratio: 16/9;
}
.live-page .pill {
font-weight: 700;
font-size: 14px;
line-height: 18px;
color: #A7A7A7;
}
.live-page .pill.live {
color: inherit;
}
.live-page .info {
margin-top: 32px;
}
.live-page .info h1 {
margin: 0 0 8px 0;
font-weight: 600;
font-size: 28px;
line-height: 35px;
}
.live-page .info p {
margin: 0 0 12px 0;
}
.live-page .tags {
display: flex;
gap: 8px;
}
button.zap {
background: linear-gradient(94.73deg, #2BD9FF 0%, #F838D9 100%);
border-radius: 16px;
padding: 12px 16px;
font-weight: 700;
font-size: 16px;
line-height: 20px;
color: white;
outline: none;
border: none;
cursor: pointer;
}
button.zap>span {
display: flex;
align-items: center;
gap: 8px;
}

59
src/pages/stream-page.tsx Normal file
View File

@ -0,0 +1,59 @@
import "./stream-page.css";
import { parseNostrLink } from "@snort/system";
import { useParams } from "react-router-dom";
import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player";
import { findTag } from "utils";
import { Profile } from "element/profile";
import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button";
import { Icon } from "element/icon";
export function StreamPage() {
const params = useParams();
const link = parseNostrLink(params.id!);
const thisEvent = useEventFeed(link);
const stream = findTag(thisEvent.data, "streaming");
const status = findTag(thisEvent.data, "status");
const isLive = status === "live";
return (
<div className="live-page">
<div>
<LiveVideoPlayer stream={stream} autoPlay={true} />
<div className="flex info">
<div className="f-grow">
<h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p>
<div className="tags">
<span className={`pill${isLive ? " live" : ""}`}>
{status}
</span>
{thisEvent.data?.tags
.filter(a => a[0] === "t")
.map(a => a[1])
.map(a => (
<span className="pill" key={a}>
{a}
</span>
))}
</div>
</div>
<div>
<div className="flex g24">
<Profile
pubkey={thisEvent.data?.pubkey ?? ""}
/>
<AsyncButton onClick={() => { }} className="zap">
Zap
<Icon name="zap" size={16} />
</AsyncButton>
</div>
</div>
</div>
</div>
<LiveChat link={link} />
</div>
);
}

8
src/utils.ts Normal file
View File

@ -0,0 +1,8 @@
import { NostrEvent } from "@snort/system";
export function findTag(e: NostrEvent | undefined, tag: string) {
const maybeTag = e?.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es2020",
"module": "es2020",
"jsx": "react-jsx",
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"allowJs": true
}
}

9567
yarn.lock Normal file

File diff suppressed because it is too large Load Diff