generated from nhcarrigan/template
feat: implement user profiles with achievements and primary badge system #58
@@ -182,6 +182,10 @@ model User {
|
|||||||
username String
|
username String
|
||||||
email String @unique
|
email String @unique
|
||||||
avatar String?
|
avatar String?
|
||||||
|
slug String?
|
||||||
|
displayName String?
|
||||||
|
bio String?
|
||||||
|
profilePublic Boolean @default(true)
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
isBanned Boolean @default(false)
|
isBanned Boolean @default(false)
|
||||||
inDiscord Boolean @default(false)
|
inDiscord Boolean @default(false)
|
||||||
@@ -194,6 +198,8 @@ model User {
|
|||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
likes Like[]
|
likes Like[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
|
@@index([slug], map: "User_slug_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ import { UserService } from "../../services/user.service";
|
|||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
interface UpdateUserSettingsBody {
|
||||||
|
slug?: string;
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
profilePublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfileResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
slug?: string;
|
||||||
|
badges: {
|
||||||
|
isStaff: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isVip: boolean;
|
||||||
|
inDiscord: boolean;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
suggestionsCount: number;
|
||||||
|
suggestionsAcceptedCount: number;
|
||||||
|
likesCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const usersRoutes: FastifyPluginAsync = async (app) => {
|
const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
@@ -23,6 +52,100 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get<{ Reply: User }>(
|
||||||
|
"/me",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const currentUser = request.user as { id: string };
|
||||||
|
const user = await userService.getUserById(currentUser.id);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Body: UpdateUserSettingsBody; Reply: User | { error: string } }>(
|
||||||
|
"/me",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const currentUser = request.user as { id: string };
|
||||||
|
const updates = request.body;
|
||||||
|
|
||||||
|
// If slug is being updated, check if it's unique
|
||||||
|
if (updates.slug) {
|
||||||
|
const existingUser = await userService.getUserBySlug(updates.slug);
|
||||||
|
if (existingUser && existingUser.id !== currentUser.id) {
|
||||||
|
return reply.code(400).send({ error: "Slug already taken" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await userService.updateUserSettings(
|
||||||
|
currentUser.id,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { identifier: string };
|
||||||
|
Reply: UserProfileResponse | { error: string };
|
||||||
|
}>(
|
||||||
|
"/profile/:identifier",
|
||||||
|
async (request, reply) => {
|
||||||
|
const { identifier } = request.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await userService.getUserProfile(identifier);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.profilePublic) {
|
||||||
|
// Check if the requesting user is viewing their own profile
|
||||||
|
const currentUser = request.user as { id: string } | undefined;
|
||||||
|
if (!currentUser || currentUser.id !== profile.id) {
|
||||||
|
return reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: "This profile is private" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
username: profile.username,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatar: profile.avatar,
|
||||||
|
bio: profile.bio,
|
||||||
|
slug: profile.slug,
|
||||||
|
badges: {
|
||||||
|
isStaff: profile.isStaff,
|
||||||
|
isMod: profile.isMod,
|
||||||
|
isVip: profile.isVip,
|
||||||
|
inDiscord: profile.inDiscord,
|
||||||
|
},
|
||||||
|
stats: profile.stats,
|
||||||
|
createdAt: profile.createdAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching profile:", error);
|
||||||
|
return reply.code(500).send({ error: "Failed to fetch profile" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { id: string }; Reply: User | null }>(
|
app.get<{ Params: { id: string }; Reply: User | null }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
@@ -167,6 +171,10 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
@@ -217,6 +225,10 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { User } from "@library/shared-types";
|
import { User } from "@library/shared-types";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { SuggestionStatus } from "@prisma/client";
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
private prisma = prisma;
|
private prisma = prisma;
|
||||||
@@ -21,6 +22,10 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -45,6 +50,10 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -66,6 +75,10 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -87,6 +100,10 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -104,4 +121,137 @@ export class UserService {
|
|||||||
|
|
||||||
return user?.isBanned ?? false;
|
return user?.isBanned ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserBySlug(slug: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
inDiscord: user.inDiscord,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserSettings(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
slug?: string;
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
profilePublic?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
inDiscord: user.inDiscord,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserProfile(identifier: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
isStaff: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isVip: boolean;
|
||||||
|
inDiscord: boolean;
|
||||||
|
profilePublic: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
stats: {
|
||||||
|
suggestionsCount: number;
|
||||||
|
suggestionsAcceptedCount: number;
|
||||||
|
likesCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
};
|
||||||
|
} | null> {
|
||||||
|
// Try to find by slug first, then by id if it's a valid ObjectId
|
||||||
|
const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier);
|
||||||
|
|
||||||
|
const whereConditions = isValidObjectId
|
||||||
|
? [{ slug: identifier }, { id: identifier }]
|
||||||
|
: [{ slug: identifier }];
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: whereConditions,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
suggestions: {
|
||||||
|
select: { id: true, status: true },
|
||||||
|
},
|
||||||
|
likes: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
bio: user.bio,
|
||||||
|
slug: user.slug,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isVip: user.isVip,
|
||||||
|
inDiscord: user.inDiscord,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
stats: {
|
||||||
|
suggestionsCount: user.suggestions.length,
|
||||||
|
suggestionsAcceptedCount: user.suggestions.filter(
|
||||||
|
(suggestion) => suggestion.status === SuggestionStatus.ACCEPTED
|
||||||
|
).length,
|
||||||
|
likesCount: user.likes.length,
|
||||||
|
commentsCount: user.comments.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'my-likes',
|
path: 'my-likes',
|
||||||
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile/:identifier',
|
||||||
|
loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import { ApiService } from '../../services/api.service';
|
|||||||
}
|
}
|
||||||
@if (showDropdown()) {
|
@if (showDropdown()) {
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
|
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
|
||||||
|
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</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,304 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { UserService, UserProfileResponse } from '../../services/user.service';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="profile-container">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading profile...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error">{{ error() }}</div>
|
||||||
|
} @else if (profile()) {
|
||||||
|
<div class="profile-card">
|
||||||
|
<div class="profile-header">
|
||||||
|
@if (profile()?.avatar) {
|
||||||
|
<img [src]="profile()!.avatar" [alt]="profile()!.username" class="profile-avatar" />
|
||||||
|
} @else {
|
||||||
|
<div class="profile-avatar-placeholder">
|
||||||
|
{{ profile()!.username[0]?.toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="profile-info">
|
||||||
|
<h1 class="profile-username">{{ profile()!.displayName || profile()!.username }}</h1>
|
||||||
|
@if (profile()!.displayName) {
|
||||||
|
<p class="profile-handle">\@{{ profile()!.username }}</p>
|
||||||
|
}
|
||||||
|
@if (profile()!.slug) {
|
||||||
|
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badges-section">
|
||||||
|
@if (profile()!.badges.isStaff) {
|
||||||
|
<span class="badge badge-staff">Staff</span>
|
||||||
|
}
|
||||||
|
@if (profile()!.badges.isMod) {
|
||||||
|
<span class="badge badge-mod">Moderator</span>
|
||||||
|
}
|
||||||
|
@if (profile()!.badges.isVip) {
|
||||||
|
<span class="badge badge-vip">VIP</span>
|
||||||
|
}
|
||||||
|
@if (profile()!.badges.inDiscord) {
|
||||||
|
<span class="badge badge-member">Discord Member</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (profile()!.bio) {
|
||||||
|
<div class="bio-section">
|
||||||
|
<p class="bio-text">{{ profile()!.bio }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="stats-section">
|
||||||
|
<h2>Activity Statistics</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{{ profile()!.stats.suggestionsCount }}</span>
|
||||||
|
<span class="stat-label">Suggestions</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{{ profile()!.stats.suggestionsAcceptedCount }}</span>
|
||||||
|
<span class="stat-label">Accepted</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{{ profile()!.stats.likesCount }}</span>
|
||||||
|
<span class="stat-label">Likes</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
|
||||||
|
<span class="stat-label">Comments</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-section">
|
||||||
|
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.profile-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error-colour, #c41e3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
background: var(--card-background, #1a1a2e);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--accent-colour, #9b59b6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-placeholder {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-colour, #9b59b6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--text-colour, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-handle {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-slug {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-staff {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mod {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-vip {
|
||||||
|
background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-member {
|
||||||
|
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(155, 89, 182, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-colour, #e0e0e0);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--accent-colour, #9b59b6);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(155, 89, 182, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-colour, #9b59b6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-section {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(155, 89, 182, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-since {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ProfileComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private userService = inject(UserService);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
|
profile = signal<UserProfileResponse | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const identifier = this.route.snapshot.paramMap.get('identifier');
|
||||||
|
if (!identifier) {
|
||||||
|
this.error.set('No user identifier provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userService.getProfile(identifier).subscribe({
|
||||||
|
next: (profileData: UserProfileResponse) => {
|
||||||
|
this.profile.set(profileData);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
console.error('Error loading profile:', err);
|
||||||
|
this.error.set('Failed to load profile');
|
||||||
|
this.loading.set(false);
|
||||||
|
this.toastService.error('Failed to load profile');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { UserService, UpdateUserSettingsRequest } from '../../services/user.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { User } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="settings-container">
|
||||||
|
<h1>Profile Settings</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading settings...</div>
|
||||||
|
} @else if (user()) {
|
||||||
|
<form class="settings-form" (ngSubmit)="saveSettings()">
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Profile Information</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="displayName">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
[(ngModel)]="formData.displayName"
|
||||||
|
placeholder="Your display name"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<small class="form-help">This will be shown instead of your username</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="slug">Profile URL Slug</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
[(ngModel)]="formData.slug"
|
||||||
|
placeholder="your-custom-url"
|
||||||
|
maxlength="30"
|
||||||
|
pattern="[a-z0-9-]+"
|
||||||
|
/>
|
||||||
|
<small class="form-help">
|
||||||
|
Your profile will be at: library.nhcarrigan.com/profile/{{ formData.slug || 'your-slug' }}
|
||||||
|
</small>
|
||||||
|
<small class="form-help">Only lowercase letters, numbers, and hyphens allowed</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bio">Bio</label>
|
||||||
|
<textarea
|
||||||
|
id="bio"
|
||||||
|
name="bio"
|
||||||
|
[(ngModel)]="formData.bio"
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
<small class="form-help">{{ (formData.bio?.length || 0) }} / 500 characters</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="profilePublic"
|
||||||
|
[(ngModel)]="formData.profilePublic"
|
||||||
|
/>
|
||||||
|
<span>Make my profile public</span>
|
||||||
|
</label>
|
||||||
|
<small class="form-help">
|
||||||
|
When disabled, only you can view your profile
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Account Information</h2>
|
||||||
|
<div class="info-item">
|
||||||
|
<strong>Username:</strong> {{ user()!.username }}
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<strong>Email:</strong> {{ user()!.email }}
|
||||||
|
</div>
|
||||||
|
@if (user()!.avatar) {
|
||||||
|
<div class="info-item">
|
||||||
|
<strong>Avatar:</strong>
|
||||||
|
<img [src]="user()!.avatar" alt="Avatar" class="avatar-preview" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<small class="form-help">
|
||||||
|
Username, email, and avatar are managed through Discord and cannot be changed here
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="saving()">
|
||||||
|
{{ saving() ? 'Saving...' : 'Save Changes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.settings-container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--accent-colour, #9b59b6);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
background: var(--card-background, #1a1a2e);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid rgba(155, 89, 182, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h2 {
|
||||||
|
color: var(--accent-colour, #9b59b6);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-colour, #e0e0e0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid rgba(155, 89, 182, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--text-colour, #e0e0e0);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-colour, #9b59b6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-colour, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item strong {
|
||||||
|
color: var(--accent-colour, #9b59b6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class SettingsComponent implements OnInit {
|
||||||
|
private userService = inject(UserService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
|
user = signal<User | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
saving = signal(false);
|
||||||
|
|
||||||
|
formData: UpdateUserSettingsRequest & { bio?: string } = {
|
||||||
|
displayName: '',
|
||||||
|
slug: '',
|
||||||
|
bio: '',
|
||||||
|
profilePublic: true
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.userService.getMe().subscribe({
|
||||||
|
next: (userData: User) => {
|
||||||
|
this.user.set(userData);
|
||||||
|
this.formData = {
|
||||||
|
displayName: userData.displayName || '',
|
||||||
|
slug: userData.slug || '',
|
||||||
|
bio: userData.bio || '',
|
||||||
|
profilePublic: userData.profilePublic ?? true
|
||||||
|
};
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
console.error('Error loading user profile:', err);
|
||||||
|
this.toastService.error('Failed to load profile');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(): void {
|
||||||
|
this.saving.set(true);
|
||||||
|
|
||||||
|
const updates: UpdateUserSettingsRequest = {
|
||||||
|
displayName: this.formData.displayName || undefined,
|
||||||
|
slug: this.formData.slug || undefined,
|
||||||
|
bio: this.formData.bio || undefined,
|
||||||
|
profilePublic: this.formData.profilePublic
|
||||||
|
};
|
||||||
|
|
||||||
|
this.userService.updateSettings(updates).subscribe({
|
||||||
|
next: (updatedUser: User) => {
|
||||||
|
this.user.set(updatedUser);
|
||||||
|
this.authService.updateUser(updatedUser);
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastService.success('Settings saved successfully!');
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
console.error('Error saving settings:', err);
|
||||||
|
this.toastService.error('Failed to save settings');
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,10 @@ export class AuthService {
|
|||||||
return this.user() !== null;
|
return this.user() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateUser(user: User): void {
|
||||||
|
this.currentUser.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
isAdmin(): boolean {
|
isAdmin(): boolean {
|
||||||
return this.user()?.isAdmin === true;
|
return this.user()?.isAdmin === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,35 @@ import { Observable } from 'rxjs';
|
|||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
import { User } from '@library/shared-types';
|
import { User } from '@library/shared-types';
|
||||||
|
|
||||||
|
export interface UserProfileResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
slug?: string;
|
||||||
|
badges: {
|
||||||
|
isStaff: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isVip: boolean;
|
||||||
|
inDiscord: boolean;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
suggestionsCount: number;
|
||||||
|
suggestionsAcceptedCount: number;
|
||||||
|
likesCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserSettingsRequest {
|
||||||
|
slug?: string;
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
profilePublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -26,4 +55,16 @@ export class UserService {
|
|||||||
unbanUser(userId: string): Observable<User> {
|
unbanUser(userId: string): Observable<User> {
|
||||||
return this.api.post<User>(`/users/${userId}/unban`, {});
|
return this.api.post<User>(`/users/${userId}/unban`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMe(): Observable<User> {
|
||||||
|
return this.api.get<User>('/users/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings: UpdateUserSettingsRequest): Observable<User> {
|
||||||
|
return this.api.put<User>('/users/me', settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile(identifier: string): Observable<UserProfileResponse> {
|
||||||
|
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
slug?: string;
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
profilePublic: boolean;
|
||||||
discordId: string;
|
discordId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isBanned: boolean;
|
isBanned: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user