Skip to content

Commit 094e62f

Browse files
🚀 feat: Implement reaction feature and video player enhancements
- Introduced `reactToUpload` action to handle emoji reactions for uploads. - Added `EmojiToolbar` and `Player` components to enhance interactive elements in video views. - Schema update to support reactions with new `Reaction` model. - Improved user experience with animated reactions and dynamic video loading indicators.
1 parent debf2e6 commit 094e62f

10 files changed

Lines changed: 238 additions & 19 deletions

File tree

apps/web/actions/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export * from './updateUploadTitle';
22
export * from './navigate';
3-
3+
export * from './reactToUpload';
44
export * from './changeProject';

apps/web/actions/reactToUpload.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use server';
2+
3+
import { getSession, parseZodError, prisma } from '@/app/utils';
4+
import { revalidatePath } from 'next/cache';
5+
import { z } from 'zod';
6+
7+
8+
export type EmojiType = "😍" | "🙌" | "😮" | "👍" | "👎";
9+
const allowedEmoji = ["😍", "🙌", "😮", "👍", "👎"] as const;
10+
11+
const reactSchema = z.object({
12+
uploadId: z.string(),
13+
emoji: z.enum(allowedEmoji),
14+
userId: z.string().optional(),
15+
});
16+
17+
export async function reactToUpload(params: { uploadId: string; emoji: typeof allowedEmoji[number]; }) {
18+
const session = await getSession();
19+
20+
const result = reactSchema.safeParse(params);
21+
if (!result.success) {
22+
throw new Error(parseZodError(result.error));
23+
}
24+
25+
const { uploadId, emoji } = result.data;
26+
27+
await prisma.reaction.create({
28+
data: {
29+
emoji,
30+
reactedBy: session?.user?.id ?? "anonymous",
31+
upload: {
32+
connect: {
33+
id: uploadId,
34+
},
35+
},
36+
},
37+
});
38+
39+
await revalidatePath(`/view/${uploadId}`);
40+
}
41+
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* ReactionToolbar Component
3+
*
4+
* This component allows users to react to uploads with emojis.
5+
* Credit https://github.com/mfts/reaction-demo/
6+
*/
7+
8+
"use client";
9+
import { reactToUpload, EmojiType } from "@/actions";
10+
import { useEffect, useRef, useState } from "react";
11+
import { toast } from "sonner";
12+
13+
type ReactionToolbarProps = {
14+
uploadId: string;
15+
};
16+
17+
export const ReactionToolbar = ({ uploadId }: ReactionToolbarProps) => {
18+
const [currentEmoji, setCurrentEmoji] = useState<{
19+
emoji: string;
20+
id: number;
21+
} | null>(null);
22+
const clearEmojiTimeout = useRef<any>(null);
23+
24+
useEffect(() => {
25+
return () => {
26+
if (clearEmojiTimeout.current) {
27+
clearTimeout(clearEmojiTimeout.current);
28+
}
29+
};
30+
}, []);
31+
32+
const handleEmojiClick = async (emoji: EmojiType) => {
33+
if (clearEmojiTimeout.current) {
34+
clearTimeout(clearEmojiTimeout.current);
35+
}
36+
37+
setCurrentEmoji({ emoji, id: Date.now() });
38+
39+
try {
40+
toast.promise(reactToUpload({ uploadId, emoji }), {
41+
loading: "Reacting...",
42+
success: () => {
43+
return `Added ${emoji} reaction!`;
44+
},
45+
error: `Error adding ${emoji} reaction`,
46+
});
47+
} catch (error) {
48+
console.error("Error reacting to upload:", error);
49+
}
50+
51+
clearEmojiTimeout.current = setTimeout(() => {
52+
setCurrentEmoji(null);
53+
}, 3000);
54+
};
55+
56+
const Emoji = ({ label, emoji }: { label: string; emoji: EmojiType }) => (
57+
<div className="relative w-fit">
58+
<button
59+
className="font-emoji text-2xl leading-6 bg-transparent p-1 relative transition-bg-color duration-600 inline-flex justify-center items-center align-middle rounded-full ease-in-out hover:bg-gray-200 active:bg-gray-400 active:duration-0 dark:hover:bg-gray-600 dark:active:bg-gray-700"
60+
role="img"
61+
aria-label={label ? label : ""}
62+
aria-hidden={label ? "false" : "true"}
63+
onClick={() => handleEmojiClick(emoji)}
64+
>
65+
{emoji}
66+
{currentEmoji && currentEmoji.emoji === emoji && (
67+
<span
68+
key={currentEmoji.id}
69+
className="font-emoji absolute -top-10 left-0 right-0 mx-auto animate-flyEmoji duration-3000"
70+
>
71+
{currentEmoji.emoji}
72+
</span>
73+
)}
74+
</button>
75+
</div>
76+
);
77+
78+
return (
79+
<>
80+
<div className="bg-white border border-gray-300 rounded-full mx-auto mt-4 mb-4 dark:bg-gray-800 dark:border-gray-600 w-auto inline-block">
81+
<div className="grid items-center justify-start">
82+
<div className="p-2">
83+
<div className="grid items-center justify-start grid-flow-col">
84+
{REACTIONS.map((reaction) => (
85+
<Emoji
86+
key={reaction.emoji}
87+
emoji={reaction.emoji}
88+
label={reaction.label}
89+
/>
90+
))}
91+
</div>
92+
</div>
93+
</div>
94+
</div>
95+
</>
96+
);
97+
};
98+
99+
const REACTIONS: { emoji: EmojiType; label: string }[] = [
100+
{
101+
emoji: "😍",
102+
label: "love",
103+
},
104+
{
105+
emoji: "🙌",
106+
label: "yay",
107+
},
108+
{
109+
emoji: "😮",
110+
label: "wow",
111+
},
112+
{
113+
emoji: "👍",
114+
label: "up",
115+
},
116+
{
117+
emoji: "👎",
118+
label: "down",
119+
},
120+
];
File renamed without changes.
File renamed without changes.
File renamed without changes.

apps/web/app/(view)/view/[id]/ViewHeader.tsx renamed to apps/web/app/(view)/view/[id]/_components/ViewHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
2-
import { UserUpload } from "./page";
2+
import { UserUpload } from "../page";
33
import { formatDistanceToNow } from "date-fns";
4-
import { ShareUploadButton } from "./ShareUploadButton";
4+
import { ShareUploadButton } from "../ShareUploadButton";
55
import {
66
HoverCard,
77
HoverCardContent,

apps/web/app/(view)/view/[id]/page.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Upload, User } from "@prisma/client";
2-
import Player, { ErrorBanner } from "../Player";
2+
import Player, { ErrorBanner } from "./_components/Player";
33
import { Metadata } from "next";
44
import Mux, { Upload as MuxUpload } from "@mux/mux-node";
55
import { posthog_serverside, prisma } from "@/app/utils";
6-
import { ViewHeader } from "./ViewHeader";
6+
import { ViewHeader } from "./_components/ViewHeader";
7+
import { ReactionToolbar } from "./_components/EmojiToolbar";
78

89
export type UserUpload = Upload & {
910
User: User | null;
@@ -63,7 +64,7 @@ export default async function View({ params }: { params: { id: string } }) {
6364
let errorMessage;
6465
let upload = await prisma.upload.findUnique({
6566
where: { id },
66-
include: { User: true },
67+
include: { User: true, reactions: true },
6768
});
6869

6970
posthog_serverside.capture({
@@ -101,7 +102,7 @@ export default async function View({ params }: { params: { id: string } }) {
101102
assetId: muxUpload.asset_id,
102103
playbackId,
103104
},
104-
include: { User: true },
105+
include: { User: true, reactions: true },
105106
});
106107
}
107108
}
@@ -122,6 +123,15 @@ export default async function View({ params }: { params: { id: string } }) {
122123
// Check if the video is ready
123124
const isReady = muxVideo?.status === "ready" ?? false;
124125

126+
const reactions = upload?.reactions.reduce((acc, reaction) => {
127+
if (acc[reaction.emoji]) {
128+
acc[reaction.emoji].count++;
129+
} else {
130+
acc[reaction.emoji] = { emoji: reaction.emoji, count: 1 };
131+
}
132+
return acc;
133+
}, {} as Record<string, { emoji: string; count: number }>);
134+
125135
return (
126136
<section className="relative">
127137
<div className="relative max-w-6xl mx-auto px-4 py-10 sm:px-6 lg:px-8">
@@ -132,7 +142,28 @@ export default async function View({ params }: { params: { id: string } }) {
132142
message={errorMessage ?? "Video could not be loaded"}
133143
/>
134144
) : (
135-
<Player id={id} video={upload} isUploadReady={isReady} />
145+
<>
146+
<Player id={id} video={upload} isUploadReady={isReady} />
147+
<ReactionToolbar uploadId={upload.id} />
148+
<div className="mt-8">
149+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
150+
Reactions
151+
</h2>
152+
<div className="mt-4 flex flex-wrap gap-4">
153+
{Object.values(reactions ?? {}).map(({ emoji, count }) => (
154+
<div
155+
key={emoji}
156+
className="flex items-center space-x-2 rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1"
157+
>
158+
<span className="text-xl">{emoji}</span>
159+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
160+
{count}
161+
</span>
162+
</div>
163+
))}
164+
</div>
165+
</div>
166+
</>
136167
)}
137168
</div>
138169
</div>

apps/web/prisma/schema.prisma

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,41 @@ datasource db {
99
}
1010

1111
model Upload {
12-
id String @id @default(cuid())
12+
id String @id @default(cuid())
1313
provider String // can be 'mux' or 'cloudflare'
14-
uploadLink String @db.VarChar(2048)
14+
uploadLink String @db.VarChar(2048)
1515
assetId String
1616
uploadId String
1717
playbackId String?
18-
status String @default("unknown")
19-
sourceTitle String? @default("ScreenLink Recording") @db.VarChar(2048)
20-
createdAt DateTime @default(now())
21-
updatedAt DateTime @updatedAt
22-
Project Project? @relation(fields: [projectId], references: [id])
18+
status String @default("unknown")
19+
sourceTitle String? @default("ScreenLink Recording") @db.VarChar(2048)
20+
createdAt DateTime @default(now())
21+
updatedAt DateTime @updatedAt
22+
Project Project? @relation(fields: [projectId], references: [id])
2323
projectId String?
24-
User User? @relation(fields: [userId], references: [id])
24+
User User? @relation(fields: [userId], references: [id])
2525
userId String?
26-
views Int @default(0)
26+
views Int @default(0)
2727
deviceId String?
28-
Device Devices? @relation(fields: [deviceId], references: [id])
28+
Device Devices? @relation(fields: [deviceId], references: [id])
29+
reactions Reaction[]
2930
3031
@@index([deviceId])
3132
@@index([userId])
3233
@@index([projectId])
3334
}
3435

36+
model Reaction {
37+
id String @id @default(cuid())
38+
emoji String
39+
reactedBy String @default("anonymous")
40+
createdAt DateTime @default(now())
41+
upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
42+
uploadId String
43+
44+
@@index([uploadId])
45+
}
46+
3547
model Account {
3648
id String @id @default(cuid())
3749
userId String

apps/web/tailwind.config.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,21 @@ module.exports = {
156156
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
157157
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
158158
},
159+
keyframes: {
160+
flyEmoji: {
161+
"0%": {
162+
transform: "translateY(0) scale(1)",
163+
opacity: "0.7",
164+
},
165+
"100%": {
166+
transform: "translateY(-150px) scale(2)",
167+
opacity: "0",
168+
},
169+
},
170+
},
171+
animation: {
172+
flyEmoji: "flyEmoji 1s forwards",
173+
},
159174
},
160175
},
161176
safelist: [
@@ -188,4 +203,4 @@ module.exports = {
188203
},
189204
],
190205
plugins: [require("tailwindcss-animate")],
191-
}
206+
};

0 commit comments

Comments
 (0)