feat: new design progress

This commit is contained in:
2024-05-17 23:17:15 +01:00
parent 1b5fa7a5ca
commit 75c90e9dc4
36 changed files with 675 additions and 375 deletions

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>
);
}