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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Views -->
|
<!-- Play Count -->
|
||||||
<!-- <div
|
{#if video.plays_count}
|
||||||
class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
<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}
|
<span class="icon-[ri--play-fill] w-3 h-3"></span>
|
||||||
</div> -->
|
{video.plays_count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
<!-- Play Overlay -->
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
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 }) {
|
export async function load({ fetch, params, locals }) {
|
||||||
const video = await getVideoBySlug(params.slug, fetch);
|
const video = await getVideoBySlug(params.slug, fetch);
|
||||||
const comments = await getCommentsForVideo(video.id, 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 {
|
try {
|
||||||
return { video, comments, authStatus: locals.authStatus };
|
return {
|
||||||
|
video,
|
||||||
|
comments,
|
||||||
|
authStatus: locals.authStatus,
|
||||||
|
likeStatus
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
error(404, "Video not found");
|
error(404, "Video not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,24 @@ import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
|||||||
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
import { formatVideoDuration, getUserInitials } from "$lib/utils";
|
||||||
import { invalidateAll } from "$app/navigation";
|
import { invalidateAll } from "$app/navigation";
|
||||||
import { toast } from "svelte-sonner";
|
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 NewsletterSignup from "$lib/components/newsletter-signup/newsletter-signup-widget.svelte";
|
||||||
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
import SharingPopupButton from "$lib/components/sharing-popup/sharing-popup-button.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
const timeAgo = new TimeAgo("en");
|
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 isBookmarked = $state(false);
|
||||||
let newComment = $state("");
|
let newComment = $state("");
|
||||||
let showComments = $state(true);
|
let showComments = $state(true);
|
||||||
let isCommentLoading = $state(false);
|
let isCommentLoading = $state(false);
|
||||||
let isCommentError = $state(false);
|
let isCommentError = $state(false);
|
||||||
let commentError = $state();
|
let commentError = $state();
|
||||||
|
let currentPlayId = $state<string | null>(null);
|
||||||
|
let lastTrackedTime = $state(0);
|
||||||
|
|
||||||
const relatedVideos = [
|
const relatedVideos = [
|
||||||
{
|
{
|
||||||
@@ -63,8 +69,30 @@ const relatedVideos = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleLike() {
|
async function handleLike() {
|
||||||
isLiked = !isLiked;
|
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() {
|
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>
|
</script>
|
||||||
|
|
||||||
<Meta
|
<Meta
|
||||||
@@ -121,6 +169,7 @@ const { data } = $props();
|
|||||||
src={getAssetUrl(data.video.movie.id)}
|
src={getAssetUrl(data.video.movie.id)}
|
||||||
poster={getAssetUrl(data.video.image, 'preview')}
|
poster={getAssetUrl(data.video.image, 'preview')}
|
||||||
autoplay
|
autoplay
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
class="inline"
|
class="inline"
|
||||||
>
|
>
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
@@ -155,6 +204,21 @@ const { data } = $props();
|
|||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
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 -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<!-- <Button
|
<Button
|
||||||
variant={isLiked ? "default" : "outline"}
|
variant={isLiked ? "default" : "outline"}
|
||||||
onclick={handleLike}
|
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'
|
? 'bg-gradient-to-r from-primary to-accent'
|
||||||
: 'border-primary/20 hover:bg-primary/10'}"
|
: 'border-primary/20 hover:bg-primary/10'}"
|
||||||
>
|
>
|
||||||
<ThumbsUpIcon class="w-4 h-4 {isLiked ? 'fill-current' : ''}" />
|
<span class="icon-[ri--heart-{isLiked ? 'fill' : 'line'}] w-4 h-4"></span>
|
||||||
{data.video.likes}
|
{likesCount}
|
||||||
</Button> -->
|
</Button>
|
||||||
<SharingPopupButton
|
<SharingPopupButton
|
||||||
content={{
|
content={{
|
||||||
title: data.video.title,
|
title: data.video.title,
|
||||||
@@ -221,9 +286,7 @@ const { data } = $props();
|
|||||||
? 'bg-gradient-to-r from-primary to-accent'
|
? 'bg-gradient-to-r from-primary to-accent'
|
||||||
: 'border-primary/20 hover:bg-primary/10'}"
|
: 'border-primary/20 hover:bg-primary/10'}"
|
||||||
>
|
>
|
||||||
<BookmarkIcon
|
<span class="icon-[ri--bookmark-{isBookmarked ? 'fill' : 'line'}] w-4 h-4"></span>
|
||||||
class="w-4 h-4 {isBookmarked ? 'fill-current' : ''}"
|
|
||||||
/>
|
|
||||||
Save
|
Save
|
||||||
</Button> -->
|
</Button> -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user