Various Fixes and QoL enhancements.

This commit is contained in:
Stefan Hardegger
2025-07-26 12:05:54 +02:00
parent 5e8164c6a4
commit f95d7aa8bb
32 changed files with 758 additions and 136 deletions

View File

@@ -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">

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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) {