Image Handling
This commit is contained in:
@@ -20,6 +20,9 @@ public class AsyncImageProcessingService {
|
|||||||
private final StoryService storyService;
|
private final StoryService storyService;
|
||||||
private final ImageProcessingProgressService progressService;
|
private final ImageProcessingProgressService progressService;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${storycove.app.public-url:http://localhost:6925}")
|
||||||
|
private String publicUrl;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AsyncImageProcessingService(ImageService imageService,
|
public AsyncImageProcessingService(ImageService imageService,
|
||||||
StoryService storyService,
|
StoryService storyService,
|
||||||
@@ -103,10 +106,54 @@ public class AsyncImageProcessingService {
|
|||||||
return count;
|
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) {
|
private boolean isExternalUrl(String url) {
|
||||||
return url != null &&
|
if (url == null || url.trim().isEmpty()) {
|
||||||
(url.startsWith("http://") || url.startsWith("https://")) &&
|
return false;
|
||||||
!url.contains("/api/files/images/");
|
}
|
||||||
|
|
||||||
|
// 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(
|
private ImageService.ContentImageProcessingResult processImagesWithProgress(
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public class ImageService {
|
|||||||
@Value("${storycove.images.max-file-size:5242880}") // 5MB default
|
@Value("${storycove.images.max-file-size:5242880}") // 5MB default
|
||||||
private long maxFileSize;
|
private long maxFileSize;
|
||||||
|
|
||||||
|
@Value("${storycove.app.public-url:http://localhost:6925}")
|
||||||
|
private String publicUrl;
|
||||||
|
|
||||||
public enum ImageType {
|
public enum ImageType {
|
||||||
COVER("covers"),
|
COVER("covers"),
|
||||||
AVATAR("avatars"),
|
AVATAR("avatars"),
|
||||||
@@ -286,9 +289,9 @@ public class ImageService {
|
|||||||
logger.debug("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
logger.debug("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip if it's already a local path or data URL
|
// Skip if it's already a local path, data URL, or from this application
|
||||||
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
if (!isExternalUrl(imageUrl)) {
|
||||||
logger.debug("Skipping local/data URL: {}", imageUrl);
|
logger.debug("Skipping local/internal URL: {}", imageUrl);
|
||||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -366,7 +369,7 @@ public class ImageService {
|
|||||||
Matcher countMatcher = imgPattern.matcher(htmlContent);
|
Matcher countMatcher = imgPattern.matcher(htmlContent);
|
||||||
while (countMatcher.find()) {
|
while (countMatcher.find()) {
|
||||||
String imageUrl = countMatcher.group(1);
|
String imageUrl = countMatcher.group(1);
|
||||||
if (!imageUrl.startsWith("/") && !imageUrl.startsWith("data:")) {
|
if (isExternalUrl(imageUrl)) {
|
||||||
externalImages.add(imageUrl);
|
externalImages.add(imageUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,9 +387,9 @@ public class ImageService {
|
|||||||
logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag);
|
logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip if it's already a local path or data URL
|
// Skip if it's already a local path, data URL, or from this application
|
||||||
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
if (!isExternalUrl(imageUrl)) {
|
||||||
logger.debug("Skipping local/data URL: {}", imageUrl);
|
logger.debug("Skipping local/internal URL: {}", imageUrl);
|
||||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -429,6 +432,56 @@ public class ImageService {
|
|||||||
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
|
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
|
* Download an image from a URL and store it locally
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { authorApi } from '../../lib/api';
|
import { authorApi } from '../../lib/api';
|
||||||
import { Author } from '../../types/api';
|
import { Author } from '../../types/api';
|
||||||
|
|
||||||
@@ -25,50 +25,64 @@ export default function AuthorSelector({
|
|||||||
}: AuthorSelectorProps) {
|
}: AuthorSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [authors, setAuthors] = useState<Author[]>([]);
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Load authors when component mounts or when dropdown opens
|
// Search authors dynamically based on input
|
||||||
useEffect(() => {
|
const searchAuthors = useCallback(async (query: string) => {
|
||||||
const loadAuthors = async () => {
|
try {
|
||||||
try {
|
setLoading(true);
|
||||||
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);
|
setAuthors(result.content);
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Failed to load authors:', error);
|
// Search by name
|
||||||
} finally {
|
const result = await authorApi.searchAuthorsByName(query, { page: 0, size: 20 });
|
||||||
setLoading(false);
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}, [inputValue, isOpen, searchAuthors]);
|
||||||
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
|
// Update input value when prop value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== inputValue) {
|
if (value !== inputValue) {
|
||||||
setInputValue(value || '');
|
setInputValue(value || '');
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value, inputValue]);
|
||||||
|
|
||||||
// Handle clicking outside to close dropdown
|
// Handle clicking outside to close dropdown
|
||||||
useEffect(() => {
|
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">
|
<div className="absolute z-50 w-full mt-1 theme-card theme-shadow border theme-border rounded-md max-h-60 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-3 py-2 text-sm theme-text text-center">
|
<div className="px-3 py-2 text-sm theme-text text-center">
|
||||||
Loading authors...
|
Searching authors...
|
||||||
</div>
|
</div>
|
||||||
) : filteredAuthors.length > 0 ? (
|
) : authors.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Existing authors */}
|
{/* Search results */}
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{filteredAuthors.map((author) => (
|
{authors.map((author) => (
|
||||||
<button
|
<button
|
||||||
key={author.id}
|
key={author.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -180,7 +194,7 @@ export default function AuthorSelector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New author option if input doesn't match exactly */}
|
{/* 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="border-t theme-border"></div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
@@ -213,9 +227,9 @@ export default function AuthorSelector({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* No authors loaded or empty input */
|
/* Empty state - show prompt */
|
||||||
<div className="px-3 py-2 text-sm theme-text-muted text-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { seriesApi, storyApi } from '../../lib/api';
|
import { seriesApi } from '../../lib/api';
|
||||||
import { Series } from '../../types/api';
|
import { Series } from '../../types/api';
|
||||||
|
|
||||||
interface SeriesSelectorProps {
|
interface SeriesSelectorProps {
|
||||||
@@ -27,97 +27,63 @@ export default function SeriesSelector({
|
|||||||
}: SeriesSelectorProps) {
|
}: SeriesSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [series, setSeries] = useState<Series[]>([]);
|
const [series, setSeries] = useState<Series[]>([]);
|
||||||
const [filteredSeries, setFilteredSeries] = useState<Series[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
const [authorSeriesMap, setAuthorSeriesMap] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const loadSeriesData = async () => {
|
// Clear existing timer
|
||||||
try {
|
if (debounceTimerRef.current) {
|
||||||
setLoading(true);
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Load all series
|
// Only search when dropdown is open
|
||||||
const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series
|
if (isOpen) {
|
||||||
setSeries(seriesResult.content);
|
// Set new timer for debounced search
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
searchSeries(inputValue);
|
||||||
|
}, 300); // 300ms debounce delay
|
||||||
|
}
|
||||||
|
|
||||||
// Load some recent stories to build author-series mapping
|
// Cleanup timer on unmount
|
||||||
// This gives us a sample of which authors have written in which series
|
return () => {
|
||||||
try {
|
if (debounceTimerRef.current) {
|
||||||
const storiesResult = await storyApi.getStories({ page: 0, size: 200 }); // Get recent stories
|
clearTimeout(debounceTimerRef.current);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}, [inputValue, isOpen, searchSeries]);
|
||||||
if (isOpen && series.length === 0) {
|
|
||||||
loadSeriesData();
|
|
||||||
}
|
|
||||||
}, [isOpen, series.length]);
|
|
||||||
|
|
||||||
// Update internal value when prop changes
|
// Update internal value when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInputValue(value || '');
|
setInputValue(value || '');
|
||||||
}, [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
|
// Handle clicks outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
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"
|
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 ? (
|
{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 ? (
|
{series.length > 0 ? (
|
||||||
filteredSeries.map((s) => {
|
series.map((s) => (
|
||||||
const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name);
|
<button
|
||||||
|
key={s.id}
|
||||||
return (
|
type="button"
|
||||||
<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"
|
||||||
key={s.id}
|
onClick={() => handleSeriesSelect(s)}
|
||||||
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 ${
|
<span>{s.name}</span>
|
||||||
isAuthorSeries ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-400' : ''
|
<span className="text-xs text-gray-500">
|
||||||
}`}
|
{s.storyCount} {s.storyCount === 1 ? 'story' : 'stories'}
|
||||||
onClick={() => handleSeriesSelect(s)}
|
</span>
|
||||||
>
|
</button>
|
||||||
<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
|
<button
|
||||||
type="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"
|
||||||
@@ -258,17 +211,16 @@ export default function SeriesSelector({
|
|||||||
>
|
>
|
||||||
Create new series: "{inputValue.trim()}"
|
Create new series: "{inputValue.trim()}"
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : (
|
||||||
{!inputValue.trim() && (
|
<div className="px-3 py-2 text-sm text-gray-500">Type to search for series or create a new one</div>
|
||||||
<div className="px-3 py-2 text-sm text-gray-500">No series found</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
|
{inputValue.trim() && !series.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onChange(inputValue.trim());
|
onChange(inputValue.trim());
|
||||||
|
|||||||
@@ -35,12 +35,11 @@ interface SlateEditorProps {
|
|||||||
|
|
||||||
// Custom types for our editor
|
// Custom types for our editor
|
||||||
type CustomElement = {
|
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[];
|
children: CustomText[];
|
||||||
src?: string; // for images
|
src?: string; // for images
|
||||||
alt?: string; // for images
|
alt?: string; // for images
|
||||||
caption?: string; // for images
|
caption?: string; // for images
|
||||||
language?: string; // for code blocks
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type CustomText = {
|
type CustomText = {
|
||||||
@@ -49,7 +48,6 @@ type CustomText = {
|
|||||||
italic?: boolean;
|
italic?: boolean;
|
||||||
underline?: boolean;
|
underline?: boolean;
|
||||||
strikethrough?: boolean;
|
strikethrough?: boolean;
|
||||||
code?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module 'slate' {
|
declare module 'slate' {
|
||||||
@@ -100,12 +98,19 @@ const htmlToSlate = (html: string): Descendant[] => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
results.push({
|
case 'pre':
|
||||||
type: 'blockquote',
|
case 'code': {
|
||||||
children: [{ text: element.textContent || '' }]
|
// 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;
|
break;
|
||||||
case 'img':
|
}
|
||||||
|
case 'img': {
|
||||||
const img = element as HTMLImageElement;
|
const img = element as HTMLImageElement;
|
||||||
results.push({
|
results.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
@@ -115,18 +120,9 @@ const htmlToSlate = (html: string): Descendant[] => {
|
|||||||
children: [{ text: '' }] // Images need children in Slate
|
children: [{ text: '' }] // Images need children in Slate
|
||||||
});
|
});
|
||||||
break;
|
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 'p':
|
||||||
case 'div':
|
case 'div': {
|
||||||
// Check if this paragraph contains mixed content (text + images)
|
// Check if this paragraph contains mixed content (text + images)
|
||||||
if (element.querySelector('img')) {
|
if (element.querySelector('img')) {
|
||||||
// Process mixed content - handle both text and images in order
|
// Process mixed content - handle both text and images in order
|
||||||
@@ -141,6 +137,7 @@ const htmlToSlate = (html: string): Descendant[] => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'br':
|
case 'br':
|
||||||
// Handle line breaks by creating empty paragraphs
|
// Handle line breaks by creating empty paragraphs
|
||||||
results.push({
|
results.push({
|
||||||
@@ -148,7 +145,7 @@ const htmlToSlate = (html: string): Descendant[] => {
|
|||||||
children: [{ text: '' }]
|
children: [{ text: '' }]
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default: {
|
||||||
// For other elements, try to extract text or recurse
|
// For other elements, try to extract text or recurse
|
||||||
const text = element.textContent || '';
|
const text = element.textContent || '';
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
@@ -158,6 +155,7 @@ const htmlToSlate = (html: string): Descendant[] => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||||
const text = node.textContent || '';
|
const text = node.textContent || '';
|
||||||
@@ -210,9 +208,6 @@ const slateToHtml = (nodes: Descendant[]): string => {
|
|||||||
case 'heading-three':
|
case 'heading-three':
|
||||||
htmlParts.push(`<h3>${text}</h3>`);
|
htmlParts.push(`<h3>${text}</h3>`);
|
||||||
break;
|
break;
|
||||||
case 'blockquote':
|
|
||||||
htmlParts.push(`<blockquote>${text}</blockquote>`);
|
|
||||||
break;
|
|
||||||
case 'image':
|
case 'image':
|
||||||
const attrs: string[] = [];
|
const attrs: string[] = [];
|
||||||
if (element.src) attrs.push(`src="${element.src}"`);
|
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}"`);
|
if (element.caption) attrs.push(`title="${element.caption}"`);
|
||||||
htmlParts.push(`<img ${attrs.join(' ')} />`);
|
htmlParts.push(`<img ${attrs.join(' ')} />`);
|
||||||
break;
|
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':
|
case 'paragraph':
|
||||||
default:
|
default:
|
||||||
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
|
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>;
|
return <h2 {...attributes} className="text-2xl font-bold mb-3">{children}</h2>;
|
||||||
case 'heading-three':
|
case 'heading-three':
|
||||||
return <h3 {...attributes} className="text-xl font-bold mb-3">{children}</h3>;
|
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':
|
case 'image':
|
||||||
return (
|
return (
|
||||||
<ImageElement
|
<ImageElement
|
||||||
@@ -510,12 +493,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
|
|||||||
children={children}
|
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:
|
default:
|
||||||
return <p {...attributes} className="mb-2">{children}</p>;
|
return <p {...attributes} className="mb-2">{children}</p>;
|
||||||
}
|
}
|
||||||
@@ -541,16 +518,12 @@ const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
|||||||
children = <s>{children}</s>;
|
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>;
|
return <span {...attributes}>{children}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toolbar component
|
// Toolbar component
|
||||||
const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
||||||
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code';
|
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough';
|
||||||
|
|
||||||
const isMarkActive = (format: MarkFormat) => {
|
const isMarkActive = (format: MarkFormat) => {
|
||||||
const marks = Editor.marks(editor);
|
const marks = Editor.marks(editor);
|
||||||
@@ -627,7 +600,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleBlock('paragraph')}
|
onClick={() => toggleBlock('paragraph')}
|
||||||
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
|
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
|
||||||
title="Normal paragraph"
|
title="Normal paragraph (Ctrl+Shift+0)"
|
||||||
>
|
>
|
||||||
P
|
P
|
||||||
</Button>
|
</Button>
|
||||||
@@ -637,7 +610,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleBlock('heading-one')}
|
onClick={() => toggleBlock('heading-one')}
|
||||||
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
|
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
title="Heading 1"
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
>
|
>
|
||||||
H1
|
H1
|
||||||
</Button>
|
</Button>
|
||||||
@@ -647,7 +620,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleBlock('heading-two')}
|
onClick={() => toggleBlock('heading-two')}
|
||||||
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
|
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
title="Heading 2"
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
</Button>
|
</Button>
|
||||||
@@ -657,7 +630,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleBlock('heading-three')}
|
onClick={() => toggleBlock('heading-three')}
|
||||||
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
|
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
title="Heading 3"
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
>
|
>
|
||||||
H3
|
H3
|
||||||
</Button>
|
</Button>
|
||||||
@@ -691,7 +664,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleMark('underline')}
|
onClick={() => toggleMark('underline')}
|
||||||
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
|
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
title="Underline"
|
title="Underline (Ctrl+U)"
|
||||||
>
|
>
|
||||||
U
|
U
|
||||||
</Button>
|
</Button>
|
||||||
@@ -701,7 +674,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => toggleMark('strikethrough')}
|
onClick={() => toggleMark('strikethrough')}
|
||||||
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
|
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
title="Strike-through"
|
title="Strikethrough (Ctrl+D)"
|
||||||
>
|
>
|
||||||
S
|
S
|
||||||
</Button>
|
</Button>
|
||||||
@@ -826,49 +799,126 @@ export default function SlateEditor({
|
|||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
if (!event.ctrlKey && !event.metaKey) return;
|
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) {
|
switch (event.key) {
|
||||||
case 'b': {
|
// Text formatting shortcuts
|
||||||
event.preventDefault();
|
case 'b':
|
||||||
const marks = Editor.marks(editor);
|
toggleMarkShortcut('bold');
|
||||||
const isActive = marks ? marks.bold === true : false;
|
break;
|
||||||
if (isActive) {
|
case 'i':
|
||||||
Editor.removeMark(editor, 'bold');
|
toggleMarkShortcut('italic');
|
||||||
} else {
|
break;
|
||||||
Editor.addMark(editor, 'bold', true);
|
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;
|
break;
|
||||||
}
|
case '2':
|
||||||
case 'i': {
|
if (event.shiftKey) {
|
||||||
event.preventDefault();
|
// Ctrl+Shift+2 for H2
|
||||||
const marks = Editor.marks(editor);
|
toggleBlockShortcut('heading-two');
|
||||||
const isActive = marks ? marks.italic === true : false;
|
|
||||||
if (isActive) {
|
|
||||||
Editor.removeMark(editor, 'italic');
|
|
||||||
} else {
|
|
||||||
Editor.addMark(editor, 'italic', true);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case '3':
|
||||||
case 'a': {
|
if (event.shiftKey) {
|
||||||
// Handle Ctrl+A / Cmd+A to select all
|
// 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();
|
event.preventDefault();
|
||||||
Transforms.select(editor, {
|
Transforms.select(editor, {
|
||||||
anchor: Editor.start(editor, []),
|
anchor: Editor.start(editor, []),
|
||||||
focus: Editor.end(editor, []),
|
focus: Editor.end(editor, []),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-xs theme-text">
|
<div className="text-xs theme-text space-y-1">
|
||||||
<p>
|
<p>
|
||||||
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
|
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
|
||||||
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -371,6 +371,21 @@ export const authorApi = {
|
|||||||
return response.data;
|
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
|
// Tag endpoints
|
||||||
@@ -506,6 +521,21 @@ export const seriesApi = {
|
|||||||
return response.data;
|
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[]> => {
|
getSeriesStories: async (id: string): Promise<Story[]> => {
|
||||||
const response = await api.get(`/stories/series/${id}`);
|
const response = await api.get(`/stories/series/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user