generated from nhcarrigan/template
1462 lines
41 KiB
TypeScript
1462 lines
41 KiB
TypeScript
/**
|
||
* @copyright 2026 NHCarrigan
|
||
* @license Naomi's Public License
|
||
* @author Naomi Carrigan
|
||
*/
|
||
|
||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { GamesService } from '../../services/games.service';
|
||
import { AuthService } from '../../services/auth.service';
|
||
import { CommentsService } from '../../services/comments.service';
|
||
import { SanitizeService } from '../../services/sanitize.service';
|
||
import { SuggestionService } from '../../services/suggestion.service';
|
||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||
|
||
@Component({
|
||
selector: 'app-games-list',
|
||
standalone: true,
|
||
imports: [CommonModule, FormsModule],
|
||
template: `
|
||
<div class="container">
|
||
<div class="header-section">
|
||
<h2>My Game Collection</h2>
|
||
@if (authService.isAdmin()) {
|
||
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||
{{ showAddForm() ? 'Cancel' : 'Add Game' }}
|
||
</button>
|
||
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
|
||
<button (click)="toggleSuggestForm()" class="btn btn-primary">
|
||
{{ showSuggestForm() ? 'Cancel' : 'Suggest a Game' }}
|
||
</button>
|
||
}
|
||
</div>
|
||
|
||
@if (showAddForm() && authService.isAdmin()) {
|
||
<form (ngSubmit)="addGame()" class="add-form">
|
||
<h3>Add New Game</h3>
|
||
<div class="form-group">
|
||
<label for="title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
[(ngModel)]="newGame.title"
|
||
name="title"
|
||
required
|
||
placeholder="Enter game title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="platform">Platform</label>
|
||
<input
|
||
type="text"
|
||
id="platform"
|
||
[(ngModel)]="newGame.platform"
|
||
name="platform"
|
||
placeholder="PC, PS5, Xbox, Switch, etc."
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="status">Status</label>
|
||
<select id="status" [(ngModel)]="newGame.status" name="status" required>
|
||
<option [value]="GameStatus.playing">Currently Playing</option>
|
||
<option [value]="GameStatus.completed">Completed</option>
|
||
<option [value]="GameStatus.backlog">In Backlog</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="rating">Rating (1-10)</label>
|
||
<input
|
||
type="number"
|
||
id="rating"
|
||
[(ngModel)]="newGame.rating"
|
||
name="rating"
|
||
min="1"
|
||
max="10"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="notes">Notes</label>
|
||
<textarea
|
||
id="notes"
|
||
[(ngModel)]="newGame.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Any thoughts about the game..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="coverImage">Box Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="coverImage"
|
||
name="coverImage"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'new')"
|
||
>
|
||
@if (newGameImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="newGameImagePreview()" alt="Box art preview">
|
||
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
|
||
</div>
|
||
}
|
||
@if (imageError()) {
|
||
<span class="error-text">{{ imageError() }}</span>
|
||
}
|
||
</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">
|
||
<button type="submit" class="btn btn-primary">Add Game</button>
|
||
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||
</div>
|
||
</form>
|
||
}
|
||
|
||
@if (editingGame() && authService.isAdmin()) {
|
||
<form (ngSubmit)="saveEdit()" class="add-form">
|
||
<h3>Edit Game</h3>
|
||
<div class="form-group">
|
||
<label for="edit-title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="edit-title"
|
||
[(ngModel)]="editGame.title"
|
||
name="title"
|
||
required
|
||
placeholder="Enter game title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-platform">Platform</label>
|
||
<input
|
||
type="text"
|
||
id="edit-platform"
|
||
[(ngModel)]="editGame.platform"
|
||
name="platform"
|
||
placeholder="PC, PS5, Xbox, Switch, etc."
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-status">Status</label>
|
||
<select id="edit-status" [(ngModel)]="editGame.status" name="status" required>
|
||
<option [value]="GameStatus.playing">Currently Playing</option>
|
||
<option [value]="GameStatus.completed">Completed</option>
|
||
<option [value]="GameStatus.backlog">In Backlog</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-rating">Rating (1-10)</label>
|
||
<input
|
||
type="number"
|
||
id="edit-rating"
|
||
[(ngModel)]="editGame.rating"
|
||
name="rating"
|
||
min="1"
|
||
max="10"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-notes">Notes</label>
|
||
<textarea
|
||
id="edit-notes"
|
||
[(ngModel)]="editGame.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Any thoughts about the game..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="edit-coverImage"
|
||
name="coverImage"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'edit')"
|
||
>
|
||
@if (editGameImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="editGameImagePreview()" alt="Box art preview">
|
||
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
|
||
</div>
|
||
}
|
||
@if (imageError()) {
|
||
<span class="error-text">{{ imageError() }}</span>
|
||
}
|
||
</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">
|
||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
|
||
</div>
|
||
</form>
|
||
}
|
||
|
||
@if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {
|
||
<form (ngSubmit)="submitSuggestion()" class="add-form suggest-form">
|
||
<h3>Suggest a Game</h3>
|
||
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the backlog!</p>
|
||
<div class="form-group">
|
||
<label for="suggest-title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-title"
|
||
[(ngModel)]="suggestedGame.title"
|
||
name="title"
|
||
required
|
||
placeholder="Enter game title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-platform">Platform</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-platform"
|
||
[(ngModel)]="suggestedGame.platform"
|
||
name="platform"
|
||
placeholder="PC, PS5, Xbox, Switch, etc."
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-notes">Notes (why should Naomi play this?)</label>
|
||
<textarea
|
||
id="suggest-notes"
|
||
[(ngModel)]="suggestedGame.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Tell Naomi why this game is worth playing..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-coverImage">Box Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="suggest-coverImage"
|
||
name="coverImage"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'suggest')"
|
||
>
|
||
@if (suggestGameImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="suggestGameImagePreview()" alt="Box art preview">
|
||
<button type="button" (click)="clearImage('suggest')" class="btn btn-danger btn-sm">Remove</button>
|
||
</div>
|
||
}
|
||
@if (imageError()) {
|
||
<span class="error-text">{{ imageError() }}</span>
|
||
}
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary">Submit Suggestion</button>
|
||
<button type="button" (click)="toggleSuggestForm()" class="btn btn-secondary">Cancel</button>
|
||
</div>
|
||
</form>
|
||
}
|
||
|
||
<div class="filters">
|
||
<button
|
||
(click)="setFilter('all')"
|
||
[class.active]="statusFilter() === 'all'"
|
||
class="filter-btn"
|
||
>
|
||
All ({{ games().length }})
|
||
</button>
|
||
<button
|
||
(click)="setFilter(GameStatus.playing)"
|
||
[class.active]="statusFilter() === GameStatus.playing"
|
||
class="filter-btn"
|
||
>
|
||
Playing ({{ playingCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setFilter(GameStatus.completed)"
|
||
[class.active]="statusFilter() === GameStatus.completed"
|
||
class="filter-btn"
|
||
>
|
||
Completed ({{ completedCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setFilter(GameStatus.backlog)"
|
||
[class.active]="statusFilter() === GameStatus.backlog"
|
||
class="filter-btn"
|
||
>
|
||
Backlog ({{ backlogCount() }})
|
||
</button>
|
||
</div>
|
||
|
||
@if (loading()) {
|
||
<div class="loading">Loading games...</div>
|
||
} @else if (filteredGames().length === 0) {
|
||
<div class="empty-state">
|
||
<p>No games found in this category.</p>
|
||
</div>
|
||
} @else {
|
||
<div class="games-grid">
|
||
@for (game of filteredGames(); track game.id) {
|
||
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
|
||
@if (game.coverImage) {
|
||
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
|
||
}
|
||
|
||
<div class="game-info">
|
||
<h3>{{ game.title }}</h3>
|
||
@if (game.platform) {
|
||
<p class="platform">{{ game.platform }}</p>
|
||
}
|
||
<span class="status status-{{ game.status }}">
|
||
{{ getStatusLabel(game.status) }}
|
||
</span>
|
||
|
||
@if (game.rating) {
|
||
<div class="rating">
|
||
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||
<span [class.filled]="star <= game.rating!">★</span>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@if (game.notes) {
|
||
<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()) {
|
||
<div class="actions">
|
||
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
|
||
Edit
|
||
</button>
|
||
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
}
|
||
|
||
<div class="comments-section">
|
||
<button (click)="toggleComments(game.id)" class="btn btn-secondary btn-sm comments-toggle">
|
||
{{ expandedComments()[game.id] ? 'Hide' : 'Show' }} Comments{{ comments()[game.id] ? ' (' + getCommentCount(game.id) + ')' : '' }}
|
||
</button>
|
||
|
||
@if (expandedComments()[game.id]) {
|
||
<div class="comments-container">
|
||
@if (authService.isAuthenticated()) {
|
||
@if (authService.user()?.isBanned) {
|
||
<div class="banned-notice">
|
||
You have been banned from commenting.
|
||
</div>
|
||
} @else {
|
||
<form (ngSubmit)="addComment(game.id)" class="comment-form">
|
||
<textarea
|
||
[(ngModel)]="newCommentContent[game.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()[game.id]) {
|
||
<div class="comments-loading">Loading comments...</div>
|
||
} @else {
|
||
@for (comment of comments()[game.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>
|
||
@if (comment.user.inDiscord) {
|
||
<span class="discord-badge">Discord</span>
|
||
}
|
||
@if (comment.user.isVip) {
|
||
<span class="vip-badge">VIP</span>
|
||
}
|
||
@if (comment.user.isMod) {
|
||
<span class="mod-badge">Mod</span>
|
||
}
|
||
@if (comment.user.isStaff) {
|
||
<span class="staff-badge">Staff</span>
|
||
}
|
||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||
@if (canEditComment(comment)) {
|
||
<button (click)="startEditComment(game.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||
}
|
||
@if (canDeleteComment(comment)) {
|
||
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||
}
|
||
</div>
|
||
@if (editingCommentId() === comment.id) {
|
||
<div class="comment-edit-form">
|
||
<textarea
|
||
[(ngModel)]="editCommentContent"
|
||
name="editComment"
|
||
rows="3"
|
||
></textarea>
|
||
<div class="comment-edit-actions">
|
||
<button (click)="saveCommentEdit(game.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
||
</div>
|
||
</div>
|
||
} @else {
|
||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||
}
|
||
</div>
|
||
} @empty {
|
||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
`,
|
||
styles: [`
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.header-section {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
}
|
||
|
||
.add-form {
|
||
background: #f8f9fa;
|
||
padding: 1.5rem;
|
||
border-radius: 8px;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.suggest-form {
|
||
border: 2px solid #ff6b6b;
|
||
background: #fff9f9;
|
||
}
|
||
|
||
.suggest-note {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
margin-bottom: 1rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 0.5rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select,
|
||
.form-group textarea {
|
||
width: 100%;
|
||
padding: 0.5rem;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 0.5rem 1rem;
|
||
background: #e5e7eb;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.filter-btn:hover {
|
||
background: #d1d5db;
|
||
}
|
||
|
||
.filter-btn.active {
|
||
background: #ff6b6b;
|
||
color: white;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 2rem;
|
||
color: #666;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: #666;
|
||
}
|
||
|
||
.games-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
.game-card {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
transition: transform 0.3s, box-shadow 0.3s;
|
||
}
|
||
|
||
.game-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.game-card.completed {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.game-cover {
|
||
width: 100%;
|
||
height: 200px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.game-info {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.game-info h3 {
|
||
margin: 0 0 0.5rem 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.platform {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
.status {
|
||
display: inline-block;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status-playing { background: #fef3c7; color: #92400e; }
|
||
.status-completed { background: #d1fae5; color: #065f46; }
|
||
.status-backlog { background: #e0e7ff; color: #3730a3; }
|
||
|
||
.rating {
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
.rating span {
|
||
color: #e5e7eb;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.rating span.filled {
|
||
color: #f59e0b;
|
||
}
|
||
|
||
.notes {
|
||
font-size: 0.9rem;
|
||
color: #4b5563;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
.actions {
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.btn {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.btn:hover {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.btn-primary { background: #ff6b6b; color: white; }
|
||
.btn-secondary { background: #6b7280; color: white; }
|
||
.btn-danger { background: #ef4444; color: white; }
|
||
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
|
||
|
||
.comments-section {
|
||
margin-top: 1rem;
|
||
border-top: 1px solid #e5e7eb;
|
||
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: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
resize: vertical;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.comment {
|
||
background: #f8f9fa;
|
||
border: 1px solid #e5e7eb;
|
||
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: #374151;
|
||
}
|
||
|
||
.discord-badge {
|
||
background: #5865f2;
|
||
color: white;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.vip-badge {
|
||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||
color: #1a1a1a;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.mod-badge {
|
||
background: linear-gradient(135deg, #00b894, #00cec9);
|
||
color: white;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.staff-badge {
|
||
background: linear-gradient(135deg, #e84393, #fd79a8);
|
||
color: white;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.comment-date {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.comment-content {
|
||
font-size: 0.9rem;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.comments-loading,
|
||
.no-comments {
|
||
text-align: center;
|
||
padding: 1rem;
|
||
color: #6b7280;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.banned-notice {
|
||
background: #fef2f2;
|
||
border: 1px solid #fecaca;
|
||
color: #991b1b;
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 4px;
|
||
text-align: center;
|
||
margin-bottom: 1rem;
|
||
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 {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.comment-edit-form textarea {
|
||
width: 100%;
|
||
padding: 0.5rem;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
resize: vertical;
|
||
}
|
||
|
||
.comment-edit-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.image-preview {
|
||
margin-top: 0.5rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.image-preview img {
|
||
max-width: 100px;
|
||
max-height: 150px;
|
||
border-radius: 4px;
|
||
border: 2px solid #e5e7eb;
|
||
}
|
||
|
||
.error-text {
|
||
color: #ef4444;
|
||
font-size: 0.875rem;
|
||
display: block;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
input[type="file"] {
|
||
padding: 0.5rem;
|
||
border: 2px dashed #e5e7eb;
|
||
border-radius: 4px;
|
||
background: #f9fafb;
|
||
cursor: pointer;
|
||
}
|
||
|
||
input[type="file"]:hover {
|
||
border-color: #10b981;
|
||
}
|
||
`]
|
||
})
|
||
export class GamesListComponent implements OnInit {
|
||
gamesService = inject(GamesService);
|
||
authService = inject(AuthService);
|
||
commentsService = inject(CommentsService);
|
||
sanitizeService = inject(SanitizeService);
|
||
suggestionService = inject(SuggestionService);
|
||
|
||
games = signal<Game[]>([]);
|
||
loading = signal(true);
|
||
showAddForm = signal(false);
|
||
editingGame = signal<Game | null>(null);
|
||
statusFilter = signal<'all' | GameStatus>('all');
|
||
|
||
// Comments state
|
||
comments = signal<Record<string, Comment[]>>({});
|
||
commentsLoading = signal<Record<string, boolean>>({});
|
||
expandedComments = signal<Record<string, boolean>>({});
|
||
newCommentContent: Record<string, string> = {};
|
||
editingCommentId = signal<string | null>(null);
|
||
editCommentContent = '';
|
||
|
||
// Image upload state
|
||
newGameImagePreview = signal<string | null>(null);
|
||
editGameImagePreview = signal<string | null>(null);
|
||
suggestGameImagePreview = signal<string | null>(null);
|
||
imageError = signal<string | null>(null);
|
||
private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB
|
||
|
||
// Suggestion state
|
||
showSuggestForm = signal(false);
|
||
suggestedGame: { title: string; platform?: string; notes?: string; coverImage?: string } = {
|
||
title: '',
|
||
platform: '',
|
||
notes: '',
|
||
coverImage: undefined
|
||
};
|
||
|
||
// Expose GameStatus enum to template
|
||
GameStatus = GameStatus;
|
||
|
||
// Computed signals for reactive count updates
|
||
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
|
||
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
|
||
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
|
||
|
||
filteredGames = computed(() => {
|
||
const filter = this.statusFilter();
|
||
if (filter === 'all') {
|
||
return this.games();
|
||
}
|
||
return this.games().filter(game => game.status === filter);
|
||
});
|
||
|
||
newGame: Partial<CreateGameDto> = {
|
||
title: '',
|
||
platform: '',
|
||
status: GameStatus.backlog,
|
||
rating: undefined,
|
||
notes: '',
|
||
tags: [],
|
||
links: []
|
||
};
|
||
|
||
editGame: Partial<UpdateGameDto> = {};
|
||
|
||
// Tags and links input state
|
||
newTagInput = '';
|
||
editTagInput = '';
|
||
newLinkTitle = '';
|
||
newLinkUrl = '';
|
||
editLinkTitle = '';
|
||
editLinkUrl = '';
|
||
|
||
ngOnInit() {
|
||
this.loadGames();
|
||
}
|
||
|
||
loadGames() {
|
||
this.loading.set(true);
|
||
this.gamesService.getAllGames().subscribe({
|
||
next: (games) => {
|
||
this.games.set(games);
|
||
this.loading.set(false);
|
||
},
|
||
error: () => {
|
||
this.loading.set(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
setFilter(filter: 'all' | GameStatus) {
|
||
this.statusFilter.set(filter);
|
||
}
|
||
|
||
getStatusLabel(status: GameStatus): string {
|
||
switch (status) {
|
||
case GameStatus.playing: return 'Currently Playing';
|
||
case GameStatus.completed: return 'Completed';
|
||
case GameStatus.backlog: return 'In Backlog';
|
||
}
|
||
}
|
||
|
||
toggleAddForm() {
|
||
this.showAddForm.update(v => !v);
|
||
if (!this.showAddForm()) {
|
||
this.resetForm();
|
||
}
|
||
}
|
||
|
||
resetForm() {
|
||
this.newGame = {
|
||
title: '',
|
||
platform: '',
|
||
status: GameStatus.backlog,
|
||
rating: undefined,
|
||
notes: '',
|
||
coverImage: undefined,
|
||
tags: [],
|
||
links: []
|
||
};
|
||
this.newGameImagePreview.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() {
|
||
if (!this.newGame.title || !this.newGame.status) return;
|
||
|
||
const gameToAdd: CreateGameDto = {
|
||
title: this.newGame.title,
|
||
platform: this.newGame.platform,
|
||
status: this.newGame.status,
|
||
rating: this.newGame.rating,
|
||
notes: this.newGame.notes,
|
||
coverImage: this.newGame.coverImage,
|
||
tags: this.newGame.tags || [],
|
||
links: this.newGame.links || []
|
||
};
|
||
|
||
this.gamesService.createGame(gameToAdd).subscribe(() => {
|
||
this.loadGames();
|
||
this.toggleAddForm();
|
||
});
|
||
}
|
||
|
||
deleteGame(game: Game) {
|
||
if (confirm(`Are you sure you want to delete "${game.title}"?`)) {
|
||
this.gamesService.deleteGame(game.id).subscribe(() => {
|
||
this.loadGames();
|
||
});
|
||
}
|
||
}
|
||
|
||
startEdit(game: Game) {
|
||
this.editingGame.set(game);
|
||
this.editGame = {
|
||
title: game.title,
|
||
platform: game.platform,
|
||
status: game.status,
|
||
rating: game.rating,
|
||
notes: game.notes,
|
||
coverImage: game.coverImage,
|
||
tags: [...(game.tags || [])],
|
||
links: [...(game.links || [])]
|
||
};
|
||
this.editGameImagePreview.set(game.coverImage || null);
|
||
this.showAddForm.set(false);
|
||
this.imageError.set(null);
|
||
this.editTagInput = '';
|
||
this.editLinkTitle = '';
|
||
this.editLinkUrl = '';
|
||
}
|
||
|
||
cancelEdit() {
|
||
this.editingGame.set(null);
|
||
this.editGame = {};
|
||
this.editGameImagePreview.set(null);
|
||
this.imageError.set(null);
|
||
this.editTagInput = '';
|
||
this.editLinkTitle = '';
|
||
this.editLinkUrl = '';
|
||
}
|
||
|
||
saveEdit() {
|
||
const game = this.editingGame();
|
||
if (!game || !this.editGame.title || !this.editGame.status) return;
|
||
|
||
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
|
||
this.loadGames();
|
||
this.cancelEdit();
|
||
});
|
||
}
|
||
|
||
// Image handling methods
|
||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
|
||
if (!file) return;
|
||
|
||
this.imageError.set(null);
|
||
|
||
if (file.size > this.MAX_IMAGE_SIZE) {
|
||
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
this.imageError.set('Please select an image file.');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const base64 = reader.result as string;
|
||
if (target === 'new') {
|
||
this.newGameImagePreview.set(base64);
|
||
this.newGame.coverImage = base64;
|
||
} else if (target === 'edit') {
|
||
this.editGameImagePreview.set(base64);
|
||
this.editGame.coverImage = base64;
|
||
} else {
|
||
this.suggestGameImagePreview.set(base64);
|
||
this.suggestedGame.coverImage = base64;
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
clearImage(target: 'new' | 'edit' | 'suggest') {
|
||
if (target === 'new') {
|
||
this.newGameImagePreview.set(null);
|
||
this.newGame.coverImage = undefined;
|
||
} else if (target === 'edit') {
|
||
this.editGameImagePreview.set(null);
|
||
this.editGame.coverImage = undefined;
|
||
} else {
|
||
this.suggestGameImagePreview.set(null);
|
||
this.suggestedGame.coverImage = undefined;
|
||
}
|
||
this.imageError.set(null);
|
||
}
|
||
|
||
// Comments methods
|
||
formatDate(date: Date | string): string {
|
||
return new Date(date).toLocaleDateString();
|
||
}
|
||
|
||
toggleComments(gameId: string) {
|
||
const expanded = this.expandedComments();
|
||
const isCurrentlyExpanded = expanded[gameId];
|
||
|
||
this.expandedComments.set({
|
||
...expanded,
|
||
[gameId]: !isCurrentlyExpanded
|
||
});
|
||
|
||
if (!isCurrentlyExpanded && !this.comments()[gameId]) {
|
||
this.loadComments(gameId);
|
||
}
|
||
}
|
||
|
||
loadComments(gameId: string) {
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[gameId]: true
|
||
});
|
||
|
||
this.commentsService.getCommentsForGame(gameId).subscribe({
|
||
next: (comments) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[gameId]: comments
|
||
});
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[gameId]: false
|
||
});
|
||
},
|
||
error: () => {
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[gameId]: false
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
getCommentCount(gameId: string): number {
|
||
return this.comments()[gameId]?.length || 0;
|
||
}
|
||
|
||
addComment(gameId: string) {
|
||
const content = this.newCommentContent[gameId];
|
||
if (!content?.trim()) return;
|
||
|
||
this.commentsService.addCommentToGame(gameId, { content }).subscribe({
|
||
next: (comment) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[gameId]: [comment, ...(this.comments()[gameId] || [])]
|
||
});
|
||
this.newCommentContent[gameId] = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
deleteComment(gameId: string, commentId: string) {
|
||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||
|
||
this.commentsService.deleteCommentFromGame(gameId, commentId).subscribe({
|
||
next: () => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[gameId]: (this.comments()[gameId] || []).filter(c => c.id !== commentId)
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
canEditComment(comment: Comment): boolean {
|
||
const user = this.authService.user();
|
||
if (!user) return false;
|
||
return comment.userId === user.id || this.authService.isAdmin();
|
||
}
|
||
|
||
canDeleteComment(comment: Comment): boolean {
|
||
const user = this.authService.user();
|
||
if (!user) return false;
|
||
return comment.userId === user.id || this.authService.isAdmin();
|
||
}
|
||
|
||
startEditComment(gameId: string, comment: Comment) {
|
||
this.editingCommentId.set(comment.id);
|
||
this.editCommentContent = comment.rawContent ?? comment.content;
|
||
}
|
||
|
||
cancelCommentEdit() {
|
||
this.editingCommentId.set(null);
|
||
this.editCommentContent = '';
|
||
}
|
||
|
||
saveCommentEdit(gameId: string, commentId: string) {
|
||
if (!this.editCommentContent.trim()) return;
|
||
|
||
this.commentsService.updateCommentOnGame(gameId, commentId, this.editCommentContent).subscribe({
|
||
next: (updatedComment) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[gameId]: (this.comments()[gameId] || []).map(c =>
|
||
c.id === commentId ? updatedComment : c
|
||
)
|
||
});
|
||
this.cancelCommentEdit();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Suggestion methods
|
||
toggleSuggestForm() {
|
||
this.showSuggestForm.update(v => !v);
|
||
if (!this.showSuggestForm()) {
|
||
this.resetSuggestForm();
|
||
}
|
||
}
|
||
|
||
resetSuggestForm() {
|
||
this.suggestedGame = {
|
||
title: '',
|
||
platform: '',
|
||
notes: '',
|
||
coverImage: undefined
|
||
};
|
||
this.suggestGameImagePreview.set(null);
|
||
this.imageError.set(null);
|
||
}
|
||
|
||
async submitSuggestion() {
|
||
if (!this.suggestedGame.title) return;
|
||
|
||
try {
|
||
await this.suggestionService.createSuggestion({
|
||
entityType: SuggestionEntity.GAME,
|
||
title: this.suggestedGame.title,
|
||
platform: this.suggestedGame.platform,
|
||
notes: this.suggestedGame.notes,
|
||
coverImage: this.suggestedGame.coverImage
|
||
});
|
||
alert('Thank you for your suggestion! It will be reviewed soon.');
|
||
this.toggleSuggestForm();
|
||
} catch {
|
||
alert('Failed to submit suggestion. Please try again.');
|
||
}
|
||
}
|
||
} |