Init
This commit is contained in:
commit
9ca496b1f9
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
70
README.md
Normal 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
44
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
21
public/index.html
Normal file
21
public/index.html
Normal 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
BIN
public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
44
src/d.ts
Normal file
44
src/d.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
14
src/element/async-button.css
Normal file
14
src/element/async-button.css
Normal 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;
|
||||
}
|
40
src/element/async-button.tsx
Normal file
40
src/element/async-button.tsx
Normal 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
20
src/element/icon.tsx
Normal 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
69
src/element/live-chat.css
Normal 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
80
src/element/live-chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
src/element/live-video-player.tsx
Normal file
19
src/element/live-video-player.tsx
Normal 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
17
src/element/profile.css
Normal 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
12
src/element/profile.tsx
Normal 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
33
src/element/spinner.css
Normal 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
17
src/element/spinner.tsx
Normal 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;
|
21
src/element/video-tile.css
Normal file
21
src/element/video-tile.css
Normal 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;
|
||||
}
|
27
src/element/video-tile.tsx
Normal file
27
src/element/video-tile.tsx
Normal 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
31
src/hooks/event-feed.ts
Normal 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
20
src/hooks/live-chat.tsx
Normal 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
20
src/icons.svg
Normal 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
61
src/index.css
Normal 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
39
src/index.tsx
Normal 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
55
src/pages/layout.css
Normal 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
25
src/pages/layout.tsx
Normal 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
6
src/pages/root.css
Normal 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
33
src/pages/root.tsx
Normal 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
72
src/pages/stream-page.css
Normal 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
59
src/pages/stream-page.tsx
Normal 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
8
src/utils.ts
Normal 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
15
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user