feat: popular categories
This commit is contained in:
parent
daa687957e
commit
2bc996d981
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 { 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
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;
|
||||
}
|
||||
|
||||
.h-inherit {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea,
|
||||
input[type="datetime-local"],
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -710,6 +710,9 @@
|
||||
"p4N05H": {
|
||||
"defaultMessage": "Upload"
|
||||
},
|
||||
"pxF+t0": {
|
||||
"defaultMessage": "Popular"
|
||||
},
|
||||
"q+zTWM": {
|
||||
"defaultMessage": "<s>{person}</s> zapped <s>{amount}</s> sats"
|
||||
},
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -13,7 +13,7 @@ export function RootPage() {
|
||||
<CategoryLink key={a.id} {...a} />
|
||||
))}
|
||||
</div>
|
||||
<VideoGridSorted evs={streams} />
|
||||
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)",
|
||||
|
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 };
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user