feat: add art component

This commit is contained in:
2026-02-04 13:40:52 -08:00
parent d338c8b52f
commit cbd6499079
12 changed files with 1162 additions and 0 deletions
+14
View File
@@ -84,6 +84,18 @@ enum MusicStatus {
WANT_TO_LISTEN
}
model Art {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
artist String
description String?
imageUrl String
dateAdded DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments Comment[]
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
discordId String @unique
@@ -107,6 +119,8 @@ model Comment {
book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id])
artId String? @db.ObjectId
art Art? @relation(fields: [artId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+123
View File
@@ -0,0 +1,123 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto } from "@library/shared-types";
import { ArtService } from "../../services/art.service";
import { CommentService } from "../../services/comment.service";
import { adminGuard } from "../../middleware/admin-guard";
const artRoutes: FastifyPluginAsync = async (app) => {
const artService = new ArtService();
const commentService = new CommentService();
/**
* Get all art (public route).
*/
app.get<{ Reply: Art[] }>("/", async () => {
return artService.getAllArt();
});
/**
* Get single art piece by ID (public route).
*/
app.get<{ Params: { id: string }; Reply: Art | null }>(
"/:id",
async (request) => {
const { id } = request.params;
return artService.getArtById(id);
}
);
/**
* Create new art piece (admin only).
*/
app.post<{ Body: CreateArtDto; Reply: Art }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
return artService.createArt(request.body);
}
);
/**
* Update art by ID (admin only).
*/
app.put<{
Params: { id: string };
Body: UpdateArtDto;
Reply: Art | null;
}>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { id } = request.params;
return artService.updateArt(id, request.body);
}
);
/**
* Delete art by ID (admin only).
*/
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { id } = request.params;
await artService.deleteArt(id);
return { success: true };
}
);
/**
* Get comments for an art piece (public route).
*/
app.get<{ Params: { id: string }; Reply: Comment[] }>(
"/:id/comments",
async (request) => {
const { id } = request.params;
return commentService.getCommentsForArt(id);
}
);
/**
* Add comment to an art piece (authenticated users).
*/
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForArt(id, userId, request.body);
}
);
/**
* Delete comment (admin only).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { commentId } = request.params;
await commentService.deleteComment(commentId);
return { success: true };
}
);
};
export default artRoutes;
+94
View File
@@ -0,0 +1,94 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Art, CreateArtDto, UpdateArtDto } from "@library/shared-types";
import { prisma } from "../lib/prisma";
export class ArtService {
private prisma = prisma;
constructor() {}
/**
* Get all art pieces.
*/
async getAllArt(): Promise<Art[]> {
const artPieces = await this.prisma.art.findMany({
orderBy: { createdAt: "desc" },
});
return artPieces.map((art) => ({
...art,
description: art.description || undefined,
dateAdded: art.dateAdded,
createdAt: art.createdAt,
updatedAt: art.updatedAt,
}));
}
/**
* Get art by ID.
*/
async getArtById(id: string): Promise<Art | null> {
const art = await this.prisma.art.findUnique({
where: { id },
});
if (!art) return null;
return {
...art,
description: art.description || undefined,
dateAdded: art.dateAdded,
createdAt: art.createdAt,
updatedAt: art.updatedAt,
};
}
/**
* Create new art piece.
*/
async createArt(data: CreateArtDto): Promise<Art> {
const art = await this.prisma.art.create({
data,
});
return {
...art,
description: art.description || undefined,
dateAdded: art.dateAdded,
createdAt: art.createdAt,
updatedAt: art.updatedAt,
};
}
/**
* Update art by ID.
*/
async updateArt(id: string, data: UpdateArtDto): Promise<Art> {
const art = await this.prisma.art.update({
where: { id },
data,
});
return {
...art,
description: art.description || undefined,
dateAdded: art.dateAdded,
createdAt: art.createdAt,
updatedAt: art.updatedAt,
};
}
/**
* Delete art by ID.
*/
async deleteArt(id: string): Promise<void> {
await this.prisma.art.delete({
where: { id },
});
}
}
+27
View File
@@ -63,6 +63,7 @@ export class CommentService {
gameId: comment.gameId || undefined,
bookId: comment.bookId || undefined,
musicId: comment.musicId || undefined,
artId: comment.artId || undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
@@ -146,6 +147,32 @@ export class CommentService {
return this.mapComment(comment);
}
async getCommentsForArt(artId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { artId },
include: { user: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
}
async createCommentForArt(
artId: string,
userId: string,
data: CreateCommentDto
): Promise<Comment> {
const sanitizedContent = this.sanitizeMarkdown(data.content);
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
userId,
artId,
},
include: { user: true },
});
return this.mapComment(comment);
}
async deleteComment(commentId: string): Promise<void> {
await this.prisma.comment.delete({
where: { id: commentId },
+4
View File
@@ -17,6 +17,10 @@ export const appRoutes: Route[] = [
path: 'music',
loadComponent: () => import('./components/music/music-list.component').then(m => m.MusicListComponent)
},
{
path: 'art',
loadComponent: () => import('./components/art/art-gallery.component').then(m => m.ArtGalleryComponent)
},
{
path: '**',
redirectTo: ''
@@ -0,0 +1,824 @@
/**
* @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 { FormsModule } from '@angular/forms';
import { ArtService } from '../../services/art.service';
import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-art-gallery',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="container">
<div class="header-section">
<h2>Art Gallery</h2>
<p class="subtitle">Artwork of Naomi</p>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Art' }}
</button>
}
</div>
<div class="disclaimer">
<p>
<strong>A note on AI-generated art:</strong> Yes, we understand the negative impacts of AI art on working artists.
However, seeing ourselves represented gives us gender euphoria and dopamine hits in an otherwise shitty and depressing world.
If you want to yell at someone about AI art, go find someone who's actually profiting from their AI slop instead of bothering us. Thanks!
</p>
<p class="callout">
That said, if you'd like to draw Naomi and send it our way, we'd be absolutely delighted to display it here!
</p>
</div>
@if (showAddForm() && authService.isAdmin()) {
<form (ngSubmit)="addArt()" class="add-form">
<h3>Add New Artwork</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="newArt.title"
name="title"
required
placeholder="Artwork title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="newArt.artist"
name="artist"
required
placeholder="Who created this artwork"
>
</div>
<div class="form-group">
<label for="imageUrl">Image URL (CDN)</label>
<input
type="url"
id="imageUrl"
[(ngModel)]="newArt.imageUrl"
name="imageUrl"
required
placeholder="https://cdn.example.com/image.png"
>
</div>
<div class="form-group">
<label for="description">Description (used as alt text)</label>
<textarea
id="description"
[(ngModel)]="newArt.description"
name="description"
rows="2"
placeholder="Describe the artwork for accessibility..."
></textarea>
</div>
@if (newArt.imageUrl) {
<div class="image-preview">
<p>Preview:</p>
<img [src]="newArt.imageUrl" [alt]="newArt.description || 'Preview'" (error)="onImageError($event)">
</div>
}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Artwork</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (editingArt() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Artwork</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editArt.title"
name="title"
required
placeholder="Artwork title"
>
</div>
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editArt.artist"
name="artist"
required
placeholder="Who created this artwork"
>
</div>
<div class="form-group">
<label for="edit-imageUrl">Image URL (CDN)</label>
<input
type="url"
id="edit-imageUrl"
[(ngModel)]="editArt.imageUrl"
name="imageUrl"
required
placeholder="https://cdn.example.com/image.png"
>
</div>
<div class="form-group">
<label for="edit-description">Description (used as alt text)</label>
<textarea
id="edit-description"
[(ngModel)]="editArt.description"
name="description"
rows="2"
placeholder="Describe the artwork for accessibility..."
></textarea>
</div>
@if (editArt.imageUrl) {
<div class="image-preview">
<p>Preview:</p>
<img [src]="editArt.imageUrl" [alt]="editArt.description || 'Preview'" (error)="onImageError($event)">
</div>
}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (loading()) {
<div class="loading">Loading gallery...</div>
} @else if (artPieces().length === 0) {
<div class="empty-state">
<p>No artwork in the gallery yet.</p>
</div>
} @else {
<div class="gallery-grid">
@for (art of artPieces(); track art.id) {
<div class="art-card">
<div class="art-image-container">
<img
[src]="art.imageUrl"
[alt]="art.description || art.title"
class="art-image"
(click)="openLightbox(art)"
>
</div>
<div class="art-info">
<h3>{{ art.title }}</h3>
<p class="artist">by {{ art.artist }}</p>
<p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p>
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(art)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteArt(art)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(art.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[art.id] ? 'Hide' : 'Show' }} Comments{{ comments()[art.id] ? ' (' + getCommentCount(art.id) + ')' : '' }}
</button>
@if (expandedComments()[art.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
<form (ngSubmit)="addComment(art.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[art.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
@if (commentsLoading()[art.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[art.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
</div>
}
@if (lightboxArt()) {
<div class="lightbox" (click)="closeLightbox()">
<div class="lightbox-content" (click)="$event.stopPropagation()">
<button class="lightbox-close" (click)="closeLightbox()">&times;</button>
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
<div class="lightbox-info">
<h3>{{ lightboxArt()!.title }}</h3>
<p>by {{ lightboxArt()!.artist }}</p>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
gap: 0.5rem;
}
.header-section h2 {
margin: 0;
}
.subtitle {
color: var(--witch-mauve);
margin: 0;
font-style: italic;
}
.disclaimer {
background: linear-gradient(135deg, var(--witch-lavender) 0%, var(--witch-mauve) 100%);
border: 2px solid var(--witch-plum);
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 2rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.disclaimer p {
margin: 0;
color: var(--witch-purple);
font-size: 0.95rem;
line-height: 1.6;
text-align: center;
}
.disclaimer p.callout {
margin-top: 0.75rem;
font-style: italic;
color: var(--witch-plum);
}
.disclaimer strong {
color: var(--witch-plum);
}
.add-form {
background: rgba(255, 255, 255, 0.95);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
border: 2px solid var(--witch-lavender);
backdrop-filter: blur(10px);
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--witch-plum);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
font-size: 1rem;
background-color: var(--witch-moon);
color: var(--witch-purple);
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.image-preview {
margin: 1rem 0;
text-align: center;
}
.image-preview img {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
border: 2px solid var(--witch-lavender);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.loading, .empty-state {
text-align: center;
padding: 3rem;
color: var(--witch-mauve);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
}
.art-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px var(--witch-shadow);
border: 2px solid var(--witch-lavender);
transition: transform 0.3s, box-shadow 0.3s;
}
.art-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px var(--witch-shadow);
}
.art-image-container {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
}
.art-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.art-image:hover {
transform: scale(1.05);
}
.art-info {
padding: 1rem;
}
.art-info h3 {
margin: 0 0 0.5rem 0;
color: var(--witch-plum);
}
.artist {
color: var(--witch-mauve);
font-style: italic;
margin: 0 0 0.5rem 0;
}
.date-added {
font-size: 0.85rem;
color: var(--witch-mauve);
margin: 0 0 1rem 0;
}
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-2px);
}
.btn-primary {
background: var(--witch-rose);
color: var(--witch-moon);
}
.btn-secondary {
background: var(--witch-mauve);
color: var(--witch-purple);
}
.btn-danger {
background: var(--witch-plum);
color: var(--witch-moon);
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.btn-xs {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
}
/* Lightbox */
.lightbox {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
text-align: center;
}
.lightbox-content img {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
}
.lightbox-info {
color: white;
margin-top: 1rem;
}
.lightbox-info h3 {
margin: 0;
color: white;
}
.lightbox-info p {
margin: 0.5rem 0 0 0;
opacity: 0.8;
}
/* Comments */
.comments-section {
margin-top: 1rem;
border-top: 1px solid var(--witch-lavender);
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--witch-moon);
color: var(--witch-purple);
resize: vertical;
margin-bottom: 0.5rem;
}
.comment-form textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.comment {
background: var(--witch-moon);
border: 1px solid var(--witch-lavender);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: var(--witch-plum);
}
.comment-date {
font-size: 0.75rem;
color: var(--witch-mauve);
}
.comment-content {
font-size: 0.9rem;
color: var(--witch-purple);
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: var(--witch-mauve);
font-size: 0.9rem;
}
`]
})
export class ArtGalleryComponent implements OnInit {
artService = inject(ArtService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
artPieces = signal<Art[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingArt = signal<Art | null>(null);
lightboxArt = signal<Art | null>(null);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
newArt: Partial<CreateArtDto> = {
title: '',
artist: '',
imageUrl: '',
description: ''
};
editArt: Partial<UpdateArtDto> = {};
ngOnInit() {
this.loadArt();
}
loadArt() {
this.loading.set(true);
this.artService.getAllArt().subscribe({
next: (art) => {
this.artPieces.set(art);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newArt = {
title: '',
artist: '',
imageUrl: '',
description: ''
};
}
addArt() {
if (!this.newArt.title || !this.newArt.artist || !this.newArt.imageUrl) return;
const artToAdd: CreateArtDto = {
title: this.newArt.title,
artist: this.newArt.artist,
imageUrl: this.newArt.imageUrl,
description: this.newArt.description
};
this.artService.createArt(artToAdd).subscribe(() => {
this.loadArt();
this.toggleAddForm();
});
}
deleteArt(art: Art) {
if (confirm(`Are you sure you want to delete "${art.title}"?`)) {
this.artService.deleteArt(art.id).subscribe(() => {
this.loadArt();
});
}
}
startEdit(art: Art) {
this.editingArt.set(art);
this.editArt = {
title: art.title,
artist: art.artist,
imageUrl: art.imageUrl,
description: art.description
};
this.showAddForm.set(false);
}
cancelEdit() {
this.editingArt.set(null);
this.editArt = {};
}
saveEdit() {
const art = this.editingArt();
if (!art || !this.editArt.title || !this.editArt.artist || !this.editArt.imageUrl) return;
this.artService.updateArt(art.id, this.editArt).subscribe(() => {
this.loadArt();
this.cancelEdit();
});
}
openLightbox(art: Art) {
this.lightboxArt.set(art);
}
closeLightbox() {
this.lightboxArt.set(null);
}
onImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.style.display = 'none';
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
// Comments methods
toggleComments(artId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[artId];
this.expandedComments.set({
...expanded,
[artId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[artId]) {
this.loadComments(artId);
}
}
loadComments(artId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: true
});
this.commentsService.getCommentsForArt(artId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[artId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[artId]: false
});
}
});
}
getCommentCount(artId: string): number {
return this.comments()[artId]?.length || 0;
}
addComment(artId: string) {
const content = this.newCommentContent[artId];
if (!content?.trim()) return;
this.commentsService.addCommentToArt(artId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[artId]: [comment, ...(this.comments()[artId] || [])]
});
this.newCommentContent[artId] = '';
}
});
}
deleteComment(artId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromArt(artId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[artId]: (this.comments()[artId] || []).filter(c => c.id !== commentId)
});
}
});
}
}
@@ -24,6 +24,7 @@ import { AuthService } from '../../services/auth.service';
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
</ul>
<div class="auth-section">
@@ -0,0 +1,37 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@Injectable({
providedIn: 'root'
})
export class ArtService {
constructor(private api: ApiService) {}
getAllArt(): Observable<Art[]> {
return this.api.get<Art[]>('/art');
}
getArtById(id: string): Observable<Art | null> {
return this.api.get<Art | null>(`/art/${id}`);
}
createArt(art: CreateArtDto): Observable<Art> {
return this.api.post<Art>('/art', art);
}
updateArt(id: string, art: UpdateArtDto): Observable<Art> {
return this.api.put<Art>(`/art/${id}`, art);
}
deleteArt(id: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/art/${id}`);
}
}
@@ -50,4 +50,16 @@ export class CommentsService {
deleteCommentFromMusic(musicId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/music/${musicId}/comments/${commentId}`);
}
getCommentsForArt(artId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/art/${artId}/comments`);
}
addCommentToArt(artId: string, comment: CreateCommentDto): Observable<Comment> {
return this.api.post<Comment>(`/art/${artId}/comments`, comment);
}
deleteCommentFromArt(artId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/art/${artId}/comments/${commentId}`);
}
}
+1
View File
@@ -6,5 +6,6 @@
export * from "./lib/game.types";
export * from "./lib/book.types";
export * from "./lib/music.types";
export * from "./lib/art.types";
export type * from "./lib/auth.types";
export * from "./lib/comment.types";
+24
View File
@@ -0,0 +1,24 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
*/
export interface Art {
id: string;
title: string;
artist: string;
description?: string;
imageUrl: string;
dateAdded: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateArtDto {
title: string;
artist: string;
description?: string;
imageUrl: string;
}
export interface UpdateArtDto extends Partial<CreateArtDto> {}
+1
View File
@@ -18,6 +18,7 @@ export interface Comment {
gameId?: string;
bookId?: string;
musicId?: string;
artId?: string;
createdAt: Date;
updatedAt: Date;
}