Image Handling

This commit is contained in:
Stefan Hardegger
2025-10-09 14:39:55 +02:00
parent 4e02cd8eaa
commit 20d0652c85
6 changed files with 390 additions and 244 deletions

View File

@@ -20,6 +20,9 @@ public class AsyncImageProcessingService {
private final StoryService storyService;
private final ImageProcessingProgressService progressService;
@org.springframework.beans.factory.annotation.Value("${storycove.app.public-url:http://localhost:6925}")
private String publicUrl;
@Autowired
public AsyncImageProcessingService(ImageService imageService,
StoryService storyService,
@@ -103,10 +106,54 @@ public class AsyncImageProcessingService {
return count;
}
/**
* Check if a URL is external (not from this application).
* Returns true if the URL should be downloaded, false if it's already local.
*/
private boolean isExternalUrl(String url) {
return url != null &&
(url.startsWith("http://") || url.startsWith("https://")) &&
!url.contains("/api/files/images/");
if (url == null || url.trim().isEmpty()) {
return false;
}
// Skip data URLs
if (url.startsWith("data:")) {
return false;
}
// Skip relative URLs (local paths)
if (url.startsWith("/")) {
return false;
}
// Skip URLs that are already pointing to our API
if (url.contains("/api/files/images/")) {
return false;
}
// Check if URL starts with the public URL (our own domain)
if (publicUrl != null && !publicUrl.trim().isEmpty()) {
String normalizedUrl = url.trim().toLowerCase();
String normalizedPublicUrl = publicUrl.trim().toLowerCase();
// Remove trailing slash from public URL for comparison
if (normalizedPublicUrl.endsWith("/")) {
normalizedPublicUrl = normalizedPublicUrl.substring(0, normalizedPublicUrl.length() - 1);
}
if (normalizedUrl.startsWith(normalizedPublicUrl)) {
logger.debug("URL is from this application (matches publicUrl): {}", url);
return false;
}
}
// If it's an HTTP(S) URL that didn't match our filters, it's external
if (url.startsWith("http://") || url.startsWith("https://")) {
logger.debug("URL is external: {}", url);
return true;
}
// For any other format, consider it non-external (safer default)
return false;
}
private ImageService.ContentImageProcessingResult processImagesWithProgress(

View File

@@ -70,6 +70,9 @@ public class ImageService {
@Value("${storycove.images.max-file-size:5242880}") // 5MB default
private long maxFileSize;
@Value("${storycove.app.public-url:http://localhost:6925}")
private String publicUrl;
public enum ImageType {
COVER("covers"),
AVATAR("avatars"),
@@ -286,9 +289,9 @@ public class ImageService {
logger.debug("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
try {
// Skip if it's already a local path or data URL
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
logger.debug("Skipping local/data URL: {}", imageUrl);
// Skip if it's already a local path, data URL, or from this application
if (!isExternalUrl(imageUrl)) {
logger.debug("Skipping local/internal URL: {}", imageUrl);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
continue;
}
@@ -366,7 +369,7 @@ public class ImageService {
Matcher countMatcher = imgPattern.matcher(htmlContent);
while (countMatcher.find()) {
String imageUrl = countMatcher.group(1);
if (!imageUrl.startsWith("/") && !imageUrl.startsWith("data:")) {
if (isExternalUrl(imageUrl)) {
externalImages.add(imageUrl);
}
}
@@ -384,9 +387,9 @@ public class ImageService {
logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag);
try {
// Skip if it's already a local path or data URL
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
logger.debug("Skipping local/data URL: {}", imageUrl);
// Skip if it's already a local path, data URL, or from this application
if (!isExternalUrl(imageUrl)) {
logger.debug("Skipping local/internal URL: {}", imageUrl);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
continue;
}
@@ -429,6 +432,56 @@ public class ImageService {
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Check if a URL is external (not from this application).
* Returns true if the URL should be downloaded, false if it's already local.
*/
private boolean isExternalUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
// Skip data URLs
if (url.startsWith("data:")) {
return false;
}
// Skip relative URLs (local paths)
if (url.startsWith("/")) {
return false;
}
// Skip URLs that are already pointing to our API
if (url.contains("/api/files/images/")) {
return false;
}
// Check if URL starts with the public URL (our own domain)
if (publicUrl != null && !publicUrl.trim().isEmpty()) {
String normalizedUrl = url.trim().toLowerCase();
String normalizedPublicUrl = publicUrl.trim().toLowerCase();
// Remove trailing slash from public URL for comparison
if (normalizedPublicUrl.endsWith("/")) {
normalizedPublicUrl = normalizedPublicUrl.substring(0, normalizedPublicUrl.length() - 1);
}
if (normalizedUrl.startsWith(normalizedPublicUrl)) {
logger.debug("URL is from this application (matches publicUrl): {}", url);
return false;
}
}
// If it's an HTTP(S) URL that didn't match our filters, it's external
if (url.startsWith("http://") || url.startsWith("https://")) {
logger.debug("URL is external: {}", url);
return true;
}
// For any other format, consider it non-external (safer default)
return false;
}
/**
* Download an image from a URL and store it locally
*/

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { authorApi } from '../../lib/api';
import { Author } from '../../types/api';
@@ -25,50 +25,64 @@ export default function AuthorSelector({
}: 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);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(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
// Search authors dynamically based on input
const searchAuthors = useCallback(async (query: string) => {
try {
setLoading(true);
if (!query.trim()) {
// If empty query, load recent/popular authors
const result = await authorApi.getAuthors({ page: 0, size: 20, sortBy: 'name', sortDir: 'asc' });
setAuthors(result.content);
} catch (error) {
console.error('Failed to load authors:', error);
} finally {
setLoading(false);
} else {
// Search by name
const result = await authorApi.searchAuthorsByName(query, { page: 0, size: 20 });
setAuthors(result.content);
}
} catch (error) {
console.error('Failed to search authors:', error);
setAuthors([]);
} finally {
setLoading(false);
}
}, []);
// Debounced search effect
useEffect(() => {
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Only search when dropdown is open
if (isOpen) {
// Set new timer for debounced search
debounceTimerRef.current = setTimeout(() => {
searchAuthors(inputValue);
}, 300); // 300ms debounce delay
}
// Cleanup timer on unmount
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
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]);
}, [inputValue, isOpen, searchAuthors]);
// Update input value when prop value changes
useEffect(() => {
if (value !== inputValue) {
setInputValue(value || '');
}
}, [value]);
}, [value, inputValue]);
// Handle clicking outside to close dropdown
useEffect(() => {
@@ -158,13 +172,13 @@ export default function AuthorSelector({
<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...
Searching authors...
</div>
) : filteredAuthors.length > 0 ? (
) : authors.length > 0 ? (
<>
{/* Existing authors */}
{/* Search results */}
<div className="py-1">
{filteredAuthors.map((author) => (
{authors.map((author) => (
<button
key={author.id}
type="button"
@@ -180,7 +194,7 @@ export default function AuthorSelector({
</div>
{/* New author option if input doesn't match exactly */}
{inputValue.trim() && !filteredAuthors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && (
{inputValue.trim() && !authors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && (
<>
<div className="border-t theme-border"></div>
<div className="py-1">
@@ -213,9 +227,9 @@ export default function AuthorSelector({
</button>
</div>
) : (
/* No authors loaded or empty input */
/* Empty state - show prompt */
<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'}
Type to search for authors or create a new one
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { seriesApi, storyApi } from '../../lib/api';
import { useState, useEffect, useRef, useCallback } from 'react';
import { seriesApi } from '../../lib/api';
import { Series } from '../../types/api';
interface SeriesSelectorProps {
@@ -27,97 +27,63 @@ export default function SeriesSelector({
}: SeriesSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [series, setSeries] = useState<Series[]>([]);
const [filteredSeries, setFilteredSeries] = useState<Series[]>([]);
const [loading, setLoading] = useState(false);
const [inputValue, setInputValue] = useState(value || '');
const [authorSeriesMap, setAuthorSeriesMap] = useState<Record<string, string[]>>({});
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Load series and author-series mappings when component mounts or when dropdown opens
// Search series dynamically based on input
const searchSeries = useCallback(async (query: string) => {
try {
setLoading(true);
if (!query.trim()) {
// If empty query, load recent/popular series
const result = await seriesApi.getSeries({ page: 0, size: 20, sortBy: 'name', sortDir: 'asc' });
setSeries(result.content);
} else {
// Search by name
const result = await seriesApi.searchSeriesByName(query, { page: 0, size: 20 });
setSeries(result.content);
}
} catch (error) {
console.error('Failed to search series:', error);
setSeries([]);
} finally {
setLoading(false);
}
}, []);
// Debounced search effect
useEffect(() => {
const loadSeriesData = async () => {
try {
setLoading(true);
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Load all series
const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series
setSeries(seriesResult.content);
// Only search when dropdown is open
if (isOpen) {
// Set new timer for debounced search
debounceTimerRef.current = setTimeout(() => {
searchSeries(inputValue);
}, 300); // 300ms debounce delay
}
// Load some recent stories to build author-series mapping
// This gives us a sample of which authors have written in which series
try {
const storiesResult = await storyApi.getStories({ page: 0, size: 200 }); // Get recent stories
const newAuthorSeriesMap: Record<string, string[]> = {};
storiesResult.content.forEach(story => {
if (story.authorId && story.seriesName) {
if (!newAuthorSeriesMap[story.authorId]) {
newAuthorSeriesMap[story.authorId] = [];
}
if (!newAuthorSeriesMap[story.authorId].includes(story.seriesName)) {
newAuthorSeriesMap[story.authorId].push(story.seriesName);
}
}
});
setAuthorSeriesMap(newAuthorSeriesMap);
} catch (error) {
console.error('Failed to load author-series mapping:', error);
// Continue without author prioritization if this fails
}
} catch (error) {
console.error('Failed to load series:', error);
} finally {
setLoading(false);
// Cleanup timer on unmount
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
if (isOpen && series.length === 0) {
loadSeriesData();
}
}, [isOpen, series.length]);
}, [inputValue, isOpen, searchSeries]);
// Update internal value when prop changes
useEffect(() => {
setInputValue(value || '');
}, [value]);
// Filter and sort series based on input and author priority
useEffect(() => {
let filtered: Series[];
if (!inputValue.trim()) {
filtered = [...series];
} else {
filtered = series.filter(s =>
s.name.toLowerCase().includes(inputValue.toLowerCase())
);
}
// Sort series: prioritize those from the current author if authorId is provided
if (authorId && authorSeriesMap[authorId]) {
const authorSeriesNames = authorSeriesMap[authorId];
filtered.sort((a, b) => {
const aIsAuthorSeries = authorSeriesNames.includes(a.name);
const bIsAuthorSeries = authorSeriesNames.includes(b.name);
if (aIsAuthorSeries && !bIsAuthorSeries) return -1; // a first
if (!aIsAuthorSeries && bIsAuthorSeries) return 1; // b first
// If both or neither are author series, sort alphabetically
return a.name.localeCompare(b.name);
});
} else {
// No author prioritization, just sort alphabetically
filtered.sort((a, b) => a.name.localeCompare(b.name));
}
setFilteredSeries(filtered);
}, [inputValue, series, authorId, authorSeriesMap]);
// Handle clicks outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -215,39 +181,26 @@ export default function SeriesSelector({
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border theme-border rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
{loading ? (
<div className="px-3 py-2 text-sm theme-text">Loading series...</div>
<div className="px-3 py-2 text-sm theme-text">Searching series...</div>
) : (
<>
{filteredSeries.length > 0 ? (
filteredSeries.map((s) => {
const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name);
return (
<button
key={s.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 ${
isAuthorSeries ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-400' : ''
}`}
onClick={() => handleSeriesSelect(s)}
>
<div className="flex items-center gap-2">
<span>{s.name}</span>
{isAuthorSeries && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-100">
Author
</span>
)}
</div>
<span className="text-xs text-gray-500">
{s.storyCount} {s.storyCount === 1 ? 'story' : 'stories'}
</span>
</button>
);
})
{series.length > 0 ? (
series.map((s) => (
<button
key={s.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={() => handleSeriesSelect(s)}
>
<span>{s.name}</span>
<span className="text-xs text-gray-500">
{s.storyCount} {s.storyCount === 1 ? 'story' : 'stories'}
</span>
</button>
))
) : (
<>
{inputValue.trim() && (
{inputValue.trim() ? (
<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"
@@ -258,17 +211,16 @@ export default function SeriesSelector({
>
Create new series: &quot;{inputValue.trim()}&quot;
</button>
)}
{!inputValue.trim() && (
<div className="px-3 py-2 text-sm text-gray-500">No series found</div>
) : (
<div className="px-3 py-2 text-sm text-gray-500">Type to search for series or create a new one</div>
)}
</>
)}
{inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
{inputValue.trim() && !series.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
<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"
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors border-t theme-border"
onClick={() => {
setIsOpen(false);
onChange(inputValue.trim());

View File

@@ -35,12 +35,11 @@ interface SlateEditorProps {
// Custom types for our editor
type CustomElement = {
type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'blockquote' | 'image' | 'code-block';
type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'image';
children: CustomText[];
src?: string; // for images
alt?: string; // for images
caption?: string; // for images
language?: string; // for code blocks
};
type CustomText = {
@@ -49,7 +48,6 @@ type CustomText = {
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: boolean;
};
declare module 'slate' {
@@ -100,12 +98,19 @@ const htmlToSlate = (html: string): Descendant[] => {
});
break;
case 'blockquote':
results.push({
type: 'blockquote',
children: [{ text: element.textContent || '' }]
});
case 'pre':
case 'code': {
// Filter out blockquotes, code blocks, and code - convert to paragraph
const text = element.textContent || '';
if (text.trim()) {
results.push({
type: 'paragraph',
children: [{ text: text.trim() }]
});
}
break;
case 'img':
}
case 'img': {
const img = element as HTMLImageElement;
results.push({
type: 'image',
@@ -115,18 +120,9 @@ const htmlToSlate = (html: string): Descendant[] => {
children: [{ text: '' }] // Images need children in Slate
});
break;
case 'pre':
const codeEl = element.querySelector('code');
const code = codeEl ? codeEl.textContent || '' : element.textContent || '';
const language = codeEl?.className?.replace('language-', '') || '';
results.push({
type: 'code-block',
language,
children: [{ text: code }]
});
break;
}
case 'p':
case 'div':
case 'div': {
// Check if this paragraph contains mixed content (text + images)
if (element.querySelector('img')) {
// Process mixed content - handle both text and images in order
@@ -141,6 +137,7 @@ const htmlToSlate = (html: string): Descendant[] => {
}
}
break;
}
case 'br':
// Handle line breaks by creating empty paragraphs
results.push({
@@ -148,7 +145,7 @@ const htmlToSlate = (html: string): Descendant[] => {
children: [{ text: '' }]
});
break;
default:
default: {
// For other elements, try to extract text or recurse
const text = element.textContent || '';
if (text.trim()) {
@@ -158,6 +155,7 @@ const htmlToSlate = (html: string): Descendant[] => {
});
}
break;
}
}
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
@@ -210,9 +208,6 @@ const slateToHtml = (nodes: Descendant[]): string => {
case 'heading-three':
htmlParts.push(`<h3>${text}</h3>`);
break;
case 'blockquote':
htmlParts.push(`<blockquote>${text}</blockquote>`);
break;
case 'image':
const attrs: string[] = [];
if (element.src) attrs.push(`src="${element.src}"`);
@@ -220,16 +215,6 @@ const slateToHtml = (nodes: Descendant[]): string => {
if (element.caption) attrs.push(`title="${element.caption}"`);
htmlParts.push(`<img ${attrs.join(' ')} />`);
break;
case 'code-block':
const langClass = element.language ? ` class="language-${element.language}"` : '';
const escapedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
htmlParts.push(`<pre><code${langClass}>${escapedText}</code></pre>`);
break;
case 'paragraph':
default:
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
@@ -500,8 +485,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
return <h2 {...attributes} className="text-2xl font-bold mb-3">{children}</h2>;
case 'heading-three':
return <h3 {...attributes} className="text-xl font-bold mb-3">{children}</h3>;
case 'blockquote':
return <blockquote {...attributes} className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
case 'image':
return (
<ImageElement
@@ -510,12 +493,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
children={children}
/>
);
case 'code-block':
return (
<pre {...attributes} className="my-4 p-3 bg-gray-100 rounded-lg overflow-x-auto">
<code className="text-sm font-mono">{children}</code>
</pre>
);
default:
return <p {...attributes} className="mb-2">{children}</p>;
}
@@ -541,16 +518,12 @@ const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
children = <s>{children}</s>;
}
if (customLeaf.code) {
children = <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
}
return <span {...attributes}>{children}</span>;
};
// Toolbar component
const Toolbar = ({ editor }: { editor: ReactEditor }) => {
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code';
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough';
const isMarkActive = (format: MarkFormat) => {
const marks = Editor.marks(editor);
@@ -627,7 +600,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('paragraph')}
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
title="Normal paragraph"
title="Normal paragraph (Ctrl+Shift+0)"
>
P
</Button>
@@ -637,7 +610,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-one')}
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 1"
title="Heading 1 (Ctrl+Shift+1)"
>
H1
</Button>
@@ -647,7 +620,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-two')}
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 2"
title="Heading 2 (Ctrl+Shift+2)"
>
H2
</Button>
@@ -657,7 +630,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-three')}
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 3"
title="Heading 3 (Ctrl+Shift+3)"
>
H3
</Button>
@@ -691,7 +664,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleMark('underline')}
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
title="Underline"
title="Underline (Ctrl+U)"
>
U
</Button>
@@ -701,7 +674,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleMark('strikethrough')}
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
title="Strike-through"
title="Strikethrough (Ctrl+D)"
>
S
</Button>
@@ -826,49 +799,126 @@ export default function SlateEditor({
// Handle keyboard shortcuts
if (!event.ctrlKey && !event.metaKey) return;
// Helper function to toggle marks
const toggleMarkShortcut = (format: 'bold' | 'italic' | 'underline' | 'strikethrough') => {
event.preventDefault();
const marks = Editor.marks(editor);
const isActive = marks ? marks[format] === true : false;
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
// Helper function to toggle blocks
const toggleBlockShortcut = (format: CustomElement['type']) => {
event.preventDefault();
const isActive = isBlockActive(format);
Transforms.setNodes(
editor,
{ type: isActive ? 'paragraph' : format },
{ match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) }
);
};
// Check if block is active
const isBlockActive = (format: CustomElement['type']) => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format,
})
);
return !!match;
};
switch (event.key) {
case 'b': {
event.preventDefault();
const marks = Editor.marks(editor);
const isActive = marks ? marks.bold === true : false;
if (isActive) {
Editor.removeMark(editor, 'bold');
} else {
Editor.addMark(editor, 'bold', true);
// Text formatting shortcuts
case 'b':
toggleMarkShortcut('bold');
break;
case 'i':
toggleMarkShortcut('italic');
break;
case 'u':
toggleMarkShortcut('underline');
break;
case 'd':
// Ctrl+D for strikethrough
toggleMarkShortcut('strikethrough');
break;
// Block formatting shortcuts
case '1':
if (event.shiftKey) {
// Ctrl+Shift+1 for H1
toggleBlockShortcut('heading-one');
}
break;
}
case 'i': {
event.preventDefault();
const marks = Editor.marks(editor);
const isActive = marks ? marks.italic === true : false;
if (isActive) {
Editor.removeMark(editor, 'italic');
} else {
Editor.addMark(editor, 'italic', true);
case '2':
if (event.shiftKey) {
// Ctrl+Shift+2 for H2
toggleBlockShortcut('heading-two');
}
break;
}
case 'a': {
// Handle Ctrl+A / Cmd+A to select all
case '3':
if (event.shiftKey) {
// Ctrl+Shift+3 for H3
toggleBlockShortcut('heading-three');
}
break;
case '0':
if (event.shiftKey) {
// Ctrl+Shift+0 for normal paragraph
toggleBlockShortcut('paragraph');
}
break;
// Select all
case 'a':
event.preventDefault();
Transforms.select(editor, {
anchor: Editor.start(editor, []),
focus: Editor.end(editor, []),
});
break;
}
}
}}
/>
</div>
<div className="flex justify-between items-center">
<div className="text-xs theme-text">
<div className="text-xs theme-text space-y-1">
<p>
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
</p>
<details className="text-xs">
<summary className="cursor-pointer hover:theme-accent-text font-medium"> Keyboard Shortcuts</summary>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 p-2 theme-card border theme-border rounded">
<div>
<p className="font-semibold mb-1">Text Formatting:</p>
<ul className="space-y-0.5">
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+B</kbd> Bold</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+I</kbd> Italic</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+U</kbd> Underline</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+D</kbd> Strikethrough</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">Block Formatting:</p>
<ul className="space-y-0.5">
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+0</kbd> Paragraph</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+1</kbd> Heading 1</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+2</kbd> Heading 2</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+3</kbd> Heading 3</li>
</ul>
</div>
</div>
</details>
</div>
<Button

View File

@@ -371,6 +371,21 @@ export const authorApi = {
return response.data;
},
// Simple name-based search (faster for autocomplete)
searchAuthorsByName: async (query: string, params?: {
page?: number;
size?: number;
}): Promise<PagedResult<Author>> => {
const response = await api.get('/authors/search', {
params: {
query,
page: params?.page ?? 0,
size: params?.size ?? 20,
},
});
return response.data;
},
};
// Tag endpoints
@@ -506,6 +521,21 @@ export const seriesApi = {
return response.data;
},
// Simple name-based search (faster for autocomplete)
searchSeriesByName: async (query: string, params?: {
page?: number;
size?: number;
}): Promise<PagedResult<Series>> => {
const response = await api.get('/series/search', {
params: {
query,
page: params?.page ?? 0,
size: params?.size ?? 20,
},
});
return response.data;
},
getSeriesStories: async (id: string): Promise<Story[]> => {
const response = await api.get(`/stories/series/${id}`);
return response.data;