generated from nhcarrigan/template
feat: implement comprehensive activity feed feature
Implements issue #56 with a timeline-style activity feed showing recent user activity across the library. Activity Types Displayed: - Suggestions: Shows suggested items with status badges - Likes: Shows liked content with links to items - Comments: Shows comments with preview text - Achievements: Shows earned achievements with icons and points Database & Backend: - Created ActivityService to aggregate from existing data sources - No new database table needed (uses existing suggestions, likes, comments, achievements) - Efficient querying with parallel data fetching - Privacy-aware: Only shows activity from users with profilePublic: true - Filters out banned users automatically API Endpoints: - GET /api/activity - Get general activity feed with pagination - GET /api/activity/:userId - Get specific user's activity - Query parameters: limit (max 100, default 50), offset (default 0) - Returns: activities array, total count, hasMore flag Frontend Activity Feed: - Beautiful timeline layout with user avatars and badges - Relative timestamps ("5m ago", "2h ago", "3d ago") - Activity type icons (💡 💬 ❤️ 🏆) - Colour-coded status badges for suggestions - Comment previews with styled quote blocks - Achievement displays with icons and points - Infinite scroll with "Load More" button - Links to user profiles and content items UI Components: - Responsive card-based design - User avatars with placeholder fallback - Badge display (STAFF, MOD, VIP, primary badges) - Entity links that route to appropriate media pages - Clean typography and spacing - Loading and empty states Privacy & Performance: - Respects profilePublic setting (existing privacy control) - Only displays activity from non-banned users - Pagination to prevent loading too much data - Efficient aggregation with limit multipliers - Clean separation of concerns (service/routes/component) Navigation: - Added /activity route - Added "📰 Activity Feed" link to user dropdown menu - Positioned between Leaderboard and About Implementation Notes: - Built entirely from existing data (no activity table needed) - Retroactive: Shows all historical activity automatically - Type-safe with full TypeScript interfaces - Activity union type for type discrimination - Standalone Angular component - Clean, maintainable code structure
This commit is contained in:
@@ -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;
|
||||||
@@ -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<ActivityFeedResponse> {
|
||||||
|
// 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<string> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'leaderboard',
|
path: 'leaderboard',
|
||||||
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
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',
|
path: 'about',
|
||||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="activity-container">
|
||||||
|
<h1>Recent Activity</h1>
|
||||||
|
<p class="subtitle">See what's happening in the library community</p>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p class="loading">Loading activities...</p>
|
||||||
|
} @else if (activities().length === 0) {
|
||||||
|
<p class="no-activities">No recent activity to display.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="activity-feed">
|
||||||
|
@for (activity of activities(); track activity.id) {
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="activity-header">
|
||||||
|
<div class="user-info">
|
||||||
|
@if (activity.user.avatar) {
|
||||||
|
<img [src]="activity.user.avatar" [alt]="activity.user.username" class="user-avatar">
|
||||||
|
} @else {
|
||||||
|
<div class="user-avatar-placeholder">
|
||||||
|
{{ activity.user.username.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="user-details">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/profile', activity.user.slug || activity.user.id]"
|
||||||
|
class="username"
|
||||||
|
>
|
||||||
|
{{ activity.user.username }}
|
||||||
|
</a>
|
||||||
|
@if (activity.user.primaryBadge) {
|
||||||
|
<span class="badge badge-{{ activity.user.primaryBadge.toLowerCase() }}">
|
||||||
|
{{ activity.user.primaryBadge }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (activity.user.isStaff && !activity.user.primaryBadge) {
|
||||||
|
<span class="badge badge-staff">STAFF</span>
|
||||||
|
}
|
||||||
|
@if (activity.user.isMod && !activity.user.primaryBadge) {
|
||||||
|
<span class="badge badge-mod">MOD</span>
|
||||||
|
}
|
||||||
|
@if (activity.user.isVip && !activity.user.primaryBadge) {
|
||||||
|
<span class="badge badge-vip">VIP</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="timestamp">{{ formatTime(activity.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-content">
|
||||||
|
@switch (activity.type) {
|
||||||
|
@case ('SUGGESTION') {
|
||||||
|
<div class="activity-suggestion">
|
||||||
|
<span class="activity-icon">💡</span>
|
||||||
|
<span class="activity-text">
|
||||||
|
suggested
|
||||||
|
<strong>{{ activity.suggestionTitle }}</strong>
|
||||||
|
<span class="status-badge status-{{ activity.status.toLowerCase() }}">
|
||||||
|
{{ formatStatus(activity.status) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('LIKE') {
|
||||||
|
<div class="activity-like">
|
||||||
|
<span class="activity-icon">❤️</span>
|
||||||
|
<span class="activity-text">
|
||||||
|
liked
|
||||||
|
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||||
|
{{ activity.entityTitle }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('COMMENT') {
|
||||||
|
<div class="activity-comment">
|
||||||
|
<span class="activity-icon">💬</span>
|
||||||
|
<span class="activity-text">
|
||||||
|
commented on
|
||||||
|
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||||
|
{{ activity.entityTitle }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<p class="comment-preview">"{{ activity.commentPreview }}"</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('ACHIEVEMENT') {
|
||||||
|
<div class="activity-achievement">
|
||||||
|
<span class="activity-icon">{{ activity.achievementIcon }}</span>
|
||||||
|
<span class="activity-text">
|
||||||
|
earned the
|
||||||
|
<strong>{{ activity.achievementName }}</strong>
|
||||||
|
achievement
|
||||||
|
<span class="points">({{ activity.achievementPoints }} pts)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasMore()) {
|
||||||
|
<div class="load-more-container">
|
||||||
|
<button (click)="loadMore()" class="btn btn-primary" [disabled]="loadingMore()">
|
||||||
|
{{ loadingMore() ? 'Loading...' : 'Load More' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<Activity[]>([]);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import { ApiService } from '../../services/api.service';
|
|||||||
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||||
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
||||||
|
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a>
|
||||||
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
||||||
@if (!user.isAdmin) {
|
@if (!user.isAdmin) {
|
||||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||||
|
|||||||
@@ -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<ActivityFeedResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('limit', limit.toString());
|
||||||
|
params.append('offset', offset.toString());
|
||||||
|
if (userId) {
|
||||||
|
params.append('userId', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.apiService.get<ActivityFeedResponse>(`/activity?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity feed for a specific user.
|
||||||
|
*/
|
||||||
|
getUserActivityFeed(userId: string, limit = 50, offset = 0): Observable<ActivityFeedResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('limit', limit.toString());
|
||||||
|
params.append('offset', offset.toString());
|
||||||
|
|
||||||
|
return this.apiService.get<ActivityFeedResponse>(`/activity/${userId}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
export * from "./lib/achievement.constants";
|
export * from "./lib/achievement.constants";
|
||||||
export * from "./lib/achievement.types";
|
export * from "./lib/achievement.types";
|
||||||
|
export type * from "./lib/activity.types";
|
||||||
export type * from "./lib/art.types";
|
export type * from "./lib/art.types";
|
||||||
export * from "./lib/audit.types";
|
export * from "./lib/audit.types";
|
||||||
export * from "./lib/auth.types";
|
export * from "./lib/auth.types";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user