Skip to content

Commit 352a0af

Browse files
committed
feat: chat ux, recording share, and clip og metadata
- fix window.location.replace for live redirect (avoid RSC fetch failure) - add mobile chat clearance for bottom nav - extract shared emoji data to lib/emoji-categories.ts (DRY) - add emoji picker to dashboard stream-manager chat - increase chat icon spacing - add fullscreen chat overlay outside-click-to-close on mobile - move fullscreen collapsed chat button to bottom on mobile - fix StreamInfo empty src when thumbnail is null - add share button to clip player page and clips grid cards - add OG metadata layout and branded opengraph-image for clip detail pages
1 parent 398dd31 commit 352a0af

10 files changed

Lines changed: 536 additions & 169 deletions

File tree

app/[username]/UsernameLayoutClient.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ export default function UsernameLayoutClient({
5151
const isOwner = loggedInUsername?.toLowerCase() === username.toLowerCase();
5252

5353
// When the user visits /{username} and they're live, redirect to the canonical watch URL.
54+
// Use window.location to avoid the RSC fetch that router.replace() triggers, which can
55+
// fail with "Failed to fetch" when Turbopack hasn't compiled the route yet.
5456
useEffect(() => {
5557
if (isDefaultRoute && isLive === true) {
56-
router.replace(`/${username}/watch`);
58+
window.location.replace(`/${username}/watch`);
5759
}
5860
// eslint-disable-next-line react-hooks/exhaustive-deps
5961
}, [isDefaultRoute, isLive, username]);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Metadata } from "next";
2+
import type { ReactNode } from "react";
3+
import { sql } from "@vercel/postgres";
4+
5+
const BASE = "https://www.streamfi.media";
6+
7+
interface Props {
8+
children: ReactNode;
9+
params: Promise<{ username: string; id: string }>;
10+
}
11+
12+
export async function generateMetadata({
13+
params,
14+
}: Props): Promise<Metadata> {
15+
const { username, id } = await params;
16+
17+
try {
18+
const { rows } = await sql`
19+
SELECT r.playback_id, r.title, u.avatar, u.bio
20+
FROM stream_recordings r
21+
JOIN users u ON u.id = r.user_id
22+
WHERE r.id = ${id} AND r.status = 'ready'
23+
LIMIT 1
24+
`;
25+
const rec = rows[0];
26+
if (!rec) {
27+
return { title: `${username} – StreamFi` };
28+
}
29+
30+
const recTitle: string = rec.title ?? `${username}'s Past Stream`;
31+
const description: string =
32+
rec.bio?.trim() || `Watch ${username}'s past stream on StreamFi.`;
33+
const pageUrl = `${BASE}/${username}/clips/${id}`;
34+
35+
// Use Mux thumbnail as the OG image — real video frame, best for sharing
36+
const thumbUrl = `https://image.mux.com/${rec.playback_id}/thumbnail.jpg?width=1280&height=720&fit_mode=smartcrop`;
37+
38+
const images = [{ url: thumbUrl, width: 1280, height: 720, alt: recTitle }];
39+
40+
return {
41+
title: `${recTitle}${username} | StreamFi`,
42+
description,
43+
alternates: { canonical: pageUrl },
44+
openGraph: {
45+
title: `${recTitle}${username}`,
46+
description,
47+
url: pageUrl,
48+
type: "video.other",
49+
images,
50+
},
51+
twitter: {
52+
card: "summary_large_image",
53+
title: `${recTitle}${username}`,
54+
description,
55+
images: [thumbUrl],
56+
},
57+
};
58+
} catch {
59+
return { title: `${username} – StreamFi` };
60+
}
61+
}
62+
63+
export default function ClipLayout({ children }: Props) {
64+
return <>{children}</>;
65+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { ImageResponse } from "next/og";
2+
import { sql } from "@vercel/postgres";
3+
4+
export const runtime = "nodejs";
5+
export const contentType = "image/png";
6+
export const size = { width: 1200, height: 630 };
7+
8+
interface Props {
9+
params: Promise<{ username: string; id: string }>;
10+
}
11+
12+
export default async function Image({ params }: Props) {
13+
const { username, id } = await params;
14+
15+
let playbackId: string | null = null;
16+
let recTitle: string | null = null;
17+
let avatarUrl: string | null = null;
18+
let displayName = username;
19+
20+
try {
21+
const { rows } = await sql`
22+
SELECT r.playback_id, r.title, u.username, u.avatar
23+
FROM stream_recordings r
24+
JOIN users u ON u.id = r.user_id
25+
WHERE r.id = ${id} AND r.status = 'ready'
26+
LIMIT 1
27+
`;
28+
const rec = rows[0];
29+
if (rec) {
30+
playbackId = rec.playback_id ?? null;
31+
recTitle = rec.title ?? null;
32+
avatarUrl = rec.avatar ?? null;
33+
displayName = rec.username ?? username;
34+
}
35+
} catch {
36+
// Fall through to defaults
37+
}
38+
39+
const thumbUrl = playbackId
40+
? `https://image.mux.com/${playbackId}/thumbnail.jpg?width=1200&height=630&fit_mode=smartcrop`
41+
: null;
42+
43+
const title = recTitle ?? `${displayName}'s Past Stream`;
44+
45+
return new ImageResponse(
46+
<div
47+
style={{
48+
width: "1200px",
49+
height: "630px",
50+
display: "flex",
51+
position: "relative",
52+
fontFamily: "sans-serif",
53+
overflow: "hidden",
54+
background: "#07060f",
55+
}}
56+
>
57+
{/* Mux video thumbnail as full background */}
58+
{thumbUrl && (
59+
// eslint-disable-next-line @next/next/no-img-element
60+
<img
61+
src={thumbUrl}
62+
alt=""
63+
width={1200}
64+
height={630}
65+
style={{
66+
position: "absolute",
67+
inset: 0,
68+
width: "100%",
69+
height: "100%",
70+
objectFit: "cover",
71+
}}
72+
/>
73+
)}
74+
75+
{/* Dark gradient overlay — bottom-heavy so text stays readable */}
76+
<div
77+
style={{
78+
position: "absolute",
79+
inset: 0,
80+
background:
81+
"linear-gradient(to top, rgba(7,6,15,0.95) 0%, rgba(7,6,15,0.5) 50%, rgba(7,6,15,0.15) 100%)",
82+
display: "flex",
83+
}}
84+
/>
85+
86+
{/* Purple glow accent */}
87+
<div
88+
style={{
89+
position: "absolute",
90+
bottom: 0,
91+
left: 0,
92+
width: "100%",
93+
height: "60%",
94+
background:
95+
"radial-gradient(ellipse at 20% 100%, rgba(172,57,242,0.2) 0%, transparent 60%)",
96+
display: "flex",
97+
}}
98+
/>
99+
100+
{/* Content anchored to bottom-left */}
101+
<div
102+
style={{
103+
position: "absolute",
104+
bottom: "50px",
105+
left: "60px",
106+
right: "60px",
107+
display: "flex",
108+
flexDirection: "column",
109+
gap: "0px",
110+
}}
111+
>
112+
{/* Streamer row */}
113+
<div
114+
style={{
115+
display: "flex",
116+
alignItems: "center",
117+
gap: "16px",
118+
marginBottom: "16px",
119+
}}
120+
>
121+
{avatarUrl ? (
122+
// eslint-disable-next-line @next/next/no-img-element
123+
<img
124+
src={avatarUrl}
125+
alt={displayName}
126+
width={56}
127+
height={56}
128+
style={{
129+
borderRadius: "50%",
130+
border: "2.5px solid #ac39f2",
131+
objectFit: "cover",
132+
}}
133+
/>
134+
) : (
135+
<div
136+
style={{
137+
width: "56px",
138+
height: "56px",
139+
borderRadius: "50%",
140+
background: "#ac39f2",
141+
border: "2.5px solid #ac39f2",
142+
display: "flex",
143+
alignItems: "center",
144+
justifyContent: "center",
145+
fontSize: "24px",
146+
fontWeight: "700",
147+
color: "white",
148+
}}
149+
>
150+
{displayName.charAt(0).toUpperCase()}
151+
</div>
152+
)}
153+
<span
154+
style={{
155+
fontSize: "28px",
156+
fontWeight: "600",
157+
color: "rgba(255,255,255,0.85)",
158+
}}
159+
>
160+
{displayName}
161+
</span>
162+
</div>
163+
164+
{/* Recording title */}
165+
<div
166+
style={{
167+
fontSize: "46px",
168+
fontWeight: "800",
169+
color: "white",
170+
lineHeight: "1.15",
171+
display: "flex",
172+
}}
173+
>
174+
{title.length > 60 ? title.slice(0, 57) + "…" : title}
175+
</div>
176+
</div>
177+
178+
{/* StreamFi wordmark */}
179+
<div
180+
style={{
181+
position: "absolute",
182+
top: "44px",
183+
right: "56px",
184+
fontSize: "22px",
185+
fontWeight: "700",
186+
color: "#ac39f2",
187+
display: "flex",
188+
}}
189+
>
190+
StreamFi
191+
</div>
192+
193+
{/* Bottom purple line */}
194+
<div
195+
style={{
196+
position: "absolute",
197+
bottom: 0,
198+
left: 0,
199+
width: "100%",
200+
height: "4px",
201+
background: "linear-gradient(90deg, #ac39f2 0%, #7c3aed 100%)",
202+
display: "flex",
203+
}}
204+
/>
205+
</div>,
206+
{ ...size }
207+
);
208+
}

app/[username]/clips/[id]/page.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { use, useState, useEffect } from "react";
44
import { notFound } from "next/navigation";
55
import Link from "next/link";
66
import Image from "next/image";
7-
import { ArrowLeft, Clock, Calendar, Users } from "lucide-react";
7+
import { ArrowLeft, Clock, Calendar, Users, Share2 } from "lucide-react";
88
import MuxPlayer from "@/components/MuxPlayerLazy";
99
import { Button } from "@/components/ui/button";
1010
import { toast } from "sonner";
@@ -144,6 +144,15 @@ const ClipPlayerPage = ({ params }: PageProps) => {
144144
}
145145
};
146146

147+
const handleShare = async () => {
148+
try {
149+
await navigator.clipboard.writeText(window.location.href);
150+
toast.success("Link copied to clipboard");
151+
} catch {
152+
toast.error("Could not copy link");
153+
}
154+
};
155+
147156
const handleUnfollow = async () => {
148157
if (!loggedInUsername) {
149158
toast.error("You must be logged in to unfollow.");
@@ -268,19 +277,31 @@ const ClipPlayerPage = ({ params }: PageProps) => {
268277
</div>
269278
</div>
270279

271-
{!isOwner && (
280+
<div className="flex items-center gap-2 flex-shrink-0">
281+
{!isOwner && (
282+
<Button
283+
className={
284+
isFollowing
285+
? "bg-muted hover:bg-accent text-foreground border-none"
286+
: "bg-highlight hover:bg-highlight/90 text-white border-none"
287+
}
288+
onClick={isFollowing ? handleUnfollow : handleFollow}
289+
disabled={followLoading}
290+
>
291+
{followLoading ? "…" : isFollowing ? "Unfollow" : "Follow"}
292+
</Button>
293+
)}
272294
<Button
273-
className={
274-
isFollowing
275-
? "bg-muted hover:bg-accent text-foreground border-none flex-shrink-0"
276-
: "bg-highlight hover:bg-highlight/90 text-white border-none flex-shrink-0"
277-
}
278-
onClick={isFollowing ? handleUnfollow : handleFollow}
279-
disabled={followLoading}
295+
variant="outline"
296+
size="icon"
297+
onClick={handleShare}
298+
aria-label="Share recording"
299+
title="Copy link"
300+
className="bg-transparent border border-border hover:bg-accent text-foreground"
280301
>
281-
{followLoading ? "…" : isFollowing ? "Unfollow" : "Follow"}
302+
<Share2 className="w-4 h-4" />
282303
</Button>
283-
)}
304+
</div>
284305
</div>
285306
</div>
286307

0 commit comments

Comments
 (0)