feat: add tags and links

This commit is contained in:
2026-02-04 19:49:27 -08:00
parent 9902c5ad45
commit b9f33bc055
21 changed files with 1873 additions and 31 deletions
+17
View File
@@ -13,6 +13,11 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
type Link {
title String
url String
}
model Game { model Game {
id String @id @default(auto()) @map("_id") @db.ObjectId id String @id @default(auto()) @map("_id") @db.ObjectId
title String title String
@@ -23,6 +28,8 @@ model Game {
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
tags String[]
links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -45,6 +52,8 @@ model Book {
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
tags String[]
links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -67,6 +76,8 @@ model Music {
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverArt String? coverArt String?
tags String[]
links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -90,6 +101,8 @@ model Art {
artist String artist String
description String? description String?
imageUrl String imageUrl String
tags String[]
links Link[]
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -106,6 +119,8 @@ model Show {
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
tags String[]
links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -134,6 +149,8 @@ model Manga {
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
tags String[]
links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
+8
View File
@@ -23,6 +23,8 @@ export class ArtService {
return artPieces.map((art) => ({ return artPieces.map((art) => ({
...art, ...art,
description: art.description || undefined, description: art.description || undefined,
tags: art.tags ?? [],
links: art.links ?? [],
dateAdded: art.dateAdded, dateAdded: art.dateAdded,
createdAt: art.createdAt, createdAt: art.createdAt,
updatedAt: art.updatedAt, updatedAt: art.updatedAt,
@@ -42,6 +44,8 @@ export class ArtService {
return { return {
...art, ...art,
description: art.description || undefined, description: art.description || undefined,
tags: art.tags ?? [],
links: art.links ?? [],
dateAdded: art.dateAdded, dateAdded: art.dateAdded,
createdAt: art.createdAt, createdAt: art.createdAt,
updatedAt: art.updatedAt, updatedAt: art.updatedAt,
@@ -59,6 +63,8 @@ export class ArtService {
return { return {
...art, ...art,
description: art.description || undefined, description: art.description || undefined,
tags: art.tags ?? [],
links: art.links ?? [],
dateAdded: art.dateAdded, dateAdded: art.dateAdded,
createdAt: art.createdAt, createdAt: art.createdAt,
updatedAt: art.updatedAt, updatedAt: art.updatedAt,
@@ -77,6 +83,8 @@ export class ArtService {
return { return {
...art, ...art,
description: art.description || undefined, description: art.description || undefined,
tags: art.tags ?? [],
links: art.links ?? [],
dateAdded: art.dateAdded, dateAdded: art.dateAdded,
createdAt: art.createdAt, createdAt: art.createdAt,
updatedAt: art.updatedAt, updatedAt: art.updatedAt,
+8
View File
@@ -25,6 +25,8 @@ export class BookService {
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
createdAt: book.createdAt, createdAt: book.createdAt,
updatedAt: book.updatedAt, updatedAt: book.updatedAt,
})); }));
@@ -45,6 +47,8 @@ export class BookService {
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
createdAt: book.createdAt, createdAt: book.createdAt,
updatedAt: book.updatedAt, updatedAt: book.updatedAt,
}; };
@@ -66,6 +70,8 @@ export class BookService {
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
createdAt: book.createdAt, createdAt: book.createdAt,
updatedAt: book.updatedAt, updatedAt: book.updatedAt,
}; };
@@ -90,6 +96,8 @@ export class BookService {
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
createdAt: book.createdAt, createdAt: book.createdAt,
updatedAt: book.updatedAt, updatedAt: book.updatedAt,
}; };
+8
View File
@@ -25,6 +25,8 @@ export class GameService {
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
updatedAt: game.updatedAt, updatedAt: game.updatedAt,
})); }));
@@ -45,6 +47,8 @@ export class GameService {
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
updatedAt: game.updatedAt, updatedAt: game.updatedAt,
}; };
@@ -66,6 +70,8 @@ export class GameService {
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
updatedAt: game.updatedAt, updatedAt: game.updatedAt,
}; };
@@ -90,6 +96,8 @@ export class GameService {
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
updatedAt: game.updatedAt, updatedAt: game.updatedAt,
}; };
+8
View File
@@ -22,6 +22,8 @@ export class MangaService {
status: m.status as unknown as MangaStatus, status: m.status as unknown as MangaStatus,
dateAdded: m.dateAdded, dateAdded: m.dateAdded,
dateCompleted: m.dateCompleted || undefined, dateCompleted: m.dateCompleted || undefined,
tags: m.tags ?? [],
links: m.links ?? [],
createdAt: m.createdAt, createdAt: m.createdAt,
updatedAt: m.updatedAt, updatedAt: m.updatedAt,
})); }));
@@ -39,6 +41,8 @@ export class MangaService {
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
updatedAt: manga.updatedAt, updatedAt: manga.updatedAt,
}; };
@@ -57,6 +61,8 @@ export class MangaService {
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
updatedAt: manga.updatedAt, updatedAt: manga.updatedAt,
}; };
@@ -78,6 +84,8 @@ export class MangaService {
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
updatedAt: manga.updatedAt, updatedAt: manga.updatedAt,
}; };
+8
View File
@@ -26,6 +26,8 @@ export class MusicService {
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
updatedAt: music.updatedAt, updatedAt: music.updatedAt,
})); }));
@@ -47,6 +49,8 @@ export class MusicService {
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
updatedAt: music.updatedAt, updatedAt: music.updatedAt,
}; };
@@ -70,6 +74,8 @@ export class MusicService {
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
updatedAt: music.updatedAt, updatedAt: music.updatedAt,
}; };
@@ -98,6 +104,8 @@ export class MusicService {
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
updatedAt: music.updatedAt, updatedAt: music.updatedAt,
}; };
+8
View File
@@ -23,6 +23,8 @@ export class ShowService {
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
updatedAt: show.updatedAt, updatedAt: show.updatedAt,
})); }));
@@ -41,6 +43,8 @@ export class ShowService {
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
updatedAt: show.updatedAt, updatedAt: show.updatedAt,
}; };
@@ -61,6 +65,8 @@ export class ShowService {
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
updatedAt: show.updatedAt, updatedAt: show.updatedAt,
}; };
@@ -86,6 +92,8 @@ export class ShowService {
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
updatedAt: show.updatedAt, updatedAt: show.updatedAt,
}; };
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-art-gallery', selector: 'app-art-gallery',
@@ -102,6 +102,52 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity } from '@lib
</div> </div>
} }
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newArt.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., DeviantArt)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Artwork</button> <button type="submit" class="btn btn-primary">Add Artwork</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -231,6 +277,52 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity } from '@lib
</div> </div>
} }
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editArt.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., DeviantArt)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -262,6 +354,24 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity } from '@lib
<p class="artist">by {{ art.artist }}</p> <p class="artist">by {{ art.artist }}</p>
<p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p> <p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p>
@if (art.tags && art.tags.length > 0) {
<div class="tags-display">
@for (tag of art.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (art.links && art.links.length > 0) {
<div class="links-display">
@for (link of art.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(art)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(art)" class="btn btn-secondary btn-sm">
@@ -812,6 +922,116 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity } from '@lib
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
background: var(--witch-moon);
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #fdcb6e;
color: #333;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: #333;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(253, 203, 110, 0.2);
color: #b38f00;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--witch-moon);
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #b38f00;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(253, 203, 110, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(253, 203, 110, 0.2);
text-decoration: underline;
}
`] `]
}) })
export class ArtGalleryComponent implements OnInit { export class ArtGalleryComponent implements OnInit {
@@ -848,11 +1068,21 @@ export class ArtGalleryComponent implements OnInit {
title: '', title: '',
artist: '', artist: '',
imageUrl: '', imageUrl: '',
description: '' description: '',
tags: [],
links: []
}; };
editArt: Partial<UpdateArtDto> = {}; editArt: Partial<UpdateArtDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadArt(); this.loadArt();
} }
@@ -882,8 +1112,58 @@ export class ArtGalleryComponent implements OnInit {
title: '', title: '',
artist: '', artist: '',
imageUrl: '', imageUrl: '',
description: '' description: '',
tags: [],
links: []
}; };
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newArt.tags = [...(this.newArt.tags || []), input];
this.newTagInput = '';
} else {
this.editArt.tags = [...(this.editArt.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newArt.tags = (this.newArt.tags || []).filter((_, i) => i !== index);
} else {
this.editArt.tags = (this.editArt.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newArt.links = [...(this.newArt.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editArt.links = [...(this.editArt.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newArt.links = (this.newArt.links || []).filter((_, i) => i !== index);
} else {
this.editArt.links = (this.editArt.links || []).filter((_, i) => i !== index);
}
} }
addArt() { addArt() {
@@ -893,7 +1173,9 @@ export class ArtGalleryComponent implements OnInit {
title: this.newArt.title, title: this.newArt.title,
artist: this.newArt.artist, artist: this.newArt.artist,
imageUrl: this.newArt.imageUrl, imageUrl: this.newArt.imageUrl,
description: this.newArt.description description: this.newArt.description,
tags: this.newArt.tags || [],
links: this.newArt.links || []
}; };
this.artService.createArt(artToAdd).subscribe(() => { this.artService.createArt(artToAdd).subscribe(() => {
@@ -916,14 +1198,22 @@ export class ArtGalleryComponent implements OnInit {
title: art.title, title: art.title,
artist: art.artist, artist: art.artist,
imageUrl: art.imageUrl, imageUrl: art.imageUrl,
description: art.description description: art.description,
tags: [...(art.tags || [])],
links: [...(art.links || [])]
}; };
this.showAddForm.set(false); this.showAddForm.set(false);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
this.editingArt.set(null); this.editingArt.set(null);
this.editArt = {}; this.editArt = {};
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-books-list', selector: 'app-books-list',
@@ -123,6 +123,52 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newBook.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newBook.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., Goodreads)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Book</button> <button type="submit" class="btn btn-primary">Add Book</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -220,6 +266,52 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editBook.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editBook.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., Goodreads)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -375,6 +467,24 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<p class="notes">{{ book.notes }}</p> <p class="notes">{{ book.notes }}</p>
} }
@if (book.tags && book.tags.length > 0) {
<div class="tags-display">
@for (tag of book.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (book.links && book.links.length > 0) {
<div class="links-display">
@for (link of book.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (book.dateFinished) { @if (book.dateFinished) {
<p class="date-finished"> <p class="date-finished">
Finished: {{ formatDate(book.dateFinished) }} Finished: {{ formatDate(book.dateFinished) }}
@@ -938,6 +1048,116 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
input[type="file"]:hover { input[type="file"]:hover {
border-color: var(--witch-rose); border-color: var(--witch-rose);
} }
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
background: var(--witch-moon);
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #8b6f47;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(139, 111, 71, 0.2);
color: #8b6f47;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--witch-moon);
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #8b6f47;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(139, 111, 71, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(139, 111, 71, 0.2);
text-decoration: underline;
}
`] `]
}) })
export class BooksListComponent implements OnInit { export class BooksListComponent implements OnInit {
@@ -1000,11 +1220,21 @@ export class BooksListComponent implements OnInit {
isbn: '', isbn: '',
status: BookStatus.toRead, status: BookStatus.toRead,
rating: undefined, rating: undefined,
notes: '' notes: '',
tags: [],
links: []
}; };
editBook: Partial<UpdateBookDto> = {}; editBook: Partial<UpdateBookDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadBooks(); this.loadBooks();
} }
@@ -1049,10 +1279,60 @@ export class BooksListComponent implements OnInit {
status: BookStatus.toRead, status: BookStatus.toRead,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined coverImage: undefined,
tags: [],
links: []
}; };
this.newBookImagePreview.set(null); this.newBookImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newBook.tags = [...(this.newBook.tags || []), input];
this.newTagInput = '';
} else {
this.editBook.tags = [...(this.editBook.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newBook.tags = (this.newBook.tags || []).filter((_, i) => i !== index);
} else {
this.editBook.tags = (this.editBook.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newBook.links = [...(this.newBook.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editBook.links = [...(this.editBook.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newBook.links = (this.newBook.links || []).filter((_, i) => i !== index);
} else {
this.editBook.links = (this.editBook.links || []).filter((_, i) => i !== index);
}
} }
addBook() { addBook() {
@@ -1065,7 +1345,9 @@ export class BooksListComponent implements OnInit {
status: this.newBook.status, status: this.newBook.status,
rating: this.newBook.rating, rating: this.newBook.rating,
notes: this.newBook.notes, notes: this.newBook.notes,
coverImage: this.newBook.coverImage coverImage: this.newBook.coverImage,
tags: this.newBook.tags || [],
links: this.newBook.links || []
}; };
this.booksService.createBook(bookToAdd).subscribe(() => { this.booksService.createBook(bookToAdd).subscribe(() => {
@@ -1091,11 +1373,16 @@ export class BooksListComponent implements OnInit {
status: book.status, status: book.status,
rating: book.rating, rating: book.rating,
notes: book.notes, notes: book.notes,
coverImage: book.coverImage coverImage: book.coverImage,
tags: [...(book.tags || [])],
links: [...(book.links || [])]
}; };
this.editBookImagePreview.set(book.coverImage || null); this.editBookImagePreview.set(book.coverImage || null);
this.showAddForm.set(false); this.showAddForm.set(false);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
@@ -1103,6 +1390,9 @@ export class BooksListComponent implements OnInit {
this.editBook = {}; this.editBook = {};
this.editBookImagePreview.set(null); this.editBookImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-games-list', selector: 'app-games-list',
@@ -111,6 +111,52 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newGame.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., Steam)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Game</button> <button type="submit" class="btn btn-primary">Add Game</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -196,6 +242,52 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editGame.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., Steam)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -334,6 +426,24 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<p class="notes">{{ game.notes }}</p> <p class="notes">{{ game.notes }}</p>
} }
@if (game.tags && game.tags.length > 0) {
<div class="tags-display">
@for (tag of game.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (game.links && game.links.length > 0) {
<div class="links-display">
@for (link of game.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
@@ -747,6 +857,112 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Tags and Links Styling */
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
}
.tags-input-container input {
border: none;
outline: none;
flex: 1;
min-width: 150px;
padding: 0.25rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
margin-left: 0.25rem;
}
.tag-remove:hover {
color: #fecaca;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid #eee;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #ff6b6b;
text-decoration: none;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border: 1px solid #ff6b6b;
border-radius: 4px;
transition: all 0.2s;
}
.external-link:hover {
background: #ff6b6b;
color: white;
}
.comment-edit-form { .comment-edit-form {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -858,11 +1074,21 @@ export class GamesListComponent implements OnInit {
platform: '', platform: '',
status: GameStatus.backlog, status: GameStatus.backlog,
rating: undefined, rating: undefined,
notes: '' notes: '',
tags: [],
links: []
}; };
editGame: Partial<UpdateGameDto> = {}; editGame: Partial<UpdateGameDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadGames(); this.loadGames();
} }
@@ -906,10 +1132,60 @@ export class GamesListComponent implements OnInit {
status: GameStatus.backlog, status: GameStatus.backlog,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined coverImage: undefined,
tags: [],
links: []
}; };
this.newGameImagePreview.set(null); this.newGameImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newGame.tags = [...(this.newGame.tags || []), input];
this.newTagInput = '';
} else {
this.editGame.tags = [...(this.editGame.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newGame.tags = (this.newGame.tags || []).filter((_, i) => i !== index);
} else {
this.editGame.tags = (this.editGame.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newGame.links = [...(this.newGame.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editGame.links = [...(this.editGame.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newGame.links = (this.newGame.links || []).filter((_, i) => i !== index);
} else {
this.editGame.links = (this.editGame.links || []).filter((_, i) => i !== index);
}
} }
addGame() { addGame() {
@@ -921,7 +1197,9 @@ export class GamesListComponent implements OnInit {
status: this.newGame.status, status: this.newGame.status,
rating: this.newGame.rating, rating: this.newGame.rating,
notes: this.newGame.notes, notes: this.newGame.notes,
coverImage: this.newGame.coverImage coverImage: this.newGame.coverImage,
tags: this.newGame.tags || [],
links: this.newGame.links || []
}; };
this.gamesService.createGame(gameToAdd).subscribe(() => { this.gamesService.createGame(gameToAdd).subscribe(() => {
@@ -946,11 +1224,16 @@ export class GamesListComponent implements OnInit {
status: game.status, status: game.status,
rating: game.rating, rating: game.rating,
notes: game.notes, notes: game.notes,
coverImage: game.coverImage coverImage: game.coverImage,
tags: [...(game.tags || [])],
links: [...(game.links || [])]
}; };
this.editGameImagePreview.set(game.coverImage || null); this.editGameImagePreview.set(game.coverImage || null);
this.showAddForm.set(false); this.showAddForm.set(false);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
@@ -958,6 +1241,9 @@ export class GamesListComponent implements OnInit {
this.editGame = {}; this.editGame = {};
this.editGameImagePreview.set(null); this.editGameImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-manga-list', selector: 'app-manga-list',
@@ -112,6 +112,52 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newManga.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., MyAnimeList)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Manga</button> <button type="submit" class="btn btn-primary">Add Manga</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -198,6 +244,52 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editManga.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., MyAnimeList)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -335,6 +427,24 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<p class="notes">{{ manga.notes }}</p> <p class="notes">{{ manga.notes }}</p>
} }
@if (manga.tags && manga.tags.length > 0) {
<div class="tags-display">
@for (tag of manga.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (manga.links && manga.links.length > 0) {
<div class="links-display">
@for (link of manga.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
@@ -801,6 +911,116 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
input[type="file"]:hover { input[type="file"]:hover {
border-color: #ec4899; border-color: #ec4899;
} }
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9fafb;
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #00b894;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(0, 184, 148, 0.2);
color: #00b894;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #00b894;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(0, 184, 148, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(0, 184, 148, 0.2);
text-decoration: underline;
}
`] `]
}) })
export class MangaListComponent implements OnInit { export class MangaListComponent implements OnInit {
@@ -857,11 +1077,21 @@ export class MangaListComponent implements OnInit {
author: '', author: '',
status: MangaStatus.wantToRead, status: MangaStatus.wantToRead,
rating: undefined, rating: undefined,
notes: '' notes: '',
tags: [],
links: []
}; };
editManga: Partial<UpdateMangaDto> = {}; editManga: Partial<UpdateMangaDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadManga(); this.loadManga();
} }
@@ -905,10 +1135,60 @@ export class MangaListComponent implements OnInit {
status: MangaStatus.wantToRead, status: MangaStatus.wantToRead,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined coverImage: undefined,
tags: [],
links: []
}; };
this.newMangaImagePreview.set(null); this.newMangaImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newManga.tags = [...(this.newManga.tags || []), input];
this.newTagInput = '';
} else {
this.editManga.tags = [...(this.editManga.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newManga.tags = (this.newManga.tags || []).filter((_, i) => i !== index);
} else {
this.editManga.tags = (this.editManga.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newManga.links = [...(this.newManga.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editManga.links = [...(this.editManga.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newManga.links = (this.newManga.links || []).filter((_, i) => i !== index);
} else {
this.editManga.links = (this.editManga.links || []).filter((_, i) => i !== index);
}
} }
addManga() { addManga() {
@@ -920,7 +1200,9 @@ export class MangaListComponent implements OnInit {
status: this.newManga.status, status: this.newManga.status,
rating: this.newManga.rating, rating: this.newManga.rating,
notes: this.newManga.notes, notes: this.newManga.notes,
coverImage: this.newManga.coverImage coverImage: this.newManga.coverImage,
tags: this.newManga.tags || [],
links: this.newManga.links || []
}; };
this.mangaService.createManga(mangaToAdd).subscribe(() => { this.mangaService.createManga(mangaToAdd).subscribe(() => {
@@ -945,11 +1227,16 @@ export class MangaListComponent implements OnInit {
status: manga.status, status: manga.status,
rating: manga.rating, rating: manga.rating,
notes: manga.notes, notes: manga.notes,
coverImage: manga.coverImage coverImage: manga.coverImage,
tags: [...(manga.tags || [])],
links: [...(manga.links || [])]
}; };
this.editMangaImagePreview.set(manga.coverImage || null); this.editMangaImagePreview.set(manga.coverImage || null);
this.showAddForm.set(false); this.showAddForm.set(false);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
@@ -957,6 +1244,9 @@ export class MangaListComponent implements OnInit {
this.editManga = {}; this.editManga = {};
this.editMangaImagePreview.set(null); this.editMangaImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-music-list', selector: 'app-music-list',
@@ -121,6 +121,52 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newMusic.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newMusic.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., Spotify)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Music</button> <button type="submit" class="btn btn-primary">Add Music</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -216,6 +262,52 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editMusicData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editMusicData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., Spotify)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -411,6 +503,24 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<p class="notes">{{ music.notes }}</p> <p class="notes">{{ music.notes }}</p>
} }
@if (music.tags && music.tags.length > 0) {
<div class="tags-display">
@for (tag of music.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (music.links && music.links.length > 0) {
<div class="links-display">
@for (link of music.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (music.dateCompleted) { @if (music.dateCompleted) {
<p class="date-completed"> <p class="date-completed">
Completed: {{ formatDate(music.dateCompleted) }} Completed: {{ formatDate(music.dateCompleted) }}
@@ -1008,6 +1118,116 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
input[type="file"]:hover { input[type="file"]:hover {
border-color: var(--witch-rose); border-color: var(--witch-rose);
} }
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
background: var(--witch-moon);
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #74b9ff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(116, 185, 255, 0.2);
color: #74b9ff;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--witch-moon);
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #74b9ff;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(116, 185, 255, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(116, 185, 255, 0.2);
text-decoration: underline;
}
`] `]
}) })
export class MusicListComponent implements OnInit { export class MusicListComponent implements OnInit {
@@ -1085,11 +1305,21 @@ export class MusicListComponent implements OnInit {
type: MusicType.album, type: MusicType.album,
status: MusicStatus.wantToListen, status: MusicStatus.wantToListen,
rating: undefined, rating: undefined,
notes: '' notes: '',
tags: [],
links: []
}; };
editMusicData: Partial<UpdateMusicDto> = {}; editMusicData: Partial<UpdateMusicDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadMusic(); this.loadMusic();
} }
@@ -1146,10 +1376,60 @@ export class MusicListComponent implements OnInit {
status: MusicStatus.wantToListen, status: MusicStatus.wantToListen,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverArt: undefined coverArt: undefined,
tags: [],
links: []
}; };
this.newMusicImagePreview.set(null); this.newMusicImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newMusic.tags = [...(this.newMusic.tags || []), input];
this.newTagInput = '';
} else {
this.editMusicData.tags = [...(this.editMusicData.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newMusic.tags = (this.newMusic.tags || []).filter((_, i) => i !== index);
} else {
this.editMusicData.tags = (this.editMusicData.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newMusic.links = [...(this.newMusic.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editMusicData.links = [...(this.editMusicData.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newMusic.links = (this.newMusic.links || []).filter((_, i) => i !== index);
} else {
this.editMusicData.links = (this.editMusicData.links || []).filter((_, i) => i !== index);
}
} }
addMusic() { addMusic() {
@@ -1162,7 +1442,9 @@ export class MusicListComponent implements OnInit {
status: this.newMusic.status, status: this.newMusic.status,
rating: this.newMusic.rating, rating: this.newMusic.rating,
notes: this.newMusic.notes, notes: this.newMusic.notes,
coverArt: this.newMusic.coverArt coverArt: this.newMusic.coverArt,
tags: this.newMusic.tags || [],
links: this.newMusic.links || []
}; };
this.musicService.createMusic(musicToAdd).subscribe(() => { this.musicService.createMusic(musicToAdd).subscribe(() => {
@@ -1188,11 +1470,16 @@ export class MusicListComponent implements OnInit {
status: music.status, status: music.status,
rating: music.rating, rating: music.rating,
notes: music.notes, notes: music.notes,
coverArt: music.coverArt coverArt: music.coverArt,
tags: [...(music.tags || [])],
links: [...(music.links || [])]
}; };
this.editMusicImagePreview.set(music.coverArt || null); this.editMusicImagePreview.set(music.coverArt || null);
this.showAddForm.set(false); this.showAddForm.set(false);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
@@ -1200,6 +1487,9 @@ export class MusicListComponent implements OnInit {
this.editMusicData = {}; this.editMusicData = {};
this.editMusicImagePreview.set(null); this.editMusicImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
@@ -12,7 +12,7 @@ import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity } from '@library/shared-types'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-shows-list', selector: 'app-shows-list',
@@ -110,6 +110,52 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newShow.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., IMDB)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Add Show</button> <button type="submit" class="btn btn-primary">Add Show</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
@@ -194,6 +240,52 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} }
</div> </div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editShow.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., IMDB)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button> <button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
@@ -329,6 +421,24 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<p class="notes">{{ show.notes }}</p> <p class="notes">{{ show.notes }}</p>
} }
@if (show.tags && show.tags.length > 0) {
<div class="tags-display">
@for (tag of show.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (show.links && show.links.length > 0) {
<div class="links-display">
@for (link of show.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
@@ -794,6 +904,116 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
input[type="file"]:hover { input[type="file"]:hover {
border-color: #8b5cf6; border-color: #8b5cf6;
} }
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9fafb;
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #e84393;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(232, 67, 147, 0.2);
color: #e84393;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #e84393;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(232, 67, 147, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(232, 67, 147, 0.2);
text-decoration: underline;
}
`] `]
}) })
export class ShowsListComponent implements OnInit { export class ShowsListComponent implements OnInit {
@@ -851,11 +1071,21 @@ export class ShowsListComponent implements OnInit {
type: ShowType.tvSeries, type: ShowType.tvSeries,
status: ShowStatus.wantToWatch, status: ShowStatus.wantToWatch,
rating: undefined, rating: undefined,
notes: '' notes: '',
tags: [],
links: []
}; };
editShow: Partial<UpdateShowDto> = {}; editShow: Partial<UpdateShowDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() { ngOnInit() {
this.loadShows(); this.loadShows();
} }
@@ -908,10 +1138,60 @@ export class ShowsListComponent implements OnInit {
status: ShowStatus.wantToWatch, status: ShowStatus.wantToWatch,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined coverImage: undefined,
tags: [],
links: []
}; };
this.newShowImagePreview.set(null); this.newShowImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newShow.tags = [...(this.newShow.tags || []), input];
this.newTagInput = '';
} else {
this.editShow.tags = [...(this.editShow.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newShow.tags = (this.newShow.tags || []).filter((_, i) => i !== index);
} else {
this.editShow.tags = (this.editShow.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newShow.links = [...(this.newShow.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editShow.links = [...(this.editShow.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newShow.links = (this.newShow.links || []).filter((_, i) => i !== index);
} else {
this.editShow.links = (this.editShow.links || []).filter((_, i) => i !== index);
}
} }
addShow() { addShow() {
@@ -923,7 +1203,9 @@ export class ShowsListComponent implements OnInit {
status: this.newShow.status, status: this.newShow.status,
rating: this.newShow.rating, rating: this.newShow.rating,
notes: this.newShow.notes, notes: this.newShow.notes,
coverImage: this.newShow.coverImage coverImage: this.newShow.coverImage,
tags: this.newShow.tags || [],
links: this.newShow.links || []
}; };
this.showsService.createShow(showToAdd).subscribe(() => { this.showsService.createShow(showToAdd).subscribe(() => {
@@ -948,11 +1230,16 @@ export class ShowsListComponent implements OnInit {
status: show.status, status: show.status,
rating: show.rating, rating: show.rating,
notes: show.notes, notes: show.notes,
coverImage: show.coverImage coverImage: show.coverImage,
tags: [...(show.tags || [])],
links: [...(show.links || [])]
}; };
this.editShowImagePreview.set(show.coverImage || null); this.editShowImagePreview.set(show.coverImage || null);
this.showAddForm.set(false); this.showAddForm.set(false);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
cancelEdit() { cancelEdit() {
@@ -960,6 +1247,9 @@ export class ShowsListComponent implements OnInit {
this.editShow = {}; this.editShow = {};
this.editShowImagePreview.set(null); this.editShowImagePreview.set(null);
this.imageError.set(null); this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
} }
saveEdit() { saveEdit() {
+1
View File
@@ -13,3 +13,4 @@ export type * from "./lib/auth.types";
export * from "./lib/comment.types"; export * from "./lib/comment.types";
export * from "./lib/audit.types"; export * from "./lib/audit.types";
export * from "./lib/suggestion.types"; export * from "./lib/suggestion.types";
export * from "./lib/common.types";
+6
View File
@@ -3,12 +3,16 @@
* @license Naomi's Public License * @license Naomi's Public License
*/ */
import { Link } from "./common.types";
export interface Art { export interface Art {
id: string; id: string;
title: string; title: string;
artist: string; artist: string;
description?: string; description?: string;
imageUrl: string; imageUrl: string;
tags: string[];
links: Link[];
dateAdded: Date; dateAdded: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -19,6 +23,8 @@ export interface CreateArtDto {
artist: string; artist: string;
description?: string; description?: string;
imageUrl: string; imageUrl: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateArtDto extends Partial<CreateArtDto> {} export interface UpdateArtDto extends Partial<CreateArtDto> {}
+6
View File
@@ -10,6 +10,8 @@ export enum BookStatus {
toRead = "TO_READ", toRead = "TO_READ",
} }
import { Link } from "./common.types";
export interface Book { export interface Book {
id: string; id: string;
title: string; title: string;
@@ -21,6 +23,8 @@ export interface Book {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: string[];
links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -33,6 +37,8 @@ export interface CreateBookDto {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateBookDto extends Partial<CreateBookDto> { export interface UpdateBookDto extends Partial<CreateBookDto> {
+4
View File
@@ -0,0 +1,4 @@
export interface Link {
title: string;
url: string;
}
+6
View File
@@ -10,6 +10,8 @@ export enum GameStatus {
backlog = "BACKLOG", backlog = "BACKLOG",
} }
import { Link } from "./common.types";
export interface Game { export interface Game {
id: string; id: string;
title: string; title: string;
@@ -20,6 +22,8 @@ export interface Game {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: string[];
links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -31,6 +35,8 @@ export interface CreateGameDto {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateGameDto extends Partial<CreateGameDto> { export interface UpdateGameDto extends Partial<CreateGameDto> {
+6
View File
@@ -10,6 +10,8 @@ export enum MangaStatus {
wantToRead = "WANT_TO_READ", wantToRead = "WANT_TO_READ",
} }
import { Link } from "./common.types";
export interface Manga { export interface Manga {
id: string; id: string;
title: string; title: string;
@@ -20,6 +22,8 @@ export interface Manga {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: string[];
links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -31,6 +35,8 @@ export interface CreateMangaDto {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateMangaDto extends Partial<CreateMangaDto> { export interface UpdateMangaDto extends Partial<CreateMangaDto> {
+6
View File
@@ -16,6 +16,8 @@ export enum MusicStatus {
wantToListen = "WANT_TO_LISTEN", wantToListen = "WANT_TO_LISTEN",
} }
import { Link } from "./common.types";
export interface Music { export interface Music {
id: string; id: string;
title: string; title: string;
@@ -27,6 +29,8 @@ export interface Music {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverArt?: string; coverArt?: string;
tags: string[];
links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -39,6 +43,8 @@ export interface CreateMusicDto {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverArt?: string; coverArt?: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateMusicDto extends Partial<CreateMusicDto> { export interface UpdateMusicDto extends Partial<CreateMusicDto> {
+6
View File
@@ -17,6 +17,8 @@ export enum ShowStatus {
wantToWatch = "WANT_TO_WATCH", wantToWatch = "WANT_TO_WATCH",
} }
import { Link } from "./common.types";
export interface Show { export interface Show {
id: string; id: string;
title: string; title: string;
@@ -27,6 +29,8 @@ export interface Show {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: string[];
links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -38,6 +42,8 @@ export interface CreateShowDto {
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags?: string[];
links?: Link[];
} }
export interface UpdateShowDto extends Partial<CreateShowDto> { export interface UpdateShowDto extends Partial<CreateShowDto> {