mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: add AI Agent chat integration with Anthropic Claude API
Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
This commit is contained in:
31
src/App.tsx
31
src/App.tsx
@@ -20,11 +20,40 @@ function App() {
|
||||
return <Write />;
|
||||
}
|
||||
|
||||
// Determine if we should use a custom homepage
|
||||
const useCustomHomepage =
|
||||
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
{/* Homepage route - either default Home or custom page/post */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
useCustomHomepage ? (
|
||||
<Post
|
||||
slug={siteConfig.homepage.slug!}
|
||||
isHomepage={true}
|
||||
homepageType={
|
||||
siteConfig.homepage.type === "default"
|
||||
? undefined
|
||||
: siteConfig.homepage.type
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Home />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Original homepage route (when custom homepage is set) */}
|
||||
{useCustomHomepage && (
|
||||
<Route
|
||||
path={siteConfig.homepage.originalHomeRoute || "/home"}
|
||||
element={<Home />}
|
||||
/>
|
||||
)}
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
{/* Blog page route - only enabled when blogPage.enabled is true */}
|
||||
{siteConfig.blogPage.enabled && (
|
||||
|
||||
719
src/components/AIChatView.tsx
Normal file
719
src/components/AIChatView.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useAction } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
PaperPlaneTilt,
|
||||
Copy,
|
||||
Check,
|
||||
Stop,
|
||||
Trash,
|
||||
FileText,
|
||||
SpinnerGap,
|
||||
Image,
|
||||
Link,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
// Generate a unique session ID for anonymous users
|
||||
function getSessionId(): string {
|
||||
const key = "ai_chat_session_id";
|
||||
let sessionId = localStorage.getItem(key);
|
||||
if (!sessionId) {
|
||||
sessionId = crypto.randomUUID();
|
||||
localStorage.setItem(key, sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
interface AIChatViewProps {
|
||||
contextId: string; // Slug or "write-page"
|
||||
pageContent?: string; // Optional page content for context
|
||||
onClose?: () => void; // Optional close handler
|
||||
hideAttachments?: boolean; // Hide image/link attachment buttons (for right sidebar)
|
||||
}
|
||||
|
||||
export default function AIChatView({
|
||||
contextId,
|
||||
pageContent,
|
||||
onClose,
|
||||
hideAttachments = false,
|
||||
}: AIChatViewProps) {
|
||||
// State
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const [copiedMessageIndex, setCopiedMessageIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [chatId, setChatId] = useState<Id<"aiChats"> | null>(null);
|
||||
const [hasLoadedContext, setHasLoadedContext] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{
|
||||
type: "image" | "link";
|
||||
storageId?: Id<"_storage">;
|
||||
url?: string;
|
||||
file?: File;
|
||||
preview?: string;
|
||||
scrapedContent?: string;
|
||||
title?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [linkInputValue, setLinkInputValue] = useState("");
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Session ID
|
||||
const sessionId = getSessionId();
|
||||
|
||||
// Convex hooks
|
||||
const chat = useQuery(
|
||||
api.aiChats.getAIChatByContext,
|
||||
chatId ? { sessionId, contextId } : "skip",
|
||||
);
|
||||
|
||||
const getOrCreateChat = useMutation(api.aiChats.getOrCreateAIChat);
|
||||
const addUserMessage = useMutation(api.aiChats.addUserMessage);
|
||||
const addUserMessageWithAttachments = useMutation(
|
||||
api.aiChats.addUserMessageWithAttachments,
|
||||
);
|
||||
const generateUploadUrl = useMutation(api.aiChats.generateUploadUrl);
|
||||
const clearChatMutation = useMutation(api.aiChats.clearChat);
|
||||
const setPageContext = useMutation(api.aiChats.setPageContext);
|
||||
const generateResponse = useAction(api.aiChatActions.generateResponse);
|
||||
|
||||
// Initialize chat
|
||||
useEffect(() => {
|
||||
const initChat = async () => {
|
||||
const id = await getOrCreateChat({ sessionId, contextId });
|
||||
setChatId(id);
|
||||
};
|
||||
initChat();
|
||||
}, [sessionId, contextId, getOrCreateChat]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [chat?.messages]);
|
||||
|
||||
// Prevent page scroll when clicking input container
|
||||
const handleInputContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only prevent if clicking the container itself, not the textarea
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Focus input after mount with delay to prevent scroll jump
|
||||
useEffect(() => {
|
||||
// Use setTimeout to delay focus until after layout settles
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [inputValue, adjustTextareaHeight]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// "/" to focus input (when not in input/textarea)
|
||||
if (
|
||||
e.key === "/" &&
|
||||
document.activeElement?.tagName !== "INPUT" &&
|
||||
document.activeElement?.tagName !== "TEXTAREA"
|
||||
) {
|
||||
e.preventDefault();
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Handle image upload
|
||||
const handleImageUpload = async (file: File) => {
|
||||
if (!chatId) return;
|
||||
|
||||
// Check attachment limit (max 3 images)
|
||||
const currentImageCount = attachments.filter(
|
||||
(a) => a.type === "image",
|
||||
).length;
|
||||
if (currentImageCount >= 3) {
|
||||
alert("Maximum 3 images per message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert("Please upload a PNG, JPEG, GIF, or WebP image");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (3MB max)
|
||||
const maxSize = 3 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
alert("Image must be smaller than 3MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// Get upload URL
|
||||
const uploadUrl = await generateUploadUrl();
|
||||
|
||||
// Upload file
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
const response = await result.json();
|
||||
const storageId = response.storageId;
|
||||
|
||||
// Add to attachments
|
||||
const preview = URL.createObjectURL(file);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "image",
|
||||
storageId: storageId as Id<"_storage">,
|
||||
file,
|
||||
preview,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading image:", error);
|
||||
alert("Failed to upload image");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle link attachment
|
||||
const handleAddLink = () => {
|
||||
if (!linkInputValue.trim()) return;
|
||||
|
||||
// Check attachment limit (max 3 links)
|
||||
const currentLinkCount = attachments.filter(
|
||||
(a) => a.type === "link",
|
||||
).length;
|
||||
if (currentLinkCount >= 3) {
|
||||
alert("Maximum 3 links per message");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = linkInputValue.trim();
|
||||
try {
|
||||
new URL(url); // Validate URL
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "link",
|
||||
url,
|
||||
},
|
||||
]);
|
||||
setLinkInputValue("");
|
||||
setShowLinkModal(false);
|
||||
} catch {
|
||||
alert("Please enter a valid URL");
|
||||
}
|
||||
};
|
||||
|
||||
// Remove attachment
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const newAttachments = [...prev];
|
||||
const removed = newAttachments[index];
|
||||
if (removed.preview) {
|
||||
URL.revokeObjectURL(removed.preview);
|
||||
}
|
||||
newAttachments.splice(index, 1);
|
||||
return newAttachments;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle send message
|
||||
const handleSend = async () => {
|
||||
if (
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
!chatId ||
|
||||
isLoading
|
||||
)
|
||||
return;
|
||||
|
||||
const message = inputValue.trim();
|
||||
setInputValue("");
|
||||
setIsStopped(false);
|
||||
|
||||
// Handle clear command
|
||||
if (message.toLowerCase() === "clear") {
|
||||
await clearChatMutation({ chatId });
|
||||
setHasLoadedContext(false);
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare attachments for sending
|
||||
const attachmentsToSend = attachments.map((att) => ({
|
||||
type: att.type as "image" | "link",
|
||||
storageId: att.storageId,
|
||||
url: att.url,
|
||||
scrapedContent: att.scrapedContent,
|
||||
title: att.title,
|
||||
}));
|
||||
|
||||
// Add user message with attachments
|
||||
if (attachmentsToSend.length > 0) {
|
||||
await addUserMessageWithAttachments({
|
||||
chatId,
|
||||
content: message || "",
|
||||
attachments: attachmentsToSend,
|
||||
});
|
||||
} else {
|
||||
await addUserMessage({ chatId, content: message });
|
||||
}
|
||||
|
||||
// Clear attachments
|
||||
attachments.forEach((att) => {
|
||||
if (att.preview) {
|
||||
URL.revokeObjectURL(att.preview);
|
||||
}
|
||||
});
|
||||
setAttachments([]);
|
||||
|
||||
// Generate AI response
|
||||
setIsLoading(true);
|
||||
setIsStopped(false);
|
||||
setError(null);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await generateResponse({
|
||||
chatId,
|
||||
userMessage: message || "",
|
||||
pageContext: hasLoadedContext ? undefined : pageContent,
|
||||
attachments:
|
||||
attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
const errorMessage =
|
||||
(error as Error).message || "Failed to generate response";
|
||||
setError(errorMessage);
|
||||
console.error("Error generating response:", error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stop generation
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setIsStopped(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key press in textarea
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle copy message
|
||||
const handleCopy = async (content: string, index: number) => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedMessageIndex(index);
|
||||
setTimeout(() => setCopiedMessageIndex(null), 2000);
|
||||
};
|
||||
|
||||
// Handle load page context
|
||||
const handleLoadContext = async () => {
|
||||
if (!chatId || !pageContent) return;
|
||||
|
||||
await setPageContext({ chatId, pageContext: pageContent });
|
||||
setHasLoadedContext(true);
|
||||
};
|
||||
|
||||
// Handle clear chat
|
||||
const handleClear = async () => {
|
||||
if (!chatId) return;
|
||||
await clearChatMutation({ chatId });
|
||||
setHasLoadedContext(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const messages = chat?.messages || [];
|
||||
|
||||
// Component to render image attachment with URL fetching
|
||||
const ImageAttachment = ({ storageId }: { storageId: Id<"_storage"> }) => {
|
||||
const imageUrl = useQuery(api.aiChats.getStorageUrl, { storageId });
|
||||
if (!imageUrl) {
|
||||
return <div className="ai-chat-attachment-loading">Loading image...</div>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Attachment"
|
||||
className="ai-chat-attachment-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-view">
|
||||
{/* Header with actions */}
|
||||
<div className="ai-chat-header">
|
||||
<span className="ai-chat-title">Agent</span>
|
||||
<div className="ai-chat-header-actions">
|
||||
{pageContent && !hasLoadedContext && (
|
||||
<button
|
||||
className="ai-chat-load-context-button"
|
||||
onClick={handleLoadContext}
|
||||
title="Load page content as context"
|
||||
>
|
||||
<FileText size={16} weight="bold" />
|
||||
<span>Load Page</span>
|
||||
</button>
|
||||
)}
|
||||
{pageContent && hasLoadedContext && (
|
||||
<span className="ai-chat-context-loaded">
|
||||
<Check size={14} weight="bold" />
|
||||
Context loaded
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="ai-chat-clear-button"
|
||||
onClick={handleClear}
|
||||
title="Clear chat (or type 'clear')"
|
||||
disabled={messages.length === 0}
|
||||
>
|
||||
<Trash size={16} weight="bold" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="ai-chat-close-button"
|
||||
onClick={onClose}
|
||||
title="Close chat"
|
||||
>
|
||||
Back to Editor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="ai-chat-messages">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="ai-chat-empty">
|
||||
<p>Ask a question.</p>
|
||||
<p className="ai-chat-empty-hint">
|
||||
Press Enter to send, Shift+Enter for new line, or type
|
||||
"clear" to reset.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.timestamp}-${index}`}
|
||||
className={`ai-chat-message ai-chat-message-${message.role}`}
|
||||
>
|
||||
<div className="ai-chat-message-content">
|
||||
{message.role === "assistant" ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<>
|
||||
{message.content && <p>{message.content}</p>}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ai-chat-attachments">
|
||||
{message.attachments.map((att, attIndex) => (
|
||||
<div key={attIndex} className="ai-chat-attachment">
|
||||
{att.type === "image" && att.storageId && (
|
||||
<ImageAttachment storageId={att.storageId} />
|
||||
)}
|
||||
{att.type === "link" && att.url && (
|
||||
<a
|
||||
href={att.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ai-chat-attachment-link"
|
||||
>
|
||||
<Link size={16} />
|
||||
<span>{att.title || att.url}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{message.role === "assistant" && (
|
||||
<button
|
||||
className="ai-chat-copy-button"
|
||||
onClick={() => handleCopy(message.content, index)}
|
||||
title="Copy message"
|
||||
>
|
||||
{copiedMessageIndex === index ? (
|
||||
<Check size={14} weight="bold" />
|
||||
) : (
|
||||
<Copy size={14} weight="bold" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="ai-chat-message ai-chat-message-assistant ai-chat-loading">
|
||||
<div className="ai-chat-loading-content">
|
||||
<SpinnerGap size={18} weight="bold" className="ai-chat-spinner" />
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
<button
|
||||
className="ai-chat-stop-button"
|
||||
onClick={handleStop}
|
||||
title="Stop generating"
|
||||
>
|
||||
<Stop size={16} weight="bold" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stopped state */}
|
||||
{isStopped && !isLoading && (
|
||||
<div className="ai-chat-stopped">Generation stopped</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="ai-chat-message ai-chat-message-assistant ai-chat-error">
|
||||
<div className="ai-chat-message-content">
|
||||
<p style={{ margin: 0 }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Attachments preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="ai-chat-attachments-preview">
|
||||
{attachments.map((att, index) => (
|
||||
<div key={index} className="ai-chat-attachment-preview">
|
||||
{att.type === "image" && att.preview && (
|
||||
<>
|
||||
<img
|
||||
src={att.preview}
|
||||
alt="Preview"
|
||||
className="ai-chat-attachment-preview-image"
|
||||
/>
|
||||
<button
|
||||
className="ai-chat-attachment-remove"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{att.type === "link" && (
|
||||
<>
|
||||
<Link size={16} />
|
||||
<span className="ai-chat-attachment-preview-url">
|
||||
{att.url}
|
||||
</span>
|
||||
<button
|
||||
className="ai-chat-attachment-remove"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
className="ai-chat-input-container"
|
||||
onClick={handleInputContainerClick}
|
||||
>
|
||||
<div className="ai-chat-input-wrapper">
|
||||
{!hideAttachments && (
|
||||
<div className="ai-chat-input-actions">
|
||||
<label
|
||||
className="ai-chat-attach-button"
|
||||
title="Upload image (max 3)"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
e.target.value = ""; // Reset input
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isUploading ||
|
||||
attachments.filter((a) => a.type === "image").length >= 3
|
||||
}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<SpinnerGap
|
||||
size={18}
|
||||
weight="bold"
|
||||
className="ai-chat-spinner"
|
||||
/>
|
||||
) : (
|
||||
<Image size={18} weight="regular" />
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
className="ai-chat-attach-button"
|
||||
onClick={() => setShowLinkModal(true)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isUploading ||
|
||||
attachments.filter((a) => a.type === "link").length >= 3
|
||||
}
|
||||
title="Add link (max 3)"
|
||||
>
|
||||
<Link size={18} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-chat-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className="ai-chat-send-button"
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!inputValue.trim() && attachments.length === 0) || isLoading
|
||||
}
|
||||
title="Send message (Enter)"
|
||||
>
|
||||
<PaperPlaneTilt size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link input modal */}
|
||||
{showLinkModal && (
|
||||
<div
|
||||
className="ai-chat-link-modal"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ai-chat-link-modal-content">
|
||||
<h3>Add Link</h3>
|
||||
<input
|
||||
type="url"
|
||||
value={linkInputValue}
|
||||
onChange={(e) => setLinkInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleAddLink();
|
||||
} else if (e.key === "Escape") {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="ai-chat-link-input"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="ai-chat-link-modal-actions">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLinkModal(false);
|
||||
setLinkInputValue("");
|
||||
}}
|
||||
className="ai-chat-link-modal-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLink}
|
||||
className="ai-chat-link-modal-add"
|
||||
disabled={!linkInputValue.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,19 +37,23 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
|
||||
// Normalize images
|
||||
const normalizedImages = config.images.map(normalizeImage);
|
||||
|
||||
|
||||
// Check if scrolling mode (default true for backwards compatibility)
|
||||
const isScrolling = config.scrolling !== false;
|
||||
|
||||
// home logos scrolling settings
|
||||
const isScrolling = config.scrolling !== true;
|
||||
|
||||
// For static mode, limit to maxItems (default 4)
|
||||
const maxItems = config.maxItems ?? 4;
|
||||
const displayImages = isScrolling
|
||||
const displayImages = isScrolling
|
||||
? [...normalizedImages, ...normalizedImages] // Duplicate for seamless scroll
|
||||
: normalizedImages.slice(0, maxItems); // Limit for static grid
|
||||
|
||||
// Render logo item (shared between modes)
|
||||
const renderLogo = (logo: LogoItem, index: number) => (
|
||||
<div key={`${logo.src}-${index}`} className={isScrolling ? "logo-marquee-item" : "logo-static-item"}>
|
||||
<div
|
||||
key={`${logo.src}-${index}`}
|
||||
className={isScrolling ? "logo-marquee-item" : "logo-static-item"}
|
||||
>
|
||||
{logo.href ? (
|
||||
<a
|
||||
href={logo.href}
|
||||
@@ -77,9 +81,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
|
||||
return (
|
||||
<div className="logo-marquee-container">
|
||||
{config.title && (
|
||||
<p className="logo-marquee-title">{config.title}</p>
|
||||
)}
|
||||
{config.title && <p className="logo-marquee-title">{config.title}</p>}
|
||||
{isScrolling ? (
|
||||
// Scrolling marquee mode
|
||||
<div
|
||||
@@ -96,9 +98,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
</div>
|
||||
) : (
|
||||
// Static grid mode
|
||||
<div className="logo-static-grid">
|
||||
{displayImages.map(renderLogo)}
|
||||
</div>
|
||||
<div className="logo-static-grid">{displayImages.map(renderLogo)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
// Right sidebar component - maintains layout spacing when sidebars are enabled
|
||||
// CopyPageDropdown is now rendered in the main content area instead
|
||||
export default function RightSidebar() {
|
||||
// Right sidebar component
|
||||
// Conditionally renders AI chat when enabled via frontmatter and siteConfig
|
||||
import AIChatView from "./AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
interface RightSidebarProps {
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true
|
||||
pageContent?: string; // Page markdown content for AI context
|
||||
slug?: string; // Page/post slug for chat context ID
|
||||
}
|
||||
|
||||
export default function RightSidebar({
|
||||
aiChatEnabled = false,
|
||||
pageContent,
|
||||
slug,
|
||||
}: RightSidebarProps) {
|
||||
// Check if AI chat should be shown
|
||||
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
|
||||
const showAIChat =
|
||||
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
<aside className="post-sidebar-right">
|
||||
<div className="right-sidebar-ai-chat">
|
||||
<AIChatView
|
||||
contextId={slug}
|
||||
pageContent={pageContent}
|
||||
hideAttachments={true}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Default empty sidebar for layout spacing
|
||||
return (
|
||||
<aside className="post-sidebar-right">
|
||||
<div className="right-sidebar-content">
|
||||
{/* Empty - CopyPageDropdown moved to main content area */}
|
||||
{/* Empty - maintains layout spacing */}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,21 @@ export interface FooterConfig {
|
||||
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
|
||||
}
|
||||
|
||||
// Homepage configuration
|
||||
// Allows setting any page or blog post to serve as the homepage
|
||||
export interface HomepageConfig {
|
||||
type: "default" | "page" | "post"; // Type of homepage: default (standard Home component), page (static page), or post (blog post)
|
||||
slug?: string; // Required if type is "page" or "post" - the slug of the page/post to use as homepage
|
||||
originalHomeRoute?: string; // Route to access the original homepage when custom homepage is set (default: "/home")
|
||||
}
|
||||
|
||||
// AI Chat configuration
|
||||
// Controls the AI writing assistant feature on Write page and content pages
|
||||
export interface AIChatConfig {
|
||||
enabledOnWritePage: boolean; // Show AI chat toggle on /write page
|
||||
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
}
|
||||
|
||||
// Site configuration interface
|
||||
export interface SiteConfig {
|
||||
// Basic site info
|
||||
@@ -150,6 +165,12 @@ export interface SiteConfig {
|
||||
|
||||
// Footer configuration
|
||||
footer: FooterConfig;
|
||||
|
||||
// Homepage configuration
|
||||
homepage: HomepageConfig;
|
||||
|
||||
// AI Chat configuration
|
||||
aiChat: AIChatConfig;
|
||||
}
|
||||
|
||||
// Default site configuration
|
||||
@@ -186,7 +207,7 @@ export const siteConfig: SiteConfig = {
|
||||
},
|
||||
{
|
||||
src: "/images/logos/netlify.svg",
|
||||
href: "https://www.netlify.com/",
|
||||
href: "https://www.netlify.com/utm_source=markdownfast",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/firecrawl.svg",
|
||||
@@ -201,8 +222,8 @@ export const siteConfig: SiteConfig = {
|
||||
href: "https://markdown.fast/setup-guide",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-5.svg",
|
||||
href: "https://markdown.fast/setup-guide",
|
||||
src: "/images/logos/agentmail.svg",
|
||||
href: "https://agentmail.to/utm_source=markdownfast",
|
||||
},
|
||||
],
|
||||
position: "above-footer",
|
||||
@@ -321,6 +342,24 @@ export const siteConfig: SiteConfig = {
|
||||
|
||||
Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Opus 4.5. Follow on [Twitter/X](https://x.com/waynesutton), [LinkedIn](https://www.linkedin.com/in/waynesutton/), and [GitHub](https://github.com/waynesutton). This project is licensed under the MIT [License](https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file).`,
|
||||
},
|
||||
|
||||
// Homepage configuration
|
||||
// Set any page or blog post to serve as the homepage
|
||||
// Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
|
||||
// Featured section is NOT shown on custom homepage (only on default Home component)
|
||||
homepage: {
|
||||
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
|
||||
slug: "undefined", // Required if type is "page" or "post" - the slug of the page/post to use default is undefined
|
||||
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
|
||||
},
|
||||
|
||||
// AI Chat configuration
|
||||
// Controls the AI writing assistant powered by Claude
|
||||
// Requires ANTHROPIC_API_KEY environment variable in Convex dashboard
|
||||
aiChat: {
|
||||
enabledOnWritePage: true, // Show AI chat toggle on /write page
|
||||
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
},
|
||||
};
|
||||
|
||||
// Export the config as default for easy importing
|
||||
|
||||
@@ -18,11 +18,25 @@ const SITE_URL = "https://markdown.fast";
|
||||
const SITE_NAME = "markdown sync framework";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
|
||||
export default function Post() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
interface PostProps {
|
||||
slug?: string; // Optional slug prop when used as homepage
|
||||
isHomepage?: boolean; // Flag to indicate this is the homepage
|
||||
homepageType?: "page" | "post"; // Type of homepage content
|
||||
}
|
||||
|
||||
export default function Post({
|
||||
slug: propSlug,
|
||||
isHomepage = false,
|
||||
homepageType,
|
||||
}: PostProps = {}) {
|
||||
const { slug: routeSlug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setHeadings, setActiveId } = useSidebar();
|
||||
|
||||
// Use prop slug if provided (for homepage), otherwise use route slug
|
||||
const slug = propSlug || routeSlug;
|
||||
|
||||
// Check for page first, then post
|
||||
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
|
||||
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
|
||||
@@ -196,8 +210,8 @@ export default function Post() {
|
||||
return (
|
||||
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
|
||||
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
|
||||
{/* Hide back-button when sidebars are enabled */}
|
||||
{!hasAnySidebar && (
|
||||
{/* Hide back-button when sidebars are enabled or when used as homepage */}
|
||||
{!hasAnySidebar && !isHomepage && (
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
@@ -269,8 +283,14 @@ export default function Post() {
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
|
||||
{hasRightSidebar && <RightSidebar />}
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
{hasRightSidebar && (
|
||||
<RightSidebar
|
||||
aiChatEnabled={page.aiChat}
|
||||
pageContent={page.content}
|
||||
slug={page.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -319,8 +339,8 @@ export default function Post() {
|
||||
return (
|
||||
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
|
||||
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
|
||||
{/* Hide back-button when sidebars are enabled */}
|
||||
{!hasAnySidebar && (
|
||||
{/* Hide back-button when sidebars are enabled or when used as homepage */}
|
||||
{!hasAnySidebar && !isHomepage && (
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
@@ -475,8 +495,14 @@ export default function Post() {
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
|
||||
{hasRightSidebar && <RightSidebar />}
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
{hasRightSidebar && (
|
||||
<RightSidebar
|
||||
aiChatEnabled={post.aiChat}
|
||||
pageContent={post.content}
|
||||
slug={post.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
File,
|
||||
Warning,
|
||||
TextAa,
|
||||
ChatCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Moon, Sun, Cloud } from "lucide-react";
|
||||
import { Half2Icon } from "@radix-ui/react-icons";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useFont } from "../context/FontContext";
|
||||
import AIChatView from "../components/AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Frontmatter field definitions for blog posts
|
||||
const POST_FIELDS = [
|
||||
@@ -37,11 +40,20 @@ const POST_FIELDS = [
|
||||
{ name: "featured", required: false, example: "true" },
|
||||
{ name: "featuredOrder", required: false, example: "1" },
|
||||
{ name: "authorName", required: false, example: '"Jane Doe"' },
|
||||
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
|
||||
{
|
||||
name: "authorImage",
|
||||
required: false,
|
||||
example: '"/images/authors/jane.png"',
|
||||
},
|
||||
{ name: "layout", required: false, example: '"sidebar"' },
|
||||
{ name: "rightSidebar", required: false, example: "true" },
|
||||
{ name: "showFooter", required: false, example: "true" },
|
||||
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
|
||||
{
|
||||
name: "footer",
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Frontmatter field definitions for pages
|
||||
@@ -56,11 +68,20 @@ const PAGE_FIELDS = [
|
||||
{ name: "featured", required: false, example: "true" },
|
||||
{ name: "featuredOrder", required: false, example: "1" },
|
||||
{ name: "authorName", required: false, example: '"Jane Doe"' },
|
||||
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
|
||||
{
|
||||
name: "authorImage",
|
||||
required: false,
|
||||
example: '"/images/authors/jane.png"',
|
||||
},
|
||||
{ name: "layout", required: false, example: '"sidebar"' },
|
||||
{ name: "rightSidebar", required: false, example: "true" },
|
||||
{ name: "showFooter", required: false, example: "true" },
|
||||
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
|
||||
{
|
||||
name: "footer",
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Generate frontmatter template based on content type
|
||||
@@ -160,8 +181,12 @@ export default function Write() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [font, setFont] = useState<"serif" | "sans" | "monospace">("serif");
|
||||
const [isAIChatMode, setIsAIChatMode] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Check if AI chat is enabled for write page
|
||||
const aiChatEnabled = siteConfig.aiChat.enabledOnWritePage;
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedContent = localStorage.getItem(STORAGE_KEY_CONTENT);
|
||||
@@ -188,7 +213,9 @@ export default function Write() {
|
||||
// Use saved font preference, or fall back to global font, or default to serif
|
||||
if (
|
||||
savedFont &&
|
||||
(savedFont === "serif" || savedFont === "sans" || savedFont === "monospace")
|
||||
(savedFont === "serif" ||
|
||||
savedFont === "sans" ||
|
||||
savedFont === "monospace")
|
||||
) {
|
||||
setFont(savedFont);
|
||||
} else {
|
||||
@@ -212,6 +239,12 @@ export default function Write() {
|
||||
localStorage.setItem(STORAGE_KEY_FONT, font);
|
||||
}, [font]);
|
||||
|
||||
// Prevent scroll when switching to AI chat mode
|
||||
useEffect(() => {
|
||||
// Lock scroll position to prevent jump when AI chat mounts
|
||||
window.scrollTo(0, 0);
|
||||
}, [isAIChatMode]);
|
||||
|
||||
// Toggle font between serif, sans-serif, and monospace
|
||||
const toggleFont = useCallback(() => {
|
||||
setFont((prev) => {
|
||||
@@ -325,6 +358,34 @@ export default function Write() {
|
||||
|
||||
<div className="write-nav-section">
|
||||
<span className="write-nav-label">Actions</span>
|
||||
{/* AI Chat toggle - only show if enabled in siteConfig */}
|
||||
{aiChatEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Prevent any scroll behavior during mode switch
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
setIsAIChatMode(!isAIChatMode);
|
||||
// Restore scroll position immediately after state change
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
}}
|
||||
className={`write-nav-item ${isAIChatMode ? "active" : ""}`}
|
||||
title={
|
||||
isAIChatMode ? "Switch to text editor" : "Switch to AI Chat"
|
||||
}
|
||||
>
|
||||
<ChatCircle
|
||||
size={18}
|
||||
weight={isAIChatMode ? "fill" : "regular"}
|
||||
/>
|
||||
<span>{isAIChatMode ? "Text Editor" : "Agent"}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClear} className="write-nav-item">
|
||||
<Trash size={18} />
|
||||
<span>Clear</span>
|
||||
@@ -357,54 +418,68 @@ export default function Write() {
|
||||
<main className="write-main">
|
||||
<div className="write-main-header">
|
||||
<h1 className="write-main-title">
|
||||
{contentType === "post" ? "Blog Post" : "Page"}
|
||||
{isAIChatMode
|
||||
? "Agent"
|
||||
: contentType === "post"
|
||||
? "Blog Post"
|
||||
: "Page"}
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`write-copy-btn ${copied ? "copied" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} weight="bold" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopySimple size={16} />
|
||||
<span>Copy All</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!isAIChatMode && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`write-copy-btn ${copied ? "copied" : ""}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} weight="bold" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopySimple size={16} />
|
||||
<span>Copy All</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
autoFocus
|
||||
style={{ fontFamily: FONTS[font] }}
|
||||
/>
|
||||
{/* Conditionally render textarea or AI chat */}
|
||||
{isAIChatMode ? (
|
||||
<div className="write-ai-chat-container">
|
||||
<AIChatView contextId="write-page" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
style={{ fontFamily: FONTS[font] }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer with stats */}
|
||||
<div className="write-main-footer">
|
||||
<div className="write-stats">
|
||||
<span>{words} words</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{lines} lines</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{characters} chars</span>
|
||||
{/* Footer with stats - only show in text editor mode */}
|
||||
{!isAIChatMode && (
|
||||
<div className="write-main-footer">
|
||||
<div className="write-stats">
|
||||
<span>{words} words</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{lines} lines</span>
|
||||
<span className="write-stats-divider" />
|
||||
<span>{characters} chars</span>
|
||||
</div>
|
||||
<div className="write-save-hint">
|
||||
Save to{" "}
|
||||
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
|
||||
then <code>npm run sync</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="write-save-hint">
|
||||
Save to{" "}
|
||||
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
|
||||
then <code>npm run sync</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar: Frontmatter fields */}
|
||||
|
||||
@@ -318,6 +318,12 @@ body {
|
||||
/* padding: 40px 24px; */
|
||||
}
|
||||
|
||||
/* Expand main-content to full width when sidebar layout is used */
|
||||
.main-content:has(.post-page-with-sidebar) {
|
||||
max-width: 100%;
|
||||
padding: 0 0px;
|
||||
}
|
||||
|
||||
/* Wide content layout for pages that need more space (stats, etc.) */
|
||||
.main-content-wide {
|
||||
flex: 1;
|
||||
@@ -341,6 +347,8 @@ body {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
/* nav bar border to match sidebar if sidebar is enabled
|
||||
border-bottom: 1px solid var(--border-sidebar); */
|
||||
}
|
||||
|
||||
/* Logo in top navigation */
|
||||
@@ -655,13 +663,11 @@ body {
|
||||
/* Full-width sidebar layout - breaks out of .main-content constraints */
|
||||
.post-page-with-sidebar {
|
||||
padding-top: 0px;
|
||||
/* Break out of the 680px max-width container */
|
||||
width: calc(100vw - 48px);
|
||||
max-width: none;
|
||||
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
position: relative;
|
||||
/* Add left padding to align content with sidebar edge
|
||||
padding-left: 24px;*/
|
||||
}
|
||||
|
||||
.post-nav {
|
||||
@@ -892,6 +898,7 @@ body {
|
||||
@media (min-width: 1135px) {
|
||||
.post-content-with-sidebar:has(.post-sidebar-right) {
|
||||
grid-template-columns: 240px 1fr 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Adjust main content padding when right sidebar exists */
|
||||
@@ -947,7 +954,7 @@ body {
|
||||
-ms-overflow-style: none; /* IE */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
background-color: var(--bg-sidebar);
|
||||
margin-right: -24px;
|
||||
margin-right: 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 24px;
|
||||
@@ -1104,6 +1111,7 @@ body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.post-content-with-sidebar {
|
||||
@@ -4465,7 +4473,9 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 280px;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden; /* Prevent any page-level scroll */
|
||||
}
|
||||
|
||||
/* Left Sidebar */
|
||||
@@ -4474,6 +4484,8 @@ body {
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
overflow-y: auto;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.write-sidebar-header {
|
||||
@@ -4577,7 +4589,14 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When AI chat is active, ensure proper height constraints */
|
||||
.write-main:has(.write-ai-chat-container) {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.write-main-header {
|
||||
@@ -4699,6 +4718,8 @@ body {
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
overflow-y: auto;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.write-sidebar-right .write-sidebar-header {
|
||||
@@ -4862,6 +4883,15 @@ body {
|
||||
|
||||
.write-main {
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* On mobile, AI chat gets fixed height */
|
||||
.write-main:has(.write-ai-chat-container) {
|
||||
height: calc(100vh - 120px); /* Account for mobile nav */
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.write-main-header {
|
||||
@@ -4932,3 +4962,830 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Chat Styles */
|
||||
/* Write page AI chat container */
|
||||
.write-ai-chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.write-ai-chat-container .ai-chat-view {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
}
|
||||
|
||||
.ai-chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow shrinking in flex container */
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-context-loaded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ai-chat-clear-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-clear-button:hover:not(:disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-clear-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-chat-close-button {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-close-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0; /* Critical: allows flex item to shrink below content size */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.ai-chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.ai-chat-empty p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ai-chat-empty-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ai-chat-message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
animation: aiChatFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes aiChatFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-message-user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-chat-message-assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ai-chat-message-content {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-assistant .ai-chat-message-content {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-chat-message-content h1,
|
||||
.ai-chat-message-content h2,
|
||||
.ai-chat-message-content h3,
|
||||
.ai-chat-message-content h4,
|
||||
.ai-chat-message-content h5,
|
||||
.ai-chat-message-content h6 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chat-message-content h1:first-child,
|
||||
.ai-chat-message-content h2:first-child,
|
||||
.ai-chat-message-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ai-chat-message-content ul,
|
||||
.ai-chat-message-content ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content code {
|
||||
font-family:
|
||||
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message-content pre {
|
||||
margin: 8px 0;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-hover);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ai-chat-message-content pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ai-chat-message-content a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ai-chat-message-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-chat-message-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-message:hover .ai-chat-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-chat-copy-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ai-chat-loading-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border-bottom-left-radius: 4px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-spinner {
|
||||
animation: aiChatSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes aiChatSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-stop-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-stop-button:hover {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.ai-chat-stopped {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-error {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ai-chat-error .ai-chat-message-content {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ai-chat-input-container {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attach-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-attach-button:hover:not(:disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-attach-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper:focus-within {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-input {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
/* Prevent scroll jump when focusing */
|
||||
scroll-margin: 0;
|
||||
}
|
||||
|
||||
.ai-chat-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-send-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
color: var(--bg-primary);
|
||||
background-color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-send-button:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ai-chat-send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Attachments preview */
|
||||
.ai-chat-attachments-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-url {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--bg-hover);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-remove:hover {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Link input modal */
|
||||
.ai-chat-link-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: backdropFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-link-input:focus {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel,
|
||||
.ai-chat-link-modal-add {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-cancel:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add {
|
||||
color: var(--bg-primary);
|
||||
background-color: var(--text-primary);
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-add:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Attachments in messages */
|
||||
.ai-chat-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-loading {
|
||||
padding: 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* AI Chat in Write Page - overrides for Write context */
|
||||
.write-ai-chat-container .ai-chat-view {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* AI Chat toggle button in Write nav */
|
||||
.write-nav-item.ai-chat-active {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.write-nav-item.ai-chat-active:hover {
|
||||
background-color: var(--text-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* AI Chat in Right Sidebar */
|
||||
.right-sidebar-ai-chat {
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-view {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-title {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-messages {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-message {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-message-content {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-input-container {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-input {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.right-sidebar-ai-chat .ai-chat-send-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Theme-specific AI Chat styles */
|
||||
:root[data-theme="dark"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #f5f5f5;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .ai-chat-message-content code {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .ai-chat-message-content pre {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #1a1a1a;
|
||||
color: #faf8f5;
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .ai-chat-message-user .ai-chat-message-content {
|
||||
background-color: #1e293b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Mobile responsive AI Chat */
|
||||
@media (max-width: 768px) {
|
||||
.ai-chat-header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-title {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ai-chat-messages {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-message {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ai-chat-message-content {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ai-chat-input-container {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-load-context-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-chat-close-button {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-chat-attachments-preview {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-preview-url {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.ai-chat-link-modal-content {
|
||||
padding: 16px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ai-chat-attachment-image {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ai-chat-header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-chat-empty {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-empty p {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user