diff --git a/api/src/app/routes/activity/index.ts b/api/src/app/routes/activity/index.ts new file mode 100644 index 0000000..f5b2f65 --- /dev/null +++ b/api/src/app/routes/activity/index.ts @@ -0,0 +1,52 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { FastifyPluginAsync } from "fastify"; +import type { ActivityFeedResponse } from "@library/shared-types"; +import { ActivityService } from "../../services/activity.service"; + +const activityRoutes: FastifyPluginAsync = async (app) => { + const activityService = new ActivityService(); + + /** + * Get activity feed with optional filters. + */ + app.get<{ + Querystring: { limit?: number; offset?: number; userId?: string }; + Reply: ActivityFeedResponse; + }>("/", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 50; + const offset = request.query.offset && request.query.offset >= 0 + ? request.query.offset + : 0; + const userId = request.query.userId; + + return activityService.getActivityFeed(limit, offset, userId); + }); + + /** + * Get activity feed for a specific user. + */ + app.get<{ + Params: { userId: string }; + Querystring: { limit?: number; offset?: number }; + Reply: ActivityFeedResponse; + }>("/:userId", async (request) => { + const { userId } = request.params; + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 50; + const offset = request.query.offset && request.query.offset >= 0 + ? request.query.offset + : 0; + + return activityService.getActivityFeed(limit, offset, userId); + }); +}; + +export default activityRoutes; diff --git a/api/src/app/services/activity.service.ts b/api/src/app/services/activity.service.ts new file mode 100644 index 0000000..ef17c02 --- /dev/null +++ b/api/src/app/services/activity.service.ts @@ -0,0 +1,350 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { + Activity, + ActivityFeedResponse, + ActivityType, + ActivityUser, +} from "@library/shared-types"; +import { ACHIEVEMENTS } from "@library/shared-types"; +import { prisma } from "../lib/prisma"; + +export class ActivityService { + private prisma = prisma; + + constructor() {} + + /** + * Get activity feed with pagination. + */ + async getActivityFeed( + limit = 50, + offset = 0, + userId?: string + ): Promise { + // Fetch suggestions, likes, comments, and achievements + const [suggestions, likes, comments, achievements] = await Promise.all([ + this.getSuggestionActivities(limit, offset, userId), + this.getLikeActivities(limit, offset, userId), + this.getCommentActivities(limit, offset, userId), + this.getAchievementActivities(limit, offset, userId), + ]); + + // Combine and sort by createdAt + const activities: Activity[] = [ + ...suggestions, + ...likes, + ...comments, + ...achievements, + ].sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + // Apply pagination to combined results + const paginatedActivities = activities.slice(offset, offset + limit); + const hasMore = activities.length > offset + limit; + + return { + activities: paginatedActivities, + total: activities.length, + hasMore, + }; + } + + /** + * Get suggestion activities. + */ + private async getSuggestionActivities( + limit: number, + offset: number, + userId?: string + ) { + const where = userId + ? { userId, user: { profilePublic: true, isBanned: false } } + : { user: { profilePublic: true, isBanned: false } }; + + const suggestions = await this.prisma.suggestion.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + slug: true, + avatar: true, + primaryBadge: true, + isVip: true, + isMod: true, + isStaff: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit * 2, // Get more since we're combining + }); + + return suggestions.map((suggestion) => ({ + id: `suggestion-${suggestion.id}`, + type: "SUGGESTION" as ActivityType, + user: suggestion.user as ActivityUser, + entityType: suggestion.entityType, + suggestionTitle: suggestion.title, + status: suggestion.status, + createdAt: suggestion.createdAt, + })); + } + + /** + * Get like activities. + */ + private async getLikeActivities( + limit: number, + offset: number, + userId?: string + ) { + const where = userId + ? { userId, user: { profilePublic: true, isBanned: false } } + : { user: { profilePublic: true, isBanned: false } }; + + const likes = await this.prisma.like.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + slug: true, + avatar: true, + primaryBadge: true, + isVip: true, + isMod: true, + isStaff: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit * 2, + }); + + // For each like, fetch the entity title + const likesWithTitles = await Promise.all( + likes.map(async (like) => { + const entityTitle = await this.getEntityTitle( + like.entityType, + like.entityId + ); + return { + id: `like-${like.id}`, + type: "LIKE" as ActivityType, + user: like.user as ActivityUser, + entityType: like.entityType, + entityId: like.entityId, + entityTitle, + createdAt: like.createdAt, + }; + }) + ); + + return likesWithTitles; + } + + /** + * Get comment activities. + */ + private async getCommentActivities( + limit: number, + offset: number, + userId?: string + ) { + const where = userId + ? { userId, user: { profilePublic: true, isBanned: false } } + : { user: { profilePublic: true, isBanned: false } }; + + const comments = await this.prisma.comment.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + slug: true, + avatar: true, + primaryBadge: true, + isVip: true, + isMod: true, + isStaff: true, + }, + }, + game: { select: { id: true, title: true } }, + book: { select: { id: true, title: true } }, + music: { select: { id: true, title: true } }, + art: { select: { id: true, title: true } }, + show: { select: { id: true, title: true } }, + manga: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: "desc" }, + take: limit * 2, + }); + + return comments.map((comment) => { + let entityType = ""; + let entityId = ""; + let entityTitle = ""; + + if (comment.game) { + entityType = "game"; + entityId = comment.game.id; + entityTitle = comment.game.title; + } else if (comment.book) { + entityType = "book"; + entityId = comment.book.id; + entityTitle = comment.book.title; + } else if (comment.music) { + entityType = "music"; + entityId = comment.music.id; + entityTitle = comment.music.title; + } else if (comment.art) { + entityType = "art"; + entityId = comment.art.id; + entityTitle = comment.art.title; + } else if (comment.show) { + entityType = "show"; + entityId = comment.show.id; + entityTitle = comment.show.title; + } else if (comment.manga) { + entityType = "manga"; + entityId = comment.manga.id; + entityTitle = comment.manga.title; + } + + // Get first 100 characters of comment + const commentPreview = + comment.content.length > 100 + ? `${comment.content.slice(0, 100)}...` + : comment.content; + + return { + id: `comment-${comment.id}`, + type: "COMMENT" as ActivityType, + user: comment.user as ActivityUser, + entityType, + entityId, + entityTitle, + commentPreview, + createdAt: comment.createdAt, + }; + }); + } + + /** + * Get achievement activities. + */ + private async getAchievementActivities( + limit: number, + offset: number, + userId?: string + ) { + const where = userId + ? { + userId, + earned: true, + user: { profilePublic: true, isBanned: false }, + } + : { earned: true, user: { profilePublic: true, isBanned: false } }; + + const userAchievements = await this.prisma.userAchievement.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + slug: true, + avatar: true, + primaryBadge: true, + isVip: true, + isMod: true, + isStaff: true, + }, + }, + }, + orderBy: { earnedAt: "desc" }, + take: limit * 2, + }); + + return userAchievements + .filter((ua) => ua.earnedAt) // Only show earned achievements + .map((ua) => { + const achievement = ACHIEVEMENTS[ua.achievementKey]; + return { + id: `achievement-${ua.id}`, + type: "ACHIEVEMENT" as ActivityType, + user: ua.user as ActivityUser, + achievementKey: ua.achievementKey, + achievementName: achievement.name, + achievementIcon: achievement.icon, + achievementPoints: achievement.points, + createdAt: ua.earnedAt!, + }; + }); + } + + /** + * Helper to get entity title by type and ID. + */ + private async getEntityTitle( + entityType: string, + entityId: string + ): Promise { + switch (entityType) { + case "game": { + const game = await this.prisma.game.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return game?.title || "Unknown Game"; + } + case "book": { + const book = await this.prisma.book.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return book?.title || "Unknown Book"; + } + case "music": { + const music = await this.prisma.music.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return music?.title || "Unknown Music"; + } + case "art": { + const art = await this.prisma.art.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return art?.title || "Unknown Art"; + } + case "show": { + const show = await this.prisma.show.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return show?.title || "Unknown Show"; + } + case "manga": { + const manga = await this.prisma.manga.findUnique({ + where: { id: entityId }, + select: { title: true }, + }); + return manga?.title || "Unknown Manga"; + } + default: + return "Unknown Item"; + } + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 8f100c7..c15c563 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -69,6 +69,10 @@ export const appRoutes: Route[] = [ path: 'leaderboard', loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent) }, + { + path: 'activity', + loadComponent: () => import('./components/activity/activity-feed.component').then(m => m.ActivityFeedComponent) + }, { path: 'about', loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent) diff --git a/apps/frontend/src/app/components/activity/activity-feed.component.ts b/apps/frontend/src/app/components/activity/activity-feed.component.ts new file mode 100644 index 0000000..c2a61c3 --- /dev/null +++ b/apps/frontend/src/app/components/activity/activity-feed.component.ts @@ -0,0 +1,426 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import type { Activity, ActivityType } from '@library/shared-types'; +import { ActivityService } from '../../services/activity.service'; + +@Component({ + selector: 'app-activity-feed', + standalone: true, + imports: [CommonModule, RouterLink], + template: ` +
+

Recent Activity

+

See what's happening in the library community

+ + @if (loading()) { +

Loading activities...

+ } @else if (activities().length === 0) { +

No recent activity to display.

+ } @else { +
+ @for (activity of activities(); track activity.id) { +
+
+ + {{ formatTime(activity.createdAt) }} +
+ +
+ @switch (activity.type) { + @case ('SUGGESTION') { +
+ 💡 + + suggested + {{ activity.suggestionTitle }} + + {{ formatStatus(activity.status) }} + + +
+ } + @case ('LIKE') { +
+ â¤ī¸ + + liked + + {{ activity.entityTitle }} + + +
+ } + @case ('COMMENT') { +
+ đŸ’Ŧ + + commented on + + {{ activity.entityTitle }} + + +

"{{ activity.commentPreview }}"

+
+ } + @case ('ACHIEVEMENT') { +
+ {{ activity.achievementIcon }} + + earned the + {{ activity.achievementName }} + achievement + ({{ activity.achievementPoints }} pts) + +
+ } + } +
+
+ } +
+ + @if (hasMore()) { +
+ +
+ } + } +
+ `, + styles: [` + .activity-container { + max-width: 800px; + margin: 2rem auto; + padding: 0 1rem; + } + + h1 { + font-size: 2rem; + margin-bottom: 0.5rem; + color: #1f2937; + } + + .subtitle { + color: #6b7280; + margin-bottom: 2rem; + } + + .loading, .no-activities { + text-align: center; + padding: 3rem; + color: #6b7280; + font-size: 1.1rem; + } + + .activity-feed { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .activity-card { + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + } + + .activity-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; + } + + .user-info { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .user-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.2rem; + } + + .user-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .username { + font-weight: 600; + color: #1f2937; + text-decoration: none; + } + + .username:hover { + color: #10b981; + } + + .badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + .badge-staff { + background: #ef4444; + color: white; + } + + .badge-mod { + background: #3b82f6; + color: white; + } + + .badge-vip { + background: #f59e0b; + color: white; + } + + .badge-discord { + background: #5865f2; + color: white; + } + + .timestamp { + font-size: 0.875rem; + color: #9ca3af; + } + + .activity-content { + padding-left: 55px; + } + + .activity-suggestion, + .activity-like, + .activity-comment, + .activity-achievement { + display: flex; + align-items: start; + gap: 0.75rem; + } + + .activity-icon { + font-size: 1.5rem; + line-height: 1; + } + + .activity-text { + color: #4b5563; + line-height: 1.6; + } + + .activity-text strong { + color: #1f2937; + font-weight: 600; + } + + .entity-link { + color: #10b981; + text-decoration: none; + font-weight: 500; + } + + .entity-link:hover { + text-decoration: underline; + } + + .comment-preview { + margin-top: 0.5rem; + padding: 0.75rem; + background: #f9fafb; + border-left: 3px solid #10b981; + border-radius: 4px; + color: #4b5563; + font-style: italic; + } + + .status-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; + } + + .status-unreviewed { + background: #fef3c7; + color: #92400e; + } + + .status-accepted { + background: #d1fae5; + color: #065f46; + } + + .status-declined { + background: #fee2e2; + color: #991b1b; + } + + .points { + color: #10b981; + font-weight: 600; + margin-left: 0.25rem; + } + + .load-more-container { + display: flex; + justify-content: center; + margin-top: 2rem; + } + + .btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .btn-primary { + background: #10b981; + color: white; + } + + .btn-primary:hover:not(:disabled) { + background: #059669; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `] +}) +export class ActivityFeedComponent implements OnInit { + private activityService = inject(ActivityService); + + activities = signal([]); + loading = signal(true); + loadingMore = signal(false); + hasMore = signal(false); + offset = 0; + limit = 50; + + ngOnInit() { + this.loadActivities(); + } + + loadActivities() { + this.activityService.getActivityFeed(this.limit, this.offset).subscribe({ + next: (response) => { + this.activities.set(response.activities); + this.hasMore.set(response.hasMore); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + } + }); + } + + loadMore() { + this.loadingMore.set(true); + this.offset += this.limit; + + this.activityService.getActivityFeed(this.limit, this.offset).subscribe({ + next: (response) => { + this.activities.update(current => [...current, ...response.activities]); + this.hasMore.set(response.hasMore); + this.loadingMore.set(false); + }, + error: () => { + this.loadingMore.set(false); + } + }); + } + + formatTime(date: Date): string { + const now = new Date(); + const activityDate = new Date(date); + const diffMs = now.getTime() - activityDate.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return activityDate.toLocaleDateString(); + } + + formatStatus(status: string): string { + switch (status) { + case 'UNREVIEWED': return 'Pending'; + case 'ACCEPTED': return 'Accepted'; + case 'DECLINED': return 'Declined'; + default: return status; + } + } +} diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index afff703..abc19d2 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -54,6 +54,7 @@ import { ApiService } from '../../services/api.service'; Settings 🏆 Achievements 🏆 Leaderboard + 📰 Activity Feed â„šī¸ About @if (!user.isAdmin) { My Suggestions diff --git a/apps/frontend/src/app/services/activity.service.ts b/apps/frontend/src/app/services/activity.service.ts new file mode 100644 index 0000000..be50c7d --- /dev/null +++ b/apps/frontend/src/app/services/activity.service.ts @@ -0,0 +1,42 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import type { ActivityFeedResponse } from '@library/shared-types'; +import { ApiService } from './api.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ActivityService { + private apiService = inject(ApiService); + + /** + * Get activity feed with pagination. + */ + getActivityFeed(limit = 50, offset = 0, userId?: string): Observable { + const params = new URLSearchParams(); + params.append('limit', limit.toString()); + params.append('offset', offset.toString()); + if (userId) { + params.append('userId', userId); + } + + return this.apiService.get(`/activity?${params.toString()}`); + } + + /** + * Get activity feed for a specific user. + */ + getUserActivityFeed(userId: string, limit = 50, offset = 0): Observable { + const params = new URLSearchParams(); + params.append('limit', limit.toString()); + params.append('offset', offset.toString()); + + return this.apiService.get(`/activity/${userId}?${params.toString()}`); + } +} diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index b11520a..dd76c94 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -5,6 +5,7 @@ */ export * from "./lib/achievement.constants"; export * from "./lib/achievement.types"; +export type * from "./lib/activity.types"; export type * from "./lib/art.types"; export * from "./lib/audit.types"; export * from "./lib/auth.types"; diff --git a/shared-types/src/lib/activity.types.ts b/shared-types/src/lib/activity.types.ts new file mode 100644 index 0000000..b247828 --- /dev/null +++ b/shared-types/src/lib/activity.types.ts @@ -0,0 +1,79 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +enum ActivityType { + suggestion = "SUGGESTION", + like = "LIKE", + comment = "COMMENT", + achievement = "ACHIEVEMENT", +} + +interface ActivityUser { + id: string; + username: string; + slug: string | null; + avatar: string | null; + primaryBadge: string | null; + isVip: boolean; + isMod: boolean; + isStaff: boolean; +} + +interface BaseActivity { + id: string; + type: ActivityType; + user: ActivityUser; + createdAt: Date; +} + +interface SuggestionActivity extends BaseActivity { + type: ActivityType.suggestion; + entityType: string; + suggestionTitle: string; + status: string; +} + +interface LikeActivity extends BaseActivity { + type: ActivityType.like; + entityType: string; + entityId: string; + entityTitle: string; +} + +interface CommentActivity extends BaseActivity { + type: ActivityType.comment; + entityType: string; + entityId: string; + entityTitle: string; + commentPreview: string; +} + +interface AchievementActivity extends BaseActivity { + type: ActivityType.achievement; + achievementKey: string; + achievementName: string; + achievementIcon: string; + achievementPoints: number; +} + +type Activity = SuggestionActivity | LikeActivity | CommentActivity | AchievementActivity; + +interface ActivityFeedResponse { + activities: Activity[]; + total: number; + hasMore: boolean; +} + +export { ActivityType }; +export type { + Activity, + ActivityFeedResponse, + ActivityUser, + AchievementActivity, + CommentActivity, + LikeActivity, + SuggestionActivity, +};