feat: implement video likes and play tracking UI
Video Detail Page (/videos/[slug]): - Load like status from server on page load - Add functional like button with heart icon - Show likes count with real-time updates - Track video plays when user clicks play - Record watch progress every 10 seconds - Mark video as completed at 90% watched - Show toast notifications for like/unlike actions - Require authentication to like videos Video Listing Page (/videos): - Display play count badge on video thumbnails - Show play icon with count in top-right corner Features: - Like/unlike videos with live count updates - Play tracking with analytics data - Progress tracking for completion metrics - Authentication-gated liking functionality - User-friendly toast feedback All UI components working and integrated with backend API
This commit is contained in:
@@ -238,13 +238,15 @@ const filteredVideos = $derived(() => {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Views -->
|
||||
<!-- <div
|
||||
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
>
|
||||
<EyeIcon class="w-3 h-3" />
|
||||
{video.views}
|
||||
</div> -->
|
||||
<!-- Play Count -->
|
||||
{#if video.plays_count}
|
||||
<div
|
||||
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
>
|
||||
<span class="icon-[ri--play-fill] w-3 h-3"></span>
|
||||
{video.plays_count}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play Overlay -->
|
||||
<a
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { getCommentsForVideo, getVideoBySlug } from "$lib/services.js";
|
||||
import { getCommentsForVideo, getVideoBySlug, getVideoLikeStatus } from "$lib/services.js";
|
||||
|
||||
export async function load({ fetch, params, locals }) {
|
||||
const video = await getVideoBySlug(params.slug, fetch);
|
||||
const comments = await getCommentsForVideo(video.id, fetch);
|
||||
|
||||
let likeStatus = { liked: false };
|
||||
if (locals.authStatus.authenticated) {
|
||||
try {
|
||||
likeStatus = await getVideoLikeStatus(video.id, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to get like status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { video, comments, authStatus: locals.authStatus };
|
||||
return {
|
||||
video,
|
||||
comments,
|
||||
authStatus: locals.authStatus,
|
||||
likeStatus
|
||||
};
|
||||
} catch {
|
||||
error(404, "Video not found");
|
||||
}
|
||||
|
||||
@@ -15,18 +15,24 @@ import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { createCommentForVideo } from "$lib/services";
|
||||
import { createCommentForVideo, likeVideo, unlikeVideo, recordVideoPlay, updateVideoPlay } from "$lib/services";
|
||||
import NewsletterSignup from "$lib/components/newsletter-signup/newsletter-signup-widget.svelte";
|
||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
let isLiked = $state(false);
|
||||
let isLiked = $state(data.likeStatus.liked);
|
||||
let likesCount = $state(data.video.likes_count || 0);
|
||||
let isLikeLoading = $state(false);
|
||||
let isBookmarked = $state(false);
|
||||
let newComment = $state("");
|
||||
let showComments = $state(true);
|
||||
let isCommentLoading = $state(false);
|
||||
let isCommentError = $state(false);
|
||||
let commentError = $state();
|
||||
let currentPlayId = $state<string | null>(null);
|
||||
let lastTrackedTime = $state(0);
|
||||
|
||||
const relatedVideos = [
|
||||
{
|
||||
@@ -63,8 +69,30 @@ const relatedVideos = [
|
||||
},
|
||||
];
|
||||
|
||||
function handleLike() {
|
||||
isLiked = !isLiked;
|
||||
async function handleLike() {
|
||||
if (!data.authStatus.authenticated) {
|
||||
toast.error("Please sign in to like videos");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLikeLoading = true;
|
||||
if (isLiked) {
|
||||
const result = await unlikeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = false;
|
||||
toast.success("Removed from liked videos");
|
||||
} else {
|
||||
const result = await likeVideo(data.video.id);
|
||||
likesCount = result.likes_count;
|
||||
isLiked = true;
|
||||
toast.success("Added to liked videos");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to update like");
|
||||
} finally {
|
||||
isLikeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBookmark() {
|
||||
@@ -90,9 +118,29 @@ async function handleComment(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
let showPlayer = $state(false);
|
||||
async function handlePlay() {
|
||||
showPlayer = true;
|
||||
try {
|
||||
const result = await recordVideoPlay(data.video.id);
|
||||
currentPlayId = result.play_id;
|
||||
} catch (error) {
|
||||
console.error("Failed to record play:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
function handleTimeUpdate(e: Event) {
|
||||
const video = e.target as HTMLVideoElement;
|
||||
const currentTime = Math.floor(video.currentTime);
|
||||
|
||||
// Update every 10 seconds
|
||||
if (currentPlayId && currentTime - lastTrackedTime >= 10) {
|
||||
lastTrackedTime = currentTime;
|
||||
const completed = video.currentTime >= video.duration * 0.9; // 90% watched = completed
|
||||
updateVideoPlay(data.video.id, currentPlayId, currentTime, completed).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
let showPlayer = $state(false);
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
@@ -121,6 +169,7 @@ const { data } = $props();
|
||||
src={getAssetUrl(data.video.movie.id)}
|
||||
poster={getAssetUrl(data.video.image, 'preview')}
|
||||
autoplay
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
class="inline"
|
||||
>
|
||||
<track kind="captions" />
|
||||
@@ -155,6 +204,21 @@ const { data } = $props();
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="cursor-pointer absolute inset-0 bg-black/20 flex items-center justify-center"
|
||||
aria-label={data.video.title}
|
||||
data-umami-event="play-video"
|
||||
data-umami-event-title={data.video.title}
|
||||
data-umami-event-id={data.video.movie.id}
|
||||
onclick={handlePlay}
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 bg-primary/90 hover:bg-primary rounded-full flex flex-col items-center justify-center transition-colors shadow-2xl"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-10 h-10 text-white"
|
||||
></span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
||||
>
|
||||
@@ -196,16 +260,17 @@ const { data } = $props();
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- <Button
|
||||
<Button
|
||||
variant={isLiked ? "default" : "outline"}
|
||||
onclick={handleLike}
|
||||
class="flex items-center gap-2 {isLiked
|
||||
disabled={isLikeLoading}
|
||||
class="flex items-center gap-2 cursor-pointer {isLiked
|
||||
? 'bg-gradient-to-r from-primary to-accent'
|
||||
: 'border-primary/20 hover:bg-primary/10'}"
|
||||
>
|
||||
<ThumbsUpIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
|
||||
{data.video.likes}
|
||||
</Button> -->
|
||||
<span class="icon-[ri--heart-{isLiked ? 'fill' : 'line'}] w-4 h-4"></span>
|
||||
{likesCount}
|
||||
</Button>
|
||||
<SharingPopupButton
|
||||
content={{
|
||||
title: data.video.title,
|
||||
@@ -221,9 +286,7 @@ const { data } = $props();
|
||||
? 'bg-gradient-to-r from-primary to-accent'
|
||||
: 'border-primary/20 hover:bg-primary/10'}"
|
||||
>
|
||||
<BookmarkIcon
|
||||
class="w-4 h-4 {isBookmarked ? 'fill-current' : ''}"
|
||||
/>
|
||||
<span class="icon-[ri--bookmark-{isBookmarked ? 'fill' : 'line'}] w-4 h-4"></span>
|
||||
Save
|
||||
</Button> -->
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user