diff --git a/docs/killrvideo_openapi.yaml b/docs/killrvideo_openapi.yaml index 6fc07ed..64e761b 100644 --- a/docs/killrvideo_openapi.yaml +++ b/docs/killrvideo_openapi.yaml @@ -262,7 +262,11 @@ paths: \ Authenticated *viewer*-level callers receive 403.\n \u2013 The owner\ \ (*creator*) or any *moderator* can still access \u2192 404 to\n remain\ \ consistent with current spec (not explicitly tested yet).\n\u2022 A READY\ - \ video is public: anyone can record a view (204)." + \ video is public: anyone can record a view (204).\n\nActivity logging: When\ + \ an authenticated user records a view, the backend **must** write a row to\ + \ the `user_activity` table with `activity_type = 'view'`, including the caller's\ + \ `userid`, the `videoid`, and an `activity_timestamp` (timeuuid). Unauthenticated\ + \ views increment the counter only and do not produce an activity row." operationId: record_view_api_v1_videos_id__video_id_path__view_post security: - OAuth2PasswordBearer: [] @@ -714,7 +718,10 @@ paths: tags: - Comments & Ratings summary: Add comment to video - description: Endpoint for viewers to add a comment to a READY video. + description: "Endpoint for viewers to add a comment to a READY video.\n\nActivity\ + \ logging: On success the backend **must** write a row to the `user_activity`\ + \ table with `activity_type = 'comment'`, including the caller's `userid`,\ + \ the `videoid`, and an `activity_timestamp` (timeuuid)." operationId: post_comment_to_video_api_v1_videos__video_id_path__comments_post security: - OAuth2PasswordBearer: [] @@ -845,7 +852,11 @@ paths: tags: - Comments & Ratings summary: Rate a video (create or update) - description: Upsert a rating (1-5) for the specified video by the current viewer. + description: "Upsert a rating (1-5) for the specified video by the current viewer.\n\ + \nActivity logging: On success the backend **must** write a row to the `user_activity`\ + \ table with `activity_type = 'rate'`, including the caller's `userid`, the\ + \ `videoid`, and an `activity_timestamp` (timeuuid). This row is written for\ + \ both new ratings and updates to an existing rating." operationId: post_rating_video_api_v1_videos__video_id_path__ratings_post security: - OAuth2PasswordBearer: [] @@ -1271,6 +1282,66 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/v1/users/{user_id_path}/activity: + get: + tags: + - User Activity + summary: Get user activity timeline + description: Return a paginated timeline of a user's activity over the last + 30 days. + operationId: get_user_activity_api_v1_users__user_id_path__activity_get + parameters: + - name: user_id_path + in: path + required: true + schema: + type: string + format: uuid + title: User Id Path + - name: activity_type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Filter by activity type (view, comment, rate) + title: Activity Type + description: Filter by activity type (view, comment, rate) + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page + description: Page number + - name: pageSize + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + description: Items per page + default: 10 + title: Pagesize + description: Items per page + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse_UserActivityResponse_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /: get: summary: Health check @@ -1579,6 +1650,20 @@ components: - data - pagination title: PaginatedResponse[FlagResponse] + PaginatedResponse_UserActivityResponse_: + properties: + data: + items: + $ref: '#/components/schemas/UserActivityResponse' + type: array + title: Data + pagination: + $ref: '#/components/schemas/Pagination' + type: object + required: + - data + - pagination + title: PaginatedResponse[UserActivityResponse] PaginatedResponse_VideoSummary_: properties: data: @@ -1743,6 +1828,40 @@ components: - email - userId title: User + UserActivityResponse: + properties: + userid: + type: string + format: uuid + title: Userid + activity_type: + type: string + enum: + - view + - comment + - rate + title: Activity Type + activity_id: + type: string + format: uuid + title: Activity Id + activity_timestamp: + type: string + format: date-time + title: Activity Timestamp + videoid: + type: string + format: uuid + title: Videoid + type: object + required: + - userid + - activity_type + - activity_id + - activity_timestamp + - videoid + title: UserActivityResponse + description: API response representation for a single user activity item. UserCreateRequest: properties: firstName: @@ -1873,7 +1992,7 @@ components: description: anyOf: - type: string - maxLength: 1000 + maxLength: 2000 - type: 'null' title: Description tags: @@ -2127,7 +2246,7 @@ components: description: anyOf: - type: string - maxLength: 1000 + maxLength: 2000 - type: 'null' title: Description tags: diff --git a/eslint.config.js b/eslint.config.js index e67846f..1077168 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["dist", ".claude"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], @@ -25,5 +25,11 @@ export default tseslint.config( ], "@typescript-eslint/no-unused-vars": "off", }, + }, + { + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": "off", + }, } ); diff --git a/src/components/comments/CommentsSection.tsx b/src/components/comments/CommentsSection.tsx index 7f07472..149573c 100644 --- a/src/components/comments/CommentsSection.tsx +++ b/src/components/comments/CommentsSection.tsx @@ -34,6 +34,7 @@ const CommentsSection = ({ videoId }: CommentsSectionProps) => { // Append new comments when page data arrives useEffect(() => { if (commentPage) { + // eslint-disable-next-line react-hooks/set-state-in-effect setComments((prev) => page === 1 ? (commentPage.data as Comment[]) : [...prev, ...(commentPage.data as Comment[])] ); diff --git a/src/components/educational/ExplainerModal.tsx b/src/components/educational/ExplainerModal.tsx index 58ed5ea..19b1ee7 100644 --- a/src/components/educational/ExplainerModal.tsx +++ b/src/components/educational/ExplainerModal.tsx @@ -34,6 +34,7 @@ export const ExplainerModal = ({ const abortController = new AbortController(); + // eslint-disable-next-line react-hooks/set-state-in-effect setIsLoading(true); setError(null); diff --git a/src/components/educational/WelcomeModal.tsx b/src/components/educational/WelcomeModal.tsx index 6d19759..7073488 100644 --- a/src/components/educational/WelcomeModal.tsx +++ b/src/components/educational/WelcomeModal.tsx @@ -24,6 +24,7 @@ export const WelcomeModal = () => { // Show modal if not welcomed and tour is not already enabled if (!hasBeenWelcomed && !guidedTourEnabled) { + // eslint-disable-next-line react-hooks/set-state-in-effect setOpen(true); } }, [guidedTourEnabled]); diff --git a/src/components/profile/ActivityTimeline.tsx b/src/components/profile/ActivityTimeline.tsx new file mode 100644 index 0000000..4db340e --- /dev/null +++ b/src/components/profile/ActivityTimeline.tsx @@ -0,0 +1,246 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useUserActivity } from '@/hooks/useApi'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Eye, MessageSquare, Star, Activity, ChevronLeft, ChevronRight } from 'lucide-react'; +import { UserActivity } from '@/types/api'; +import { PAGINATION } from '@/lib/constants'; + +const PAGE_SIZE = PAGINATION.DEFAULT_PAGE_SIZE; + +type ActivityFilter = 'all' | 'view' | 'comment' | 'rate'; + +function formatRelativeTime(timestamp: string): string { + const now = Date.now(); + const then = new Date(timestamp).getTime(); + const diffSeconds = Math.floor((now - then) / 1000); + + if (diffSeconds < 60) return 'just now'; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays === 1) return 'yesterday'; + if (diffDays < 30) return `${diffDays} days ago`; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`; + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`; +} + +function activityIcon(type: UserActivity['activity_type']) { + switch (type) { + case 'view': + return ; + case 'comment': + return ; + case 'rate': + return ; + } +} + +function activityBadgeVariant(type: UserActivity['activity_type']): 'default' | 'secondary' | 'outline' { + switch (type) { + case 'view': + return 'secondary'; + case 'comment': + return 'default'; + case 'rate': + return 'outline'; + } +} + +function activityLabel(type: UserActivity['activity_type']): string { + switch (type) { + case 'view': + return 'Viewed'; + case 'comment': + return 'Commented'; + case 'rate': + return 'Rated'; + } +} + +interface ActivityItemRowProps { + activity: UserActivity; +} + +const ActivityItemRow = ({ activity }: ActivityItemRowProps) => { + return ( +
+
+ {activityIcon(activity.activity_type)} +
+
+
+ + {activityLabel(activity.activity_type)} + + + Video: {activity.videoid} + +
+

+ {formatRelativeTime(activity.activity_timestamp)} +

+
+
+ ); +}; + +interface ActivityListProps { + userId: string; + activityType?: string; +} + +const ActivityList = ({ userId, activityType }: ActivityListProps) => { + const [page, setPage] = useState(1); + + const { data, isLoading, error } = useUserActivity( + userId, + activityType, + page, + PAGE_SIZE, + ); + + const activities = data?.data ?? []; + const pagination = data?.pagination; + const totalPages = pagination?.totalPages ?? 1; + + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+

Failed to load activity. Please try again later.

+
+ ); + } + + if (activities.length === 0) { + return ( +
+ +

No activity found.

+
+ ); + } + + return ( +
+
+ {activities.map((activity) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + + Page {page} of {totalPages} + + + +
+ )} + + {pagination && ( +

+ Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, pagination.totalItems)} of {pagination.totalItems} activities +

+ )} +
+ ); +}; + +interface ActivityTimelineProps { + userId: string; +} + +const ActivityTimeline = ({ userId }: ActivityTimelineProps) => { + const [activeTab, setActiveTab] = useState('all'); + + const activityTypeParam = activeTab === 'all' ? undefined : activeTab; + + return ( + + + Activity Timeline + + Your recent video views, comments, and ratings + + + + setActiveTab(val as ActivityFilter)} + className="space-y-4" + > + + + + All + + + + Views + + + + Comments + + + + Ratings + + + + + + + + + + ); +}; + +export default ActivityTimeline; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 1a566bf..16e32cf 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -649,9 +649,7 @@ const SidebarMenuSkeleton = React.forwardRef< } >(({ className, showIcon = false, ...props }, ref) => { // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }, []) + const [width] = React.useState(() => `${Math.floor(Math.random() * 40) + 50}%`) return (
{ export const useSubmitVideo = () => { const queryClient = useQueryClient(); - + return useMutation({ mutationFn: (data: VideoSubmitRequest) => apiClient.submitVideo(data), @@ -82,6 +84,12 @@ export const useSubmitVideo = () => { }); }; +export const usePreviewVideo = () => { + return useMutation({ + mutationFn: (youtubeUrl: string) => apiClient.previewYoutubeVideo(youtubeUrl), + }); +}; + export const useUpdateVideo = () => { const queryClient = useQueryClient(); @@ -402,6 +410,21 @@ export const useRevokeModerator = () => { }); }; +// User activity timeline hook +export const useUserActivity = ( + userId: string | undefined, + activityType?: string, + page?: number, + pageSize?: number +) => { + return useQuery({ + queryKey: ['userActivity', userId, activityType, page, pageSize], + queryFn: () => apiClient.getUserActivity(userId!, activityType, page, pageSize), + enabled: !!userId, + staleTime: CACHE_STRATEGY.SHORT, + }); +}; + // Public user fetch by ID (new endpoint) export const useUser = (userId: string) => { return useQuery({ @@ -412,13 +435,14 @@ export const useUser = (userId: string) => { }); }; +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Prefetch multiple users in parallel to avoid N+1 queries in VideoCard. * Returns a map of userId -> display name for easy lookup. * Uses useQueries for proper React Query integration. */ export const useUserNames = (userIds: string[]) => { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // Memoize unique IDs to prevent unnecessary re-renders const uniqueIds = useMemo(() => { diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index a85fd7f..6a0ac99 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -72,6 +72,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { ); }; +// eslint-disable-next-line react-refresh/only-export-components export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { diff --git a/src/hooks/useTooltipContent.ts b/src/hooks/useTooltipContent.ts index 5ab341c..e927636 100644 --- a/src/hooks/useTooltipContent.ts +++ b/src/hooks/useTooltipContent.ts @@ -39,6 +39,7 @@ export const useTooltipContent = (tooltipId: string): TooltipContent => { useEffect(() => { if (!metadata) { + // eslint-disable-next-line react-hooks/set-state-in-effect setError(new Error(`Tooltip ID "${tooltipId}" not found in manifest`)); return; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 0a78dee..ab028e2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -101,6 +101,13 @@ class ApiClient { } // Video endpoints + async previewYoutubeVideo(youtubeUrl: string): Promise { + return this.request('/videos/preview', { + method: 'POST', + body: JSON.stringify({ youtubeUrl }), + }); + } + async submitVideo(data: import('../types/api').VideoSubmitRequest): Promise { return this.request('/videos', { method: 'POST', @@ -130,7 +137,7 @@ class ApiClient { } async getLatestVideos(page: number = PAGINATION.DEFAULT_PAGE, pageSize: number = PAGINATION.DEFAULT_PAGE_SIZE): Promise { - return this.request(`/videos/latest?page=${page}&page_size=${pageSize}`); + return this.request(`/videos/latest?page=${page}&pageSize=${pageSize}`); } async getTrendingVideos(days: number = 1, limit: number = PAGINATION.DEFAULT_PAGE_SIZE): Promise> { @@ -262,6 +269,20 @@ class ApiClient { async getUser(userId: string): Promise { return this.request(`/users/${userId}`); } + + async getUserActivity( + userId: string, + activityType?: string, + page?: number, + pageSize?: number + ): Promise { + const params = new URLSearchParams(); + if (activityType) params.set('activity_type', activityType); + if (page) params.set('page', String(page)); + if (pageSize) params.set('pageSize', String(pageSize)); + const query = params.toString() ? `?${params.toString()}` : ''; + return this.request(`/users/${userId}/activity${query}`); + } } export const apiClient = new ApiClient(API_BASE_URL); \ No newline at end of file diff --git a/src/pages/Creator.tsx b/src/pages/Creator.tsx index 0ee5aa5..48557c0 100644 --- a/src/pages/Creator.tsx +++ b/src/pages/Creator.tsx @@ -47,6 +47,7 @@ export default function Creator() { // When full video arrives, initialize edit form useEffect(() => { if (fullVideo) { + // eslint-disable-next-line react-hooks/set-state-in-effect setEditingVideo(fullVideo as VideoDetailResponse); setEditForm({ title: fullVideo.title, diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index aa5ee99..1a7e3cd 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -8,11 +8,12 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Separator } from '@/components/ui/separator'; -import { User, MessageSquare } from 'lucide-react'; +import { User, MessageSquare, Activity } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { apiClient } from '@/lib/api'; import { CommentResponse } from '@/types/api'; import { PAGINATION } from '@/lib/constants'; +import ActivityTimeline from '@/components/profile/ActivityTimeline'; const Profile = () => { const { user, isLoading } = useAuth(); @@ -106,6 +107,10 @@ const Profile = () => { My Comments + + + Activity + @@ -258,6 +263,10 @@ const Profile = () => { + + + +
diff --git a/src/pages/Watch.tsx b/src/pages/Watch.tsx index 80a1745..c32febb 100644 --- a/src/pages/Watch.tsx +++ b/src/pages/Watch.tsx @@ -17,7 +17,6 @@ import RelatedVideos from '@/components/video/RelatedVideos'; import { useAuth } from '@/hooks/useAuth'; import ReportFlagDialog from '@/components/moderation/ReportFlagDialog'; import { EducationalTooltip } from '@/components/educational/EducationalTooltip'; - // Utilities const formatNumber = (raw?: number | null) => { const num = raw ?? 0; @@ -66,6 +65,7 @@ const Watch = () => { } }, [video, id, recordViewMutate]); + return (
diff --git a/src/types/api.ts b/src/types/api.ts index d21cd2d..83d1ff5 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -154,6 +154,10 @@ export interface RatingCreateOrUpdateRequest { rating: number; } +export interface VideoPreviewResponse { + title: string; +} + export interface FlagCreateRequest { contentType: 'video' | 'comment'; contentId: string; @@ -179,3 +183,13 @@ export interface ApiError { detail?: string; instance?: string; } + +export interface UserActivity { + userid: string; + activity_type: 'view' | 'comment' | 'rate'; + activity_id: string; + activity_timestamp: string; + videoid: string; +} + +export type UserActivityResponse = PaginatedResponse;