feat: popular categories
This commit is contained in:
48
src/element/category/category-tile.tsx
Normal file
48
src/element/category/category-tile.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import useGameInfo from "@/hooks/game-info";
|
||||||
|
import Pill from "../pill";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
export function CategoryTile({
|
||||||
|
gameId,
|
||||||
|
showDetail,
|
||||||
|
children,
|
||||||
|
showFooterTitle,
|
||||||
|
extraDetail,
|
||||||
|
}: {
|
||||||
|
gameId: string;
|
||||||
|
showDetail?: boolean;
|
||||||
|
showFooterTitle?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
extraDetail?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const game = useGameInfo(gameId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{game?.cover && (
|
||||||
|
<img src={game?.cover} className="max-lg:w-full sm:h-full lg:h-[200px] xl:h-[250px] aspect-[3/4]" />
|
||||||
|
)}
|
||||||
|
{!game?.cover && game?.className && (
|
||||||
|
<div className={classNames("w-full aspect-[3/4] xl:h-[250px]", game.className)} />
|
||||||
|
)}
|
||||||
|
{showDetail && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1>{game?.name}</h1>
|
||||||
|
{game?.genres && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{game?.genres?.map(a => (
|
||||||
|
<Pill>{a}</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extraDetail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showFooterTitle && <p>{game?.name}</p>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
17
src/element/category/zaps.tsx
Normal file
17
src/element/category/zaps.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useCategoryZaps } from "@/hooks/category-zaps";
|
||||||
|
import Pill from "../pill";
|
||||||
|
import { Icon } from "../icon";
|
||||||
|
import { formatSatsCompact } from "@/number";
|
||||||
|
|
||||||
|
export function CategoryZaps({ gameId }: { gameId: string }) {
|
||||||
|
const zaps = useCategoryZaps(gameId);
|
||||||
|
|
||||||
|
const total = zaps.reduce((acc, v) => (acc += v.amount), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pill className="flex gap-2">
|
||||||
|
<Icon name="zap-filled" />
|
||||||
|
{formatSatsCompact(total)}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,26 @@
|
|||||||
import { useLogin } from "@/hooks/login";
|
import { useLogin } from "@/hooks/login";
|
||||||
import { useSortedStreams } from "@/hooks/useLiveStreams";
|
import { useSortedStreams } from "@/hooks/useLiveStreams";
|
||||||
import { getTagValues, getHost } from "@/utils";
|
import { getTagValues, getHost, extractStreamInfo } from "@/utils";
|
||||||
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
|
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
|
||||||
import { ReactNode, useCallback, useMemo } from "react";
|
import { ReactNode, useCallback, useMemo } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import VideoGrid from "./video-grid";
|
import VideoGrid from "./video-grid";
|
||||||
import { VideoTile } from "./video-tile";
|
import { VideoTile } from "./video-tile";
|
||||||
|
import { CategoryTile } from "./category/category-tile";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Pill from "./pill";
|
||||||
|
import { CategoryZaps } from "./category/zaps";
|
||||||
|
import { StreamState } from "@/const";
|
||||||
|
|
||||||
export default function VideoGridSorted({ evs, showAll }: { evs: Array<TaggedNostrEvent>; showAll?: boolean }) {
|
interface VideoGridSortedProps {
|
||||||
|
evs: Array<TaggedNostrEvent>;
|
||||||
|
showAll?: boolean;
|
||||||
|
showEnded?: boolean;
|
||||||
|
showPlanned?: boolean;
|
||||||
|
showPopular?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoGridSorted({ evs, showAll, showEnded, showPlanned, showPopular }: VideoGridSortedProps) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
|
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
|
||||||
const tags = login?.follows.tags ?? [];
|
const tags = login?.follows.tags ?? [];
|
||||||
@ -57,13 +70,14 @@ export default function VideoGridSorted({ evs, showAll }: { evs: Array<TaggedNos
|
|||||||
{liveByHashtag.map(t => (
|
{liveByHashtag.map(t => (
|
||||||
<GridSection header={`#${t.tag}`} items={t.live} />
|
<GridSection header={`#${t.tag}`} items={t.live} />
|
||||||
))}
|
))}
|
||||||
|
{showPopular && <PopularCategories items={evs} />}
|
||||||
{hasFollowingLive && liveNow.length > 0 && (
|
{hasFollowingLive && liveNow.length > 0 && (
|
||||||
<GridSection header={<FormattedMessage defaultMessage="Live" id="Dn82AL" />} items={liveNow} />
|
<GridSection header={<FormattedMessage defaultMessage="Live" id="Dn82AL" />} items={liveNow} />
|
||||||
)}
|
)}
|
||||||
{plannedEvents.length > 0 && (
|
{plannedEvents.length > 0 && (showPlanned ?? true) && (
|
||||||
<GridSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} />
|
<GridSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} />
|
||||||
)}
|
)}
|
||||||
{endedEvents.length > 0 && (
|
{endedEvents.length > 0 && (showEnded ?? true) && (
|
||||||
<GridSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} />
|
<GridSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -74,7 +88,7 @@ function GridSection({ header, items }: { header: ReactNode; items: Array<Tagged
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h2 className="whitespace-nowrap">{header}</h2>
|
<h3 className="whitespace-nowrap">{header}</h3>
|
||||||
<span className="h-[1px] bg-layer-1 w-full" />
|
<span className="h-[1px] bg-layer-1 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<VideoGrid>
|
<VideoGrid>
|
||||||
@ -85,3 +99,71 @@ function GridSection({ header, items }: { header: ReactNode; items: Array<Tagged
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PopularCategories({ items }: { items: Array<TaggedNostrEvent> }) {
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const grouped = items.reduce(
|
||||||
|
(acc, v) => {
|
||||||
|
const { gameId, participants, status } = extractStreamInfo(v);
|
||||||
|
if (gameId) {
|
||||||
|
acc[gameId] ??= {
|
||||||
|
gameId,
|
||||||
|
viewers: 0,
|
||||||
|
zaps: 0,
|
||||||
|
streams: 0,
|
||||||
|
};
|
||||||
|
if (participants && status === StreamState.Live) {
|
||||||
|
acc[gameId].viewers += Number(participants);
|
||||||
|
}
|
||||||
|
acc[gameId].streams++;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
gameId: string;
|
||||||
|
viewers: number;
|
||||||
|
zaps: number;
|
||||||
|
streams: number;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(grouped)
|
||||||
|
.sort((a, b) => (a.streams > b.streams ? -1 : 1))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h3 className="whitespace-nowrap">
|
||||||
|
<FormattedMessage defaultMessage="Popular" />
|
||||||
|
</h3>
|
||||||
|
<span className="h-[1px] bg-layer-1 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{categories.map(a => (
|
||||||
|
<Link to={`/category/${a.gameId}`} className="xl:w-[180px] lg:w-[170px] max-lg:w-[calc(30dvw-1rem)]">
|
||||||
|
<CategoryTile gameId={a.gameId} showFooterTitle={true}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CategoryZaps gameId={a.gameId} />
|
||||||
|
{a.viewers > 0 && (
|
||||||
|
<Pill>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="{n} viewers"
|
||||||
|
values={{
|
||||||
|
n: a.viewers,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CategoryTile>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -50,11 +50,7 @@ export function VideoTile({
|
|||||||
{showStatus && <StatePill state={status as StreamState} />}
|
{showStatus && <StatePill state={status as StreamState} />}
|
||||||
{participants && (
|
{participants && (
|
||||||
<Pill>
|
<Pill>
|
||||||
<FormattedMessage
|
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(participants)) }} />
|
||||||
defaultMessage="{n} viewers"
|
|
||||||
id="3adEeb"
|
|
||||||
values={{ n: formatSats(Number(participants)) }}
|
|
||||||
/>
|
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
25
src/hooks/category-zaps.ts
Normal file
25
src/hooks/category-zaps.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { EventKind, NostrLink, RequestBuilder, parseZap } from "@snort/system";
|
||||||
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useCategoryZaps(gameId: string) {
|
||||||
|
const rb = new RequestBuilder(`cat-zaps:${gameId}`);
|
||||||
|
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", [gameId]);
|
||||||
|
const evs = useRequestBuilder(rb);
|
||||||
|
const links = evs.map(a => NostrLink.fromEvent(a));
|
||||||
|
|
||||||
|
const rbZaps = useMemo(() => {
|
||||||
|
const rb = new RequestBuilder(`cat-zaps:zaps:${gameId}`);
|
||||||
|
if (links.length > 0) {
|
||||||
|
rb.withFilter().kinds([EventKind.ZapReceipt]).replyToLink(links);
|
||||||
|
return rb;
|
||||||
|
}
|
||||||
|
}, [links]);
|
||||||
|
const zapEvents = useRequestBuilder(rbZaps);
|
||||||
|
|
||||||
|
const zaps = useMemo(() => {
|
||||||
|
return zapEvents.map(a => parseZap(a));
|
||||||
|
}, [zapEvents]);
|
||||||
|
|
||||||
|
return zaps;
|
||||||
|
}
|
@ -73,6 +73,10 @@ a {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-inherit {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
textarea,
|
textarea,
|
||||||
input[type="datetime-local"],
|
input[type="datetime-local"],
|
||||||
|
@ -66,7 +66,10 @@ async function doInit() {
|
|||||||
await wasmInit(WasmPath);
|
await wasmInit(WasmPath);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await workerRelay.init("relay.db");
|
await workerRelay.init({
|
||||||
|
databasePath: "relay.db",
|
||||||
|
insertBatchSize: 100,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
@ -710,6 +710,9 @@
|
|||||||
"p4N05H": {
|
"p4N05H": {
|
||||||
"defaultMessage": "Upload"
|
"defaultMessage": "Upload"
|
||||||
},
|
},
|
||||||
|
"pxF+t0": {
|
||||||
|
"defaultMessage": "Popular"
|
||||||
|
},
|
||||||
"q+zTWM": {
|
"q+zTWM": {
|
||||||
"defaultMessage": "<s>{person}</s> zapped <s>{amount}</s> sats"
|
"defaultMessage": "<s>{person}</s> zapped <s>{amount}</s> sats"
|
||||||
},
|
},
|
||||||
|
@ -3,6 +3,11 @@ const intlSats = new Intl.NumberFormat(undefined, {
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const intlShortSats = new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export function formatShort(fmt: Intl.NumberFormat, n: number) {
|
export function formatShort(fmt: Intl.NumberFormat, n: number) {
|
||||||
if (n < 2e3) {
|
if (n < 2e3) {
|
||||||
return n;
|
return n;
|
||||||
@ -18,3 +23,7 @@ export function formatShort(fmt: Intl.NumberFormat, n: number) {
|
|||||||
export function formatSats(n: number) {
|
export function formatSats(n: number) {
|
||||||
return formatShort(intlSats, n);
|
return formatShort(intlSats, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSatsCompact(n: number) {
|
||||||
|
return formatShort(intlShortSats, n);
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import CategoryLink from "@/element/category-link";
|
import CategoryLink from "@/element/category-link";
|
||||||
import Pill from "@/element/pill";
|
import { CategoryTile } from "@/element/category/category-tile";
|
||||||
|
import { CategoryZaps } from "@/element/category/zaps";
|
||||||
import VideoGridSorted from "@/element/video-grid-sorted";
|
import VideoGridSorted from "@/element/video-grid-sorted";
|
||||||
import useGameInfo from "@/hooks/game-info";
|
|
||||||
import { EventKind, RequestBuilder } from "@snort/system";
|
import { EventKind, RequestBuilder } from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@ -29,7 +29,7 @@ export const AllCategories = [
|
|||||||
id: "music",
|
id: "music",
|
||||||
name: <FormattedMessage defaultMessage="Music" />,
|
name: <FormattedMessage defaultMessage="Music" />,
|
||||||
icon: "music",
|
icon: "music",
|
||||||
tags: ["music"],
|
tags: ["music", "radio"],
|
||||||
priority: 0,
|
priority: 0,
|
||||||
className: "bg-category-gradient-3",
|
className: "bg-category-gradient-3",
|
||||||
},
|
},
|
||||||
@ -69,7 +69,6 @@ export const AllCategories = [
|
|||||||
|
|
||||||
export default function Category() {
|
export default function Category() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const game = useGameInfo(id);
|
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@ -90,19 +89,19 @@ export default function Category() {
|
|||||||
<CategoryLink key={a.id} {...a} />
|
<CategoryLink key={a.id} {...a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8 py-8">
|
{id && (
|
||||||
{game?.cover && <img src={game?.cover} className="h-[250px]" />}
|
<div className="flex gap-4 py-8">
|
||||||
<div className="flex flex-col gap-4">
|
<CategoryTile
|
||||||
<h1>{game?.name}</h1>
|
gameId={id}
|
||||||
{game?.genres && (
|
showDetail={true}
|
||||||
<div className="flex gap-2">
|
extraDetail={
|
||||||
{game?.genres?.map(a => (
|
<div className="flex">
|
||||||
<Pill>{a}</Pill>
|
<CategoryZaps gameId={id} />
|
||||||
))}
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<VideoGridSorted evs={results} showAll={true} />
|
<VideoGridSorted evs={results} showAll={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -13,7 +13,7 @@ export function RootPage() {
|
|||||||
<CategoryLink key={a.id} {...a} />
|
<CategoryLink key={a.id} {...a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<VideoGridSorted evs={streams} />
|
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,18 @@ export default class GameDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGame(id: string) {
|
async getGame(id: string) {
|
||||||
|
const cacheKey = `game:${id}`;
|
||||||
|
const cached = window.sessionStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached) as GameInfo;
|
||||||
|
}
|
||||||
const rsp = await fetch(`${this.url}/games/${id}`);
|
const rsp = await fetch(`${this.url}/games/${id}`);
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
return (await rsp.json()) as GameInfo | undefined;
|
const info = (await rsp.json()) as GameInfo | undefined;
|
||||||
|
if (info) {
|
||||||
|
window.sessionStorage.setItem(cacheKey, JSON.stringify(info));
|
||||||
|
}
|
||||||
|
return info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -235,6 +235,7 @@
|
|||||||
"oHPB8Q": "Zap {name}",
|
"oHPB8Q": "Zap {name}",
|
||||||
"oZrFyI": "Stream type should be HLS",
|
"oZrFyI": "Stream type should be HLS",
|
||||||
"p4N05H": "Upload",
|
"p4N05H": "Upload",
|
||||||
|
"pxF+t0": "Popular",
|
||||||
"q+zTWM": "<s>{person}</s> zapped <s>{amount}</s> sats",
|
"q+zTWM": "<s>{person}</s> zapped <s>{amount}</s> sats",
|
||||||
"q9ryv4": "Cover image URL (optional)",
|
"q9ryv4": "Cover image URL (optional)",
|
||||||
"qx6bv2": "Stream Goal (optional)",
|
"qx6bv2": "Stream Goal (optional)",
|
||||||
|
12
src/utils.ts
12
src/utils.ts
@ -172,6 +172,18 @@ export function extractGameTag(tags: Array<string>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (gameId === undefined) {
|
||||||
|
const lowerTags = tags.map(a => a.toLowerCase());
|
||||||
|
const anyCat = AllCategories.find(a => a.tags.some(b => lowerTags.includes(b)));
|
||||||
|
if (anyCat) {
|
||||||
|
gameInfo = {
|
||||||
|
id: anyCat?.id,
|
||||||
|
name: anyCat.name,
|
||||||
|
genres: anyCat.tags,
|
||||||
|
className: anyCat.className,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
return { gameInfo, gameId };
|
return { gameInfo, gameId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user