feat: new design progress
This commit is contained in:
@ -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 && (
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
})}>
|
||||
|
28
src/pages/layout/context.tsx
Normal file
28
src/pages/layout/context.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
39
src/pages/layout/index.tsx
Normal file
39
src/pages/layout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
34
src/pages/layout/left-nav.tsx
Normal file
34
src/pages/layout/left-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
30
src/pages/layout/nav-icon.tsx
Normal file
30
src/pages/layout/nav-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
src/pages/layout/search.tsx
Normal file
39
src/pages/layout/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
src/pages/link-handler.tsx
Normal file
41
src/pages/link-handler.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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} />);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
}}
|
||||
|
@ -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
1
src/pages/upload.tsx
Normal file
@ -0,0 +1 @@
|
||||
export function UploadPage() {}
|
28
src/pages/video.tsx
Normal file
28
src/pages/video.tsx
Normal 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
29
src/pages/videos.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user