[FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert

This commit is contained in:
gamer147
2025-12-08 18:30:00 -05:00
parent c9d93a4e55
commit 81e4e88ad4
48 changed files with 3298 additions and 329 deletions

View File

@@ -0,0 +1,115 @@
import type { NovelDtoFilterInput, NovelStatus } from '$lib/graphql/__generated__/graphql';
export interface NovelFilters {
search: string;
statuses: NovelStatus[];
tags: string[];
}
export const EMPTY_FILTERS: NovelFilters = {
search: '',
statuses: [],
tags: []
};
const VALID_STATUSES: NovelStatus[] = ['ABANDONED', 'COMPLETED', 'HIATUS', 'IN_PROGRESS', 'UNKNOWN'];
/**
* Parse filter state from URL search parameters
*/
export function parseFiltersFromURL(searchParams?: URLSearchParams): NovelFilters {
const params = searchParams ?? new URLSearchParams(window.location.search);
const search = params.get('search') ?? '';
const statusParam = params.get('status') ?? '';
const statuses = statusParam
.split(',')
.filter((s) => s && VALID_STATUSES.includes(s as NovelStatus)) as NovelStatus[];
const tagsParam = params.get('tags') ?? '';
const tags = tagsParam.split(',').filter((t) => t.length > 0);
return { search, statuses, tags };
}
/**
* Convert filter state to URL search params string
*/
export function filtersToURLParams(filters: NovelFilters): string {
const params = new URLSearchParams();
if (filters.search.trim()) {
params.set('search', filters.search.trim());
}
if (filters.statuses.length > 0) {
params.set('status', filters.statuses.join(','));
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
}
return params.toString();
}
/**
* Update browser URL with current filters (without page reload)
*/
export function syncFiltersToURL(filters: NovelFilters): void {
const params = filtersToURLParams(filters);
const newUrl = params ? `${window.location.pathname}?${params}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
/**
* Convert filter state to GraphQL where input
* Returns null if no filters are active
*/
export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInput | null {
const conditions: NovelDtoFilterInput[] = [];
// Text search on name (case-insensitive contains)
if (filters.search.trim()) {
conditions.push({
name: { contains: filters.search.trim() }
});
}
// Status filter
if (filters.statuses.length > 0) {
conditions.push({
rawStatus: { in: filters.statuses }
});
}
// Tag filter (match novels that have ANY of the selected tags)
if (filters.tags.length > 0) {
conditions.push({
tags: {
some: {
key: { in: filters.tags }
}
}
});
}
// Return null if no filters, single condition if one filter, AND for multiple
if (conditions.length === 0) {
return null;
}
if (conditions.length === 1) {
return conditions[0];
}
return { and: conditions };
}
/**
* Check if any filters are active
*/
export function hasActiveFilters(filters: NovelFilters): boolean {
return filters.search.trim().length > 0 || filters.statuses.length > 0 || filters.tags.length > 0;
}

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify';
import DOMPurify from 'isomorphic-dompurify';
/**
* Sanitizes HTML content, allowing only safe inline formatting elements.

View File

@@ -0,0 +1,74 @@
import DOMPurify from 'isomorphic-dompurify';
/**
* Sanitizes chapter HTML content with extended allowed tags.
* More permissive than the description sanitizer to support
* formatted novel content including headings, lists, and images.
*/
export function sanitizeChapterHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
// Basic formatting
'b',
'i',
'em',
'strong',
'u',
's',
'strike',
'del',
'ins',
// Structure
'p',
'br',
'hr',
'div',
'span',
// Headings
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
// Lists
'ul',
'ol',
'li',
// Quotes
'blockquote',
'q',
'cite',
// Preformatted
'pre',
'code',
// Ruby (for Asian language annotations)
'ruby',
'rt',
'rp',
// Images
'img',
// Tables
'table',
'thead',
'tbody',
'tr',
'th',
'td'
],
ALLOWED_ATTR: [
// Image attributes
'src',
'alt',
'title',
'width',
'height',
// Table attributes
'colspan',
'rowspan',
// Generic styling (limited)
'class'
],
ALLOW_DATA_ATTR: false
});
}