Various Fixes and QoL enhancements.
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import TagInput from '../../components/stories/TagInput';
|
||||
import RichTextEditor from '../../components/stories/RichTextEditor';
|
||||
import ImageUpload from '../../components/ui/ImageUpload';
|
||||
import { storyApi } from '../../lib/api';
|
||||
import { storyApi, authorApi } from '../../lib/api';
|
||||
|
||||
export default function AddStoryPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -25,8 +26,84 @@ export default function AddStoryPage() {
|
||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||
show: boolean;
|
||||
count: number;
|
||||
duplicates: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}>({ show: false, count: 0, duplicates: [] });
|
||||
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// Pre-fill author if authorId is provided in URL
|
||||
useEffect(() => {
|
||||
const authorId = searchParams.get('authorId');
|
||||
if (authorId) {
|
||||
const loadAuthor = async () => {
|
||||
try {
|
||||
const author = await authorApi.getAuthor(authorId);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
authorName: author.name
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load author:', error);
|
||||
}
|
||||
};
|
||||
loadAuthor();
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Check for duplicates when title and author are both present
|
||||
useEffect(() => {
|
||||
const checkDuplicates = async () => {
|
||||
const title = formData.title.trim();
|
||||
const authorName = formData.authorName.trim();
|
||||
|
||||
// Don't check if user isn't authenticated or if title/author are empty
|
||||
if (!isAuthenticated || !title || !authorName) {
|
||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the check to avoid too many API calls
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setCheckingDuplicates(true);
|
||||
const result = await storyApi.checkDuplicate(title, authorName);
|
||||
|
||||
if (result.hasDuplicates) {
|
||||
setDuplicateWarning({
|
||||
show: true,
|
||||
count: result.count,
|
||||
duplicates: result.duplicates
|
||||
});
|
||||
} else {
|
||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for duplicates:', error);
|
||||
// Clear any existing duplicate warnings on error
|
||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||
// Don't show error to user as this is just a helpful warning
|
||||
// Authentication errors will be handled by the API interceptor
|
||||
} finally {
|
||||
setCheckingDuplicates(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
checkDuplicates();
|
||||
}, [formData.title, formData.authorName, isAuthenticated]);
|
||||
|
||||
const handleInputChange = (field: string) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
@@ -150,6 +227,46 @@ export default function AddStoryPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Duplicate Warning */}
|
||||
{duplicateWarning.show && (
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
|
||||
⚠️
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Potential Duplicate Detected
|
||||
</h4>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{duplicateWarning.duplicates.map((duplicate, index) => (
|
||||
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
• <span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
|
||||
<span className="text-xs ml-2">
|
||||
(added {new Date(duplicate.createdAt).toLocaleDateString()})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
||||
You can still create this story if it's different from the existing ones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checking indicator */}
|
||||
{checkingDuplicates && (
|
||||
<div className="flex items-center gap-2 text-sm theme-text">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
||||
Checking for duplicates...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
|
||||
@@ -207,9 +207,14 @@ export default function AuthorDetailPage() {
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold theme-header">Stories</h2>
|
||||
<p className="theme-text">
|
||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="theme-text">
|
||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||
</p>
|
||||
<Button href={`/add-story?authorId=${authorId}`}>
|
||||
Add Story
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stories.length === 0 ? (
|
||||
|
||||
@@ -26,19 +26,27 @@ export default function CollectionsPage() {
|
||||
const [totalCollections, setTotalCollections] = useState(0);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Load tags for filtering
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const tagsResult = await tagApi.getTags({ page: 0, size: 1000 });
|
||||
setTags(tagsResult?.content || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract tags from current collection results with counts
|
||||
const extractTagsFromResults = (collections: Collection[]): Tag[] => {
|
||||
const tagCounts: { [key: string]: number } = {};
|
||||
|
||||
loadTags();
|
||||
}, []);
|
||||
collections.forEach(collection => {
|
||||
collection.tagNames?.forEach(tagName => {
|
||||
if (tagCounts[tagName]) {
|
||||
tagCounts[tagName]++;
|
||||
} else {
|
||||
tagCounts[tagName] = 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Object.entries(tagCounts).map(([tagName, count]) => ({
|
||||
id: tagName, // Use tag name as ID since we don't have actual IDs from search results
|
||||
name: tagName,
|
||||
collectionCount: count
|
||||
}));
|
||||
};
|
||||
|
||||
// Load collections with search and filters
|
||||
useEffect(() => {
|
||||
@@ -55,9 +63,14 @@ export default function CollectionsPage() {
|
||||
archived: showArchived,
|
||||
});
|
||||
|
||||
setCollections(result?.results || []);
|
||||
const currentCollections = result?.results || [];
|
||||
setCollections(currentCollections);
|
||||
setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize));
|
||||
setTotalCollections(result?.totalHits || 0);
|
||||
|
||||
// Always update tags based on current search results (including initial wildcard search)
|
||||
const resultTags = extractTagsFromResults(currentCollections);
|
||||
setTags(resultTags);
|
||||
} catch (error) {
|
||||
console.error('Failed to load collections:', error);
|
||||
setCollections([]);
|
||||
@@ -223,6 +236,7 @@ export default function CollectionsPage() {
|
||||
tags={tags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
showCollectionCount={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { searchApi, tagApi } from '../../lib/api';
|
||||
import { searchApi } from '../../lib/api';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
@@ -11,7 +11,7 @@ import TagFilter from '../../components/stories/TagFilter';
|
||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating';
|
||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
@@ -28,19 +28,27 @@ export default function LibraryPage() {
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
|
||||
// Load tags for filtering
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const tagsResult = await tagApi.getTags({ page: 0, size: 1000 });
|
||||
setTags(tagsResult?.content || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract tags from current search results with counts
|
||||
const extractTagsFromResults = (stories: Story[]): Tag[] => {
|
||||
const tagCounts: { [key: string]: number } = {};
|
||||
|
||||
loadTags();
|
||||
}, []);
|
||||
stories.forEach(story => {
|
||||
story.tagNames?.forEach(tagName => {
|
||||
if (tagCounts[tagName]) {
|
||||
tagCounts[tagName]++;
|
||||
} else {
|
||||
tagCounts[tagName] = 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Object.entries(tagCounts).map(([tagName, count]) => ({
|
||||
id: tagName, // Use tag name as ID since we don't have actual IDs from search results
|
||||
name: tagName,
|
||||
storyCount: count
|
||||
}));
|
||||
};
|
||||
|
||||
// Debounce search to avoid too many API calls
|
||||
useEffect(() => {
|
||||
@@ -59,9 +67,14 @@ export default function LibraryPage() {
|
||||
sortDir: sortDirection,
|
||||
});
|
||||
|
||||
setStories(result?.results || []);
|
||||
const currentStories = result?.results || [];
|
||||
setStories(currentStories);
|
||||
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
|
||||
setTotalElements(result?.totalHits || 0);
|
||||
|
||||
// Always update tags based on current search results (including initial wildcard search)
|
||||
const resultTags = extractTagsFromResults(currentStories);
|
||||
setTags(resultTags);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stories:', error);
|
||||
setStories([]);
|
||||
@@ -99,16 +112,21 @@ export default function LibraryPage() {
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortOption: SortOption) => {
|
||||
if (newSortOption === sortOption) {
|
||||
// Toggle direction if same option
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
setSortOption(newSortOption);
|
||||
// Set appropriate default direction for the sort option
|
||||
if (newSortOption === 'title' || newSortOption === 'authorName') {
|
||||
setSortDirection('asc'); // Alphabetical fields default to ascending
|
||||
} else {
|
||||
setSortOption(newSortOption);
|
||||
setSortDirection('desc'); // Default to desc for new sort option
|
||||
setSortDirection('desc'); // Numeric/date fields default to descending
|
||||
}
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const toggleSortDirection = () => {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
@@ -203,7 +221,18 @@ export default function LibraryPage() {
|
||||
<option value="title">Title</option>
|
||||
<option value="authorName">Author</option>
|
||||
<option value="rating">Rating</option>
|
||||
<option value="wordCount">Word Count</option>
|
||||
</select>
|
||||
|
||||
{/* Sort Direction Toggle */}
|
||||
<button
|
||||
onClick={toggleSortDirection}
|
||||
className="p-2 rounded-lg theme-card theme-text hover:bg-opacity-80 transition-colors border theme-border"
|
||||
title={`Sort ${sortDirection === 'asc' ? 'Ascending' : 'Descending'}`}
|
||||
aria-label={`Toggle sort direction - currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}`}
|
||||
>
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Settings {
|
||||
fontFamily: FontFamily;
|
||||
fontSize: FontSize;
|
||||
readingWidth: ReadingWidth;
|
||||
readingSpeed: number; // words per minute
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -22,6 +23,7 @@ const defaultSettings: Settings = {
|
||||
fontFamily: 'serif',
|
||||
fontSize: 'medium',
|
||||
readingWidth: 'medium',
|
||||
readingSpeed: 200,
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -288,6 +290,33 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Speed */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Reading Speed (words per minute)
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="400"
|
||||
step="25"
|
||||
value={settings.readingSpeed}
|
||||
onChange={(e) => updateSetting('readingSpeed', parseInt(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
<div className="min-w-[80px] text-center">
|
||||
<span className="text-lg font-medium theme-header">{settings.readingSpeed}</span>
|
||||
<div className="text-xs theme-text">WPM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs theme-text mt-1">
|
||||
<span>Slow (100)</span>
|
||||
<span>Average (200)</span>
|
||||
<span>Fast (400)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Story, Collection } from '../../../../types/api';
|
||||
import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import { calculateReadingTime } from '../../../../lib/settings';
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -73,9 +74,7 @@ export default function StoryDetailPage() {
|
||||
};
|
||||
|
||||
const estimateReadingTime = (wordCount: number) => {
|
||||
const wordsPerMinute = 200; // Average reading speed
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return minutes;
|
||||
return calculateReadingTime(wordCount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -23,6 +23,62 @@ export default function RichTextEditor({
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const visualDivRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||
|
||||
// Utility functions for cursor position preservation
|
||||
const saveCursorPosition = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const div = visualDivRef.current;
|
||||
if (!div) return null;
|
||||
|
||||
return {
|
||||
startContainer: range.startContainer,
|
||||
startOffset: range.startOffset,
|
||||
endContainer: range.endContainer,
|
||||
endOffset: range.endOffset
|
||||
};
|
||||
};
|
||||
|
||||
const restoreCursorPosition = (position: any) => {
|
||||
if (!position) return;
|
||||
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(position.startContainer, position.startOffset);
|
||||
range.setEnd(position.endContainer, position.endOffset);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.warn('Could not restore cursor position:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial content when component mounts
|
||||
useEffect(() => {
|
||||
const div = visualDivRef.current;
|
||||
if (div && div.innerHTML !== value) {
|
||||
div.innerHTML = value || '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update div content when value changes externally (not from user typing)
|
||||
useEffect(() => {
|
||||
const div = visualDivRef.current;
|
||||
if (div && !isUserTyping && div.innerHTML !== value) {
|
||||
const cursorPosition = saveCursorPosition();
|
||||
div.innerHTML = value || '';
|
||||
if (cursorPosition) {
|
||||
setTimeout(() => restoreCursorPosition(cursorPosition), 0);
|
||||
}
|
||||
}
|
||||
}, [value, isUserTyping]);
|
||||
|
||||
// Preload sanitization config
|
||||
useEffect(() => {
|
||||
@@ -38,8 +94,16 @@ export default function RichTextEditor({
|
||||
const div = visualDivRef.current;
|
||||
if (div) {
|
||||
const newHtml = div.innerHTML;
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
setIsUserTyping(true);
|
||||
|
||||
// Only call onChange if content actually changed
|
||||
if (newHtml !== value) {
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
}
|
||||
|
||||
// Reset typing state after a short delay
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,8 +219,10 @@ export default function RichTextEditor({
|
||||
}
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
} else if (textarea) {
|
||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||
const start = textarea.selectionStart;
|
||||
@@ -213,8 +279,10 @@ export default function RichTextEditor({
|
||||
visualDiv.innerHTML += textAsHtml;
|
||||
}
|
||||
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
}
|
||||
} else {
|
||||
console.log('No usable clipboard content found');
|
||||
@@ -229,8 +297,10 @@ export default function RichTextEditor({
|
||||
.filter(paragraph => paragraph.trim())
|
||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('\n');
|
||||
setIsUserTyping(true);
|
||||
onChange(value + textAsHtml);
|
||||
setHtmlValue(value + textAsHtml);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -293,8 +363,10 @@ export default function RichTextEditor({
|
||||
}
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
}
|
||||
} else {
|
||||
// HTML mode - existing logic with improvements
|
||||
@@ -434,16 +506,25 @@ export default function RichTextEditor({
|
||||
{/* Editor */}
|
||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<div
|
||||
ref={visualDivRef}
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
onPaste={handlePaste}
|
||||
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||
style={{ minHeight: '300px' }}
|
||||
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={visualDivRef}
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
onPaste={handlePaste}
|
||||
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||
style={{ minHeight: '300px' }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
{!value && (
|
||||
<div
|
||||
className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none"
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlValue}
|
||||
|
||||
@@ -6,17 +6,21 @@ interface TagFilterProps {
|
||||
tags: Tag[];
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tagName: string) => void;
|
||||
showCollectionCount?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) {
|
||||
export default function TagFilter({ tags, selectedTags, onTagToggle, showCollectionCount = false }: TagFilterProps) {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return null;
|
||||
|
||||
// Filter out tags with no stories, then sort by usage count (descending) and then alphabetically
|
||||
// Filter out tags with no count, then sort by usage count (descending) and then alphabetically
|
||||
const sortedTags = [...tags]
|
||||
.filter(tag => (tag.storyCount || 0) > 0)
|
||||
.filter(tag => {
|
||||
const count = showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0);
|
||||
return count > 0;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aCount = a.storyCount || 0;
|
||||
const bCount = b.storyCount || 0;
|
||||
const aCount = showCollectionCount ? (a.collectionCount || 0) : (a.storyCount || 0);
|
||||
const bCount = showCollectionCount ? (b.collectionCount || 0) : (b.storyCount || 0);
|
||||
if (bCount !== aCount) {
|
||||
return bCount - aCount;
|
||||
}
|
||||
@@ -40,7 +44,7 @@ export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilter
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount || 0})
|
||||
{tag.name} ({showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0)})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { authApi } from '../lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { authApi, setGlobalAuthFailureHandler } from '../lib/api';
|
||||
import { preloadSanitizationConfig } from '../lib/sanitization';
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -16,8 +17,18 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
// Handle authentication failures from API calls
|
||||
const handleAuthFailure = () => {
|
||||
console.log('Authentication token expired, logging out user');
|
||||
setIsAuthenticated(false);
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Register the auth failure handler for API interceptor
|
||||
setGlobalAuthFailureHandler(handleAuthFailure);
|
||||
// Check if user is already authenticated on app load
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
@@ -42,7 +53,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
checkAuth();
|
||||
loadSanitizationConfig();
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
const login = async (password: string) => {
|
||||
try {
|
||||
@@ -57,6 +68,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const logout = () => {
|
||||
authApi.logout();
|
||||
setIsAuthenticated(false);
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,15 +21,36 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Global auth failure handler - can be set by AuthContext
|
||||
let globalAuthFailureHandler: (() => void) | null = null;
|
||||
|
||||
export const setGlobalAuthFailureHandler = (handler: () => void) => {
|
||||
globalAuthFailureHandler = handler;
|
||||
};
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear invalid token and redirect to login
|
||||
// Handle authentication failures
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
console.warn('Authentication failed, token may be expired or invalid');
|
||||
|
||||
// Clear invalid token
|
||||
localStorage.removeItem('auth-token');
|
||||
window.location.href = '/login';
|
||||
|
||||
// Use global handler if available (from AuthContext), otherwise fallback to direct redirect
|
||||
if (globalAuthFailureHandler) {
|
||||
globalAuthFailureHandler();
|
||||
} else {
|
||||
// Fallback for cases where AuthContext isn't available
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Return a more specific error for components to handle gracefully
|
||||
return Promise.reject(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -150,6 +171,22 @@ export const storyApi = {
|
||||
const response = await api.post('/stories/recreate-typesense-collection');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
checkDuplicate: async (title: string, authorName: string): Promise<{
|
||||
hasDuplicates: boolean;
|
||||
count: number;
|
||||
duplicates: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await api.get('/stories/check-duplicate', {
|
||||
params: { title, authorName }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Author endpoints
|
||||
@@ -240,6 +277,11 @@ export const tagApi = {
|
||||
// Backend returns TagDto[], extract just the names
|
||||
return response.data.map((tag: Tag) => tag.name);
|
||||
},
|
||||
|
||||
getCollectionTags: async (): Promise<Tag[]> => {
|
||||
const response = await api.get('/tags/collections');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Series endpoints
|
||||
|
||||
33
frontend/src/lib/settings.ts
Normal file
33
frontend/src/lib/settings.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
interface Settings {
|
||||
theme: 'light' | 'dark';
|
||||
fontFamily: 'serif' | 'sans' | 'mono';
|
||||
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
|
||||
readingWidth: 'narrow' | 'medium' | 'wide';
|
||||
readingSpeed: number; // words per minute
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
theme: 'light',
|
||||
fontFamily: 'serif',
|
||||
fontSize: 'medium',
|
||||
readingWidth: 'medium',
|
||||
readingSpeed: 200,
|
||||
};
|
||||
|
||||
export const getReadingSpeed = (): number => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('storycove-settings');
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
return parsed.readingSpeed || defaultSettings.readingSpeed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved settings:', error);
|
||||
}
|
||||
return defaultSettings.readingSpeed;
|
||||
};
|
||||
|
||||
export const calculateReadingTime = (wordCount: number): number => {
|
||||
const wordsPerMinute = getReadingSpeed();
|
||||
return Math.max(1, Math.round(wordCount / wordsPerMinute));
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export interface Story {
|
||||
rating?: number;
|
||||
coverPath?: string;
|
||||
tags: Tag[];
|
||||
tagNames?: string[] | null; // Used in search results
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -41,6 +42,7 @@ export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
storyCount?: number;
|
||||
collectionCount?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -85,6 +87,7 @@ export interface Collection {
|
||||
coverImagePath?: string;
|
||||
isArchived: boolean;
|
||||
tags: Tag[];
|
||||
tagNames?: string[] | null; // Used in search results
|
||||
collectionStories?: CollectionStory[];
|
||||
stories?: CollectionStory[]; // For compatibility
|
||||
storyCount: number;
|
||||
|
||||
Reference in New Issue
Block a user