Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f40f917bc5 | |||
| 1fd2086afe | |||
| 3668a67a62 |
@@ -22,8 +22,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log unauthorized access attempts (exclude /api/auth/me as 401s there are expected during token refresh)
|
// Log unauthorized access attempts
|
||||||
if ((error.statusCode === 401 || error.statusCode === 403) && request.url !== '/api/auth/me') {
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||||
await AuditService.log({
|
await AuditService.log({
|
||||||
action: AuditAction.unauthorizedAccess,
|
action: AuditAction.unauthorizedAccess,
|
||||||
category: AuditCategory.security,
|
category: AuditCategory.security,
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ const helmetPlugin: FastifyPluginAsync = async (app) => {
|
|||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
// Angular uses inline styles for component encapsulation, so we need to allow them
|
// Angular uses inline styles for component encapsulation, so we need to allow them
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'"],
|
||||||
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
||||||
fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
|
fontSrc: ["'self'", "data:"],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
baseUri: ["'self'"],
|
baseUri: ["'self'"],
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export default async function staticPlugin(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Catch-all route for Angular SPA routing (must be registered after API routes)
|
// Catch-all route for Angular SPA routing (must be registered after API routes)
|
||||||
app.setNotFoundHandler((request, reply) => {
|
app.setNotFoundHandler((request, reply) => {
|
||||||
// Only catch routes that don't start with /api or /assets
|
// Only catch routes that don't start with /api
|
||||||
if (!request.url.startsWith("/api") && !request.url.startsWith("/assets")) {
|
if (!request.url.startsWith("/api")) {
|
||||||
reply.sendFile("index.html");
|
reply.sendFile("index.html");
|
||||||
} else {
|
} else {
|
||||||
reply.code(404).send({ error: "Not Found" });
|
reply.code(404).send({ error: "Not Found" });
|
||||||
|
|||||||
@@ -102,53 +102,6 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign library member role if user is in Discord server but doesn't have it
|
|
||||||
const libraryRoleId = process.env.LIBRARY_ROLE_ID;
|
|
||||||
if (inDiscord && guildId && libraryRoleId) {
|
|
||||||
try {
|
|
||||||
const memberResponse = await fetch(
|
|
||||||
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (memberResponse.ok) {
|
|
||||||
const memberData = await memberResponse.json() as { roles: string[] };
|
|
||||||
const hasLibraryRole = memberData.roles.includes(libraryRoleId);
|
|
||||||
|
|
||||||
if (!hasLibraryRole) {
|
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
||||||
if (botToken) {
|
|
||||||
const assignRoleResponse = await fetch(
|
|
||||||
`https://discord.com/api/v10/guilds/${guildId}/members/${userData.id}/roles/${libraryRoleId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bot ${botToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (assignRoleResponse.ok || assignRoleResponse.status === 204) {
|
|
||||||
app.log.info(`Assigned library role to user ${user.username} (${user.id})`);
|
|
||||||
} else {
|
|
||||||
app.log.error(
|
|
||||||
`Failed to assign library role to user ${user.username}: ${assignRoleResponse.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Don't fail the login if role assignment fails
|
|
||||||
app.log.error({ err: error }, "Error assigning library role");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set signed cookies and redirect to frontend
|
// Set signed cookies and redirect to frontend
|
||||||
reply
|
reply
|
||||||
.setCookie("auth-token", accessToken, {
|
.setCookie("auth-token", accessToken, {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export class BookService {
|
|||||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||||
}
|
}
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate rating
|
// Validate rating
|
||||||
if (!validateRating(data.rating)) {
|
if (!validateRating(data.rating)) {
|
||||||
throw new Error("Rating must be an integer between 0 and 10.");
|
throw new Error("Rating must be an integer between 0 and 10.");
|
||||||
@@ -43,11 +47,7 @@ export class BookService {
|
|||||||
|
|
||||||
if (data.coverImage) {
|
if (data.coverImage) {
|
||||||
if (data.coverImage.startsWith("data:")) {
|
if (data.coverImage.startsWith("data:")) {
|
||||||
const base64Data = data.coverImage.split(",")[1];
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error("Invalid image data URL format.");
|
|
||||||
}
|
|
||||||
const sizeInBytes = base64Data.length * 0.75;
|
|
||||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
throw new Error("Cover image must be under 5MB.");
|
throw new Error("Cover image must be under 5MB.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class MangaService {
|
|||||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||||
}
|
}
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate rating
|
// Validate rating
|
||||||
if (!validateRating(data.rating)) {
|
if (!validateRating(data.rating)) {
|
||||||
throw new Error("Rating must be an integer between 0 and 10.");
|
throw new Error("Rating must be an integer between 0 and 10.");
|
||||||
@@ -41,11 +45,7 @@ export class MangaService {
|
|||||||
// Validate cover image URL
|
// Validate cover image URL
|
||||||
if (data.coverImage) {
|
if (data.coverImage) {
|
||||||
if (data.coverImage.startsWith("data:")) {
|
if (data.coverImage.startsWith("data:")) {
|
||||||
const base64Data = data.coverImage.split(",")[1];
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error("Invalid image data URL format.");
|
|
||||||
}
|
|
||||||
const sizeInBytes = base64Data.length * 0.75;
|
|
||||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
throw new Error("Cover image must be under 5MB.");
|
throw new Error("Cover image must be under 5MB.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class MusicService {
|
|||||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||||
}
|
}
|
||||||
|
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate rating
|
// Validate rating
|
||||||
if (data.rating !== undefined && !validateRating(data.rating)) {
|
if (data.rating !== undefined && !validateRating(data.rating)) {
|
||||||
throw new Error("Rating must be an integer between 0 and 10.");
|
throw new Error("Rating must be an integer between 0 and 10.");
|
||||||
@@ -41,11 +45,7 @@ export class MusicService {
|
|||||||
// Validate cover art URL
|
// Validate cover art URL
|
||||||
if (data.coverArt) {
|
if (data.coverArt) {
|
||||||
if (data.coverArt.startsWith("data:")) {
|
if (data.coverArt.startsWith("data:")) {
|
||||||
const base64Data = data.coverArt.split(",")[1];
|
const sizeInBytes = data.coverArt.length * 0.75;
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error("Invalid image data URL format.");
|
|
||||||
}
|
|
||||||
const sizeInBytes = base64Data.length * 0.75;
|
|
||||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
throw new Error("Cover image must be under 5MB.");
|
throw new Error("Cover image must be under 5MB.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class ShowService {
|
|||||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||||
}
|
}
|
||||||
|
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||||
|
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate rating
|
// Validate rating
|
||||||
if (!validateRating(data.rating)) {
|
if (!validateRating(data.rating)) {
|
||||||
throw new Error("Rating must be an integer between 0 and 10.");
|
throw new Error("Rating must be an integer between 0 and 10.");
|
||||||
@@ -38,11 +42,7 @@ export class ShowService {
|
|||||||
// Validate cover image URL
|
// Validate cover image URL
|
||||||
if (data.coverImage) {
|
if (data.coverImage) {
|
||||||
if (data.coverImage.startsWith("data:")) {
|
if (data.coverImage.startsWith("data:")) {
|
||||||
const base64Data = data.coverImage.split(",")[1];
|
const sizeInBytes = data.coverImage.length * 0.75;
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error("Invalid image data URL format.");
|
|
||||||
}
|
|
||||||
const sizeInBytes = base64Data.length * 0.75;
|
|
||||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||||
throw new Error("Cover image must be under 5MB.");
|
throw new Error("Cover image must be under 5MB.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"optimization": {
|
|
||||||
"styles": {
|
|
||||||
"inlineCritical": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 8.5 MiB |
|
Before Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 9.1 MiB |
|
Before Width: | Height: | Size: 8.1 MiB |
|
Before Width: | Height: | Size: 8.2 MiB |
|
Before Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 8.6 MiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -10,18 +10,12 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { GameFormComponent } from '../shared/game-form.component';
|
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
|
||||||
import { BookFormComponent } from '../shared/book-form.component';
|
|
||||||
import { MusicFormComponent } from '../shared/music-form.component';
|
|
||||||
import { ShowFormComponent } from '../shared/show-form.component';
|
|
||||||
import { MangaFormComponent } from '../shared/manga-form.component';
|
|
||||||
import { ArtFormComponent } from '../shared/art-form.component';
|
|
||||||
import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGameDto, GameStatus, CreateBookDto, UpdateBookDto, BookStatus, CreateMusicDto, UpdateMusicDto, MusicStatus, MusicType, CreateShowDto, UpdateShowDto, ShowStatus, ShowType, CreateMangaDto, UpdateMangaDto, MangaStatus, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-suggestions',
|
selector: 'app-admin-suggestions',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, GameFormComponent, BookFormComponent, MusicFormComponent, ShowFormComponent, MangaFormComponent, ArtFormComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -237,62 +231,158 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
|
|||||||
@if (showEditModal()) {
|
@if (showEditModal()) {
|
||||||
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
|
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
|
||||||
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||||
|
<h3>Review & Edit Before Accepting</h3>
|
||||||
|
<p>Review and edit the details before adding to your collection.</p>
|
||||||
|
|
||||||
@if (editingSuggestion()) {
|
@if (editingSuggestion()) {
|
||||||
@if (editingSuggestion()!.entityType === SuggestionEntity.game) {
|
<form (ngSubmit)="confirmAcceptWithEdits()">
|
||||||
<h3>Review & Edit Game Before Accepting</h3>
|
<div class="form-group">
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
<label for="edit-title">Title</label>
|
||||||
<app-game-form
|
<input
|
||||||
mode="add"
|
type="text"
|
||||||
[initialData]="getGameInitialData(editingSuggestion()!)"
|
id="edit-title"
|
||||||
(formSubmit)="saveGameFromSuggestion($event)"
|
[(ngModel)]="editedData.title"
|
||||||
(formCancel)="closeEditModal()"
|
name="title"
|
||||||
></app-game-form>
|
required
|
||||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
|
>
|
||||||
<h3>Review & Edit Book Before Accepting</h3>
|
</div>
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
|
||||||
<app-book-form
|
@switch (editingSuggestion()!.entityType) {
|
||||||
mode="add"
|
@case (SuggestionEntity.book) {
|
||||||
[initialData]="getBookInitialData(editingSuggestion()!)"
|
<div class="form-group">
|
||||||
(formSubmit)="saveBookFromSuggestion($event)"
|
<label for="edit-author">Author</label>
|
||||||
(formCancel)="closeEditModal()"
|
<input
|
||||||
></app-book-form>
|
type="text"
|
||||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
|
id="edit-author"
|
||||||
<h3>Review & Edit Music Before Accepting</h3>
|
[(ngModel)]="editedData.author"
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
name="author"
|
||||||
<app-music-form
|
required
|
||||||
mode="add"
|
>
|
||||||
[initialData]="getMusicInitialData(editingSuggestion()!)"
|
</div>
|
||||||
(formSubmit)="saveMusicFromSuggestion($event)"
|
<div class="form-group">
|
||||||
(formCancel)="closeEditModal()"
|
<label for="edit-isbn">ISBN</label>
|
||||||
></app-music-form>
|
<input
|
||||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
|
type="text"
|
||||||
<h3>Review & Edit Show Before Accepting</h3>
|
id="edit-isbn"
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
[(ngModel)]="editedData.isbn"
|
||||||
<app-show-form
|
name="isbn"
|
||||||
mode="add"
|
>
|
||||||
[initialData]="getShowInitialData(editingSuggestion()!)"
|
</div>
|
||||||
(formSubmit)="saveShowFromSuggestion($event)"
|
}
|
||||||
(formCancel)="closeEditModal()"
|
@case (SuggestionEntity.game) {
|
||||||
></app-show-form>
|
<div class="form-group">
|
||||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
|
<label for="edit-platform">Platform</label>
|
||||||
<h3>Review & Edit Manga Before Accepting</h3>
|
<input
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
type="text"
|
||||||
<app-manga-form
|
id="edit-platform"
|
||||||
mode="add"
|
[(ngModel)]="editedData.platform"
|
||||||
[initialData]="getMangaInitialData(editingSuggestion()!)"
|
name="platform"
|
||||||
(formSubmit)="saveMangaFromSuggestion($event)"
|
>
|
||||||
(formCancel)="closeEditModal()"
|
</div>
|
||||||
></app-manga-form>
|
}
|
||||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
|
@case (SuggestionEntity.music) {
|
||||||
<h3>Review & Edit Art Before Accepting</h3>
|
<div class="form-group">
|
||||||
<p>Review and edit all the details before adding to your collection.</p>
|
<label for="edit-artist">Artist</label>
|
||||||
<app-art-form
|
<input
|
||||||
mode="add"
|
type="text"
|
||||||
[initialData]="getArtInitialData(editingSuggestion()!)"
|
id="edit-artist"
|
||||||
(formSubmit)="saveArtFromSuggestion($event)"
|
[(ngModel)]="editedData.artist"
|
||||||
(formCancel)="closeEditModal()"
|
name="artist"
|
||||||
></app-art-form>
|
required
|
||||||
}
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-type">Type</label>
|
||||||
|
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||||
|
<option value="ALBUM">Album</option>
|
||||||
|
<option value="SINGLE">Single</option>
|
||||||
|
<option value="EP">EP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (SuggestionEntity.art) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-artist">Artist</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-artist"
|
||||||
|
[(ngModel)]="editedData.artist"
|
||||||
|
name="artist"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-description"
|
||||||
|
[(ngModel)]="editedData.description"
|
||||||
|
name="description"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-imageUrl">Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-imageUrl"
|
||||||
|
[(ngModel)]="editedData.imageUrl"
|
||||||
|
name="imageUrl"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (SuggestionEntity.show) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-type">Type</label>
|
||||||
|
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||||
|
<option value="TV_SERIES">TV Series</option>
|
||||||
|
<option value="ANIME">Anime</option>
|
||||||
|
<option value="FILM">Film</option>
|
||||||
|
<option value="DOCUMENTARY">Documentary</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (SuggestionEntity.manga) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-author">Author</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-author"
|
||||||
|
[(ngModel)]="editedData.author"
|
||||||
|
name="author"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-notes"
|
||||||
|
[(ngModel)]="editedData.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-coverImage">Cover Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-coverImage"
|
||||||
|
[(ngModel)]="editedData.coverImage"
|
||||||
|
name="coverImage"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-accept">Accept with Edits</button>
|
||||||
|
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,174 +810,59 @@ export class AdminSuggestionsComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
return new Date(date).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
getGameInitialData(suggestion: Suggestion): Partial<CreateGameDto> {
|
|
||||||
const gameData = suggestion.gameData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
platform: gameData?.platform || undefined,
|
|
||||||
status: GameStatus.backlog, // Default to backlog for new suggestions
|
|
||||||
notes: gameData?.notes || undefined,
|
|
||||||
coverImage: gameData?.coverImage || undefined,
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveGameFromSuggestion(data: CreateGameDto | UpdateGameDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Treat it as CreateGameDto since we're creating a new item from a suggestion
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateGameDto);
|
|
||||||
alert(`"${(data as CreateGameDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBookInitialData(suggestion: Suggestion): Partial<CreateBookDto> {
|
|
||||||
const bookData = suggestion.bookData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
author: bookData?.author || '',
|
|
||||||
isbn: bookData?.isbn || undefined,
|
|
||||||
status: BookStatus.toRead,
|
|
||||||
notes: bookData?.notes || undefined,
|
|
||||||
coverImage: bookData?.coverImage || undefined,
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveBookFromSuggestion(data: CreateBookDto | UpdateBookDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateBookDto);
|
|
||||||
alert(`"${(data as CreateBookDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMusicInitialData(suggestion: Suggestion): Partial<CreateMusicDto> {
|
|
||||||
const musicData = suggestion.musicData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
artist: musicData?.artist || '',
|
|
||||||
type: musicData?.type || MusicType.album,
|
|
||||||
status: MusicStatus.wantToListen,
|
|
||||||
notes: musicData?.notes || undefined,
|
|
||||||
coverArt: musicData?.coverArt || undefined,
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveMusicFromSuggestion(data: CreateMusicDto | UpdateMusicDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMusicDto);
|
|
||||||
alert(`"${(data as CreateMusicDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getShowInitialData(suggestion: Suggestion): Partial<CreateShowDto> {
|
|
||||||
const showData = suggestion.showData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
type: showData?.type || ShowType.tvSeries,
|
|
||||||
status: ShowStatus.wantToWatch,
|
|
||||||
notes: showData?.notes || undefined,
|
|
||||||
coverImage: showData?.coverImage || undefined,
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveShowFromSuggestion(data: CreateShowDto | UpdateShowDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateShowDto);
|
|
||||||
alert(`"${(data as CreateShowDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMangaInitialData(suggestion: Suggestion): Partial<CreateMangaDto> {
|
|
||||||
const mangaData = suggestion.mangaData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
author: mangaData?.author || '',
|
|
||||||
status: MangaStatus.wantToRead,
|
|
||||||
notes: mangaData?.notes || undefined,
|
|
||||||
coverImage: mangaData?.coverImage || undefined,
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveMangaFromSuggestion(data: CreateMangaDto | UpdateMangaDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMangaDto);
|
|
||||||
alert(`"${(data as CreateMangaDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtInitialData(suggestion: Suggestion): Partial<CreateArtDto> {
|
|
||||||
const artData = suggestion.artData as any;
|
|
||||||
return {
|
|
||||||
title: suggestion.title,
|
|
||||||
artist: artData?.artist || '',
|
|
||||||
description: artData?.description || undefined,
|
|
||||||
imageUrl: artData?.imageUrl || '',
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveArtFromSuggestion(data: CreateArtDto | UpdateArtDto) {
|
|
||||||
const suggestion = this.editingSuggestion();
|
|
||||||
if (!suggestion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateArtDto);
|
|
||||||
alert(`"${(data as CreateArtDto).title}" has been added to your collection!`);
|
|
||||||
this.closeEditModal();
|
|
||||||
this.loadSuggestions();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to accept suggestion. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptSuggestion(suggestion: Suggestion) {
|
async acceptSuggestion(suggestion: Suggestion) {
|
||||||
this.editingSuggestion.set(suggestion);
|
this.editingSuggestion.set(suggestion);
|
||||||
|
|
||||||
// For all entity types, we'll use the form components which have their own initialization logic
|
// Pre-populate the edit form with suggestion data
|
||||||
|
this.editedData = {
|
||||||
|
title: suggestion.title,
|
||||||
|
notes: '',
|
||||||
|
coverImage: '',
|
||||||
|
coverArt: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add entity-specific data
|
||||||
|
switch (suggestion.entityType) {
|
||||||
|
case SuggestionEntity.book:
|
||||||
|
const bookData = suggestion.bookData as any;
|
||||||
|
this.editedData.author = bookData?.author || '';
|
||||||
|
this.editedData.isbn = bookData?.isbn || '';
|
||||||
|
this.editedData.notes = bookData?.notes || '';
|
||||||
|
this.editedData.coverImage = bookData?.coverImage || '';
|
||||||
|
break;
|
||||||
|
case SuggestionEntity.game:
|
||||||
|
const gameData = suggestion.gameData as any;
|
||||||
|
this.editedData.platform = gameData?.platform || '';
|
||||||
|
this.editedData.notes = gameData?.notes || '';
|
||||||
|
this.editedData.coverImage = gameData?.coverImage || '';
|
||||||
|
break;
|
||||||
|
case SuggestionEntity.music:
|
||||||
|
const musicData = suggestion.musicData as any;
|
||||||
|
this.editedData.artist = musicData?.artist || '';
|
||||||
|
this.editedData.type = musicData?.type || 'ALBUM';
|
||||||
|
this.editedData.notes = musicData?.notes || '';
|
||||||
|
this.editedData.coverArt = musicData?.coverArt || '';
|
||||||
|
break;
|
||||||
|
case SuggestionEntity.art:
|
||||||
|
const artData = suggestion.artData as any;
|
||||||
|
this.editedData.artist = artData?.artist || '';
|
||||||
|
this.editedData.description = artData?.description || '';
|
||||||
|
this.editedData.imageUrl = artData?.imageUrl || '';
|
||||||
|
break;
|
||||||
|
case SuggestionEntity.show:
|
||||||
|
const showData = suggestion.showData as any;
|
||||||
|
this.editedData.type = showData?.type || 'TV_SERIES';
|
||||||
|
this.editedData.notes = showData?.notes || '';
|
||||||
|
this.editedData.coverImage = showData?.coverImage || '';
|
||||||
|
break;
|
||||||
|
case SuggestionEntity.manga:
|
||||||
|
const mangaData = suggestion.mangaData as any;
|
||||||
|
this.editedData.author = mangaData?.author || '';
|
||||||
|
this.editedData.notes = mangaData?.notes || '';
|
||||||
|
this.editedData.coverImage = mangaData?.coverImage || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
this.showEditModal.set(true);
|
this.showEditModal.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +884,20 @@ export class AdminSuggestionsComponent implements OnInit {
|
|||||||
this.editedData = {};
|
this.editedData = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async confirmAcceptWithEdits() {
|
||||||
|
const suggestion = this.editingSuggestion();
|
||||||
|
if (!suggestion) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData);
|
||||||
|
alert(`"${this.editedData.title}" has been added to your collection with your edits!`);
|
||||||
|
this.closeEditModal();
|
||||||
|
this.loadSuggestions();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to accept suggestion. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async confirmDecline() {
|
async confirmDecline() {
|
||||||
const suggestion = this.decliningsuggestion();
|
const suggestion = this.decliningsuggestion();
|
||||||
if (!suggestion) return;
|
if (!suggestion) return;
|
||||||
|
|||||||
@@ -14,28 +14,18 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { ArtFormComponent } from '../shared/art-form.component';
|
import { Art, Comment } from '@library/shared-types';
|
||||||
import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-art-detail',
|
selector: 'app-art-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ArtFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
|
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && art()) {
|
|
||||||
<app-art-form
|
|
||||||
mode="edit"
|
|
||||||
[art]="art()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-art-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading artwork details...</div>
|
<div class="loading">Loading artwork details...</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
@@ -46,9 +36,11 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
|||||||
</div>
|
</div>
|
||||||
} @else if (art()) {
|
} @else if (art()) {
|
||||||
<div class="art-detail-card">
|
<div class="art-detail-card">
|
||||||
<div class="art-image-section">
|
@if (art()!.imageUrl) {
|
||||||
<img [src]="art()!.imageUrl || '/assets/default-cover.jpg'" [alt]="art()!.description || art()!.title" class="art-image-large">
|
<div class="art-image-section">
|
||||||
</div>
|
<img [src]="art()!.imageUrl" [alt]="art()!.description || art()!.title" class="art-image-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="art-content">
|
<div class="art-content">
|
||||||
<div class="art-header">
|
<div class="art-header">
|
||||||
@@ -56,12 +48,6 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="artist">by {{ art()!.artist }}</p>
|
<p class="artist">by {{ art()!.artist }}</p>
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
|
||||||
<button (click)="deleteArt()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Added:</span>
|
<span class="info-label">Added:</span>
|
||||||
@@ -246,35 +232,6 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
|||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -469,36 +426,7 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -524,7 +452,6 @@ export class ArtDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const artId = this.route.snapshot.paramMap.get('id');
|
const artId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -612,45 +539,4 @@ export class ArtDetailComponent implements OnInit {
|
|||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleEditForm() {
|
|
||||||
this.showEditForm.update(value => !value);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateArtDto) {
|
|
||||||
const art = this.art();
|
|
||||||
if (!art) return;
|
|
||||||
|
|
||||||
this.artService.updateArt(art.id, data).subscribe({
|
|
||||||
next: (updatedArt) => {
|
|
||||||
this.art.set(updatedArt);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update art: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteArt() {
|
|
||||||
const art = this.art();
|
|
||||||
if (!art) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${art.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.artService.deleteArt(art.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/art']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete art: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/art-avatar.jpg" alt="Art avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>Art Gallery</h2>
|
<h2>Art Gallery</h2>
|
||||||
<p class="subtitle">Artwork of Naomi</p>
|
<p class="subtitle">Artwork of Naomi</p>
|
||||||
@@ -399,7 +395,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
<a [routerLink]="['/art', art.id]" class="card-link">
|
<a [routerLink]="['/art', art.id]" class="card-link">
|
||||||
<div class="art-image-container">
|
<div class="art-image-container">
|
||||||
<img
|
<img
|
||||||
[src]="art.imageUrl || '/assets/default-cover.jpg'"
|
[src]="art.imageUrl"
|
||||||
[alt]="art.description || art.title"
|
[alt]="art.description || art.title"
|
||||||
class="art-image"
|
class="art-image"
|
||||||
>
|
>
|
||||||
@@ -462,26 +458,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #fdcb6e;
|
|
||||||
box-shadow: 0 4px 12px rgba(253, 203, 110, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(253, 203, 110, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1145,9 +1121,6 @@ export class ArtGalleryComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
this.editLinkUrl = '';
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -14,28 +14,18 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { BookFormComponent } from '../shared/book-form.component';
|
import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||||
import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-book-detail',
|
selector: 'app-book-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, BookFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
|
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && book()) {
|
|
||||||
<app-book-form
|
|
||||||
mode="edit"
|
|
||||||
[book]="book()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-book-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading book details...</div>
|
<div class="loading">Loading book details...</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
@@ -46,9 +36,11 @@ import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types'
|
|||||||
</div>
|
</div>
|
||||||
} @else if (book()) {
|
} @else if (book()) {
|
||||||
<div class="book-detail-card">
|
<div class="book-detail-card">
|
||||||
<div class="book-cover-section">
|
@if (book()!.coverImage) {
|
||||||
<img [src]="book()!.coverImage || '/assets/default-cover.jpg'" [alt]="book()!.title" class="book-cover-large">
|
<div class="book-cover-section">
|
||||||
</div>
|
<img [src]="book()!.coverImage" [alt]="book()!.title" class="book-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="book-content">
|
<div class="book-content">
|
||||||
<div class="book-header">
|
<div class="book-header">
|
||||||
@@ -59,12 +51,6 @@ import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types'
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="author">by {{ book()!.author }}</p>
|
<p class="author">by {{ book()!.author }}</p>
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="editBook()" class="btn btn-edit">✏️ Edit</button>
|
|
||||||
<button (click)="deleteBook()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (book()!.series) {
|
@if (book()!.series) {
|
||||||
<p class="series">
|
<p class="series">
|
||||||
@@ -323,35 +309,6 @@ import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types'
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series {
|
.series {
|
||||||
color: #8b6f47;
|
color: #8b6f47;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -585,7 +542,6 @@ export class BookDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const bookId = this.route.snapshot.paramMap.get('id');
|
const bookId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -695,45 +651,4 @@ export class BookDetailComponent implements OnInit {
|
|||||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editBook() {
|
|
||||||
this.showEditForm.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateBookDto) {
|
|
||||||
const book = this.book();
|
|
||||||
if (!book) return;
|
|
||||||
|
|
||||||
this.booksService.updateBook(book.id, data).subscribe({
|
|
||||||
next: (updatedBook) => {
|
|
||||||
this.book.set(updatedBook);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update book: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteBook() {
|
|
||||||
const book = this.book();
|
|
||||||
if (!book) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${book.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.booksService.deleteBook(book.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/books']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete book: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/books-avatar.jpg" alt="Books avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>My Book Collection</h2>
|
<h2>My Book Collection</h2>
|
||||||
@if (authService.isAdmin()) {
|
@if (authService.isAdmin()) {
|
||||||
@@ -647,7 +643,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
@for (book of paginatedBooks(); track book.id) {
|
@for (book of paginatedBooks(); track book.id) {
|
||||||
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
|
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
|
||||||
<a [routerLink]="['/books', book.id]" class="card-link">
|
<a [routerLink]="['/books', book.id]" class="card-link">
|
||||||
<img [src]="book.coverImage || '/assets/default-cover.jpg'" [alt]="book.title" class="book-cover">
|
@if (book.coverImage) {
|
||||||
|
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
|
||||||
|
} @else {
|
||||||
|
<div class="book-cover placeholder">📚</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="book-info">
|
<div class="book-info">
|
||||||
<h3>{{ book.title }}</h3>
|
<h3>{{ book.title }}</h3>
|
||||||
@@ -705,26 +705,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #8b6f47;
|
|
||||||
box-shadow: 0 4px 12px rgba(139, 111, 71, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(139, 111, 71, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1581,9 +1561,6 @@ export class BooksListComponent implements OnInit {
|
|||||||
this.editBookTimeHours = 0;
|
this.editBookTimeHours = 0;
|
||||||
this.editBookTimeMinutes = 0;
|
this.editBookTimeMinutes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { GameFormComponent } from '../shared/game-form.component';
|
import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||||
import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-game-detail',
|
selector: 'app-game-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
@@ -37,9 +36,11 @@ import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'
|
|||||||
</div>
|
</div>
|
||||||
} @else if (game()) {
|
} @else if (game()) {
|
||||||
<div class="game-detail-card">
|
<div class="game-detail-card">
|
||||||
<div class="game-cover-section">
|
@if (game()!.coverImage) {
|
||||||
<img [src]="game()!.coverImage || '/assets/default-cover.jpg'" [alt]="game()!.title" class="game-cover-large">
|
<div class="game-cover-section">
|
||||||
</div>
|
<img [src]="game()!.coverImage" [alt]="game()!.title" class="game-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="game-content">
|
<div class="game-content">
|
||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
@@ -49,22 +50,6 @@ import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
|
||||||
<button (click)="deleteGame()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && game()) {
|
|
||||||
<app-game-form
|
|
||||||
mode="edit"
|
|
||||||
[game]="game()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-game-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (game()!.series) {
|
@if (game()!.series) {
|
||||||
<p class="series">
|
<p class="series">
|
||||||
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
|
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
|
||||||
@@ -315,34 +300,6 @@ import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series {
|
.series {
|
||||||
color: #8b6f47;
|
color: #8b6f47;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -576,7 +533,6 @@ export class GameDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const gameId = this.route.snapshot.paramMap.get('id');
|
const gameId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -686,45 +642,4 @@ export class GameDetailComponent implements OnInit {
|
|||||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleEditForm() {
|
|
||||||
this.showEditForm.update(value => !value);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateGameDto) {
|
|
||||||
const game = this.game();
|
|
||||||
if (!game) return;
|
|
||||||
|
|
||||||
this.gamesService.updateGame(game.id, data).subscribe({
|
|
||||||
next: (updatedGame) => {
|
|
||||||
this.game.set(updatedGame);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update game: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteGame() {
|
|
||||||
const game = this.game();
|
|
||||||
if (!game) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${game.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.gamesService.deleteGame(game.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/games']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete game: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/games-avatar.jpg" alt="Gaming avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>My Game Collection</h2>
|
<h2>My Game Collection</h2>
|
||||||
@if (authService.isAdmin()) {
|
@if (authService.isAdmin()) {
|
||||||
@@ -629,7 +625,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
@for (game of paginatedGames(); track game.id) {
|
@for (game of paginatedGames(); track game.id) {
|
||||||
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
|
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
|
||||||
<a [routerLink]="['/games', game.id]" class="card-link">
|
<a [routerLink]="['/games', game.id]" class="card-link">
|
||||||
<img [src]="game.coverImage || '/assets/default-cover.jpg'" [alt]="game.title" class="game-cover">
|
@if (game.coverImage) {
|
||||||
|
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
|
||||||
|
}
|
||||||
|
|
||||||
<div class="game-info">
|
<div class="game-info">
|
||||||
<h3>{{ game.title }}</h3>
|
<h3>{{ game.title }}</h3>
|
||||||
@@ -688,26 +686,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #ff6b6b;
|
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1456,9 +1434,6 @@ export class GamesListComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
this.editLinkUrl = '';
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ApiService } from '../../services/api.service';
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<nav class="navbar" aria-label="Main navigation">
|
<nav class="navbar" aria-label="Main navigation">
|
||||||
<div class="nav-brand">
|
<div class="nav-brand">
|
||||||
<img src="/assets/nav-icon.jpg" alt="" class="brand-icon" role="presentation" />
|
<img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
|
||||||
<h1><a routerLink="/">Naomi's Library</a></h1>
|
<h1><a routerLink="/">Naomi's Library</a></h1>
|
||||||
@if (version()) {
|
@if (version()) {
|
||||||
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
|
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Welcome to Naomi's Library</h1>
|
<h1>Welcome to Naomi's Library</h1>
|
||||||
<img src="/assets/nav-icon.jpg" alt="Naomi's avatar" class="hero-avatar" />
|
|
||||||
<p class="tagline">A personal collection of games, books, music, manga, shows, and art</p>
|
<p class="tagline">A personal collection of games, books, music, manga, shows, and art</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,28 +190,10 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--witch-purple);
|
color: var(--witch-purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-avatar {
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 4px solid var(--witch-lavender);
|
|
||||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
|
||||||
margin: 1rem auto;
|
|
||||||
display: block;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(157, 78, 221, 0.5);
|
|
||||||
border-color: var(--witch-rose);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
.tagline {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: var(--witch-plum);
|
color: var(--witch-plum);
|
||||||
|
|||||||
@@ -14,28 +14,18 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { MangaFormComponent } from '../shared/manga-form.component';
|
import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||||
import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manga-detail',
|
selector: 'app-manga-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MangaFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
|
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && manga()) {
|
|
||||||
<app-manga-form
|
|
||||||
mode="edit"
|
|
||||||
[manga]="manga()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-manga-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading manga details...</div>
|
<div class="loading">Loading manga details...</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
@@ -46,9 +36,11 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
|
|||||||
</div>
|
</div>
|
||||||
} @else if (manga()) {
|
} @else if (manga()) {
|
||||||
<div class="manga-detail-card">
|
<div class="manga-detail-card">
|
||||||
<div class="manga-cover-section">
|
@if (manga()!.coverImage) {
|
||||||
<img [src]="manga()!.coverImage || '/assets/default-cover.jpg'" [alt]="manga()!.title" class="manga-cover-large">
|
<div class="manga-cover-section">
|
||||||
</div>
|
<img [src]="manga()!.coverImage" [alt]="manga()!.title" class="manga-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="manga-content">
|
<div class="manga-content">
|
||||||
<div class="manga-header">
|
<div class="manga-header">
|
||||||
@@ -59,12 +51,6 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="author">by {{ manga()!.author }}</p>
|
<p class="author">by {{ manga()!.author }}</p>
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
|
||||||
<button (click)="deleteManga()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (manga()!.rating) {
|
@if (manga()!.rating) {
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -310,35 +296,6 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -538,36 +495,7 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -593,7 +521,6 @@ export class MangaDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const mangaId = this.route.snapshot.paramMap.get('id');
|
const mangaId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -703,45 +630,4 @@ export class MangaDetailComponent implements OnInit {
|
|||||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleEditForm() {
|
|
||||||
this.showEditForm.update(value => !value);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateMangaDto) {
|
|
||||||
const manga = this.manga();
|
|
||||||
if (!manga) return;
|
|
||||||
|
|
||||||
this.mangaService.updateManga(manga.id, data).subscribe({
|
|
||||||
next: (updatedManga) => {
|
|
||||||
this.manga.set(updatedManga);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update manga: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteManga() {
|
|
||||||
const manga = this.manga();
|
|
||||||
if (!manga) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${manga.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mangaService.deleteManga(manga.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/manga']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete manga: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/manga-avatar.jpg" alt="Manga avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>My Manga Collection</h2>
|
<h2>My Manga Collection</h2>
|
||||||
@if (authService.isAdmin()) {
|
@if (authService.isAdmin()) {
|
||||||
@@ -566,7 +562,9 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
@for (manga of paginatedManga(); track manga.id) {
|
@for (manga of paginatedManga(); track manga.id) {
|
||||||
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
|
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
|
||||||
<a [routerLink]="['/manga', manga.id]" class="card-link">
|
<a [routerLink]="['/manga', manga.id]" class="card-link">
|
||||||
<img [src]="manga.coverImage || '/assets/default-cover.jpg'" [alt]="manga.title" class="manga-cover">
|
@if (manga.coverImage) {
|
||||||
|
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
|
||||||
|
}
|
||||||
|
|
||||||
<div class="manga-info">
|
<div class="manga-info">
|
||||||
<h3>{{ manga.title }}</h3>
|
<h3>{{ manga.title }}</h3>
|
||||||
@@ -623,26 +621,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #00b894;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(0, 184, 148, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1369,9 +1347,6 @@ export class MangaListComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
this.editLinkUrl = '';
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -14,28 +14,18 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { MusicFormComponent } from '../shared/music-form.component';
|
import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||||
import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-music-detail',
|
selector: 'app-music-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MusicFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
|
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && music()) {
|
|
||||||
<app-music-form
|
|
||||||
mode="edit"
|
|
||||||
[music]="music()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-music-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading music details...</div>
|
<div class="loading">Loading music details...</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
@@ -46,9 +36,11 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
|
|||||||
</div>
|
</div>
|
||||||
} @else if (music()) {
|
} @else if (music()) {
|
||||||
<div class="music-detail-card">
|
<div class="music-detail-card">
|
||||||
<div class="music-cover-section">
|
@if (music()!.coverArt) {
|
||||||
<img [src]="music()!.coverArt || '/assets/default-cover.jpg'" [alt]="music()!.title" class="music-cover-large">
|
<div class="music-cover-section">
|
||||||
</div>
|
<img [src]="music()!.coverArt" [alt]="music()!.title" class="music-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="music-content">
|
<div class="music-content">
|
||||||
<div class="music-header">
|
<div class="music-header">
|
||||||
@@ -62,12 +54,6 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="artist">by {{ music()!.artist }}</p>
|
<p class="artist">by {{ music()!.artist }}</p>
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="editMusic()" class="btn btn-edit">✏️ Edit</button>
|
|
||||||
<button (click)="deleteMusic()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (music()!.rating) {
|
@if (music()!.rating) {
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -336,35 +322,6 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -564,36 +521,7 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -619,7 +547,6 @@ export class MusicDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const musicId = this.route.snapshot.paramMap.get('id');
|
const musicId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -737,45 +664,4 @@ export class MusicDetailComponent implements OnInit {
|
|||||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editMusic() {
|
|
||||||
this.showEditForm.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateMusicDto) {
|
|
||||||
const music = this.music();
|
|
||||||
if (!music) return;
|
|
||||||
|
|
||||||
this.musicService.updateMusic(music.id, data).subscribe({
|
|
||||||
next: (updatedMusic) => {
|
|
||||||
this.music.set(updatedMusic);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update music: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteMusic() {
|
|
||||||
const music = this.music();
|
|
||||||
if (!music) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${music.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.musicService.deleteMusic(music.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/music']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete music: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/music-avatar.jpg" alt="Music avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>My Music Collection</h2>
|
<h2>My Music Collection</h2>
|
||||||
@if (authService.isAdmin()) {
|
@if (authService.isAdmin()) {
|
||||||
@@ -628,7 +624,17 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
@for (music of paginatedMusic(); track music.id) {
|
@for (music of paginatedMusic(); track music.id) {
|
||||||
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
|
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
|
||||||
<a [routerLink]="['/music', music.id]" class="card-link">
|
<a [routerLink]="['/music', music.id]" class="card-link">
|
||||||
<img [src]="music.coverArt || '/assets/default-cover.jpg'" [alt]="music.title" class="music-cover">
|
@if (music.coverArt) {
|
||||||
|
<img [src]="music.coverArt" [alt]="music.title" class="music-cover">
|
||||||
|
} @else {
|
||||||
|
<div class="music-cover placeholder">
|
||||||
|
@switch (music.type) {
|
||||||
|
@case (MusicType.album) { 💿 }
|
||||||
|
@case (MusicType.single) { 🎵 }
|
||||||
|
@case (MusicType.ep) { 🎶 }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="music-info">
|
<div class="music-info">
|
||||||
<h3>{{ music.title }}</h3>
|
<h3>{{ music.title }}</h3>
|
||||||
@@ -693,26 +699,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #74b9ff;
|
|
||||||
box-shadow: 0 4px 12px rgba(116, 185, 255, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(116, 185, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1585,9 +1571,6 @@ export class MusicListComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
this.editLinkUrl = '';
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -1,405 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Art, CreateArtDto, UpdateArtDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-art-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="art-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter artwork title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="artist">Artist</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="artist"
|
|
||||||
[(ngModel)]="formData.artist"
|
|
||||||
name="artist"
|
|
||||||
required
|
|
||||||
placeholder="Enter artist name"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
[(ngModel)]="formData.description"
|
|
||||||
name="description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Description of the artwork..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="imageUrl">Image (max 500KB)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="imageUrl"
|
|
||||||
name="imageUrl"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onImageSelected($event)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Artwork preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., Artist Portfolio)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Art' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.art-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.art-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class ArtFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() art?: Art;
|
|
||||||
@Input() initialData?: Partial<CreateArtDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateArtDto | UpdateArtDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
formData: Partial<CreateArtDto | UpdateArtDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.art) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.art.title,
|
|
||||||
artist: this.art.artist,
|
|
||||||
description: this.art.description,
|
|
||||||
imageUrl: this.art.imageUrl,
|
|
||||||
tags: [...(this.art.tags || [])],
|
|
||||||
links: [...(this.art.links || [])]
|
|
||||||
};
|
|
||||||
|
|
||||||
this.imagePreview.set(this.art.imageUrl || null);
|
|
||||||
} else {
|
|
||||||
this.formData = {
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.imageUrl) {
|
|
||||||
this.imagePreview.set(this.initialData.imageUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.imageUrl = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.imageUrl = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.artist || !this.formData.imageUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateArtDto | UpdateArtDto = {
|
|
||||||
...this.formData as CreateArtDto | UpdateArtDto
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-book-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="book-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter book title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="author">Author</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="author"
|
|
||||||
[(ngModel)]="formData.author"
|
|
||||||
name="author"
|
|
||||||
required
|
|
||||||
placeholder="Enter author name"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="isbn">ISBN (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="isbn"
|
|
||||||
[(ngModel)]="formData.isbn"
|
|
||||||
name="isbn"
|
|
||||||
placeholder="Enter ISBN"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
|
||||||
<option [value]="BookStatus.reading">Currently Reading</option>
|
|
||||||
<option [value]="BookStatus.finished">Finished</option>
|
|
||||||
<option [value]="BookStatus.toRead">To Read</option>
|
|
||||||
<option [value]="BookStatus.retired">Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateStarted">Date Started</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateStarted"
|
|
||||||
[(ngModel)]="formData.dateStarted"
|
|
||||||
name="dateStarted"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateFinished">Date Finished</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFinished"
|
|
||||||
[(ngModel)]="formData.dateFinished"
|
|
||||||
name="dateFinished"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rating">Rating (1-10)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="rating"
|
|
||||||
[(ngModel)]="formData.rating"
|
|
||||||
name="rating"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeHours">Time Spent (Hours)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeHours"
|
|
||||||
[(ngModel)]="timeHours"
|
|
||||||
name="timeHours"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeMinutes"
|
|
||||||
[(ngModel)]="timeMinutes"
|
|
||||||
name="timeMinutes"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
[(ngModel)]="formData.notes"
|
|
||||||
name="notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any thoughts about the book..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="series">Series (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="series"
|
|
||||||
[(ngModel)]="formData.series"
|
|
||||||
name="series"
|
|
||||||
placeholder="e.g., Harry Potter"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="seriesOrder">Series Order (optional)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="seriesOrder"
|
|
||||||
[(ngModel)]="formData.seriesOrder"
|
|
||||||
name="seriesOrder"
|
|
||||||
min="1"
|
|
||||||
placeholder="Order in series"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="coverImage"
|
|
||||||
name="coverImage"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onImageSelected($event)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Cover preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., Goodreads)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Book' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.book-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="date"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class BookFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() book?: Book;
|
|
||||||
@Input() initialData?: Partial<CreateBookDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateBookDto | UpdateBookDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
BookStatus = BookStatus;
|
|
||||||
|
|
||||||
formData: Partial<CreateBookDto | UpdateBookDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
timeHours = 0;
|
|
||||||
timeMinutes = 0;
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.book) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.book.title,
|
|
||||||
author: this.book.author,
|
|
||||||
isbn: this.book.isbn,
|
|
||||||
status: this.book.status,
|
|
||||||
dateStarted: this.book.dateStarted,
|
|
||||||
dateFinished: this.book.dateFinished,
|
|
||||||
rating: this.book.rating,
|
|
||||||
notes: this.book.notes,
|
|
||||||
coverImage: this.book.coverImage,
|
|
||||||
tags: [...(this.book.tags || [])],
|
|
||||||
links: [...(this.book.links || [])],
|
|
||||||
series: this.book.series,
|
|
||||||
seriesOrder: this.book.seriesOrder,
|
|
||||||
timeSpent: this.book.timeSpent
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.book.timeSpent) {
|
|
||||||
this.timeHours = Math.floor(this.book.timeSpent / 60);
|
|
||||||
this.timeMinutes = this.book.timeSpent % 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imagePreview.set(this.book.coverImage || null);
|
|
||||||
} else {
|
|
||||||
this.formData = {
|
|
||||||
status: BookStatus.toRead,
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.coverImage) {
|
|
||||||
this.imagePreview.set(this.initialData.coverImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTimeSpent() {
|
|
||||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.coverImage = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.coverImage = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.author || !this.formData.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateBookDto | UpdateBookDto = {
|
|
||||||
...this.formData as CreateBookDto | UpdateBookDto,
|
|
||||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
|
||||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-game-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="game-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter game title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="platform">Platform</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="platform"
|
|
||||||
[(ngModel)]="formData.platform"
|
|
||||||
name="platform"
|
|
||||||
placeholder="PC, PS5, Xbox, Switch, etc."
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
|
||||||
<option [value]="GameStatus.playing">Currently Playing</option>
|
|
||||||
<option [value]="GameStatus.completed">Completed</option>
|
|
||||||
<option [value]="GameStatus.backlog">In Backlog</option>
|
|
||||||
<option [value]="GameStatus.retired">Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateStarted">Date Started</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateStarted"
|
|
||||||
[(ngModel)]="formData.dateStarted"
|
|
||||||
name="dateStarted"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateFinished">Date Finished</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFinished"
|
|
||||||
[(ngModel)]="formData.dateFinished"
|
|
||||||
name="dateFinished"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rating">Rating (1-10)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="rating"
|
|
||||||
[(ngModel)]="formData.rating"
|
|
||||||
name="rating"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeHours">Time Spent (Hours)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeHours"
|
|
||||||
[(ngModel)]="timeHours"
|
|
||||||
name="timeHours"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeMinutes"
|
|
||||||
[(ngModel)]="timeMinutes"
|
|
||||||
name="timeMinutes"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
[(ngModel)]="formData.notes"
|
|
||||||
name="notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any thoughts about the game..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="series">Series (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="series"
|
|
||||||
[(ngModel)]="formData.series"
|
|
||||||
name="series"
|
|
||||||
placeholder="e.g., The Legend of Zelda"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="seriesOrder">Series Order (optional)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="seriesOrder"
|
|
||||||
[(ngModel)]="formData.seriesOrder"
|
|
||||||
name="seriesOrder"
|
|
||||||
min="1"
|
|
||||||
placeholder="Order in series"
|
|
||||||
>
|
|
||||||
</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)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Box art preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., Steam)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Game' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.game-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="date"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class GameFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() game?: Game;
|
|
||||||
@Input() initialData?: Partial<CreateGameDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateGameDto | UpdateGameDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
GameStatus = GameStatus;
|
|
||||||
|
|
||||||
formData: Partial<CreateGameDto | UpdateGameDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
timeHours = 0;
|
|
||||||
timeMinutes = 0;
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.game) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.game.title,
|
|
||||||
platform: this.game.platform,
|
|
||||||
status: this.game.status,
|
|
||||||
dateStarted: this.game.dateStarted,
|
|
||||||
dateFinished: this.game.dateFinished,
|
|
||||||
rating: this.game.rating,
|
|
||||||
notes: this.game.notes,
|
|
||||||
coverImage: this.game.coverImage,
|
|
||||||
tags: [...(this.game.tags || [])],
|
|
||||||
links: [...(this.game.links || [])],
|
|
||||||
series: this.game.series,
|
|
||||||
seriesOrder: this.game.seriesOrder,
|
|
||||||
timeSpent: this.game.timeSpent
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.game.timeSpent) {
|
|
||||||
this.timeHours = Math.floor(this.game.timeSpent / 60);
|
|
||||||
this.timeMinutes = this.game.timeSpent % 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imagePreview.set(this.game.coverImage || null);
|
|
||||||
} else {
|
|
||||||
// Add mode - use initialData if provided, otherwise defaults
|
|
||||||
this.formData = {
|
|
||||||
status: GameStatus.backlog,
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.coverImage) {
|
|
||||||
this.imagePreview.set(this.initialData.coverImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTimeSpent() {
|
|
||||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.coverImage = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.coverImage = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateGameDto | UpdateGameDto = {
|
|
||||||
...this.formData as CreateGameDto | UpdateGameDto,
|
|
||||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
|
||||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-manga-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="manga-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Manga' : 'Edit Manga' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter manga title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="author">Author</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="author"
|
|
||||||
[(ngModel)]="formData.author"
|
|
||||||
name="author"
|
|
||||||
required
|
|
||||||
placeholder="Enter author name"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
|
||||||
<option [value]="MangaStatus.reading">Currently Reading</option>
|
|
||||||
<option [value]="MangaStatus.completed">Completed</option>
|
|
||||||
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
|
||||||
<option [value]="MangaStatus.retired">Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateStarted">Date Started</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateStarted"
|
|
||||||
[(ngModel)]="formData.dateStarted"
|
|
||||||
name="dateStarted"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateFinished">Date Finished</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFinished"
|
|
||||||
[(ngModel)]="formData.dateFinished"
|
|
||||||
name="dateFinished"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rating">Rating (1-10)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="rating"
|
|
||||||
[(ngModel)]="formData.rating"
|
|
||||||
name="rating"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeHours">Time Spent (Hours)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeHours"
|
|
||||||
[(ngModel)]="timeHours"
|
|
||||||
name="timeHours"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeMinutes"
|
|
||||||
[(ngModel)]="timeMinutes"
|
|
||||||
name="timeMinutes"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
[(ngModel)]="formData.notes"
|
|
||||||
name="notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any thoughts about the manga..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="coverImage"
|
|
||||||
name="coverImage"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onImageSelected($event)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Cover preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., MyAnimeList)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Manga' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.manga-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manga-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="date"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class MangaFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() manga?: Manga;
|
|
||||||
@Input() initialData?: Partial<CreateMangaDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
MangaStatus = MangaStatus;
|
|
||||||
|
|
||||||
formData: Partial<CreateMangaDto | UpdateMangaDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
timeHours = 0;
|
|
||||||
timeMinutes = 0;
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.manga) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.manga.title,
|
|
||||||
author: this.manga.author,
|
|
||||||
status: this.manga.status,
|
|
||||||
dateStarted: this.manga.dateStarted,
|
|
||||||
dateFinished: this.manga.dateFinished,
|
|
||||||
rating: this.manga.rating,
|
|
||||||
notes: this.manga.notes,
|
|
||||||
coverImage: this.manga.coverImage,
|
|
||||||
tags: [...(this.manga.tags || [])],
|
|
||||||
links: [...(this.manga.links || [])],
|
|
||||||
timeSpent: this.manga.timeSpent
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.manga.timeSpent) {
|
|
||||||
this.timeHours = Math.floor(this.manga.timeSpent / 60);
|
|
||||||
this.timeMinutes = this.manga.timeSpent % 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imagePreview.set(this.manga.coverImage || null);
|
|
||||||
} else {
|
|
||||||
this.formData = {
|
|
||||||
status: MangaStatus.wantToRead,
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.coverImage) {
|
|
||||||
this.imagePreview.set(this.initialData.coverImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTimeSpent() {
|
|
||||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.coverImage = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.coverImage = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.author || !this.formData.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateMangaDto | UpdateMangaDto = {
|
|
||||||
...this.formData as CreateMangaDto | UpdateMangaDto,
|
|
||||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
|
||||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-music-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="music-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter album/single/EP title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="artist">Artist</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="artist"
|
|
||||||
[(ngModel)]="formData.artist"
|
|
||||||
name="artist"
|
|
||||||
required
|
|
||||||
placeholder="Enter artist name"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" [(ngModel)]="formData.type" name="type" required>
|
|
||||||
<option [value]="MusicType.album">Album</option>
|
|
||||||
<option [value]="MusicType.single">Single</option>
|
|
||||||
<option [value]="MusicType.ep">EP</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
|
||||||
<option [value]="MusicStatus.listening">Currently Listening</option>
|
|
||||||
<option [value]="MusicStatus.completed">Completed</option>
|
|
||||||
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
|
|
||||||
<option [value]="MusicStatus.retired">Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateStarted">Date Started</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateStarted"
|
|
||||||
[(ngModel)]="formData.dateStarted"
|
|
||||||
name="dateStarted"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateFinished">Date Finished</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFinished"
|
|
||||||
[(ngModel)]="formData.dateFinished"
|
|
||||||
name="dateFinished"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rating">Rating (1-10)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="rating"
|
|
||||||
[(ngModel)]="formData.rating"
|
|
||||||
name="rating"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeHours">Time Spent (Hours)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeHours"
|
|
||||||
[(ngModel)]="timeHours"
|
|
||||||
name="timeHours"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeMinutes"
|
|
||||||
[(ngModel)]="timeMinutes"
|
|
||||||
name="timeMinutes"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
[(ngModel)]="formData.notes"
|
|
||||||
name="notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any thoughts about the music..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="coverArt">Cover Art (max 500KB)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="coverArt"
|
|
||||||
name="coverArt"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onImageSelected($event)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Cover preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., Spotify)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Music' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.music-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="date"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class MusicFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() music?: Music;
|
|
||||||
@Input() initialData?: Partial<CreateMusicDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
MusicStatus = MusicStatus;
|
|
||||||
MusicType = MusicType;
|
|
||||||
|
|
||||||
formData: Partial<CreateMusicDto | UpdateMusicDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
timeHours = 0;
|
|
||||||
timeMinutes = 0;
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.music) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.music.title,
|
|
||||||
artist: this.music.artist,
|
|
||||||
type: this.music.type,
|
|
||||||
status: this.music.status,
|
|
||||||
dateStarted: this.music.dateStarted,
|
|
||||||
dateFinished: this.music.dateFinished,
|
|
||||||
rating: this.music.rating,
|
|
||||||
notes: this.music.notes,
|
|
||||||
coverArt: this.music.coverArt,
|
|
||||||
tags: [...(this.music.tags || [])],
|
|
||||||
links: [...(this.music.links || [])],
|
|
||||||
timeSpent: this.music.timeSpent
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.music.timeSpent) {
|
|
||||||
this.timeHours = Math.floor(this.music.timeSpent / 60);
|
|
||||||
this.timeMinutes = this.music.timeSpent % 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imagePreview.set(this.music.coverArt || null);
|
|
||||||
} else {
|
|
||||||
this.formData = {
|
|
||||||
type: MusicType.album,
|
|
||||||
status: MusicStatus.wantToListen,
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.coverArt) {
|
|
||||||
this.imagePreview.set(this.initialData.coverArt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTimeSpent() {
|
|
||||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.coverArt = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.coverArt = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.artist || !this.formData.type || !this.formData.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateMusicDto | UpdateMusicDto = {
|
|
||||||
...this.formData as CreateMusicDto | UpdateMusicDto,
|
|
||||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
|
||||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
/**
|
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
|
||||||
* @author Naomi Carrigan, Hikari
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Link } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-show-form',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: `
|
|
||||||
<form (ngSubmit)="onSubmit()" class="show-form">
|
|
||||||
<h3>{{ mode === 'add' ? 'Add New Show' : 'Edit Show' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
[(ngModel)]="formData.title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
placeholder="Enter show title"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" [(ngModel)]="formData.type" name="type" required>
|
|
||||||
<option [value]="ShowType.tvSeries">TV Series</option>
|
|
||||||
<option [value]="ShowType.anime">Anime</option>
|
|
||||||
<option [value]="ShowType.film">Film</option>
|
|
||||||
<option [value]="ShowType.documentary">Documentary</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
|
||||||
<option [value]="ShowStatus.watching">Currently Watching</option>
|
|
||||||
<option [value]="ShowStatus.completed">Completed</option>
|
|
||||||
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
|
||||||
<option [value]="ShowStatus.retired">Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateStarted">Date Started</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateStarted"
|
|
||||||
[(ngModel)]="formData.dateStarted"
|
|
||||||
name="dateStarted"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dateFinished">Date Finished</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="dateFinished"
|
|
||||||
[(ngModel)]="formData.dateFinished"
|
|
||||||
name="dateFinished"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rating">Rating (1-10)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="rating"
|
|
||||||
[(ngModel)]="formData.rating"
|
|
||||||
name="rating"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeHours">Time Spent (Hours)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeHours"
|
|
||||||
[(ngModel)]="timeHours"
|
|
||||||
name="timeHours"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="timeMinutes"
|
|
||||||
[(ngModel)]="timeMinutes"
|
|
||||||
name="timeMinutes"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
placeholder="0"
|
|
||||||
(ngModelChange)="updateTimeSpent()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
[(ngModel)]="formData.notes"
|
|
||||||
name="notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any thoughts about the show..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="coverImage"
|
|
||||||
name="coverImage"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onImageSelected($event)"
|
|
||||||
>
|
|
||||||
@if (imagePreview()) {
|
|
||||||
<div class="image-preview">
|
|
||||||
<img [src]="imagePreview()" alt="Cover preview">
|
|
||||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (imageError()) {
|
|
||||||
<span class="error-text">{{ imageError() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="tags-input-container" aria-label="Tags">
|
|
||||||
@for (tag of formData.tags; track tag; let i = $index) {
|
|
||||||
<span class="tag">
|
|
||||||
{{ tag }}
|
|
||||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="tagInput"
|
|
||||||
name="tagInput"
|
|
||||||
placeholder="Add a tag and press Enter"
|
|
||||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" aria-label="External Links">
|
|
||||||
<div class="links-list">
|
|
||||||
@for (link of formData.links; track link.url; let i = $index) {
|
|
||||||
<div class="link-item">
|
|
||||||
<span>{{ link.title }}: {{ link.url }}</span>
|
|
||||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="link-add-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="linkTitle"
|
|
||||||
name="linkTitle"
|
|
||||||
placeholder="Link title (e.g., IMDB)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
[(ngModel)]="linkUrl"
|
|
||||||
name="linkUrl"
|
|
||||||
placeholder="https://..."
|
|
||||||
>
|
|
||||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Show' : 'Save Changes' }}</button>
|
|
||||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.show-form {
|
|
||||||
background: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-form h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"],
|
|
||||||
.form-group input[type="number"],
|
|
||||||
.form-group input[type="date"],
|
|
||||||
.form-group input[type="url"],
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-input-container input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-list {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-add-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6b7280;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class ShowFormComponent implements OnInit {
|
|
||||||
@Input() mode: 'add' | 'edit' = 'add';
|
|
||||||
@Input() show?: Show;
|
|
||||||
@Input() initialData?: Partial<CreateShowDto>;
|
|
||||||
@Output() formSubmit = new EventEmitter<CreateShowDto | UpdateShowDto>();
|
|
||||||
@Output() formCancel = new EventEmitter<void>();
|
|
||||||
|
|
||||||
ShowStatus = ShowStatus;
|
|
||||||
ShowType = ShowType;
|
|
||||||
|
|
||||||
formData: Partial<CreateShowDto | UpdateShowDto> = {
|
|
||||||
tags: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
|
|
||||||
timeHours = 0;
|
|
||||||
timeMinutes = 0;
|
|
||||||
tagInput = '';
|
|
||||||
linkTitle = '';
|
|
||||||
linkUrl = '';
|
|
||||||
imagePreview = signal<string | null>(null);
|
|
||||||
imageError = signal<string | null>(null);
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
if (this.mode === 'edit' && this.show) {
|
|
||||||
this.formData = {
|
|
||||||
title: this.show.title,
|
|
||||||
type: this.show.type,
|
|
||||||
status: this.show.status,
|
|
||||||
dateStarted: this.show.dateStarted,
|
|
||||||
dateFinished: this.show.dateFinished,
|
|
||||||
rating: this.show.rating,
|
|
||||||
notes: this.show.notes,
|
|
||||||
coverImage: this.show.coverImage,
|
|
||||||
tags: [...(this.show.tags || [])],
|
|
||||||
links: [...(this.show.links || [])],
|
|
||||||
timeSpent: this.show.timeSpent
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.show.timeSpent) {
|
|
||||||
this.timeHours = Math.floor(this.show.timeSpent / 60);
|
|
||||||
this.timeMinutes = this.show.timeSpent % 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imagePreview.set(this.show.coverImage || null);
|
|
||||||
} else {
|
|
||||||
this.formData = {
|
|
||||||
type: ShowType.tvSeries,
|
|
||||||
status: ShowStatus.wantToWatch,
|
|
||||||
tags: [],
|
|
||||||
links: [],
|
|
||||||
...this.initialData
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.initialData?.coverImage) {
|
|
||||||
this.imagePreview.set(this.initialData.coverImage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTimeSpent() {
|
|
||||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag() {
|
|
||||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
|
||||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
|
||||||
this.tagInput = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTag(index: number) {
|
|
||||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addLink() {
|
|
||||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
|
||||||
const newLink: Link = {
|
|
||||||
title: this.linkTitle.trim(),
|
|
||||||
url: this.linkUrl.trim()
|
|
||||||
};
|
|
||||||
this.formData.links = [...(this.formData.links || []), newLink];
|
|
||||||
this.linkTitle = '';
|
|
||||||
this.linkUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLink(index: number) {
|
|
||||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onImageSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 500000) {
|
|
||||||
this.imageError.set('Image must be under 500KB');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.imageError.set(null);
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
this.formData.coverImage = base64String;
|
|
||||||
this.imagePreview.set(base64String);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearImage() {
|
|
||||||
this.formData.coverImage = undefined;
|
|
||||||
this.imagePreview.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.formData.title || !this.formData.type || !this.formData.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: CreateShowDto | UpdateShowDto = {
|
|
||||||
...this.formData as CreateShowDto | UpdateShowDto,
|
|
||||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
|
||||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formSubmit.emit(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel() {
|
|
||||||
this.formCancel.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,28 +14,18 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { SanitizeService } from '../../services/sanitize.service';
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
import { ShowFormComponent } from '../shared/show-form.component';
|
import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||||
import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/shared-types';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-show-detail',
|
selector: 'app-show-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ShowFormComponent],
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
|
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showEditForm() && authService.user()?.isAdmin && show()) {
|
|
||||||
<app-show-form
|
|
||||||
mode="edit"
|
|
||||||
[show]="show()!"
|
|
||||||
(formSubmit)="saveEdit($event)"
|
|
||||||
(formCancel)="cancelEdit()"
|
|
||||||
></app-show-form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading show details...</div>
|
<div class="loading">Loading show details...</div>
|
||||||
} @else if (error()) {
|
} @else if (error()) {
|
||||||
@@ -46,9 +36,11 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
|
|||||||
</div>
|
</div>
|
||||||
} @else if (show()) {
|
} @else if (show()) {
|
||||||
<div class="show-detail-card">
|
<div class="show-detail-card">
|
||||||
<div class="show-poster-section">
|
@if (show()!.coverImage) {
|
||||||
<img [src]="show()!.coverImage || '/assets/default-cover.jpg'" [alt]="show()!.title" class="show-poster-large">
|
<div class="show-poster-section">
|
||||||
</div>
|
<img [src]="show()!.coverImage" [alt]="show()!.title" class="show-poster-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="show-content">
|
<div class="show-content">
|
||||||
<div class="show-header">
|
<div class="show-header">
|
||||||
@@ -60,12 +52,6 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
|
|||||||
{{ getStatusLabel(show()!.status) }}
|
{{ getStatusLabel(show()!.status) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (authService.user()?.isAdmin) {
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
|
||||||
<button (click)="deleteShow()" class="btn btn-delete">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (show()!.rating) {
|
@if (show()!.rating) {
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -332,35 +318,6 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -560,36 +517,7 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -615,7 +543,6 @@ export class ShowDetailComponent implements OnInit {
|
|||||||
commentsLoading = signal(false);
|
commentsLoading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
newCommentContent = '';
|
newCommentContent = '';
|
||||||
showEditForm = signal(false);
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const showId = this.route.snapshot.paramMap.get('id');
|
const showId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -734,45 +661,4 @@ export class ShowDetailComponent implements OnInit {
|
|||||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleEditForm() {
|
|
||||||
this.showEditForm.update(value => !value);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(data: UpdateShowDto) {
|
|
||||||
const show = this.show();
|
|
||||||
if (!show) return;
|
|
||||||
|
|
||||||
this.showsService.updateShow(show.id, data).subscribe({
|
|
||||||
next: (updatedShow) => {
|
|
||||||
this.show.set(updatedShow);
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to update show: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit() {
|
|
||||||
this.showEditForm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteShow() {
|
|
||||||
const show = this.show();
|
|
||||||
if (!show) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete "${show.title}"? This action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showsService.deleteShow(show.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['/shows']);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
alert('Failed to delete show: ' + (err.error?.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
imports: [CommonModule, RouterLink, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, RouterLink, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-hero">
|
|
||||||
<img src="/assets/avatars/shows-avatar.jpg" alt="Shows avatar" class="page-avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h2>My Shows & Films</h2>
|
<h2>My Shows & Films</h2>
|
||||||
@if (authService.isAdmin()) {
|
@if (authService.isAdmin()) {
|
||||||
@@ -560,7 +556,9 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
@for (show of paginatedShows(); track show.id) {
|
@for (show of paginatedShows(); track show.id) {
|
||||||
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
|
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
|
||||||
<a [routerLink]="['/shows', show.id]" class="card-link">
|
<a [routerLink]="['/shows', show.id]" class="card-link">
|
||||||
<img [src]="show.coverImage || '/assets/default-cover.jpg'" [alt]="show.title" class="show-cover">
|
@if (show.coverImage) {
|
||||||
|
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
|
||||||
|
}
|
||||||
|
|
||||||
<div class="show-info">
|
<div class="show-info">
|
||||||
<h3>{{ show.title }}</h3>
|
<h3>{{ show.title }}</h3>
|
||||||
@@ -619,26 +617,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 3px solid #e84393;
|
|
||||||
box-shadow: 0 4px 12px rgba(232, 67, 147, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 8px 16px rgba(232, 67, 147, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1390,9 +1368,6 @@ export class ShowsListComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
this.editLinkUrl = '';
|
||||||
|
|
||||||
// Scroll to top so the form is visible
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ export class GlobalErrorHandler implements ErrorHandler {
|
|||||||
private toast = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
handleError(error: Error): void {
|
handleError(error: Error): void {
|
||||||
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Global error caught:', error);
|
console.error('Global error caught:', error);
|
||||||
|
|
||||||
// Show user-friendly error message
|
// Show user-friendly error message
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
|
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
|
||||||
<meta name="theme-color" content="#9d4edd" />
|
<meta name="theme-color" content="#9d4edd" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Griffy&family=Henny+Penny&family=Kalam:wght@300;400;700&display=swap" rel="stylesheet" />
|
|
||||||
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
||||||
<script>
|
<script>
|
||||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: 'Kalam', cursive;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
font-size: 14pt;
|
font-size: 14pt;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -75,30 +75,10 @@ body::after {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes wiggle {
|
|
||||||
0%, 100% { transform: rotate(-2deg); }
|
|
||||||
50% { transform: rotate(2deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--witch-purple);
|
color: var(--witch-purple);
|
||||||
font-family: 'Griffy', cursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
animation: wiggle 4s ease-in-out infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.witchy-accent,
|
|
||||||
.spooky-title {
|
|
||||||
font-family: 'Creepster', cursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mystical-text {
|
|
||||||
font-family: 'Henny Penny', cursive;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -141,7 +121,6 @@ select:focus {
|
|||||||
|
|
||||||
// Button base styles
|
// Button base styles
|
||||||
button {
|
button {
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
|
|||||||
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
|
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
|
||||||
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
||||||
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
|
|
||||||
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
|
|
||||||
|
|
||||||
# Application URL
|
# Application URL
|
||||||
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
|
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@library/source",
|
"name": "@library/source",
|
||||||
"version": "1.1.1",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
|
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
"@fastify/csrf-protection": "7.1.0",
|
"@fastify/csrf-protection": "7.1.0",
|
||||||
"@fastify/helmet": "13.0.2",
|
"@fastify/helmet": "13.0.2",
|
||||||
"@fastify/jwt": "10.0.0",
|
"@fastify/jwt": "10.0.0",
|
||||||
"@fastify/oauth2": "8.2.0",
|
"@fastify/oauth2": "8.1.2",
|
||||||
"@fastify/rate-limit": "10.3.0",
|
"@fastify/rate-limit": "10.3.0",
|
||||||
"@fastify/sensible": "6.0.4",
|
"@fastify/sensible": "6.0.4",
|
||||||
"@fastify/static": "9.0.0",
|
"@fastify/static": "9.0.0",
|
||||||
"@fortawesome/angular-fontawesome": "5.0.0",
|
"@fortawesome/angular-fontawesome": "4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "7.2.0",
|
"@fortawesome/fontawesome-svg-core": "7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "7.2.0",
|
"@fortawesome/free-brands-svg-icons": "7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "7.2.0",
|
"@fortawesome/free-solid-svg-icons": "7.2.0",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fastify": "5.7.3",
|
"fastify": "5.7.3",
|
||||||
"fastify-plugin": "5.0.1",
|
"fastify-plugin": "5.0.1",
|
||||||
"jsdom": "28.1.0",
|
"jsdom": "28.0.0",
|
||||||
"marked": "17.0.3",
|
"marked": "17.0.1",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -70,13 +70,13 @@
|
|||||||
"@schematics/angular": "21.1.2",
|
"@schematics/angular": "21.1.2",
|
||||||
"@swc-node/register": "1.9.2",
|
"@swc-node/register": "1.9.2",
|
||||||
"@swc/core": "1.5.29",
|
"@swc/core": "1.5.29",
|
||||||
"@swc/helpers": "0.5.19",
|
"@swc/helpers": "0.5.18",
|
||||||
"@types/dompurify": "3.2.0",
|
"@types/dompurify": "3.2.0",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "30.0.0",
|
||||||
"@types/jsdom": "27.0.0",
|
"@types/jsdom": "27.0.0",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "20.19.9",
|
"@types/node": "20.19.9",
|
||||||
"@typescript-eslint/utils": "8.56.0",
|
"@typescript-eslint/utils": "8.54.0",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"cypress": "15.9.0",
|
"cypress": "15.9.0",
|
||||||
"esbuild": "0.19.12",
|
"esbuild": "0.19.12",
|
||||||
@@ -91,6 +91,6 @@
|
|||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.56.0"
|
"typescript-eslint": "8.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
|
|||||||
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
|
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
|
||||||
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
||||||
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
|
|
||||||
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
|
|
||||||
|
|
||||||
# Application URL
|
# Application URL
|
||||||
BASE_URL="op://Environment Variables - Naomi/Library/base url"
|
BASE_URL="op://Environment Variables - Naomi/Library/base url"
|
||||||
|
|||||||