feat: popular categories

This commit is contained in:
kieran 2024-05-15 15:07:10 +01:00
parent daa687957e
commit 2bc996d981
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
14 changed files with 237 additions and 29 deletions

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

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

View File

@ -1,13 +1,26 @@
import { useLogin } from "@/hooks/login";
import { useSortedStreams } from "@/hooks/useLiveStreams";
import { getTagValues, getHost } from "@/utils";
import { getTagValues, getHost, extractStreamInfo } from "@/utils";
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useCallback, useMemo } from "react";
import { FormattedMessage } from "react-intl";
import VideoGrid from "./video-grid";
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 mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
const tags = login?.follows.tags ?? [];
@ -57,13 +70,14 @@ export default function VideoGridSorted({ evs, showAll }: { evs: Array<TaggedNos
{liveByHashtag.map(t => (
<GridSection header={`#${t.tag}`} items={t.live} />
))}
{showPopular && <PopularCategories items={evs} />}
{hasFollowingLive && liveNow.length > 0 && (
<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} />
)}
{endedEvents.length > 0 && (
{endedEvents.length > 0 && (showEnded ?? true) && (
<GridSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} />
)}
</div>
@ -74,7 +88,7 @@ function GridSection({ header, items }: { header: ReactNode; items: Array<Tagged
return (
<>
<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" />
</div>
<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>
</>
);
}

View File

@ -50,11 +50,7 @@ export function VideoTile({
{showStatus && <StatePill state={status as StreamState} />}
{participants && (
<Pill>
<FormattedMessage
defaultMessage="{n} viewers"
id="3adEeb"
values={{ n: formatSats(Number(participants)) }}
/>
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(participants)) }} />
</Pill>
)}
</span>

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

View File

@ -73,6 +73,10 @@ a {
outline: none;
}
.h-inherit {
height: inherit;
}
input[type="text"],
textarea,
input[type="datetime-local"],

View File

@ -66,7 +66,10 @@ async function doInit() {
await wasmInit(WasmPath);
}
try {
await workerRelay.init("relay.db");
await workerRelay.init({
databasePath: "relay.db",
insertBatchSize: 100,
});
} catch (e) {
console.error(e);
}

View File

@ -710,6 +710,9 @@
"p4N05H": {
"defaultMessage": "Upload"
},
"pxF+t0": {
"defaultMessage": "Popular"
},
"q+zTWM": {
"defaultMessage": "<s>{person}</s> zapped <s>{amount}</s> sats"
},

View File

@ -3,6 +3,11 @@ const intlSats = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
});
const intlShortSats = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
export function formatShort(fmt: Intl.NumberFormat, n: number) {
if (n < 2e3) {
return n;
@ -18,3 +23,7 @@ export function formatShort(fmt: Intl.NumberFormat, n: number) {
export function formatSats(n: number) {
return formatShort(intlSats, n);
}
export function formatSatsCompact(n: number) {
return formatShort(intlShortSats, n);
}

View File

@ -1,7 +1,7 @@
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 useGameInfo from "@/hooks/game-info";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
@ -29,7 +29,7 @@ export const AllCategories = [
id: "music",
name: <FormattedMessage defaultMessage="Music" />,
icon: "music",
tags: ["music"],
tags: ["music", "radio"],
priority: 0,
className: "bg-category-gradient-3",
},
@ -69,7 +69,6 @@ export const AllCategories = [
export default function Category() {
const { id } = useParams();
const game = useGameInfo(id);
const sub = useMemo(() => {
if (!id) return;
@ -90,19 +89,19 @@ export default function Category() {
<CategoryLink key={a.id} {...a} />
))}
</div>
<div className="flex gap-8 py-8">
{game?.cover && <img src={game?.cover} className="h-[250px]" />}
<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>
)}
{id && (
<div className="flex gap-4 py-8">
<CategoryTile
gameId={id}
showDetail={true}
extraDetail={
<div className="flex">
<CategoryZaps gameId={id} />
</div>
}
/>
</div>
</div>
)}
<VideoGridSorted evs={results} showAll={true} />
</div>
);

View File

@ -13,7 +13,7 @@ export function RootPage() {
<CategoryLink key={a.id} {...a} />
))}
</div>
<VideoGridSorted evs={streams} />
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} />
</div>
);
}

View File

@ -14,9 +14,18 @@ export default class GameDatabase {
}
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}`);
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;
}
}
}

View File

@ -235,6 +235,7 @@
"oHPB8Q": "Zap {name}",
"oZrFyI": "Stream type should be HLS",
"p4N05H": "Upload",
"pxF+t0": "Popular",
"q+zTWM": "<s>{person}</s> zapped <s>{amount}</s> sats",
"q9ryv4": "Cover image URL (optional)",
"qx6bv2": "Stream Goal (optional)",

View File

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