From 4eb176ebd8da267f15cbdf4b9e448bfb9d48dbb5 Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 16:51:38 -0300 Subject: [PATCH 1/5] feat(home): enrich list cards and add stats + activity feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: extend GET /api/lists to return poster_urls (up to 4 TMDB thumbnails), member previews (up to 5 avatars), and last_activity_at per list via batch SQL queries - Backend: new GET /api/users/stats → watched_this_month count - Backend: new GET /api/users/activity?limit=N → UNION-based feed of movie_added, movie_watched, comment, and member_joined events - Frontend: skeleton loading for lists and activity panel on initial load - Frontend: list cards now show poster stack, member avatars, and relative last-activity timestamp - Frontend: stats bar showing films watched this month - Frontend: activity feed sidebar panel (sticky on desktop) - i18n: added home.* and lists.lastActivity/justNow keys (pt + en) Co-Authored-By: Claude Sonnet 4.6 --- backend/controllers/list_controller.go | 130 +++++- backend/daos/list_dao.go | 210 +++++++++ backend/services/list_service.go | 55 +++ frontend/src/locales/en.json | 15 +- frontend/src/locales/pt.json | 15 +- frontend/src/pages/Home.tsx | 579 ++++++++++++++++--------- frontend/src/services/lists.ts | 47 ++ 7 files changed, 845 insertions(+), 206 deletions(-) diff --git a/backend/controllers/list_controller.go b/backend/controllers/list_controller.go index 219aa10..bbde2f7 100644 --- a/backend/controllers/list_controller.go +++ b/backend/controllers/list_controller.go @@ -48,6 +48,10 @@ func NewListController(router *gin.Engine, service services.ListService, recomme group.POST("/:id/movies/:movieId/comments", c.authMiddleware.Handler(), c.createComment) group.PATCH("/:id/movies/:movieId/comments/:commentId", c.authMiddleware.Handler(), c.updateComment) group.DELETE("/:id/movies/:movieId/comments/:commentId", c.authMiddleware.Handler(), c.deleteComment) + // User home routes + userGroup := router.Group("/api/users") + userGroup.GET("/stats", c.authMiddleware.Handler(), c.getUserStats) + userGroup.GET("/activity", c.authMiddleware.Handler(), c.getUserActivity) return c } @@ -138,19 +142,45 @@ func (c *ListController) list(ctx *gin.Context) { return } + listIDs := make([]int64, 0, len(memberships)) + for _, m := range memberships { + listIDs = append(listIDs, m.ListID) + } + posterURLs, memberPreviews, lastActivityMap, enrichErr := c.service.FetchListEnrichments(listIDs) + if enrichErr != nil { + posterURLs = map[int64][]string{} + memberPreviews = map[int64][]daos.MemberPreview{} + lastActivityMap = map[int64]time.Time{} + } + lists := make([]gin.H, 0, len(memberships)) for _, m := range memberships { l := m.List + lastActivity := l.UpdatedAt + if t, ok := lastActivityMap[l.ID]; ok && t.After(lastActivity) { + lastActivity = t + } + membersPayload := make([]gin.H, 0) + for _, mp := range memberPreviews[l.ID] { + membersPayload = append(membersPayload, gin.H{ + "user_id": mp.UserID, + "username": mp.Username, + "avatar_url": mp.AvatarURL, + }) + } lists = append(lists, gin.H{ - "id": l.ID, - "name": l.Name, - "description": l.Description, - "invite_code": l.InviteCode, - "your_role": m.Role, - "created_at": l.CreatedAt, - "updated_at": l.UpdatedAt, - "member_count": memberCounts[l.ID], - "movie_count": movieCounts[l.ID], + "id": l.ID, + "name": l.Name, + "description": l.Description, + "invite_code": l.InviteCode, + "your_role": m.Role, + "created_at": l.CreatedAt, + "updated_at": l.UpdatedAt, + "member_count": memberCounts[l.ID], + "movie_count": movieCounts[l.ID], + "poster_urls": posterURLs[l.ID], + "members": membersPayload, + "last_activity_at": lastActivity, }) } @@ -1783,6 +1813,88 @@ func (c *ListController) deleteComment(ctx *gin.Context) { }) } +func (c *ListController) getUserStats(ctx *gin.Context) { + rawClaims, _ := ctx.Get("auth_claims") + claims := rawClaims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + userID, err := strconv.ParseInt(sub, 10, 64) + if err != nil { + respondTokenInvalid(ctx) + return + } + stats, err := c.service.GetUserStats(userID) + if err != nil { + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch stats", + "code": "INTERNAL_ERROR", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, stats) +} + +func (c *ListController) getUserActivity(ctx *gin.Context) { + rawClaims, _ := ctx.Get("auth_claims") + claims := rawClaims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + userID, err := strconv.ParseInt(sub, 10, 64) + if err != nil { + respondTokenInvalid(ctx) + return + } + limit := 20 + if v := ctx.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + if n > 50 { + n = 50 + } + limit = n + } + } + items, err := c.service.GetUserActivity(userID, limit) + if err != nil { + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch activity", + "code": "INTERNAL_ERROR", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + return + } + payload := make([]gin.H, 0, len(items)) + for _, item := range items { + entry := gin.H{ + "type": item.Type, + "timestamp": item.Timestamp, + "list_id": item.ListID, + "list_name": item.ListName, + } + if item.MovieID != nil { + entry["movie_id"] = item.MovieID + } + if item.MovieTitle != nil { + entry["movie_title"] = item.MovieTitle + } + if item.MoviePosterPath != nil { + entry["movie_poster_url"] = item.MoviePosterPath + } + if item.UserID != nil { + entry["user_id"] = item.UserID + entry["username"] = item.Username + entry["avatar_url"] = item.AvatarURL + } + payload = append(payload, entry) + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "activity": payload, + "count": len(payload), + }) +} + func (c *ListController) getRecommendations(ctx *gin.Context) { idParam := ctx.Param("id") listID, err := strconv.ParseInt(idParam, 10, 64) diff --git a/backend/daos/list_dao.go b/backend/daos/list_dao.go index 9af8cad..31cb3ef 100644 --- a/backend/daos/list_dao.go +++ b/backend/daos/list_dao.go @@ -10,6 +10,27 @@ import ( "gorm.io/gorm/clause" ) +// MemberPreview is a lightweight user struct returned in batch list enrichment. +type MemberPreview struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + AvatarURL *string `json:"avatar_url"` +} + +// ActivityItem represents one event in the user's home-page activity feed. +type ActivityItem struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + ListID int64 `json:"list_id"` + ListName string `json:"list_name"` + MovieID *int64 `json:"movie_id,omitempty"` + MovieTitle *string `json:"movie_title,omitempty"` + MoviePosterPath *string `json:"movie_poster_path,omitempty"` + UserID *int64 `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` +} + type MovieListDAO interface { InviteCodeExists(code string) (bool, error) CreateWithOwner(list *models.MovieList, ownerUserID int64) error @@ -42,6 +63,12 @@ type MovieListDAO interface { FindCommentByID(commentID int64) (*models.Comment, error) UpdateComment(commentID int64, content string) (*models.Comment, error) DeleteComment(commentID int64) error + // Home enrichment methods + FetchPostersBatch(listIDs []int64) (map[int64][]string, error) + FetchMembersBatch(listIDs []int64) (map[int64][]MemberPreview, error) + FetchLastActivityBatch(listIDs []int64) (map[int64]time.Time, error) + CountWatchedThisMonth(userID int64) (int64, error) + FetchRecentActivity(userID int64, limit int) ([]ActivityItem, error) } type movieListDAO struct { @@ -560,3 +587,186 @@ func (d *movieListDAO) UpdateComment(commentID int64, content string) (*models.C func (d *movieListDAO) DeleteComment(commentID int64) error { return d.db.Delete(&models.Comment{}, commentID).Error } + +func (d *movieListDAO) FetchPostersBatch(listIDs []int64) (map[int64][]string, error) { + result := make(map[int64][]string) + if len(listIDs) == 0 { + return result, nil + } + type posterRow struct { + ListID int64 `gorm:"column:list_id"` + PosterPath string `gorm:"column:poster_path"` + } + var rows []posterRow + if err := d.db.Table("list_movies lm"). + Select("lm.list_id AS list_id, m.poster_path AS poster_path"). + Joins("JOIN movies m ON m.id = lm.movie_id"). + Where("lm.list_id IN ? AND m.poster_path IS NOT NULL AND m.poster_path != ''", listIDs). + Order("lm.list_id, lm.display_order ASC, lm.added_at DESC"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if len(result[r.ListID]) < 4 { + result[r.ListID] = append(result[r.ListID], r.PosterPath) + } + } + return result, nil +} + +func (d *movieListDAO) FetchMembersBatch(listIDs []int64) (map[int64][]MemberPreview, error) { + result := make(map[int64][]MemberPreview) + if len(listIDs) == 0 { + return result, nil + } + type memberRow struct { + ListID int64 `gorm:"column:list_id"` + UserID int64 `gorm:"column:user_id"` + Username string `gorm:"column:username"` + AvatarURL *string `gorm:"column:avatar_url"` + } + var rows []memberRow + if err := d.db.Table("list_members lm"). + Select("lm.list_id AS list_id, u.id AS user_id, u.username AS username, u.avatar_url AS avatar_url"). + Joins("JOIN users u ON u.id = lm.user_id"). + Where("lm.list_id IN ?", listIDs). + Order("lm.list_id, lm.added_at ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if len(result[r.ListID]) < 5 { + result[r.ListID] = append(result[r.ListID], MemberPreview{ + UserID: r.UserID, + Username: r.Username, + AvatarURL: r.AvatarURL, + }) + } + } + return result, nil +} + +func (d *movieListDAO) FetchLastActivityBatch(listIDs []int64) (map[int64]time.Time, error) { + result := make(map[int64]time.Time) + if len(listIDs) == 0 { + return result, nil + } + type activityTimeRow struct { + ListID int64 `gorm:"column:list_id"` + LastAdded *time.Time `gorm:"column:last_added"` + LastWatched *time.Time `gorm:"column:last_watched"` + } + var rows []activityTimeRow + if err := d.db.Table("list_movies"). + Select("list_id, MAX(added_at) AS last_added, MAX(watched_at) AS last_watched"). + Where("list_id IN ?", listIDs). + Group("list_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + var t time.Time + if r.LastAdded != nil && r.LastAdded.After(t) { + t = *r.LastAdded + } + if r.LastWatched != nil && r.LastWatched.After(t) { + t = *r.LastWatched + } + if !t.IsZero() { + result[r.ListID] = t + } + } + return result, nil +} + +func (d *movieListDAO) CountWatchedThisMonth(userID int64) (int64, error) { + now := time.Now() + firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + var count int64 + if err := d.db.Table("list_movies lm"). + Joins("JOIN list_members mem ON mem.list_id = lm.list_id AND mem.user_id = ?", userID). + Where("lm.status = ? AND lm.watched_at >= ?", string(models.StatusWatched), firstOfMonth). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (d *movieListDAO) FetchRecentActivity(userID int64, limit int) ([]ActivityItem, error) { + if limit <= 0 { + limit = 20 + } + if limit > 50 { + limit = 50 + } + sql := ` + (SELECT 'movie_added' AS activity_type, lm.added_at AS ts, lm.list_id, ml.name AS list_name, + lm.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path, + u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url + FROM list_movies lm + JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL + JOIN movies m ON m.id = lm.movie_id + LEFT JOIN users u ON u.id = lm.added_by + WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?)) + UNION ALL + (SELECT 'movie_watched' AS activity_type, lm.watched_at AS ts, lm.list_id, ml.name AS list_name, + lm.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path, + NULL AS actor_user_id, NULL AS actor_username, NULL AS actor_avatar_url + FROM list_movies lm + JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL + JOIN movies m ON m.id = lm.movie_id + WHERE lm.watched_at IS NOT NULL AND lm.status = 'watched' + AND EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?)) + UNION ALL + (SELECT 'comment' AS activity_type, c.created_at AS ts, c.list_id, ml.name AS list_name, + c.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path, + u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url + FROM comments c + JOIN movie_lists ml ON ml.id = c.list_id AND ml.deleted_at IS NULL + JOIN movies m ON m.id = c.movie_id + JOIN users u ON u.id = c.user_id + WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = c.list_id AND me.user_id = ?)) + UNION ALL + (SELECT 'member_joined' AS activity_type, lm.added_at AS ts, lm.list_id, ml.name AS list_name, + NULL AS movie_id, NULL AS movie_title, NULL AS movie_poster_path, + u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url + FROM list_members lm + JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL + JOIN users u ON u.id = lm.user_id + WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?)) + ORDER BY ts DESC + LIMIT ? + ` + type activityRow struct { + ActivityType string `gorm:"column:activity_type"` + Ts time.Time `gorm:"column:ts"` + ListID int64 `gorm:"column:list_id"` + ListName string `gorm:"column:list_name"` + MovieID *int64 `gorm:"column:movie_id"` + MovieTitle *string `gorm:"column:movie_title"` + MoviePosterPath *string `gorm:"column:movie_poster_path"` + ActorUserID *int64 `gorm:"column:actor_user_id"` + ActorUsername *string `gorm:"column:actor_username"` + ActorAvatarURL *string `gorm:"column:actor_avatar_url"` + } + var rows []activityRow + if err := d.db.Raw(sql, userID, userID, userID, userID, limit).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]ActivityItem, 0, len(rows)) + for _, r := range rows { + items = append(items, ActivityItem{ + Type: r.ActivityType, + Timestamp: r.Ts, + ListID: r.ListID, + ListName: r.ListName, + MovieID: r.MovieID, + MovieTitle: r.MovieTitle, + MoviePosterPath: r.MoviePosterPath, + UserID: r.ActorUserID, + Username: r.ActorUsername, + AvatarURL: r.ActorAvatarURL, + }) + } + return items, nil +} diff --git a/backend/services/list_service.go b/backend/services/list_service.go index 2c317a2..ffc8136 100644 --- a/backend/services/list_service.go +++ b/backend/services/list_service.go @@ -17,6 +17,11 @@ import ( "gorm.io/gorm" ) +// UserStats holds per-user statistics for the home page. +type UserStats struct { + WatchedThisMonth int64 `json:"watched_this_month"` +} + type ListService interface { CreateList(name string, description *string, createdBy int64) (*models.MovieList, error) JoinListByInviteCode(inviteCode string, userID int64) (*models.MovieList, models.ListMemberRole, bool, int64, error) @@ -34,6 +39,10 @@ type ListService interface { GetComments(listID, userID, movieID int64, limit, offset int) ([]models.Comment, int64, error) UpdateComment(listID, userID, commentID int64, content string) (*models.Comment, error) DeleteComment(listID, userID, commentID int64) error + // Home enrichment methods + FetchListEnrichments(listIDs []int64) (posterURLs map[int64][]string, members map[int64][]daos.MemberPreview, lastActivity map[int64]time.Time, err error) + GetUserStats(userID int64) (*UserStats, error) + GetUserActivity(userID int64, limit int) ([]daos.ActivityItem, error) } type listService struct { @@ -800,6 +809,52 @@ func (s *listService) UpdateComment(listID, userID, commentID int64, content str return s.lists.UpdateComment(commentID, content) } +func (s *listService) FetchListEnrichments(listIDs []int64) (map[int64][]string, map[int64][]daos.MemberPreview, map[int64]time.Time, error) { + posterPaths, err := s.lists.FetchPostersBatch(listIDs) + if err != nil { + return nil, nil, nil, err + } + posterURLs := make(map[int64][]string, len(posterPaths)) + for listID, paths := range posterPaths { + urls := make([]string, 0, len(paths)) + for _, p := range paths { + urls = append(urls, "https://image.tmdb.org/t/p/w92"+p) + } + posterURLs[listID] = urls + } + members, err := s.lists.FetchMembersBatch(listIDs) + if err != nil { + return nil, nil, nil, err + } + lastActivity, err := s.lists.FetchLastActivityBatch(listIDs) + if err != nil { + return nil, nil, nil, err + } + return posterURLs, members, lastActivity, nil +} + +func (s *listService) GetUserStats(userID int64) (*UserStats, error) { + count, err := s.lists.CountWatchedThisMonth(userID) + if err != nil { + return nil, err + } + return &UserStats{WatchedThisMonth: count}, nil +} + +func (s *listService) GetUserActivity(userID int64, limit int) ([]daos.ActivityItem, error) { + items, err := s.lists.FetchRecentActivity(userID, limit) + if err != nil { + return nil, err + } + for i, item := range items { + if item.MoviePosterPath != nil && *item.MoviePosterPath != "" { + url := "https://image.tmdb.org/t/p/w92" + *item.MoviePosterPath + items[i].MoviePosterPath = &url + } + } + return items, nil +} + func (s *listService) DeleteComment(listID, userID, commentID int64) error { // Check list exists if _, err := s.lists.FindByID(listID); err != nil { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index fa34353..e1f0c33 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -100,7 +100,20 @@ "leaveList": "Leave list", "confirmLeaveTitle": "Leave list?", "confirmLeaveText": "Are you sure you want to leave the list \"{{name}}\"? Your ratings and notes will be removed.", - "leaving": "Leaving…" + "leaving": "Leaving…", + "lastActivity": "{{time}} ago", + "justNow": "just now" + }, + "home": { + "watchedThisMonth": "movies watched this month", + "recentActivity": "Recent activity", + "noActivity": "No activity yet", + "activity": { + "movie_added": "added {{movie}} to {{list}}", + "movie_watched": "{{movie}} was watched in {{list}}", + "comment": "commented on {{movie}} in {{list}}", + "member_joined": "joined list {{list}}" + } }, "auth": { "email": "Email", diff --git a/frontend/src/locales/pt.json b/frontend/src/locales/pt.json index b9a2be3..1a15d83 100644 --- a/frontend/src/locales/pt.json +++ b/frontend/src/locales/pt.json @@ -100,7 +100,20 @@ "leaveList": "Sair da lista", "confirmLeaveTitle": "Sair da lista?", "confirmLeaveText": "Tem certeza de que deseja sair da lista \"{{name}}\"? Suas avaliações e notas serão removidas.", - "leaving": "Saindo…" + "leaving": "Saindo…", + "lastActivity": "há {{time}}", + "justNow": "agora mesmo" + }, + "home": { + "watchedThisMonth": "filmes assistidos este mês", + "recentActivity": "Atividade recente", + "noActivity": "Nenhuma atividade ainda", + "activity": { + "movie_added": "adicionou {{movie}} em {{list}}", + "movie_watched": "{{movie}} foi assistido em {{list}}", + "comment": "comentou em {{movie}} em {{list}}", + "member_joined": "entrou na lista {{list}}" + } }, "auth": { "email": "Email", diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 98c7edc..79a46ce 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,16 +1,173 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Plus, Copy, Check, ArrowRight, Trash2, LogOut, Crown, User } from 'lucide-react' +import { Plus, Copy, Check, ArrowRight, Trash2, LogOut, Crown, User, Film, Eye, MessageSquare, UserPlus, Clapperboard } from 'lucide-react' import Header from '@/components/Header' +import { UserAvatar } from '@/components/UserAvatar' import { ConfirmDialog } from '@/components/ConfirmDialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Skeleton } from '@/components/ui/skeleton' import { useAuth } from '@/hooks' -import { getUserLists, createList, joinList, deleteList, leaveList, type UserListDTO } from '@/services/lists' +import { + getUserLists, + createList, + joinList, + deleteList, + leaveList, + getUserStats, + getUserActivity, + type UserListDTO, + type UserStatsDTO, + type ActivityItemDTO, +} from '@/services/lists' import type { ApiException } from '@/services/api' +function relativeTime(dateStr: string): { value: string; isJustNow: boolean } { + const diff = Date.now() - new Date(dateStr).getTime() + const s = Math.floor(diff / 1000) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + const d = Math.floor(h / 24) + const mo = Math.floor(d / 30) + const y = Math.floor(d / 365) + if (s < 60) return { value: '', isJustNow: true } + if (m < 60) return { value: `${m}min`, isJustNow: false } + if (h < 24) return { value: `${h}h`, isJustNow: false } + if (d < 30) return { value: `${d}d`, isJustNow: false } + if (mo < 12) return { value: `${mo}m`, isJustNow: false } + return { value: `${y}a`, isJustNow: false } +} + +function PosterStack({ urls }: { urls?: string[] | null }) { + const filled = (urls ?? []).slice(0, 4) + if (filled.length === 0) { + return ( +
+ +
+ ) + } + return ( +
+ {filled.map((url, i) => ( +
+ +
+ ))} +
+ ) +} + +function MemberAvatars({ members }: { members?: { user_id: number; username: string; avatar_url?: string | null }[] | null }) { + const list = (members ?? []).slice(0, 4) + if (list.length === 0) return null + return ( +
+ {list.map((m) => ( + + ))} +
+ ) +} + +function SkeletonListItem() { + return ( +
  • +
    +
    +
    + + +
    + +
    + + +
    +
    +
    + {[0, 1, 2].map((i) => )} +
    + +
    +
    +
    + + +
    +
    +
  • + ) +} + +function ActivityIcon({ type }: { type: ActivityItemDTO['type'] }) { + const base = 'w-4 h-4' + switch (type) { + case 'movie_added': return + case 'movie_watched': return + case 'comment': return + case 'member_joined': return + } +} + +function ActivityFeedItem({ item }: { item: ActivityItemDTO }) { + const { t } = useTranslation() + const rt = relativeTime(item.timestamp) + const timeStr = rt.isJustNow ? t('lists.justNow') : t('lists.lastActivity', { time: rt.value }) + + let description: string + switch (item.type) { + case 'movie_added': + description = item.username + ? t('home.activity.movie_added', { movie: item.movie_title ?? '?', list: item.list_name }) + : t('home.activity.movie_added', { movie: item.movie_title ?? '?', list: item.list_name }) + break + case 'movie_watched': + description = t('home.activity.movie_watched', { movie: item.movie_title ?? '?', list: item.list_name }) + break + case 'comment': + description = t('home.activity.comment', { movie: item.movie_title ?? '?', list: item.list_name }) + break + case 'member_joined': + description = t('home.activity.member_joined', { list: item.list_name }) + break + } + + return ( +
    + {item.movie_poster_url ? ( +
    + +
    + ) : item.username ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    + {item.username && ( +

    {item.username}

    + )} +

    {description}

    +

    {timeStr}

    +
    +
    + +
    +
    + ) +} + export default function HomePage() { const navigate = useNavigate() const { t } = useTranslation() @@ -20,6 +177,8 @@ export default function HomePage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [copiedId, setCopiedId] = useState(null) + const [stats, setStats] = useState(null) + const [activity, setActivity] = useState([]) // Create/Join states const [popoverOpen, setPopoverOpen] = useState(false) @@ -41,15 +200,21 @@ export default function HomePage() { setLoading(true) setError(null) try { - const res = await getUserLists() - setLists(res.lists) - } catch (err) { - const apiErr = err as ApiException - const message = apiErr.payload?.error || apiErr.message || 'Falha ao carregar listas' - setError(message) - if (apiErr.status === 401) { - clearAuth() + const [listsRes, statsRes, activityRes] = await Promise.allSettled([ + getUserLists(), + getUserStats(), + getUserActivity(20), + ]) + if (listsRes.status === 'fulfilled') { + setLists(listsRes.value.lists) + } else { + const apiErr = listsRes.reason as ApiException + const message = apiErr.payload?.error || apiErr.message || 'Falha ao carregar listas' + setError(message) + if (apiErr.status === 401) clearAuth() } + if (statsRes.status === 'fulfilled') setStats(statsRes.value) + if (activityRes.status === 'fulfilled') setActivity(activityRes.value.activity ?? []) } finally { setLoading(false) } @@ -77,9 +242,7 @@ export default function HomePage() { const apiErr = err as ApiException const message = apiErr.payload?.error || apiErr.message || 'Falha ao excluir lista' setError(message) - if (apiErr.status === 401) { - clearAuth() - } + if (apiErr.status === 401) clearAuth() } finally { setDeleting(false) } @@ -96,9 +259,7 @@ export default function HomePage() { const apiErr = err as ApiException const message = apiErr.payload?.error || apiErr.message || 'Falha ao sair da lista' setError(message) - if (apiErr.status === 401) { - clearAuth() - } + if (apiErr.status === 401) clearAuth() } finally { setLeaving(false) } @@ -116,53 +277,66 @@ export default function HomePage() { } } - const handleCreate = async () => { - if (!createName.trim()) return - setCreating(true) - try { - await createList({ name: createName.trim(), description: createDescription.trim() || undefined }) - setCreateName('') - setCreateDescription('') - setPopoverOpen(false) - const res = await getUserLists() - setLists(res.lists) - } catch (err) { - const apiErr = err as ApiException - const message = apiErr.payload?.error || apiErr.message - setError(message) - if (apiErr.status === 401) { - clearAuth() - } - } finally { - setCreating(false) - } - } - - const handleJoin = async () => { - if (!inviteCode.trim()) return - setJoining(true) - try { - const res = await joinList(inviteCode.trim()) - setInviteCode('') - setPopoverOpen(false) - navigate(`/list/${res.list.id}`) - } catch (err) { - const apiErr = err as ApiException - const message = apiErr.payload?.error || apiErr.message - setError(message) - if (apiErr.status === 401) { - clearAuth() - } - } finally { - setJoining(false) - } - } + const handleCreate = async () => { + if (!createName.trim()) return + setCreating(true) + try { + await createList({ name: createName.trim(), description: createDescription.trim() || undefined }) + setCreateName('') + setCreateDescription('') + setPopoverOpen(false) + const res = await getUserLists() + setLists(res.lists) + } catch (err) { + const apiErr = err as ApiException + const message = apiErr.payload?.error || apiErr.message + setError(message) + if (apiErr.status === 401) clearAuth() + } finally { + setCreating(false) + } + } + + const handleJoin = async () => { + if (!inviteCode.trim()) return + setJoining(true) + try { + const res = await joinList(inviteCode.trim()) + setInviteCode('') + setPopoverOpen(false) + navigate(`/list/${res.list.id}`) + } catch (err) { + const apiErr = err as ApiException + const message = apiErr.payload?.error || apiErr.message + setError(message) + if (apiErr.status === 401) clearAuth() + } finally { + setJoining(false) + } + } return (
    -
    -
    +
    + + {/* Stats bar */} + {stats !== null && ( +
    +
    +
    + +
    +
    +

    {stats.watched_this_month}

    +

    {t('home.watchedThisMonth')}

    +
    +
    +
    + )} + + {/* Header row */} +

    {t('lists.title')}

    @@ -170,9 +344,7 @@ export default function HomePage() { - {/* Segmented control */}
    - - {/* Form content */}
    {popoverMode === 'create' ? ( <> @@ -247,38 +400,27 @@ export default function HomePage() { autoFocus /> )} - {error && (
    {error}
    )} -
    - {loading &&
    {t('misc.loading')}
    } {error && !popoverOpen && ( -
    +
    {error}
    )} @@ -296,7 +438,6 @@ export default function HomePage() {

    {t('lists.empty.createFirst')}

    {t('lists.empty.createDesc')}

    -
    )} - {/* Lists */} -
      - {lists.map((list) => ( -
    • -
      -
      -
      - {list.name} - {list.your_role === 'owner' ? ( - - - - ) : ( - 0) && ( +
      + + {/* Lists column */} +
        + {loading + ? [0, 1, 2].map((i) => ) + : lists.map((list) => { + const rt = list.last_activity_at ? relativeTime(list.last_activity_at) : null + const lastActivityStr = rt + ? rt.isJustNow + ? t('lists.justNow') + : t('lists.lastActivity', { time: rt.value }) + : null + + return ( +
      • - - - )} -
      - {list.description && ( -

      {list.description}

      - )} -
      - - {t('lists.counts.movies')}: {list.movie_count} - - - {t('lists.counts.participants')}: {list.member_count} - -
      +
      + {/* Poster stack */} +
      + +
      + + {/* Content */} +
      +
      + {list.name} + {list.your_role === 'owner' ? ( + + + + ) : ( + + + + )} +
      + {list.description && ( +

      {list.description}

      + )} +
      + + {t('lists.counts.movies')}: {list.movie_count} + + + {t('lists.counts.participants')}: {list.member_count} + +
      +
      +
      + + {lastActivityStr && ( + {lastActivityStr} + )} +
      + {/* Action buttons */} +
      + + + {list.your_role === 'owner' && ( + + )} + {list.your_role === 'participant' && ( + + )} +
      +
      +
      +
      +
    • + ) + })} +
    + + {/* Activity feed */} +
    +

    {t('home.recentActivity')}

    + {loading ? ( +
    + {[0, 1, 2, 4].map((i) => ( +
    + +
    + + + +
    +
    + ))}
    -
    - - - {list.your_role === 'owner' && ( - - )} - {list.your_role === 'participant' && ( - - )} + ) : activity.length === 0 ? ( +

    {t('home.noActivity')}

    + ) : ( +
    + {activity.map((item, i) => ( + + ))}
    -
    - - ))} - + )} +
    - {/* Delete confirmation dialog */} +
    + )} + + {/* Dialogs */} !open && setConfirmDelete(null)} @@ -418,8 +609,6 @@ export default function HomePage() { isLoading={deleting} variant="destructive" /> - - {/* Leave confirmation dialog */} !open && setConfirmLeave(null)} diff --git a/frontend/src/services/lists.ts b/frontend/src/services/lists.ts index 841ec6a..5adf106 100644 --- a/frontend/src/services/lists.ts +++ b/frontend/src/services/lists.ts @@ -1,5 +1,11 @@ import { requestJson } from './api' +export interface ListMemberPreviewDTO { + user_id: number + username: string + avatar_url?: string | null +} + export interface UserListDTO { id: number name: string @@ -10,6 +16,47 @@ export interface UserListDTO { updated_at: string member_count: number movie_count: number + poster_urls?: string[] | null + members?: ListMemberPreviewDTO[] | null + last_activity_at?: string | null +} + +export interface UserStatsDTO { + watched_this_month: number +} + +export interface ActivityItemDTO { + type: 'movie_added' | 'movie_watched' | 'comment' | 'member_joined' + timestamp: string + list_id: number + list_name: string + movie_id?: number | null + movie_title?: string | null + movie_poster_url?: string | null + user_id?: number | null + username?: string | null + avatar_url?: string | null +} + +export interface ActivityResponseDTO { + activity: ActivityItemDTO[] + count: number +} + +export async function getUserStats(): Promise { + const token = localStorage.getItem('access_token') + return requestJson('/api/users/stats', { + method: 'GET', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) +} + +export async function getUserActivity(limit = 20): Promise { + const token = localStorage.getItem('access_token') + return requestJson(`/api/users/activity?limit=${limit}`, { + method: 'GET', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) } export interface ListsResponseDTO { From 1e371eb97ba7cf6b64dc1ef6f1fcd38401a4e5f3 Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 18:10:30 -0300 Subject: [PATCH 2/5] fix(home): activity feed fills viewport without causing page scroll Switch home page to an app-shell layout on desktop (lg:h-dvh + flex column) so the two-column grid fills the remaining viewport height. Lists column scrolls internally when tall; activity panel always stretches to fill the available space without driving page scroll. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/Home.tsx | 65 +++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 79a46ce..c91c8ad 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -316,9 +316,9 @@ export default function HomePage() { } return ( -
    +
    -
    +
    {/* Stats bar */} {stats !== null && ( @@ -455,10 +455,11 @@ export default function HomePage() { {/* Main content: lists + activity feed */} {(loading || lists.length > 0) && ( -
    +
    {/* Lists column */} -
      +
      +
        {loading ? [0, 1, 2].map((i) => ) : lists.map((list) => { @@ -565,33 +566,41 @@ export default function HomePage() { ) })} -
      +
    +
    {/* Activity feed */} -
    -

    {t('home.recentActivity')}

    - {loading ? ( -
    - {[0, 1, 2, 4].map((i) => ( -
    - -
    - - - +
    +
    +

    {t('home.recentActivity')}

    +
    +
    + {loading ? ( +
    + {[0, 1, 2, 4].map((i) => ( +
    + +
    + + + +
    -
    - ))} -
    - ) : activity.length === 0 ? ( -

    {t('home.noActivity')}

    - ) : ( -
    - {activity.map((item, i) => ( - - ))} -
    - )} + ))} +
    + ) : activity.length === 0 ? ( +

    {t('home.noActivity')}

    + ) : ( +
    + {activity.map((item, i) => ( + + ))} +
    + )} +
    From ad2dc8fd450ace792f914d7de015a4d998dee448 Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 18:12:36 -0300 Subject: [PATCH 3/5] fix(home): move watched-this-month stat inline below the section title Removes the isolated floating card that wasted a full row. The stat now appears as a contextual subtitle under "Suas listas", keeping the count visible without disrupting the page layout. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/Home.tsx | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c91c8ad..19a6753 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -320,26 +320,20 @@ export default function HomePage() {
    - {/* Stats bar */} - {stats !== null && ( -
    -
    -
    - -
    -
    -

    {stats.watched_this_month}

    -

    {t('home.watchedThisMonth')}

    -
    -
    -
    - )} - {/* Header row */}
    -

    - {t('lists.title')} -

    +
    +

    + {t('lists.title')} +

    + {stats !== null && ( +

    + + {stats.watched_this_month} + {t('home.watchedThisMonth')} +

    + )} +
    From 6ba230af9db5035edeb337b0012e7c7e57e0d95a Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 18:15:43 -0300 Subject: [PATCH 4/5] fix(backend): apply gofmt formatting to fix CI lint failure Co-Authored-By: Claude Sonnet 4.6 --- backend/controllers/list_controller.go | 8 ++++---- backend/daos/list_dao.go | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/controllers/list_controller.go b/backend/controllers/list_controller.go index bbde2f7..7309918 100644 --- a/backend/controllers/list_controller.go +++ b/backend/controllers/list_controller.go @@ -1867,10 +1867,10 @@ func (c *ListController) getUserActivity(ctx *gin.Context) { payload := make([]gin.H, 0, len(items)) for _, item := range items { entry := gin.H{ - "type": item.Type, - "timestamp": item.Timestamp, - "list_id": item.ListID, - "list_name": item.ListName, + "type": item.Type, + "timestamp": item.Timestamp, + "list_id": item.ListID, + "list_name": item.ListName, } if item.MovieID != nil { entry["movie_id"] = item.MovieID diff --git a/backend/daos/list_dao.go b/backend/daos/list_dao.go index 31cb3ef..f0e244d 100644 --- a/backend/daos/list_dao.go +++ b/backend/daos/list_dao.go @@ -738,16 +738,16 @@ func (d *movieListDAO) FetchRecentActivity(userID int64, limit int) ([]ActivityI LIMIT ? ` type activityRow struct { - ActivityType string `gorm:"column:activity_type"` - Ts time.Time `gorm:"column:ts"` - ListID int64 `gorm:"column:list_id"` - ListName string `gorm:"column:list_name"` - MovieID *int64 `gorm:"column:movie_id"` - MovieTitle *string `gorm:"column:movie_title"` - MoviePosterPath *string `gorm:"column:movie_poster_path"` - ActorUserID *int64 `gorm:"column:actor_user_id"` - ActorUsername *string `gorm:"column:actor_username"` - ActorAvatarURL *string `gorm:"column:actor_avatar_url"` + ActivityType string `gorm:"column:activity_type"` + Ts time.Time `gorm:"column:ts"` + ListID int64 `gorm:"column:list_id"` + ListName string `gorm:"column:list_name"` + MovieID *int64 `gorm:"column:movie_id"` + MovieTitle *string `gorm:"column:movie_title"` + MoviePosterPath *string `gorm:"column:movie_poster_path"` + ActorUserID *int64 `gorm:"column:actor_user_id"` + ActorUsername *string `gorm:"column:actor_username"` + ActorAvatarURL *string `gorm:"column:actor_avatar_url"` } var rows []activityRow if err := d.db.Raw(sql, userID, userID, userID, userID, limit).Scan(&rows).Error; err != nil { From 8e45c4d2a66592aa56576a8a7a4236f02740b039 Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 18:23:24 -0300 Subject: [PATCH 5/5] fix(dao): exclude soft-deleted lists from watched-this-month count Co-Authored-By: Claude Sonnet 4.6 --- backend/daos/list_dao.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/daos/list_dao.go b/backend/daos/list_dao.go index f0e244d..fa246f5 100644 --- a/backend/daos/list_dao.go +++ b/backend/daos/list_dao.go @@ -685,6 +685,7 @@ func (d *movieListDAO) CountWatchedThisMonth(userID int64) (int64, error) { var count int64 if err := d.db.Table("list_movies lm"). Joins("JOIN list_members mem ON mem.list_id = lm.list_id AND mem.user_id = ?", userID). + Joins("JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL"). Where("lm.status = ? AND lm.watched_at >= ?", string(models.StatusWatched), firstOfMonth). Count(&count).Error; err != nil { return 0, err