feat: wiki like linkage for posts
This commit is contained in:
@@ -847,6 +847,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
placeholder={tr('editor.placeholder.startWriting')}
|
placeholder={tr('editor.placeholder.startWriting')}
|
||||||
|
currentPostTags={tags}
|
||||||
|
currentPostCategories={selectedCategories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -921,6 +923,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
onInsertLink={handleInsertLink}
|
onInsertLink={handleInsertLink}
|
||||||
onInsertImage={() => {}}
|
onInsertImage={() => {}}
|
||||||
onClose={() => setShowPostSearch(false)}
|
onClose={() => setShowPostSearch(false)}
|
||||||
|
currentPostTags={tags}
|
||||||
|
currentPostCategories={selectedCategories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -240,3 +240,58 @@
|
|||||||
.insert-modal-results::-webkit-scrollbar-thumb:hover {
|
.insert-modal-results::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted, #555);
|
background: var(--color-text-muted, #555);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create post option */
|
||||||
|
.insert-modal-result-create {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-create:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-create:hover,
|
||||||
|
.insert-modal-result-create.selected {
|
||||||
|
background: var(--color-bg-tertiary, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-create.selected {
|
||||||
|
border-left: 3px solid var(--vscode-notificationsInfoIcon-foreground, #75beff);
|
||||||
|
padding-left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-create:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-create-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
|
import { useAppStore } from '../../store/appStore';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
import './InsertModal.css';
|
import './InsertModal.css';
|
||||||
|
|
||||||
interface PostSearchResult {
|
interface PostSearchResult {
|
||||||
@@ -20,8 +22,8 @@ interface MediaSearchResult {
|
|||||||
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
||||||
function getMediaDisplayName(media: MediaSearchResult): string {
|
function getMediaDisplayName(media: MediaSearchResult): string {
|
||||||
if (media.title) {
|
if (media.title) {
|
||||||
return media.title.length > 60
|
return media.title.length > 60
|
||||||
? media.title.substring(0, 60) + '...'
|
? media.title.substring(0, 60) + '...'
|
||||||
: media.title;
|
: media.title;
|
||||||
}
|
}
|
||||||
return media.originalName;
|
return media.originalName;
|
||||||
@@ -38,6 +40,8 @@ interface InsertModalProps {
|
|||||||
onInsertImage: (url: string, alt: string, mediaId?: string) => void;
|
onInsertImage: (url: string, alt: string, mediaId?: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialText?: string; // Selected text in editor
|
initialText?: string; // Selected text in editor
|
||||||
|
currentPostTags?: string[];
|
||||||
|
currentPostCategories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPostResult(result: SearchResult): result is PostSearchResult {
|
function isPostResult(result: SearchResult): result is PostSearchResult {
|
||||||
@@ -54,8 +58,11 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
onInsertImage,
|
onInsertImage,
|
||||||
onClose,
|
onClose,
|
||||||
initialText = '',
|
initialText = '',
|
||||||
|
currentPostTags,
|
||||||
|
currentPostCategories,
|
||||||
}) => {
|
}) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
|
const openTabInBackground = useAppStore((s) => s.openTabInBackground);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [externalUrl, setExternalUrl] = useState('');
|
const [externalUrl, setExternalUrl] = useState('');
|
||||||
@@ -64,9 +71,20 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const externalUrlRef = useRef<HTMLInputElement>(null);
|
const externalUrlRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Whether to show the "Create post" option
|
||||||
|
const showCreateOption = mode === 'link' &&
|
||||||
|
activeTab === 'internal' &&
|
||||||
|
query.trim().length >= 2 &&
|
||||||
|
!isSearching &&
|
||||||
|
!results.some(r => isPostResult(r) && r.title.toLowerCase() === query.trim().toLowerCase());
|
||||||
|
|
||||||
|
// Total selectable items count (results + optional create option)
|
||||||
|
const totalItems = results.length + (showCreateOption ? 1 : 0);
|
||||||
|
|
||||||
// Focus appropriate input on mount and tab change
|
// Focus appropriate input on mount and tab change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'internal') {
|
if (activeTab === 'internal') {
|
||||||
@@ -106,6 +124,34 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [query, mode, activeTab]);
|
}, [query, mode, activeTab]);
|
||||||
|
|
||||||
|
// Handle creating a new post from the search query
|
||||||
|
const handleCreatePost = useCallback(async () => {
|
||||||
|
const title = query.trim();
|
||||||
|
if (!title || isCreating) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const newPost = await window.electronAPI.posts.create({
|
||||||
|
title,
|
||||||
|
tags: currentPostTags || [],
|
||||||
|
categories: currentPostCategories || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newPost) {
|
||||||
|
openTabInBackground({ type: 'post', id: newPost.id, isTransient: false });
|
||||||
|
const linkUrl = `/posts/${newPost.slug}`;
|
||||||
|
onInsertLink(linkUrl, title);
|
||||||
|
showToast.success(tr('insert.createdPost', { title }));
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
showToast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [query, isCreating, currentPostTags, currentPostCategories, openTabInBackground, onInsertLink, onClose, tr]);
|
||||||
|
|
||||||
// Keyboard navigation handler
|
// Keyboard navigation handler
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -116,7 +162,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
if (activeTab === 'internal') {
|
if (activeTab === 'internal') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
|
setSelectedIndex(prev => Math.min(prev + 1, totalItems - 1));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
@@ -127,9 +173,13 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeTab === 'internal' && results[selectedIndex]) {
|
if (activeTab === 'internal') {
|
||||||
handleSelectResult(results[selectedIndex]);
|
if (selectedIndex < results.length && results[selectedIndex]) {
|
||||||
} else if (activeTab === 'external' && externalUrl) {
|
handleSelectResult(results[selectedIndex]);
|
||||||
|
} else if (showCreateOption && selectedIndex === results.length) {
|
||||||
|
handleCreatePost();
|
||||||
|
}
|
||||||
|
} else if (externalUrl) {
|
||||||
handleExternalSubmit();
|
handleExternalSubmit();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -137,7 +187,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
// Allow tab switching with Tab key when on the tab buttons
|
// Allow tab switching with Tab key when on the tab buttons
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [activeTab, results, selectedIndex, externalUrl, onClose]);
|
}, [activeTab, results, selectedIndex, totalItems, showCreateOption, externalUrl, onClose, handleCreatePost]);
|
||||||
|
|
||||||
// Handle selecting a search result
|
// Handle selecting a search result
|
||||||
const handleSelectResult = useCallback(async (result: SearchResult) => {
|
const handleSelectResult = useCallback(async (result: SearchResult) => {
|
||||||
@@ -161,7 +211,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
// Handle external URL submission
|
// Handle external URL submission
|
||||||
const handleExternalSubmit = useCallback(() => {
|
const handleExternalSubmit = useCallback(() => {
|
||||||
if (!externalUrl) return;
|
if (!externalUrl) return;
|
||||||
|
|
||||||
if (mode === 'link') {
|
if (mode === 'link') {
|
||||||
onInsertLink(externalUrl, externalText || undefined);
|
onInsertLink(externalUrl, externalText || undefined);
|
||||||
} else {
|
} else {
|
||||||
@@ -180,7 +230,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedElement = document.querySelector('.insert-modal-result-item.selected');
|
const selectedElement = document.querySelector('.insert-modal-result-item.selected, .insert-modal-result-create.selected');
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@@ -189,7 +239,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
|
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
|
||||||
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
|
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
|
||||||
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
|
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
|
||||||
const searchPlaceholder = mode === 'link'
|
const searchPlaceholder = mode === 'link'
|
||||||
? tr('insert.searchPlaceholder.link')
|
? tr('insert.searchPlaceholder.link')
|
||||||
: tr('insert.searchPlaceholder.image');
|
: tr('insert.searchPlaceholder.image');
|
||||||
|
|
||||||
@@ -224,6 +274,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +290,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && query.length >= 2 && results.length === 0 && (
|
{!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
|
||||||
<div className="insert-modal-status">
|
<div className="insert-modal-status">
|
||||||
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
|
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +325,19 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{showCreateOption && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`insert-modal-result-create ${selectedIndex === results.length ? 'selected' : ''}`}
|
||||||
|
onClick={handleCreatePost}
|
||||||
|
onMouseEnter={() => setSelectedIndex(results.length)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<span className="insert-modal-create-icon">+</span>
|
||||||
|
<span>{tr('insert.createPost', { title: query.trim() })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -290,7 +354,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'link' ? (
|
{mode === 'link' ? (
|
||||||
<div className="insert-modal-field">
|
<div className="insert-modal-field">
|
||||||
<label className="insert-modal-label">{tr('insert.label.linkTextOptional')}</label>
|
<label className="insert-modal-label">{tr('insert.label.linkTextOptional')}</label>
|
||||||
@@ -328,7 +392,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
<div className="insert-modal-footer">
|
<div className="insert-modal-footer">
|
||||||
<div className="insert-modal-footer-content">
|
<div className="insert-modal-footer-content">
|
||||||
<span className="insert-modal-hint">
|
<span className="insert-modal-hint">
|
||||||
{activeTab === 'internal'
|
{activeTab === 'internal'
|
||||||
? tr('insert.hint.internal')
|
? tr('insert.hint.internal')
|
||||||
: tr('insert.hint.external')}
|
: tr('insert.hint.external')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface MilkdownEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
onChange: (markdown: string) => void;
|
onChange: (markdown: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
currentPostTags?: string[];
|
||||||
|
currentPostCategories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MilkdownChangePropagationInput {
|
interface MilkdownChangePropagationInput {
|
||||||
@@ -86,10 +88,12 @@ export const shouldPropagateMilkdownChange = ({
|
|||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
onUserInteraction: () => void;
|
onUserInteraction: () => void;
|
||||||
|
currentPostTags?: string[];
|
||||||
|
currentPostCategories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar component that uses the editor instance
|
// Toolbar component that uses the editor instance
|
||||||
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction, currentPostTags, currentPostCategories }) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
const [loading, getEditor] = useInstance();
|
const [loading, getEditor] = useInstance();
|
||||||
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
|
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
|
||||||
@@ -269,6 +273,8 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
|||||||
onInsertImage={handleInsertImage}
|
onInsertImage={handleInsertImage}
|
||||||
onClose={() => setInsertMode(null)}
|
onClose={() => setInsertMode(null)}
|
||||||
initialText={selectedText}
|
initialText={selectedText}
|
||||||
|
currentPostTags={currentPostTags}
|
||||||
|
currentPostCategories={currentPostCategories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -289,6 +295,8 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
|||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
currentPostTags,
|
||||||
|
currentPostCategories,
|
||||||
}) => {
|
}) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
|
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
|
||||||
@@ -376,7 +384,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
|||||||
onPasteCapture={markUserInteraction}
|
onPasteCapture={markUserInteraction}
|
||||||
onInputCapture={markUserInteraction}
|
onInputCapture={markUserInteraction}
|
||||||
>
|
>
|
||||||
<EditorToolbar onUserInteraction={markUserInteraction} />
|
<EditorToolbar onUserInteraction={markUserInteraction} currentPostTags={currentPostTags} currentPostCategories={currentPostCategories} />
|
||||||
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
|
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
|
||||||
<Milkdown />
|
<Milkdown />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -248,6 +248,8 @@
|
|||||||
"insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen",
|
"insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen",
|
||||||
"insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug",
|
"insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug",
|
||||||
"insert.hint.canonicalMedia": "Kanonisch: /media/YYYY/MM/datei.ext",
|
"insert.hint.canonicalMedia": "Kanonisch: /media/YYYY/MM/datei.ext",
|
||||||
|
"insert.createPost": "Beitrag \"{title}\" erstellen",
|
||||||
|
"insert.createdPost": "Beitrag \"{title}\" erstellt",
|
||||||
"postLinks.loading": "Links werden geladen...",
|
"postLinks.loading": "Links werden geladen...",
|
||||||
"postLinks.link": "Link",
|
"postLinks.link": "Link",
|
||||||
"postLinks.links": "Links",
|
"postLinks.links": "Links",
|
||||||
|
|||||||
@@ -248,6 +248,8 @@
|
|||||||
"insert.hint.external": "Enter URL and press Enter or click button, Esc to close",
|
"insert.hint.external": "Enter URL and press Enter or click button, Esc to close",
|
||||||
"insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug",
|
"insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug",
|
||||||
"insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext",
|
"insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext",
|
||||||
|
"insert.createPost": "Create post \"{title}\"",
|
||||||
|
"insert.createdPost": "Post \"{title}\" created",
|
||||||
"postLinks.loading": "Loading links...",
|
"postLinks.loading": "Loading links...",
|
||||||
"postLinks.link": "link",
|
"postLinks.link": "link",
|
||||||
"postLinks.links": "links",
|
"postLinks.links": "links",
|
||||||
|
|||||||
@@ -248,6 +248,8 @@
|
|||||||
"insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar",
|
"insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar",
|
||||||
"insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug",
|
"insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug",
|
||||||
"insert.hint.canonicalMedia": "Canónico: /media/YYYY/MM/archivo.ext",
|
"insert.hint.canonicalMedia": "Canónico: /media/YYYY/MM/archivo.ext",
|
||||||
|
"insert.createPost": "Crear artículo \"{title}\"",
|
||||||
|
"insert.createdPost": "Artículo \"{title}\" creado",
|
||||||
"postLinks.loading": "Cargando enlaces...",
|
"postLinks.loading": "Cargando enlaces...",
|
||||||
"postLinks.link": "enlace",
|
"postLinks.link": "enlace",
|
||||||
"postLinks.links": "enlaces",
|
"postLinks.links": "enlaces",
|
||||||
|
|||||||
@@ -246,6 +246,8 @@
|
|||||||
"insert.hint.external": "Entrez l’URL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer",
|
"insert.hint.external": "Entrez l’URL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer",
|
||||||
"insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug",
|
"insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug",
|
||||||
"insert.hint.canonicalMedia": "Canonique : /media/YYYY/MM/fichier.ext",
|
"insert.hint.canonicalMedia": "Canonique : /media/YYYY/MM/fichier.ext",
|
||||||
|
"insert.createPost": "Créer l'article « {title} »",
|
||||||
|
"insert.createdPost": "Article « {title} » créé",
|
||||||
"postLinks.loading": "Chargement des liens...",
|
"postLinks.loading": "Chargement des liens...",
|
||||||
"postLinks.link": "lien",
|
"postLinks.link": "lien",
|
||||||
"postLinks.links": "liens",
|
"postLinks.links": "liens",
|
||||||
|
|||||||
@@ -246,6 +246,8 @@
|
|||||||
"insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere",
|
"insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere",
|
||||||
"insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug",
|
"insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug",
|
||||||
"insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext",
|
"insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext",
|
||||||
|
"insert.createPost": "Crea articolo \"{title}\"",
|
||||||
|
"insert.createdPost": "Articolo \"{title}\" creato",
|
||||||
"postLinks.loading": "Caricamento link...",
|
"postLinks.loading": "Caricamento link...",
|
||||||
"postLinks.link": "collegamento",
|
"postLinks.link": "collegamento",
|
||||||
"postLinks.links": "link",
|
"postLinks.links": "link",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ interface AppState {
|
|||||||
|
|
||||||
// Tab Actions
|
// Tab Actions
|
||||||
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
||||||
|
openTabInBackground: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
pinTab: (id: string) => void;
|
pinTab: (id: string) => void;
|
||||||
@@ -274,7 +275,23 @@ export const useAppStore = create<AppState>()(
|
|||||||
const newTab: Tab = { type, id, isTransient };
|
const newTab: Tab = { type, id, isTransient };
|
||||||
return { tabs: [...state.tabs, newTab], activeTabId: id };
|
return { tabs: [...state.tabs, newTab], activeTabId: id };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
openTabInBackground: ({ type, id, isTransient }) => set((state) => {
|
||||||
|
const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type);
|
||||||
|
|
||||||
|
if (existingTabIndex >= 0) {
|
||||||
|
if (!isTransient) {
|
||||||
|
const updatedTabs = [...state.tabs];
|
||||||
|
updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false };
|
||||||
|
return { tabs: updatedTabs };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTab: Tab = { type, id, isTransient };
|
||||||
|
return { tabs: [...state.tabs, newTab] };
|
||||||
|
}),
|
||||||
|
|
||||||
closeTab: (id) => set((state) => {
|
closeTab: (id) => set((state) => {
|
||||||
const tabIndex = state.tabs.findIndex((t) => t.id === id);
|
const tabIndex = state.tabs.findIndex((t) => t.id === id);
|
||||||
if (tabIndex === -1) return state;
|
if (tabIndex === -1) return state;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal';
|
import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store/appStore';
|
||||||
|
|
||||||
describe('InsertModal format hints', () => {
|
describe('InsertModal format hints', () => {
|
||||||
it('shows canonical post link format hint in internal link mode', () => {
|
it('shows canonical post link format hint in internal link mode', () => {
|
||||||
@@ -30,3 +31,170 @@ describe('InsertModal format hints', () => {
|
|||||||
expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument();
|
expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('InsertModal create post', () => {
|
||||||
|
const mockOnInsertLink = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({ tabs: [], activeTabId: 'current-post' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show create option when query is shorter than 2 characters', () => {
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="link"
|
||||||
|
onInsertLink={mockOnInsertLink}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||||
|
fireEvent.input(input, { target: { value: 'a' } });
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Create post/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show create option in image mode', async () => {
|
||||||
|
(window.electronAPI.media.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="image"
|
||||||
|
onInsertLink={vi.fn()}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search media by name, title, or alt text...');
|
||||||
|
fireEvent.input(input, { target: { value: 'test query' } });
|
||||||
|
|
||||||
|
// Wait for search to complete by finding the no-results message
|
||||||
|
await screen.findByText(/No.*found/i);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Create post/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create option when search has no exact title match', async () => {
|
||||||
|
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: 'p1', title: 'Different Title', slug: 'different-title', excerpt: 'Some text' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="link"
|
||||||
|
onInsertLink={mockOnInsertLink}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||||
|
fireEvent.input(input, { target: { value: 'My New Post' } });
|
||||||
|
|
||||||
|
// Wait for search results to render
|
||||||
|
await screen.findByText('Different Title');
|
||||||
|
|
||||||
|
expect(screen.getByText('Create post "My New Post"')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show create option when an exact title match exists', async () => {
|
||||||
|
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: 'p1', title: 'My New Post', slug: 'my-new-post', excerpt: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="link"
|
||||||
|
onInsertLink={mockOnInsertLink}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||||
|
fireEvent.input(input, { target: { value: 'My New Post' } });
|
||||||
|
|
||||||
|
// Wait for results to render (slug appears in the result path)
|
||||||
|
await screen.findByText(/my-new-post/);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Create post "My New Post"')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates post and inserts link when create option is clicked', async () => {
|
||||||
|
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(window.electronAPI.posts.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 'new-post-id',
|
||||||
|
title: 'New Post Title',
|
||||||
|
slug: 'new-post-title',
|
||||||
|
content: '',
|
||||||
|
status: 'draft',
|
||||||
|
tags: ['tag1'],
|
||||||
|
categories: ['article'],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="link"
|
||||||
|
onInsertLink={mockOnInsertLink}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
currentPostTags={['tag1']}
|
||||||
|
currentPostCategories={['article']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||||
|
fireEvent.input(input, { target: { value: 'New Post Title' } });
|
||||||
|
|
||||||
|
// Wait for the create option to appear after debounced search completes
|
||||||
|
const createButton = await screen.findByText('Create post "New Post Title"');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.electronAPI.posts.create).toHaveBeenCalledWith({
|
||||||
|
title: 'New Post Title',
|
||||||
|
tags: ['tag1'],
|
||||||
|
categories: ['article'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnInsertLink).toHaveBeenCalledWith('/posts/new-post-title', 'New Post Title');
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Check that the tab was opened in the background
|
||||||
|
const storeState = useAppStore.getState();
|
||||||
|
expect(storeState.tabs).toContainEqual(
|
||||||
|
expect.objectContaining({ type: 'post', id: 'new-post-id', isTransient: false })
|
||||||
|
);
|
||||||
|
expect(storeState.activeTabId).toBe('current-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create option when no results exist (standalone)', async () => {
|
||||||
|
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InsertModal
|
||||||
|
mode="link"
|
||||||
|
onInsertLink={mockOnInsertLink}
|
||||||
|
onInsertImage={vi.fn()}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||||
|
fireEvent.input(input, { target: { value: 'Nonexistent Post' } });
|
||||||
|
|
||||||
|
// Wait for create option to appear (replaces no-results message)
|
||||||
|
expect(await screen.findByText('Create post "Nonexistent Post"')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The "no results" message should not appear when create option is shown
|
||||||
|
expect(screen.queryByText(/No posts found/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -215,4 +215,50 @@ describe('AppStore', () => {
|
|||||||
expectTypeOf<TaskProgress>().toEqualTypeOf<SharedTaskProgress>();
|
expectTypeOf<TaskProgress>().toEqualTypeOf<SharedTaskProgress>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Tab Management', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setState({
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open a tab in the background without changing activeTabId', () => {
|
||||||
|
// Set up an existing active tab
|
||||||
|
getStore().openTab({ type: 'post', id: 'existing-post', isTransient: false });
|
||||||
|
expect(getStore().activeTabId).toBe('existing-post');
|
||||||
|
|
||||||
|
// Open a new tab in the background
|
||||||
|
getStore().openTabInBackground({ type: 'post', id: 'background-post', isTransient: false });
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(2);
|
||||||
|
expect(getStore().tabs[1].id).toBe('background-post');
|
||||||
|
expect(getStore().activeTabId).toBe('existing-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not duplicate a tab when opening in background if it already exists', () => {
|
||||||
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
|
||||||
|
getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false });
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pin an existing transient tab when opening in background as non-transient', () => {
|
||||||
|
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
|
||||||
|
getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false });
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
|
expect(getStore().tabs[0].isTransient).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve null activeTabId when opening background tab with no prior active tab', () => {
|
||||||
|
setState({ activeTabId: null, tabs: [] });
|
||||||
|
|
||||||
|
getStore().openTabInBackground({ type: 'post', id: 'bg-post', isTransient: false });
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
|
expect(getStore().activeTabId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user