feat: new design progress

This commit is contained in:
kieran 2024-05-17 23:17:15 +01:00
parent 1b5fa7a5ca
commit 75c90e9dc4
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
36 changed files with 675 additions and 375 deletions

View File

@ -11,6 +11,8 @@ export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind;
export const VIDEO_KIND = 34_235 as EventKind;
export const DAY = 60 * 60 * 24;
export const WEEK = 7 * DAY;
@ -18,6 +20,7 @@ export enum StreamState {
Live = "live",
Ended = "ended",
Planned = "planned",
VOD = "vod",
}
export const defaultRelays = {

View File

@ -1,25 +0,0 @@
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Icon } from "./icon";
import classNames from "classnames";
export default function CategoryLink({
id,
name,
icon,
className,
}: {
id: string;
name: ReactNode;
icon: string;
className?: string;
}) {
return (
<Link to={`/category/${id}`} key={id} className={classNames("text-xl font-semibold rounded-xl", className)}>
<div className="min-w-[12rem] flex items-center justify-between gap-4 px-6 py-2 whitespace-nowrap">
{name}
<Icon name={icon} size={20} />
</div>
</Link>
);
}

View File

@ -0,0 +1,31 @@
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { Icon } from "../icon";
import classNames from "classnames";
export default function CategoryLink({
id,
name,
icon,
className,
}: {
id: string;
name: ReactNode;
icon: string;
className?: string;
}) {
return (
<Link
to={`/category/${id}`}
key={id}
className={classNames(
"text-lg font-semibold rounded-xl border border-layer-2 border-2 hover:bg-layer-2",
className
)}>
<div className="flex items-center gap-2 px-2 py-1 whitespace-nowrap">
<Icon name={icon} size={24} />
{name}
</div>
</Link>
);
}

View File

@ -43,7 +43,7 @@ export function ChatMessage({
}) {
const system = useContext(SnortContext);
const ref = useRef<HTMLDivElement | null>(null);
const inView = useIntersectionObserver(ref, {
const inView = useIntersectionObserver({
freezeOnceVisible: true,
});
const emojiRef = useRef(null);
@ -136,9 +136,7 @@ export function ChatMessage({
<Icon name="signal" size={16} />
) : (
awardedBadges.map(badge => {
return (
<img key={badge.name} className="badge-icon" src={badge.thumb || badge.image} alt={badge.name} />
);
return <img key={badge.name} className="h-4" src={badge.thumb || badge.image} alt={badge.name} />;
})
)
}

30
src/element/clip-tile.tsx Normal file
View File

@ -0,0 +1,30 @@
import { findTag, profileLink } from "@/utils";
import { NostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { getName } from "./profile";
export function ClipTile({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);
const r = findTag(ev, "r");
const title = findTag(ev, "title");
return (
<div className="h-full flex flex-col gap-4 bg-layer-1 rounded-xl px-3 py-2">
<span>
<FormattedMessage
defaultMessage="Clip by {name}"
values={{
name: (
<Link to={profileLink(profile, ev.pubkey)} className="font-medium text-primary">
{getName(ev.pubkey, profile)}
</Link>
),
}}
/>
</span>
<video src={r} controls />
{title}
</div>
);
}

View File

@ -10,9 +10,7 @@ import { formatSats } from "@/number";
import usePreviousValue from "@/hooks/usePreviousValue";
import { SendZapsDialog } from "./send-zap";
import { getName } from "./profile";
import { Icon } from "./icon";
import { useZaps } from "@/hooks/zaps";
import classNames from "classnames";
export function Goal({ ev, confetti }: { ev: NostrEvent; confetti?: boolean }) {
const profile = useUserProfile(ev.pubkey);
@ -39,27 +37,20 @@ export function Goal({ ev, confetti }: { ev: NostrEvent; confetti?: boolean }) {
const previousValue = usePreviousValue(isFinished);
const goalContent = (
<div className="flex flex-col gap-2 cursor-pointer">
<div className="flex flex-col cursor-pointer">
{ev.content.length > 0 && <p>{ev.content}</p>}
<div className="relative h-10">
<div className="absolute bg-layer-2 h-3 rounded-full my-4 w-full"></div>
<div
className="absolute bg-zap h-3 rounded-full text-xs font-medium my-4 leading-3 pl-2"
className="absolute bg-zap h-3 rounded-full text-xs font-medium my-4 leading-3 pl-1"
style={{
width: `${progress}%`,
}}>
{soFar > 0 ? formatSats(soFar) : ""}
</div>
<div className="absolute text-right text-xs right-10 font-medium my-4 leading-3">
<div className="absolute text-right text-xs right-1 font-medium my-4 leading-3">
<FormattedMessage defaultMessage="Goal: {amount}" id="QceMQZ" values={{ amount: formatSats(goalAmount) }} />
</div>
<div
className={classNames("absolute right-0 rounded-full p-2 my-1", {
"bg-zap": isFinished,
"bg-layer-2": !isFinished,
})}>
<Icon name="zap-filled" />
</div>
</div>
{isFinished && previousValue === false && (confetti ?? true) && (
<Confetti numberOfPieces={2100} recycle={false} />

View File

@ -47,7 +47,6 @@ export function LiveChat({
ev,
goal,
canWrite,
showHeader,
showTopZappers,
showGoal,
showScrollbar,
@ -59,7 +58,6 @@ export function LiveChat({
ev?: NostrEvent;
goal?: NostrEvent;
canWrite?: boolean;
showHeader?: boolean;
showTopZappers?: boolean;
showGoal?: boolean;
showScrollbar?: boolean;
@ -116,27 +114,11 @@ export function LiveChat({
}, [events, mutedPubkeys, hostMutedPubkeys]);
return (
<div className={classNames("flex flex-col gap-2", className)} style={height ? { height: `${height}px` } : {}}>
{(showHeader ?? true) && (
<div className={classNames("flex justify-between items-center")}>
<h2 className="py-4">
<FormattedMessage defaultMessage="Stream Chat" id="BGxpTN" />
</h2>
<Icon
name="link"
className="secondary"
size={32}
onClick={() => window.open(`/chat/${link.encode()}?chat=true`, "_blank", "popup,width=400,height=800")}
/>
</div>
)}
<div className={classNames("flex flex-col gap-1", className)} style={height ? { height: `${height}px` } : {}}>
{(showTopZappers ?? true) && reactions.zaps.length > 0 && (
<div className="py-2">
<h3>
<FormattedMessage defaultMessage="Top zappers" id="wzWWzV" />
</h3>
<div className="mt-1 flex gap-1 overflow-x-auto scrollbar-hidden">
<TopZappers zaps={reactions.zaps} className="border border-layer-1 rounded-full py-1 px-2" />
<div>
<div className="flex gap-1 overflow-x-auto scrollbar-hidden">
<TopZappers zaps={reactions.zaps} className="border border-layer-2 rounded-full py-1 px-2" />
</div>
</div>
)}

127
src/element/stream-info.tsx Normal file
View File

@ -0,0 +1,127 @@
import { StreamState } from "@/const";
import { useLogin } from "@/hooks/login";
import { formatSats } from "@/number";
import { getHost, extractStreamInfo, findTag } from "@/utils";
import { TaggedNostrEvent } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react";
import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { WarningButton } from "./buttons";
import { ClipButton } from "./clip-button";
import { FollowButton } from "./follow-button";
import GameInfoCard from "./game-info";
import { NewStreamDialog } from "./new-stream";
import { NotificationsButton } from "./notifications-button";
import Pill from "./pill";
import { Profile, getName } from "./profile";
import { SendZapsDialog } from "./send-zap";
import { ShareMenu } from "./share-menu";
import { StatePill } from "./state-pill";
import { StreamTimer } from "./stream-time";
import { Tags } from "./tags";
export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext);
const login = useLogin();
const navigate = useNavigate();
const host = getHost(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const { status, participants, title, summary, service, gameId, gameInfo } = extractStreamInfo(ev);
const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() {
const pub = login?.publisher();
if (pub && ev) {
const evDelete = await pub.delete(ev.id);
console.debug(evDelete);
await system.BroadcastEvent(evDelete);
navigate("/");
}
}
const viewers = Number(participants ?? "0");
return (
<>
<div className="flex gap-2 max-xl:flex-col max-xl:px-2">
<div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1>
{summary && <StreamSummary text={summary} />}
<div className="flex gap-2 flex-wrap">
<StatePill state={status as StreamState} />
<Pill>
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(viewers) }} />
</Pill>
{status === StreamState.Live && (
<Pill>
<StreamTimer ev={ev} />
</Pill>
)}
{gameId && gameInfo && (
<Pill>
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
</Pill>
)}
{ev && <Tags ev={ev} />}
</div>
{isMine && (
<div className="flex gap-4">
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
<WarningButton onClick={deleteStream}>
<FormattedMessage defaultMessage="Delete" />
</WarningButton>
</div>
)}
</div>
<div className="flex justify-between sm:gap-4 max-sm:gap-2 flex-wrap max-md:flex-col lg:items-center">
<Profile pubkey={host ?? ""} />
<div className="flex gap-2">
<FollowButton pubkey={host} hideWhenFollowing={true} />
{ev && (
<>
<ShareMenu ev={ev} />
<ClipButton ev={ev} />
{service && <NotificationsButton host={host} service={service} />}
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
eTag={goal?.id}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
</div>
</>
);
}
function StreamSummary({ text }: { text: string }) {
const [expand, setExpand] = useState(false);
const cutOff = 100;
const shouldExpand = text.length > cutOff;
return (
<div className="whitespace-pre text-pretty">
{shouldExpand && !expand ? text.slice(0, cutOff) : text}
{shouldExpand && "... "}
{shouldExpand && (
<span
className="text-primary text-bold cursor-pointer"
onClick={() => {
setExpand(x => !x);
}}>
{expand && <FormattedMessage defaultMessage="Show Less" />}
{!expand && <FormattedMessage defaultMessage="Show More" />}
</span>
)}
</div>
);
}

View File

@ -11,6 +11,8 @@ import { Link } from "react-router-dom";
import Pill from "./pill";
import { CategoryZaps } from "./category/zaps";
import { StreamState } from "@/const";
import { useRecentClips } from "@/hooks/clips";
import { ClipTile } from "./clip-tile";
interface VideoGridSortedProps {
evs: Array<TaggedNostrEvent>;
@ -18,9 +20,17 @@ interface VideoGridSortedProps {
showEnded?: boolean;
showPlanned?: boolean;
showPopular?: boolean;
showRecentClips?: boolean;
}
export default function VideoGridSorted({ evs, showAll, showEnded, showPlanned, showPopular }: VideoGridSortedProps) {
export default function VideoGridSorted({
evs,
showAll,
showEnded,
showPlanned,
showPopular,
showRecentClips,
}: VideoGridSortedProps) {
const login = useLogin();
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
const tags = login?.follows.tags ?? [];
@ -71,6 +81,7 @@ export default function VideoGridSorted({ evs, showAll, showEnded, showPlanned,
<GridSection header={`#${t.tag}`} items={t.live} />
))}
{showPopular && <PopularCategories items={evs} />}
{showRecentClips && <RecentClips />}
{hasFollowingLive && liveNow.length > 0 && (
<GridSection header={<FormattedMessage defaultMessage="Live" id="Dn82AL" />} items={liveNow} />
)}
@ -170,3 +181,23 @@ function PopularCategories({ items }: { items: Array<TaggedNostrEvent> }) {
</>
);
}
function RecentClips() {
const clips = useRecentClips();
return (
<>
<div className="flex items-center gap-4">
<h3 className="whitespace-nowrap">
<FormattedMessage defaultMessage="Recent Clips" />
</h3>
<span className="h-[1px] bg-layer-1 w-full" />
</div>
<VideoGrid>
{clips.slice(0, 5).map(a => (
<ClipTile ev={a} key={a.id} />
))}
</VideoGrid>
</>
);
}

View File

@ -2,7 +2,7 @@ import { ReactNode } from "react";
export default function VideoGrid({ children }: { children: ReactNode }) {
return (
<div className="grid gap-8 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 3xl:grid-cols-8 items-start">
<div className="grid gap-5 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 3xl:grid-cols-8 items-start">
{children}
</div>
);

View File

@ -1,18 +1,19 @@
import { Link } from "react-router-dom";
import { Profile } from "./profile";
import { getName } from "./profile";
import { NostrEvent, NostrLink } from "@snort/system";
import { FormattedMessage } from "react-intl";
import { StatePill } from "./state-pill";
import { extractStreamInfo, getHost } from "@/utils";
import { extractStreamInfo, getHost, profileLink } from "@/utils";
import { formatSats } from "@/number";
import { Tags } from "./tags";
import { StreamState } from "@/const";
import Pill from "./pill";
import classNames from "classnames";
import Logo from "./logo";
import { useContentWarning } from "./nsfw";
import { useState } from "react";
import { Avatar } from "./avatar";
import { useUserProfile } from "@snort/system-react";
export function VideoTile({
ev,
@ -25,6 +26,7 @@ export function VideoTile({
}) {
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
const host = getHost(ev);
const hostProfile = useUserProfile(host);
const isGrownUp = useContentWarning();
const link = NostrLink.fromEvent(ev);
@ -63,12 +65,18 @@ export function VideoTile({
)}
</span>
</div>
<h3>{title}</h3>
</Link>
<div className="flex gap-1 flex-wrap">
<Tags ev={ev} max={3} />
<div className="flex gap-3">
{showAuthor && (
<Link to={profileLink(hostProfile, host)}>
<Avatar pubkey={host} user={hostProfile} />
</Link>
)}
<div className="flex flex-col">
<span className="font-medium">{title}</span>
{showAuthor && <span className="text-layer-4">{getName(host, hostProfile)}</span>}
</div>
</div>
{showAuthor && <Profile pubkey={host} />}
</div>
);
}

View File

@ -3,7 +3,7 @@ import { NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
export function useClips(link?: NostrLink, limit?: number) {
export function useProfileClips(link?: NostrLink, limit?: number) {
const sub = useMemo(() => {
if (!link) return;
const rb = new RequestBuilder(`clips:${link.id.slice(0, 12)}`);
@ -13,3 +13,13 @@ export function useClips(link?: NostrLink, limit?: number) {
return useRequestBuilder(sub);
}
export function useRecentClips(limit?: number) {
const sub = useMemo(() => {
const rb = new RequestBuilder("recent-clips");
rb.withFilter().kinds([LIVE_STREAM_CLIP]).limit(limit);
return rb;
}, [limit]);
return useRequestBuilder(sub);
}

View File

@ -12,6 +12,7 @@ export default function useGameInfo(gameId?: string, gameInfo?: GameInfo) {
const ix = AllCategories.find(a => a.id === id || a.id === gameId);
if (ix) {
setGame({
...ix,
id: `internal:${ix.id}`,
name: ix.name,
genres: ix.tags,

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { EventKind, NostrLink, NoteCollection, RequestBuilder, parseZap } from "@snort/system";
import { EventKind, NostrLink, RequestBuilder, parseZap } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
export function useZaps(link?: NostrLink, leaveOpen = false) {
@ -15,10 +15,12 @@ export function useZaps(link?: NostrLink, leaveOpen = false) {
const zaps = useRequestBuilder(sub);
return (
[...(zaps ?? [])]
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
.map(ev => parseZap(ev))
.filter(z => z && z.valid) ?? []
return useMemo(
() =>
[...(zaps ?? [])]
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
.map(ev => parseZap(ev))
.filter(z => z && z.valid) ?? [],
[zaps.length]
);
}

View File

@ -125,8 +125,8 @@
<symbol id="bell-off" viewBox="0 0 24 24" fill="none">
<path d="M8.63306 3.03371C9.61959 2.3649 10.791 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 10.1008 18.2702 11.7512 18.6484 13.0324M6.25867 6.25724C6.08866 6.81726 6 7.40406 6 8C6 11.0902 5.22047 13.206 4.34966 14.6054C3.61513 15.7859 3.24786 16.3761 3.26132 16.5408C3.27624 16.7231 3.31486 16.7926 3.46178 16.9016C3.59446 17 4.19259 17 5.38885 17H17M9.35418 21C10.0593 21.6224 10.9856 22 12 22C13.0144 22 13.9407 21.6224 14.6458 21M21 21L3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="gaming-pad" viewBox="0 0 37 28" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5099 0.666992H16.1997C14.0717 0.66698 12.3762 0.66697 11.0018 0.775829C9.59216 0.887489 8.38591 1.12102 7.26586 1.66186C5.45183 2.53779 3.94463 3.94117 2.94168 5.68818C2.32242 6.76685 2.00354 7.9534 1.79173 9.35154C1.58523 10.7146 1.46444 12.4058 1.31284 14.5284L0.897789 20.3391C0.627814 24.1187 3.6213 27.3337 7.41057 27.3337C9.34835 27.3337 11.186 26.4729 12.4266 24.9843L13.0518 24.234C13.6608 23.5032 13.8439 23.2924 14.0355 23.1292C14.4906 22.7416 15.0415 22.4836 15.6306 22.3821C15.8787 22.3393 16.1578 22.3337 17.1091 22.3337H20.6005C21.5518 22.3337 21.8309 22.3393 22.079 22.3821C22.6681 22.4836 23.219 22.7416 23.6741 23.1292C23.8657 23.2924 24.0488 23.5032 24.6578 24.234L25.283 24.9843C26.5236 26.4729 28.3613 27.3337 30.299 27.3337C34.0883 27.3337 37.0818 24.1187 36.8118 20.3391L36.3968 14.5283C36.2452 12.4057 36.1244 10.7146 35.9179 9.35154C35.7061 7.9534 35.3872 6.76685 34.7679 5.68818C33.765 3.94117 32.2578 2.53779 30.4438 1.66186C29.3237 1.12102 28.1175 0.887489 26.7078 0.775829C25.3335 0.66697 23.638 0.66698 21.5099 0.666992ZM13.8549 9.00033C13.8549 8.07985 13.1087 7.33366 12.1882 7.33366C11.2678 7.33366 10.5216 8.07985 10.5216 9.00033V10.667H8.8549C7.93442 10.667 7.18823 11.4132 7.18823 12.3337C7.18823 13.2541 7.93442 14.0003 8.8549 14.0003H10.5216V15.667C10.5216 16.5875 11.2678 17.3337 12.1882 17.3337C13.1087 17.3337 13.8549 16.5875 13.8549 15.667V14.0003H15.5216C16.442 14.0003 17.1882 13.2541 17.1882 12.3337C17.1882 11.4132 16.442 10.667 15.5216 10.667H13.8549V9.00033ZM22.1882 14.0003C22.1882 13.0799 22.9344 12.3337 23.8549 12.3337H23.8716C24.792 12.3337 25.5382 13.0799 25.5382 14.0003C25.5382 14.9208 24.792 15.667 23.8716 15.667H23.8549C22.9344 15.667 22.1882 14.9208 22.1882 14.0003ZM27.1882 10.667C27.1882 9.74652 27.9344 9.00033 28.8549 9.00033H28.8716C29.792 9.00033 30.5382 9.74652 30.5382 10.667C30.5382 11.5875 29.792 12.3337 28.8716 12.3337H28.8549C27.9344 12.3337 27.1882 11.5875 27.1882 10.667Z" fill="currentColor"/>
<symbol id="gaming-pad" viewBox="0 0 22 16" fill="none">
<path d="M4.99989 7H8.99989M6.99989 5V9M13.9999 8H14.0099M16.9999 6H17.0099M9.44885 1H12.5509C15.1758 1 16.4883 1 17.5184 1.49743C18.4254 1.9354 19.179 2.63709 19.6805 3.51059C20.2501 4.5027 20.3436 5.81181 20.5306 8.43002L20.7766 11.8745C20.8973 13.5634 19.5597 15 17.8664 15C17.0005 15 16.1794 14.6154 15.6251 13.9502L15.2499 13.5C14.9068 13.0882 14.7351 12.8823 14.5398 12.7159C14.1302 12.3672 13.6344 12.1349 13.1043 12.0436C12.8514 12 12.5834 12 12.0473 12H9.95245C9.41642 12 9.14841 12 8.89553 12.0436C8.36539 12.1349 7.86957 12.3672 7.46 12.7159C7.26463 12.8823 7.09305 13.0882 6.74989 13.5L6.37473 13.9502C5.8204 14.6154 4.99924 15 4.13335 15C2.44013 15 1.1025 13.5634 1.22314 11.8745L1.46918 8.43002C1.65619 5.81181 1.7497 4.5027 2.31926 3.51059C2.82074 2.63709 3.57433 1.9354 4.48135 1.49743C5.51151 1 6.82396 1 9.44885 1Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="music" viewBox="0 0 34 34" fill="none">
<path d="M30.5657 3.9984C30.3354 4.01658 30.0182 4.06796 29.4548 4.16186L14.7881 6.60631C14.3675 6.6764 14.1433 6.7149 13.9836 6.75408L13.9673 6.75814L13.966 6.77482C13.9536 6.93879 13.9525 7.16636 13.9525 7.5927V27.0004L13.9525 27.0064C13.9492 30.6855 10.9657 33.667 7.28581 33.667C3.60391 33.667 0.619141 30.6822 0.619141 27.0003C0.619141 23.3184 3.60391 20.3337 7.28581 20.3337C8.5001 20.3337 9.63857 20.6583 10.6191 21.2255L10.6191 7.54533C10.6191 7.18514 10.6191 6.82875 10.6421 6.52345C10.6678 6.18388 10.7282 5.78915 10.9112 5.38122C11.1686 4.80743 11.583 4.31816 12.1067 3.96993C12.479 3.72235 12.8584 3.59795 13.1892 3.51679C13.4865 3.44383 13.8381 3.38528 14.1934 3.32611L28.9648 0.864195C29.4487 0.783497 29.9113 0.706348 30.3035 0.675395C30.7269 0.641978 31.2436 0.643885 31.7798 0.843043C32.5104 1.11441 33.1228 1.63321 33.5106 2.30926C33.7952 2.80542 33.882 3.31476 33.9186 3.7379C33.9526 4.12979 33.9525 4.59869 33.9525 5.08915L33.9525 23.667C33.9525 23.7598 33.9449 23.8507 33.9303 23.9393C33.7875 27.495 30.8598 30.3337 27.2691 30.3337C23.5872 30.3337 20.6025 27.3489 20.6025 23.667C20.6025 19.9851 23.5872 17.0003 27.2691 17.0003C28.4903 17.0003 29.6348 17.3287 30.6191 17.9019V5.14826C30.6191 4.57708 30.6177 4.25572 30.5977 4.02551L30.595 3.99626L30.5657 3.9984Z" fill="currentColor"/>
@ -153,8 +153,24 @@
<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"/>
<svg id="upload" viewBox="0 0 20 20" fill="none" >
<path d="M0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1C20 1.55228 19.5523 2 19 2H1C0.447715 2 0 1.55228 0 1Z" fill="currentColor"/>
<path d="M3.29289 11.7071C3.68342 12.0976 4.31658 12.0976 4.70711 11.7071L9 7.41421L9 19C9 19.5523 9.44772 20 10 20C10.5523 20 11 19.5523 11 19L11 7.41421L15.2929 11.7071C15.6834 12.0976 16.3166 12.0976 16.7071 11.7071C17.0976 11.3166 17.0976 10.6834 16.7071 10.2929L10.7071 4.29289C10.3166 3.90237 9.68342 3.90237 9.29289 4.29289L3.29289 10.2929C2.90237 10.6834 2.90237 11.3166 3.29289 11.7071Z" fill="currentColor"/>
</svg>
<symbol id="hamburger" viewBox="0 0 20 14" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7C0 6.44772 0.447715 6 1 6H19C19.5523 6 20 6.44772 20 7C20 7.55228 19.5523 8 19 8H1C0.447715 8 0 7.55228 0 7Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1C20 1.55228 19.5523 2 19 2H1C0.447715 2 0 1.55228 0 1Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13C0 12.4477 0.447715 12 1 12H19C19.5523 12 20 12.4477 20 13C20 13.5523 19.5523 14 19 14H1C0.447715 14 0 13.5523 0 13Z" fill="currentColor"/>
</symbol>
<symbol id="play-circle" viewBox="0 0 22 22" fill="none">
<path d="M11 21C16.5228 21 21 16.5228 21 11C21 5.47715 16.5228 1 11 1C5.47715 1 1 5.47715 1 11C1 16.5228 5.47715 21 11 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 7.96533C8.5 7.48805 8.5 7.24941 8.59974 7.11618C8.68666 7.00007 8.81971 6.92744 8.96438 6.9171C9.13038 6.90525 9.33112 7.03429 9.73261 7.29239L14.4532 10.3271C14.8016 10.551 14.9758 10.663 15.0359 10.8054C15.0885 10.9298 15.0885 11.0702 15.0359 11.1946C14.9758 11.337 14.8016 11.449 14.4532 11.6729L9.73261 14.7076C9.33112 14.9657 9.13038 15.0948 8.96438 15.0829C8.81971 15.0726 8.68666 14.9999 8.59974 14.8838C8.5 14.7506 8.5 14.512 8.5 14.0347V7.96533Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="grid" viewBox="0 0 20 20" fill="none">
<path d="M6.4 1H2.6C2.03995 1 1.75992 1 1.54601 1.10899C1.35785 1.20487 1.20487 1.35785 1.10899 1.54601C1 1.75992 1 2.03995 1 2.6V6.4C1 6.96005 1 7.24008 1.10899 7.45399C1.20487 7.64215 1.35785 7.79513 1.54601 7.89101C1.75992 8 2.03995 8 2.6 8H6.4C6.96005 8 7.24008 8 7.45399 7.89101C7.64215 7.79513 7.79513 7.64215 7.89101 7.45399C8 7.24008 8 6.96005 8 6.4V2.6C8 2.03995 8 1.75992 7.89101 1.54601C7.79513 1.35785 7.64215 1.20487 7.45399 1.10899C7.24008 1 6.96005 1 6.4 1Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.4 1H13.6C13.0399 1 12.7599 1 12.546 1.10899C12.3578 1.20487 12.2049 1.35785 12.109 1.54601C12 1.75992 12 2.03995 12 2.6V6.4C12 6.96005 12 7.24008 12.109 7.45399C12.2049 7.64215 12.3578 7.79513 12.546 7.89101C12.7599 8 13.0399 8 13.6 8H17.4C17.9601 8 18.2401 8 18.454 7.89101C18.6422 7.79513 18.7951 7.64215 18.891 7.45399C19 7.24008 19 6.96005 19 6.4V2.6C19 2.03995 19 1.75992 18.891 1.54601C18.7951 1.35785 18.6422 1.20487 18.454 1.10899C18.2401 1 17.9601 1 17.4 1Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.4 12H13.6C13.0399 12 12.7599 12 12.546 12.109C12.3578 12.2049 12.2049 12.3578 12.109 12.546C12 12.7599 12 13.0399 12 13.6V17.4C12 17.9601 12 18.2401 12.109 18.454C12.2049 18.6422 12.3578 18.7951 12.546 18.891C12.7599 19 13.0399 19 13.6 19H17.4C17.9601 19 18.2401 19 18.454 18.891C18.6422 18.7951 18.7951 18.6422 18.891 18.454C19 18.2401 19 17.9601 19 17.4V13.6C19 13.0399 19 12.7599 18.891 12.546C18.7951 12.3578 18.6422 12.2049 18.454 12.109C18.2401 12 17.9601 12 17.4 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.4 12H2.6C2.03995 12 1.75992 12 1.54601 12.109C1.35785 12.2049 1.20487 12.3578 1.10899 12.546C1 12.7599 1 13.0399 1 13.6V17.4C1 17.9601 1 18.2401 1.10899 18.454C1.20487 18.6422 1.35785 18.7951 1.54601 18.891C1.75992 19 2.03995 19 2.6 19H6.4C6.96005 19 7.24008 19 7.45399 18.891C7.64215 18.7951 7.79513 18.6422 7.89101 18.454C8 18.2401 8 17.9601 8 17.4V13.6C8 13.0399 8 12.7599 7.89101 12.546C7.79513 12.3578 7.64215 12.2049 7.45399 12.109C7.24008 12 6.96005 12 6.4 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -77,12 +77,12 @@ a {
height: inherit;
}
input[type="text"],
textarea,
input[type="datetime-local"],
input[type="password"],
input[type="number"],
select {
input[type="text"]:not(.reset),
textarea:not(.reset),
input[type="datetime-local"]:not(.reset),
input[type="password"]:not(.reset),
input[type="number"]:not(.reset),
select:not(.reset) {
@apply bg-layer-2 w-full font-medium px-4 py-2 rounded-xl;
}

View File

@ -12,7 +12,6 @@ import { RootPage } from "@/pages/root";
import { TagPage } from "@/pages/tag";
import { LayoutPage } from "@/pages/layout";
import { ProfilePage } from "@/pages/profile-page";
import { StreamPageHandler } from "@/pages/stream-page";
import { ChatPopout } from "@/pages/chat-popout";
import { defaultRelays } from "@/const";
import { CatchAllRoutePage } from "@/pages/catch-all";
@ -39,6 +38,10 @@ 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";
import { LayoutContextProvider } from "./pages/layout/context";
import { VideosPage } from "./pages/videos";
import { LinkHandler } from "./pages/link-handler";
import { UploadPage } from "./pages/upload";
const hasWasm = "WebAssembly" in globalThis;
const workerRelay = new WorkerRelayInterface(
@ -93,6 +96,18 @@ const router = createBrowserRouter([
path: "/",
element: <RootPage />,
},
{
path: "/streams",
element: <RootPage />,
},
{
path: "/videos",
element: <VideosPage />,
},
{
path: "/upload",
element: <UploadPage />,
},
{
path: "/t/:tag",
element: <TagPage />,
@ -103,7 +118,7 @@ const router = createBrowserRouter([
},
{
path: "/:id",
element: <StreamPageHandler />,
element: <LinkHandler />,
},
{
path: "/settings",
@ -207,7 +222,9 @@ root.render(
<React.StrictMode>
<SnortContext.Provider value={System}>
<IntlProvider>
<RouterProvider router={router} />
<LayoutContextProvider>
<RouterProvider router={router} />
</LayoutContextProvider>
</IntlProvider>
</SnortContext.Provider>
</React.StrictMode>

View File

@ -1,4 +1,4 @@
import CategoryLink from "@/element/category-link";
import CategoryLink from "@/element/category/category-link";
import { CategoryTile } from "@/element/category/category-tile";
import { CategoryZaps } from "@/element/category/zaps";
import VideoGridSorted from "@/element/video-grid-sorted";
@ -68,11 +68,10 @@ export const AllCategories = [
];
export default function Category() {
const { id } = useParams();
const params = useParams();
const id = params.id ?? AllCategories[0].id;
const sub = useMemo(() => {
if (!id) return;
const cat = AllCategories.find(a => a.id === id);
const rb = new RequestBuilder(`category:${id}`);
rb.withFilter()
@ -86,7 +85,7 @@ export default function Category() {
<div>
<div className="flex gap-4 overflow-x-scroll scrollbar-hidden">
{AllCategories.map(a => (
<CategoryLink key={a.id} {...a} />
<CategoryLink key={a.id} id={a.id} name={a.name} icon={a.icon} />
))}
</div>
{id && (

View File

@ -16,7 +16,7 @@ export function ChatPopout() {
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
return (
<div className="h-[calc(100vh-1rem)] w-screen px-2 my-2">
<div className="h-[calc(100dvh-1rem)] w-screen px-2 my-2">
<LiveChat
ev={ev}
link={lnk}

View File

@ -78,7 +78,7 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
return (
<div
className={classNames("grid gap-2 h-[calc(100%-48px-1rem)]", {
className={classNames("grid gap-2 h-[calc(100dvh-52px)]", {
"grid-cols-3": status === StreamState.Live,
"grid-cols-[20%_80%]": status === StreamState.Ended,
})}>

View File

@ -0,0 +1,28 @@
import { ReactNode, createContext, useState } from "react";
interface LayoutContextType {
leftNav: boolean;
theme: string;
update: (fn: (c: LayoutContextType) => LayoutContextType) => void;
}
const defaultLayoutContext: LayoutContextType = {
leftNav: true,
theme: "",
update: c => c,
};
export const LayoutContext = createContext<LayoutContextType>(defaultLayoutContext);
export function LayoutContextProvider({ children }: { children: ReactNode }) {
const [value, setValue] = useState<LayoutContextType>(defaultLayoutContext);
return (
<LayoutContext.Provider
value={{
...value,
update: fn => {
setValue(fn);
},
}}>
{children}
</LayoutContext.Provider>
);
}

View File

@ -1,36 +1,29 @@
import "./layout.css";
import { CSSProperties, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet";
import { FormattedMessage, useIntl } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { BorderButton, IconButton } from "@/element/buttons";
import { Icon } from "@/element/icon";
import { useLogin, useLoginEvents } from "@/hooks/login";
import { Profile } from "@/element/profile";
import { LoginSignup } from "@/element/login-signup";
import { Login } from "@/login";
import { useLang } from "@/hooks/lang";
import { AllLocales } from "@/intl";
import { profileLink, trackEvent } from "@/utils";
import { BorderButton, DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
import Logo from "@/element/logo";
import Modal from "@/element/modal";
import { AllLocales } from "@/intl";
import { Login } from "@/login";
import { profileLink } from "@/utils";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { FormattedMessage } from "react-intl";
import { Link, useNavigate } from "react-router-dom";
import { useLang } from "@/hooks/lang";
import { useLogin } from "@/hooks/login";
import { useContext, useState } from "react";
import { Profile } from "@/element/profile";
import { SearchBar } from "./search";
import { NavLinkIcon } from "./nav-icon";
import { LayoutContext } from "./context";
export function LayoutPage() {
export function HeaderNav() {
const navigate = useNavigate();
const location = useLocation();
const login = useLogin();
const [showLogin, setShowLogin] = useState(false);
const { lang, setLang } = useLang();
const country = lang.split(/[-_]/i)[1]?.toLowerCase();
useLoginEvents(login?.pubkey, true);
useEffect(() => {
trackEvent("pageview");
}, [location]);
const layoutState = useContext(LayoutContext);
function langSelector() {
return (
@ -61,23 +54,23 @@ export function LayoutPage() {
if (!login) return;
return (
<>
<div className="flex gap-3 items-center pr-4 py-1">
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && (
<Link to="/dashboard">
<DefaultButton>
<span className="max-lg:hidden">
<FormattedMessage defaultMessage="Stream" />
</span>
<Icon name="signal" />
</DefaultButton>
</Link>
<>
<Link to="/upload">
<IconButton iconName="upload" iconSize={20} className="px-3 py-2 hover:bg-layer-1 rounded-xl" />
</Link>
<Link to="/dashboard">
<IconButton iconName="signal" iconSize={20} className="px-3 py-2 hover:bg-layer-1 rounded-xl" />
</Link>
</>
)}
<Menu
menuClassName="ctx-menu"
menuButton={
<div className="profile-menu">
<Profile
avatarSize={48}
avatarSize={32}
pubkey={login.pubkey}
options={{
showName: false,
@ -105,14 +98,14 @@ export function LayoutPage() {
<FormattedMessage defaultMessage="Logout" id="C81/uG" />
</MenuItem>
</Menu>
</>
</div>
);
}
function loggedOut() {
if (login) return;
return (
<>
<div className="pr-4">
<BorderButton onClick={() => setShowLogin(true)}>
<FormattedMessage defaultMessage="Login" id="AyGauy" />
<Icon name="login" />
@ -126,83 +119,40 @@ export function LayoutPage() {
<LoginSignup close={() => setShowLogin(false)} />
</Modal>
)}
</>
</div>
);
}
const styles = {} as CSSProperties;
if (login?.color) {
(styles as Record<string, string>)["--primary"] = login.color;
}
return (
<div className="pt-4 px-2 xl:px-5 h-[calc(100dvh-1rem)]" style={styles}>
<Helmet>
<title>Home - zap.stream</title>
</Helmet>
<div className="flex justify-between mb-4">
<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" className="max-xl:hidden">
<FormattedMessage defaultMessage="Categories" id="VKb1MS" />
</Link>
<Link to="/faq" className="max-xl:hidden">
<FormattedMessage defaultMessage="FAQ" id="W8nHSd" />
</Link>
</div>
<div className="flex items-center gap-3">
<Link
to="https://discord.gg/Wtg6NVDdbT"
target="_blank"
className="flex items-center max-md:hidden gap-1 bg-layer-1 hover:bg-layer-2 font-bold p-2 rounded-xl">
<Icon name="link" />
Discord
</Link>
{langSelector()}
{loggedIn()}
{loggedOut()}
</div>
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center m-2">
<NavLinkIcon
name="hamburger"
className="!opacity-100 max-xl:hidden"
onClick={() => {
layoutState.update(c => {
c.leftNav = !c.leftNav;
return { ...c };
});
}}
/>
<Link to="/">
<Logo width={33} />
</Link>
</div>
<SearchBar />
<div className="flex items-center gap-3">
<Link
to="https://discord.gg/Wtg6NVDdbT"
target="_blank"
className="flex items-center max-md:hidden gap-1 bg-layer-1 hover:bg-layer-2 font-bold p-2 rounded-xl">
<Icon name="link" />
Discord
</Link>
{langSelector()}
{loggedIn()}
{loggedOut()}
</div>
<Outlet />
</div>
);
}
function SearchBar() {
const { term } = useParams();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const [search, setSearch] = useState(term ?? "");
return (
<div className="max-xl:bg-white xl:bg-layer-2 rounded-xl pr-4 py-1 flex items-center">
<input
type="text"
className="max-xl:hidden"
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="max-xl:text-black mx:text-layer-4 max-xl:ml-4 max-xl:my-1"
size={16}
onClick={() => {
navigate("/search");
}}
/>
</div>
);
}

View File

@ -0,0 +1,39 @@
import "./layout.css";
import { CSSProperties, useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Helmet } from "react-helmet";
import { useLogin, useLoginEvents } from "@/hooks/login";
import { trackEvent } from "@/utils";
import { HeaderNav } from "./header";
import { LeftNav } from "./left-nav";
export function LayoutPage() {
const location = useLocation();
const login = useLogin();
useLoginEvents(login?.pubkey, true);
useEffect(() => {
trackEvent("pageview");
}, [location]);
const styles = {} as CSSProperties;
if (login?.color) {
(styles as Record<string, string>)["--primary"] = login.color;
}
return (
<div style={styles}>
<Helmet>
<title>Home - zap.stream</title>
</Helmet>
<HeaderNav />
<div className="flex">
<LeftNav />
<Outlet />
</div>
</div>
);
}

View File

@ -37,8 +37,8 @@
.fi {
background-position: 50%;
background-repeat: no-repeat;
width: 30px;
height: 30px;
width: 24px;
height: 24px;
aspect-ratio: 1;
border-radius: 100%;
background-size: cover;

View File

@ -0,0 +1,34 @@
import { useContext } from "react";
import { LayoutContext } from "./context";
import { NavLinkIcon } from "./nav-icon";
import { FormattedMessage } from "react-intl";
export function LeftNav() {
const layout = useContext(LayoutContext);
return (
<div className="flex flex-col gap-4 p-2 max-xl:hidden">
<NavLinkIcon name="signal" route="/streams" className="flex gap-2 items-center">
{layout.leftNav && (
<span className="pr-3">
<FormattedMessage defaultMessage="Streams" />
</span>
)}
</NavLinkIcon>
<NavLinkIcon name="play-circle" route="/videos" className="flex gap-2 items-center">
{layout.leftNav && (
<span className="pr-3">
<FormattedMessage defaultMessage="Videos" />
</span>
)}
</NavLinkIcon>
<NavLinkIcon name="grid" route="/category" className="flex gap-2 items-center">
{layout.leftNav && (
<span className="pr-3">
<FormattedMessage defaultMessage="Categories" />
</span>
)}
</NavLinkIcon>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { Icon } from "@/element/icon";
import classNames from "classnames";
import { ReactNode } from "react";
import { Link, LinkProps, useLocation } from "react-router-dom";
export function NavLinkIcon({
name,
route,
className,
onClick,
children,
}: {
name: string;
route?: string;
className?: string;
onClick?: LinkProps["onClick"];
children?: ReactNode;
}) {
const location = useLocation();
const active = location.pathname === route;
return (
<Link
to={route ?? "#"}
onClick={onClick}
className={classNames("cursor-pointer hover:bg-neutral-800 rounded-xl", { "opacity-50": !active }, className)}>
<Icon name={name} size={20} className="m-2" />
{children}
</Link>
);
}

View File

@ -0,0 +1,39 @@
import { Icon } from "@/element/icon";
import { useState } from "react";
import { useIntl } from "react-intl";
import { useParams, useNavigate } from "react-router-dom";
export function SearchBar() {
const { term } = useParams();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const [search, setSearch] = useState(term ?? "");
return (
<div className="pr-4 h-fit flex items-center rounded-full px-3 py-1 lg:border lg:border-layer-2">
<input
type="text"
className="reset max-lg:hidden bg-transparent"
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="max-lg:text-black lg:text-layer-4 max-lg:ml-4 max-lg:my-1"
size={16}
onClick={() => {
navigate("/search");
}}
/>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { VIDEO_KIND } from "@/const";
import { useStreamLink } from "@/hooks/stream-link";
import { getEventFromLocationState } from "@/utils";
import { NostrPrefix, EventKind } from "@snort/system";
import { useLocation } from "react-router-dom";
import { StreamPage } from "./stream-page";
import { VideoPage } from "./video";
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import { FormattedMessage } from "react-intl";
export function LinkHandler() {
const location = useLocation();
const evPreload = getEventFromLocationState(location.state);
const link = useStreamLink();
if (!link) return;
if (link.type === NostrPrefix.Event) {
return (
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
<NostrEventElement link={link} />
</div>
);
} else if (link.kind === EventKind.LiveEvent) {
return (
<div className="h-[calc(100dvh-52px)] w-full">
<StreamPage link={link} evPreload={evPreload} />
</div>
);
} else if (link.kind === VIDEO_KIND) {
return <VideoPage link={link} evPreload={evPreload} />;
} else {
return (
<>
<h3>
<FormattedMessage defaultMessage="Unknown event link" />
</h3>
</>
);
}
}

View File

@ -1,6 +1,6 @@
import "./profile-page.css";
import { useMemo } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { CachedMetadata, NostrEvent, NostrLink, TaggedNostrEvent, parseNostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { unwrap } from "@snort/shared";
@ -13,7 +13,7 @@ import { FollowButton } from "@/element/follow-button";
import { MuteButton } from "@/element/mute-button";
import { useProfile } from "@/hooks/profile";
import { Text } from "@/element/text";
import { findTag, profileLink } from "@/utils";
import { findTag } from "@/utils";
import { StatePill } from "@/element/state-pill";
import { Avatar } from "@/element/avatar";
import { StreamState } from "@/const";
@ -21,9 +21,9 @@ import { DefaultButton } from "@/element/buttons";
import { useGoals } from "@/hooks/goals";
import { Goal } from "@/element/goal";
import { TopZappers } from "@/element/top-zappers";
import { useClips } from "@/hooks/clips";
import { getName } from "@/element/profile";
import { useProfileClips } from "@/hooks/clips";
import VideoGrid from "@/element/video-grid";
import { ClipTile } from "@/element/clip-tile";
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
@ -178,34 +178,9 @@ function ProfileZapGoals({ link }: { link: NostrLink }) {
}
function ProfileClips({ link }: { link: NostrLink }) {
const clips = useClips(link, 10);
const clips = useProfileClips(link, 10);
if (clips.length === 0) {
return <FormattedMessage defaultMessage="No clips yet" id="ObZZEz" />;
}
return clips.map(a => <ProfileClip ev={a} key={a.id} />);
}
function ProfileClip({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);
const r = findTag(ev, "r");
const title = findTag(ev, "title");
return (
<div className="w-[300px] flex flex-col gap-4 bg-layer-1 rounded-xl px-3 py-2">
<span>
<FormattedMessage
defaultMessage="Clip by {name}"
id="dkUMIH"
values={{
name: (
<Link to={profileLink(profile, ev.pubkey)} className="font-medium text-primary">
{getName(ev.pubkey, profile)}
</Link>
),
}}
/>
</span>
{title}
<video src={r} controls />
</div>
);
return clips.map(a => <ClipTile ev={a} key={a.id} />);
}

View File

@ -1,5 +1,5 @@
import { useStreamsFeed } from "@/hooks/live-streams";
import CategoryLink from "@/element/category-link";
import CategoryLink from "@/element/category/category-link";
import VideoGridSorted from "@/element/video-grid-sorted";
import { AllCategories } from "./category";
@ -7,13 +7,15 @@ export function RootPage() {
const streams = useStreamsFeed();
return (
<div className="flex flex-col gap-6">
<div className="flex gap-4 overflow-x-scroll scrollbar-hidden">
{AllCategories.filter(a => a.priority === 0).map(a => (
<CategoryLink key={a.id} {...a} />
))}
<div className="flex flex-col gap-6 p-4">
<div className="min-w-0 w-[calc(100dvw-2rem)]">
<div className="flex gap-4 overflow-x-scroll scrollbar-hidden">
{AllCategories.filter(a => a.priority === 0).map(a => (
<CategoryLink key={a.id} name={a.name} id={a.id} icon={a.icon} />
))}
</div>
</div>
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} />
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} showRecentClips={true} />
</div>
);
}

View File

@ -34,7 +34,6 @@ export default function SearchPage() {
type="text"
placeholder={formatMessage({
defaultMessage: "Search",
id: "xmcVZ0",
})}
value={search}
onChange={e => setSearch(e.target.value)}
@ -58,7 +57,6 @@ export default function SearchPage() {
<h2 className="mb-4">
<FormattedMessage
defaultMessage="Search results: {term}"
id="A1zT+z"
values={{
term,
}}

View File

@ -1,136 +1,17 @@
import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useLocation, useNavigate } from "react-router-dom";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { Helmet } from "react-helmet";
import { SnortContext, useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { Suspense, lazy, useContext } from "react";
import { Suspense, lazy } from "react";
import { useMediaQuery } from "usehooks-ts";
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
import { Profile, getName } from "@/element/profile";
import { extractStreamInfo, getHost } from "@/utils";
import { LiveChat } from "@/element/live-chat";
import { useLogin } from "@/hooks/login";
import { useZapGoal } from "@/hooks/goals";
import { SendZapsDialog } from "@/element/send-zap";
import { NewStreamDialog } from "@/element/new-stream";
import { Tags } from "@/element/tags";
import { StatePill } from "@/element/state-pill";
import { StreamCards } from "@/element/stream-cards";
import { formatSats } from "@/number";
import { StreamTimer } from "@/element/stream-time";
import { ShareMenu } from "@/element/share-menu";
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useStreamLink } from "@/hooks/stream-link";
import { FollowButton } from "@/element/follow-button";
import { ClipButton } from "@/element/clip-button";
import { StreamState } from "@/const";
import { NotificationsButton } from "@/element/notifications-button";
import { WarningButton } from "@/element/buttons";
import Pill from "@/element/pill";
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import GameInfoCard from "@/element/game-info";
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext);
const login = useLogin();
const navigate = useNavigate();
const host = getHost(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const { status, participants, title, summary, service, gameId, gameInfo } = extractStreamInfo(ev);
const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() {
const pub = login?.publisher();
if (pub && ev) {
const evDelete = await pub.delete(ev.id);
console.debug(evDelete);
await system.BroadcastEvent(evDelete);
navigate("/");
}
}
const viewers = Number(participants ?? "0");
return (
<>
<div className="flex gap-2 max-xl:flex-col">
<div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1>
<p>{summary}</p>
<div className="flex gap-2 flex-wrap">
<StatePill state={status as StreamState} />
<Pill>
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(viewers) }} />
</Pill>
{status === StreamState.Live && (
<Pill>
<StreamTimer ev={ev} />
</Pill>
)}
{gameId && gameInfo && (
<Pill>
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
</Pill>
)}
{ev && <Tags ev={ev} />}
</div>
{isMine && (
<div className="flex gap-4">
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
<WarningButton onClick={deleteStream}>
<FormattedMessage defaultMessage="Delete" />
</WarningButton>
</div>
)}
</div>
<div className="flex justify-between sm:gap-4 max-sm:gap-2 flex-wrap max-md:flex-col lg:items-center">
<Profile pubkey={host ?? ""} />
<div className="flex gap-2">
<FollowButton pubkey={host} hideWhenFollowing={true} />
{ev && (
<>
<ShareMenu ev={ev} />
<ClipButton ev={ev} />
{service && <NotificationsButton host={host} service={service} />}
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
eTag={goal?.id}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
</div>
</>
);
}
export function StreamPageHandler() {
const location = useLocation();
const evPreload = getEventFromLocationState(location.state);
const link = useStreamLink();
if (!link) return;
if (link.type === NostrPrefix.Event) {
return (
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
<NostrEventElement link={link} />
</div>
);
} else {
return <StreamPage link={link} evPreload={evPreload} />;
}
}
import { StreamInfo } from "@/element/stream-info";
export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent; link: NostrLink }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
@ -161,7 +42,7 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
...(tags ?? []),
].join(", ");
return (
<div className="xl:grid xl:grid-cols-[auto_450px] 2xl:xl:grid-cols-[auto_500px] max-xl:flex max-xl:flex-col xl:gap-4 h-[calc(100%-48px-1rem)]">
<div className="xl:grid xl:grid-cols-[auto_450px] 2xl:xl:grid-cols-[auto_500px] max-xl:flex max-xl:flex-col xl:gap-4 max-xl:gap-1 h-full">
<Helmet>
<title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} />
@ -189,10 +70,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
ev={ev}
goal={goal}
canWrite={status === StreamState.Live}
showHeader={isDesktop}
showTopZappers={isDesktop}
showGoal={true}
className="min-h-0 xl:border xl:border-layer-1 xl:rounded-xl xl:p-5"
className="min-h-0 xl:border xl:border-layer-2 xl:rounded-xl xl:p-3 max-xl:px-2 h-inherit"
/>
</div>
);

1
src/pages/upload.tsx Normal file
View File

@ -0,0 +1 @@
export function UploadPage() {}

28
src/pages/video.tsx Normal file
View File

@ -0,0 +1,28 @@
import { StreamInfo } from "@/element/stream-info";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { getHost, extractStreamInfo } from "@/utils";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
const host = getHost(ev);
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
const {
title,
summary,
image,
status,
tags,
contentWarning,
stream,
recording,
goal: goalTag,
} = extractStreamInfo(ev);
return (
<div className="p-4 w-[80dvw] mx-auto">
<video src={recording} controls className="w-full aspect-video" />
<StreamInfo ev={ev as TaggedNostrEvent} />
</div>
);
}

29
src/pages/videos.tsx Normal file
View File

@ -0,0 +1,29 @@
import { VIDEO_KIND } from "@/const";
import VideoGrid from "@/element/video-grid";
import { VideoTile } from "@/element/video-tile";
import { findTag } from "@/utils";
import { RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
export function VideosPage() {
const rb = new RequestBuilder("videos");
rb.withFilter().kinds([VIDEO_KIND]);
const videos = useRequestBuilder(rb);
const sorted = videos.sort((a, b) => {
const pubA = findTag(a, "published_at");
const pubB = findTag(b, "published_at");
return Number(pubA) > Number(pubB) ? -1 : 1;
});
return (
<div className="p-4">
<VideoGrid>
{sorted.map(a => (
<VideoTile ev={a} key={a.id} showStatus={false} />
))}
</VideoGrid>
</div>
);
}

View File

@ -133,6 +133,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "streaming", v => (ret.stream = v));
}
matchTag(t, "recording", v => (ret.recording = v));
matchTag(t, "url", v => (ret.recording = v));
matchTag(t, "content-warning", v => (ret.contentWarning = v));
matchTag(t, "current_participants", v => (ret.participants = v));
matchTag(t, "goal", v => (ret.goal = v));
@ -147,6 +148,10 @@ export function extractStreamInfo(ev?: NostrEvent) {
ret.gameId = gameId;
ret.gameInfo = gameInfo;
// video patch
if (ev?.kind === 34_235) {
ret.status = StreamState.VOD;
}
return ret;
}