Image Handling
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 () => {
|
||||
// Search authors dynamically based on input
|
||||
const searchAuthors = useCallback(async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await authorApi.getAuthors({ page: 0, size: 100 }); // Get first 100 authors
|
||||
|
||||
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);
|
||||
} else {
|
||||
// Search by name
|
||||
const result = await authorApi.searchAuthorsByName(query, { page: 0, size: 20 });
|
||||
setAuthors(result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load authors:', error);
|
||||
console.error('Failed to search authors:', error);
|
||||
setAuthors([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isOpen && authors.length === 0) {
|
||||
loadAuthors();
|
||||
}
|
||||
}, [isOpen, authors.length]);
|
||||
|
||||
// Filter authors based on input value
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (!inputValue.trim()) {
|
||||
setFilteredAuthors(authors);
|
||||
} else {
|
||||
const filtered = authors.filter(author =>
|
||||
author.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
setFilteredAuthors(filtered);
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
}, [inputValue, authors]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
}, [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>
|
||||
|
||||
@@ -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
|
||||
useEffect(() => {
|
||||
const loadSeriesData = async () => {
|
||||
// Search series dynamically based on input
|
||||
const searchSeries = useCallback(async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load all series
|
||||
const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series
|
||||
setSeries(seriesResult.content);
|
||||
|
||||
// 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
|
||||
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 load series:', error);
|
||||
console.error('Failed to search series:', error);
|
||||
setSeries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isOpen && series.length === 0) {
|
||||
loadSeriesData();
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
}, [isOpen, series.length]);
|
||||
|
||||
// Only search when dropdown is open
|
||||
if (isOpen) {
|
||||
// Set new timer for debounced search
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
searchSeries(inputValue);
|
||||
}, 300); // 300ms debounce delay
|
||||
}
|
||||
|
||||
// Cleanup timer on unmount
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [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 (
|
||||
{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 ${
|
||||
isAuthorSeries ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-400' : ''
|
||||
}`}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{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: "{inputValue.trim()}"
|
||||
</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());
|
||||
|
||||
@@ -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':
|
||||
case 'pre':
|
||||
case 'code': {
|
||||
// Filter out blockquotes, code blocks, and code - convert to paragraph
|
||||
const text = element.textContent || '';
|
||||
if (text.trim()) {
|
||||
results.push({
|
||||
type: 'blockquote',
|
||||
children: [{ text: element.textContent || '' }]
|
||||
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()) {
|
||||
@@ -159,6 +156,7 @@ const htmlToSlate = (html: string): Descendant[] => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
if (text.trim()) {
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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,31 +799,86 @@ 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 '3':
|
||||
if (event.shiftKey) {
|
||||
// Ctrl+Shift+3 for H3
|
||||
toggleBlockShortcut('heading-three');
|
||||
}
|
||||
case 'a': {
|
||||
// Handle Ctrl+A / Cmd+A to select all
|
||||
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, []),
|
||||
@@ -858,17 +886,39 @@ export default function SlateEditor({
|
||||
});
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user