Files
app/app/edit/page.tsx
Albert 0ed2d6c0b3 feat: Improve UI layout and navigation
- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility
- Add logo as favicon
- Add logo to mobile header
- Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile)
- Fix desktop chat layout - container structure prevents voice controls cutoff
- Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons
- Hide Create Node/New Conversation buttons on mobile to save header space
- Make fixed header and voice controls work properly with containers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 14:43:11 +00:00

303 lines
9.1 KiB
TypeScript

'use client';
/**
* Edit Node Page
*
* Editor for reviewing and publishing node drafts generated from conversations.
* Displays the AI-generated draft and allows editing before publishing.
*/
import {
Stack,
Title,
Text,
Paper,
TextInput,
Textarea,
Button,
Group,
Container,
Divider,
Checkbox,
Badge,
Loader,
} from '@mantine/core';
import { useState, useEffect } from 'react';
import { IconDeviceFloppy, IconX, IconRefresh } from '@tabler/icons-react';
import { useAppMachine } from '@/hooks/useAppMachine';
import { useSelector } from '@xstate/react';
import { notifications } from '@mantine/notifications';
interface SuggestedNode {
id: string;
title: string;
body: string;
atp_uri: string;
score: number;
}
export default function EditPage() {
const appActor = useAppMachine();
const pendingDraft = useSelector(appActor, (state) => state.context.pendingNodeDraft);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [suggestedNodes, setSuggestedNodes] = useState<SuggestedNode[]>([]);
const [selectedLinks, setSelectedLinks] = useState<string[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
// Load draft when available
useEffect(() => {
if (pendingDraft) {
setTitle(pendingDraft.title);
setContent(pendingDraft.content);
}
}, [pendingDraft]);
// Fetch link suggestions when content changes
const fetchLinkSuggestions = async () => {
if (!content.trim() || content.trim().length < 50) {
setSuggestedNodes([]);
return;
}
setIsLoadingSuggestions(true);
try {
const response = await fetch('/api/suggest-links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ body: content }),
});
if (!response.ok) {
throw new Error('Failed to fetch suggestions');
}
const suggestions = await response.json();
setSuggestedNodes(suggestions);
} catch (error) {
console.error('[Link Suggestions] Error:', error);
} finally {
setIsLoadingSuggestions(false);
}
};
// Auto-fetch suggestions when content is substantial
useEffect(() => {
const timer = setTimeout(() => {
if (content.trim().length >= 50) {
fetchLinkSuggestions();
}
}, 1000); // Debounce 1 second
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content]); // fetchLinkSuggestions is stable and doesn't need to be in deps
const handlePublish = async () => {
if (!title.trim() || !content.trim()) {
notifications.show({
title: 'Missing content',
message: 'Please provide both a title and content for your node',
color: 'red',
});
return;
}
setIsPublishing(true);
try {
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for authentication
body: JSON.stringify({
title: title.trim(),
body: content.trim(),
links: selectedLinks,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to publish node');
}
const result = await response.json();
// Show success notification
const message = result.warning || 'Your node has been published to your Bluesky account';
notifications.show({
title: 'Node published!',
message,
color: result.warning ? 'yellow' : 'green',
});
// Transition back to conversation view
// (Galaxy view requires the cache, which may have failed)
appActor.send({
type: 'CANCEL_EDIT', // Go back to conversation
});
} catch (error) {
console.error('[Publish Node] Error:', error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to publish node',
color: 'red',
});
} finally {
setIsPublishing(false);
}
};
const handleCancel = () => {
if (pendingDraft) {
appActor.send({ type: 'CANCEL_EDIT' });
} else {
// Manual node creation - go back to conversation
appActor.send({ type: 'NAVIGATE_TO_CONVO' });
}
};
const toggleLinkSelection = (nodeId: string) => {
setSelectedLinks((prev) =>
prev.includes(nodeId)
? prev.filter((id) => id !== nodeId)
: [...prev, nodeId]
);
};
return (
<Container size="md" py="xl" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Stack gap="lg" style={{ flex: 1 }}>
<Group justify="space-between">
<Title order={2}>Edit Node</Title>
<Group gap="md">
<Button
variant="subtle"
color="gray"
leftSection={<IconX size={18} />}
onClick={handleCancel}
disabled={isPublishing}
>
Cancel
</Button>
<Button
variant="filled"
color="blue"
leftSection={<IconDeviceFloppy size={18} />}
onClick={handlePublish}
loading={isPublishing}
disabled={!title.trim() || !content.trim()}
>
Publish Node
</Button>
</Group>
</Group>
<Paper p="xl" withBorder style={{ flex: 1 }}>
<Stack gap="lg">
<TextInput
label="Title"
placeholder="Enter a concise, compelling title"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
size="lg"
required
/>
<Divider />
<Textarea
label="Content"
placeholder="Write your node content in markdown..."
value={content}
onChange={(e) => setContent(e.currentTarget.value)}
minRows={15}
autosize
required
styles={{
input: {
fontFamily: 'monospace',
},
}}
/>
{/* Link Suggestions Section */}
{content.trim().length >= 50 && (
<>
<Divider />
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>Suggested Links</Title>
<Group gap="xs">
{isLoadingSuggestions && <Loader size="sm" />}
<Button
size="xs"
variant="subtle"
leftSection={<IconRefresh size={14} />}
onClick={fetchLinkSuggestions}
disabled={isLoadingSuggestions}
>
Refresh
</Button>
</Group>
</Group>
{suggestedNodes.length === 0 && !isLoadingSuggestions && (
<Text size="sm" c="dimmed">
No similar nodes found. This will be your first node on this topic!
</Text>
)}
{suggestedNodes.map((node) => (
<Paper key={node.id} p="sm" withBorder>
<Stack gap="xs">
<Group gap="xs">
<Checkbox
checked={selectedLinks.includes(node.id)}
onChange={() => toggleLinkSelection(node.id)}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between">
<Text fw={600} size="sm">
{node.title}
</Text>
<Badge size="xs" variant="light">
{(node.score * 100).toFixed(0)}% similar
</Badge>
</Group>
<Text size="xs" c="dimmed" lineClamp={2}>
{node.body}
</Text>
</div>
</Group>
</Stack>
</Paper>
))}
</Stack>
</>
)}
{pendingDraft?.conversationContext && (
<>
<Divider />
<Paper p="md" withBorder style={{ backgroundColor: '#1a1b1e' }}>
<Text size="sm" fw={700} mb="sm">
Conversation Context
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{pendingDraft.conversationContext}
</Text>
</Paper>
</>
)}
</Stack>
</Paper>
</Stack>
</Container>
);
}