Changing Authors
This commit is contained in:
@@ -105,6 +105,14 @@ public class StoryController {
|
||||
public ResponseEntity<StoryDto> updateStory(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateStoryRequest request) {
|
||||
logger.info("Updating story: {} (ID: {})", request.getTitle(), id);
|
||||
|
||||
// Handle author creation/lookup at controller level before calling service
|
||||
if (request.getAuthorName() != null && !request.getAuthorName().trim().isEmpty() && request.getAuthorId() == null) {
|
||||
Author author = findOrCreateAuthor(request.getAuthorName().trim());
|
||||
request.setAuthorId(author.getId());
|
||||
request.setAuthorName(null); // Clear author name since we now have the ID
|
||||
}
|
||||
|
||||
Story updatedStory = storyService.updateWithTagNames(id, request);
|
||||
logger.info("Successfully updated story: {}", updatedStory.getTitle());
|
||||
return ResponseEntity.ok(convertToDto(updatedStory));
|
||||
@@ -389,9 +397,13 @@ public class StoryController {
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
}
|
||||
// Handle author - either by ID or by name
|
||||
if (updateReq.getAuthorId() != null) {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
story.setAuthor(author);
|
||||
} else if (updateReq.getAuthorName() != null && !updateReq.getAuthorName().trim().isEmpty()) {
|
||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
if (updateReq.getSeriesId() != null) {
|
||||
@@ -697,6 +709,7 @@ public class StoryController {
|
||||
private String sourceUrl;
|
||||
private Integer volume;
|
||||
private UUID authorId;
|
||||
private String authorName;
|
||||
private UUID seriesId;
|
||||
private String seriesName;
|
||||
private List<String> tagNames;
|
||||
@@ -716,6 +729,8 @@ public class StoryController {
|
||||
public void setVolume(Integer volume) { this.volume = volume; }
|
||||
public UUID getAuthorId() { return authorId; }
|
||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||
public String getAuthorName() { return authorName; }
|
||||
public void setAuthorName(String authorName) { this.authorName = authorName; }
|
||||
public UUID getSeriesId() { return seriesId; }
|
||||
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
||||
public String getSeriesName() { return seriesName; }
|
||||
|
||||
@@ -610,6 +610,7 @@ public class StoryService {
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
}
|
||||
// Handle author - either by ID or by name
|
||||
if (updateReq.getAuthorId() != null) {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
story.setAuthor(author);
|
||||
@@ -620,7 +621,7 @@ public class StoryService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void updateStoryTagsByNames(Story story, java.util.List<String> tagNames) {
|
||||
// Clear existing tags first
|
||||
Set<Tag> existingTags = new HashSet<>(story.getTags());
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 AuthorSelector from '../../components/stories/AuthorSelector';
|
||||
import { storyApi, authorApi } from '../../lib/api';
|
||||
|
||||
export default function AddStoryPage() {
|
||||
@@ -19,6 +20,7 @@ export default function AddStoryPage() {
|
||||
title: '',
|
||||
summary: '',
|
||||
authorName: '',
|
||||
authorId: undefined as string | undefined,
|
||||
contentHtml: '',
|
||||
sourceUrl: '',
|
||||
tags: [] as string[],
|
||||
@@ -62,7 +64,8 @@ export default function AddStoryPage() {
|
||||
const author = await authorApi.getAuthor(authorId);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
authorName: author.name
|
||||
authorName: author.name,
|
||||
authorId: author.id
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load author:', error);
|
||||
@@ -84,6 +87,7 @@ export default function AddStoryPage() {
|
||||
...prev,
|
||||
title: storyData.title || '',
|
||||
authorName: storyData.author || '',
|
||||
authorId: undefined, // Reset author ID for bulk combined stories
|
||||
contentHtml: storyData.content || '',
|
||||
sourceUrl: storyData.sourceUrl || '',
|
||||
summary: storyData.summary || '',
|
||||
@@ -167,6 +171,19 @@ export default function AddStoryPage() {
|
||||
setFormData(prev => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
authorName,
|
||||
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
||||
}));
|
||||
|
||||
// Clear error when user changes author
|
||||
if (errors.authorName) {
|
||||
setErrors(prev => ({ ...prev, authorName: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFromUrl = async () => {
|
||||
if (!importUrl.trim()) {
|
||||
setErrors({ importUrl: 'URL is required' });
|
||||
@@ -197,6 +214,7 @@ export default function AddStoryPage() {
|
||||
title: scrapedStory.title || '',
|
||||
summary: scrapedStory.summary || '',
|
||||
authorName: scrapedStory.author || '',
|
||||
authorId: undefined, // Reset author ID when importing from URL (likely new author)
|
||||
contentHtml: scrapedStory.content || '',
|
||||
sourceUrl: scrapedStory.sourceUrl || importUrl,
|
||||
tags: scrapedStory.tags || [],
|
||||
@@ -263,7 +281,8 @@ export default function AddStoryPage() {
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
seriesName: formData.seriesName || undefined,
|
||||
authorName: formData.authorName || undefined,
|
||||
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||
};
|
||||
|
||||
@@ -358,12 +377,12 @@ export default function AddStoryPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Author */}
|
||||
<Input
|
||||
{/* Author Selector */}
|
||||
<AuthorSelector
|
||||
label="Author *"
|
||||
value={formData.authorName}
|
||||
onChange={handleInputChange('authorName')}
|
||||
placeholder="Enter the author's name"
|
||||
onChange={handleAuthorChange}
|
||||
placeholder="Select or enter author name"
|
||||
error={errors.authorName}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import { storyApi } from '../../../../lib/api';
|
||||
import { Story } from '../../../../types/api';
|
||||
@@ -26,6 +27,7 @@ export default function EditStoryPage() {
|
||||
title: '',
|
||||
summary: '',
|
||||
authorName: '',
|
||||
authorId: undefined as string | undefined,
|
||||
contentHtml: '',
|
||||
sourceUrl: '',
|
||||
tags: [] as string[],
|
||||
@@ -47,6 +49,7 @@ export default function EditStoryPage() {
|
||||
title: storyData.title,
|
||||
summary: storyData.summary || '',
|
||||
authorName: storyData.authorName,
|
||||
authorId: storyData.authorId,
|
||||
contentHtml: storyData.contentHtml,
|
||||
sourceUrl: storyData.sourceUrl || '',
|
||||
tags: storyData.tags?.map(tag => tag.name) || [],
|
||||
@@ -91,6 +94,19 @@ export default function EditStoryPage() {
|
||||
setFormData(prev => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
authorName,
|
||||
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
||||
}));
|
||||
|
||||
// Clear error when user changes author
|
||||
if (errors.authorName) {
|
||||
setErrors(prev => ({ ...prev, authorName: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@@ -136,7 +152,8 @@ export default function EditStoryPage() {
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
seriesName: formData.seriesName || undefined,
|
||||
authorId: story.authorId, // Keep existing author ID
|
||||
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
tagNames: formData.tags,
|
||||
};
|
||||
|
||||
@@ -216,18 +233,15 @@ export default function EditStoryPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Author - Display only, not editable in edit mode for simplicity */}
|
||||
<Input
|
||||
{/* Author Selector */}
|
||||
<AuthorSelector
|
||||
label="Author *"
|
||||
value={formData.authorName}
|
||||
onChange={handleInputChange('authorName')}
|
||||
placeholder="Enter the author's name"
|
||||
onChange={handleAuthorChange}
|
||||
placeholder="Select or enter author name"
|
||||
error={errors.authorName}
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
Author changes should be done through Author management
|
||||
</p>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
|
||||
231
frontend/src/components/stories/AuthorSelector.tsx
Normal file
231
frontend/src/components/stories/AuthorSelector.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { authorApi } from '../../lib/api';
|
||||
import { Author } from '../../types/api';
|
||||
|
||||
interface AuthorSelectorProps {
|
||||
value: string;
|
||||
onChange: (authorName: string, authorId?: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function AuthorSelector({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Enter or select an author',
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
label = 'Author'
|
||||
}: AuthorSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load authors when component mounts or when dropdown opens
|
||||
useEffect(() => {
|
||||
const loadAuthors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await authorApi.getAuthors({ page: 0, size: 100 }); // Get first 100 authors
|
||||
setAuthors(result.content);
|
||||
} catch (error) {
|
||||
console.error('Failed to load authors:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && authors.length === 0) {
|
||||
loadAuthors();
|
||||
}
|
||||
}, [isOpen, authors.length]);
|
||||
|
||||
// Filter authors based on input value
|
||||
useEffect(() => {
|
||||
if (!inputValue.trim()) {
|
||||
setFilteredAuthors(authors);
|
||||
} else {
|
||||
const filtered = authors.filter(author =>
|
||||
author.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
setFilteredAuthors(filtered);
|
||||
}
|
||||
}, [inputValue, authors]);
|
||||
|
||||
// Update input value when prop value changes
|
||||
useEffect(() => {
|
||||
if (value !== inputValue) {
|
||||
setInputValue(value || '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Handle clicking outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
setIsOpen(true);
|
||||
|
||||
// Call onChange for free-form text entry (new author)
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleAuthorSelect = (author: Author) => {
|
||||
setInputValue(author.name);
|
||||
setIsOpen(false);
|
||||
onChange(author.name, author.id);
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'ArrowDown' && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
{label}{required && ' *'}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:border-transparent transition-colors ${
|
||||
error
|
||||
? 'border-red-300 focus:ring-red-500 theme-error'
|
||||
: 'theme-border focus:ring-theme-accent focus:theme-accent-border'
|
||||
} ${disabled ? 'theme-disabled' : 'theme-input'}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
/>
|
||||
|
||||
{/* Dropdown arrow */}
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg
|
||||
className={`w-4 h-4 theme-text transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 theme-card theme-shadow border theme-border rounded-md max-h-60 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="px-3 py-2 text-sm theme-text text-center">
|
||||
Loading authors...
|
||||
</div>
|
||||
) : filteredAuthors.length > 0 ? (
|
||||
<>
|
||||
{/* Existing authors */}
|
||||
<div className="py-1">
|
||||
{filteredAuthors.map((author) => (
|
||||
<button
|
||||
key={author.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors flex items-center justify-between"
|
||||
onClick={() => handleAuthorSelect(author)}
|
||||
>
|
||||
<span>{author.name}</span>
|
||||
<span className="text-xs theme-text-muted">
|
||||
{author.storyCount} {author.storyCount === 1 ? 'story' : 'stories'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New author option if input doesn't match exactly */}
|
||||
{inputValue.trim() && !filteredAuthors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && (
|
||||
<>
|
||||
<div className="border-t theme-border"></div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(inputValue.trim());
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">Create new author:</span> "{inputValue.trim()}"
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : inputValue.trim() ? (
|
||||
/* No matches, show create new option */
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(inputValue.trim());
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">Create new author:</span> "{inputValue.trim()}"
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* No authors loaded or empty input */
|
||||
<div className="px-3 py-2 text-sm theme-text-muted text-center">
|
||||
{authors.length === 0 ? 'No authors yet' : 'Type to search or create new author'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user