mono/packages/ui/src/components/PageActions.tsx

573 lines
24 KiB
TypeScript

import { useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel
} from "@/components/ui/dropdown-menu";
import { T, translate } from "@/i18n";
import { PagePickerDialog } from "./widgets/PagePickerDialog";
import { PageCreationWizard } from "./widgets/PageCreationWizard";
import { CategoryManager } from "./widgets/CategoryManager";
import { cn } from "@/lib/utils";
interface Page {
id: string;
title: string;
content: any;
visible: boolean;
is_public: boolean;
owner: string;
slug: string;
parent: string | null;
meta?: any;
}
interface PageActionsProps {
page: Page;
isOwner: boolean;
isEditMode?: boolean;
onToggleEditMode?: () => void;
onPageUpdate: (updatedPage: Page) => void;
onDelete?: () => void;
className?: string;
showLabels?: boolean;
}
export const PageActions = ({
page,
isOwner,
isEditMode = false,
onToggleEditMode,
onPageUpdate,
onDelete,
className,
showLabels = true
}: PageActionsProps) => {
const [loading, setLoading] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
const [showCreationWizard, setShowCreationWizard] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const invalidatePageCache = async () => {
try {
const session = await supabase.auth.getSession();
const token = session.data.session?.access_token;
if (!token) return;
// Invalidate API and HTML routes
// API: /api/user-page/USER_ID/SLUG
// HTML: /user/USER_ID/pages/SLUG
const apiPath = `/api/user-page/${page.owner}/${page.slug}`;
const htmlPath = `/user/${page.owner}/pages/${page.slug}`;
await fetch(`${baseUrl}/api/cache/invalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
paths: [apiPath, htmlPath]
})
});
console.log('Cache invalidated for:', page.slug);
} catch (e) {
console.error('Failed to invalidate cache:', e);
}
};
const handleParentUpdate = async (parentId: string | null) => {
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ parent: parentId })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, parent: parentId });
toast.success(translate('Page parent updated'));
invalidatePageCache();
} catch (error) {
console.error('Error updating page parent:', error);
toast.error(translate('Failed to update page parent'));
} finally {
setLoading(false);
}
};
const handleMetaUpdate = (newMeta: any) => {
// PageActions locally updates the page object.
// Ideally we should reload the page via UserPage but this gives instant feedback.
onPageUpdate({ ...page, meta: newMeta });
// NOTE: If meta update persists to DB elsewhere (CategoryManager), it should probably handle invalidation too.
// But if CategoryManager is purely local until save, then we do nothing.
// Looking at CategoryManager usage, it likely saves.
// We might want to pass invalidatePageCache to it or call it here if we know it saved.
// Use timeout to debounce invalidation? For now assume CategoryManager handles its own saving/invalidation or we rely on page refresh.
// Actually, CategoryManager props has "onPageMetaUpdate", which updates local state.
// If CategoryManager saves to DB, it should invalidate.
// Let's stick to the handlers we control here.
};
const handleToggleVisibility = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, visible: !page.visible });
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
invalidatePageCache();
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
} finally {
setLoading(false);
}
};
const handleTogglePublic = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, is_public: !page.is_public });
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));
invalidatePageCache();
} catch (error) {
console.error('Error toggling public status:', error);
toast.error(translate('Failed to update page status'));
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
const url = window.location.href;
const title = page.title || 'PolyMech Page';
if (navigator.share && navigator.canShare({ url, title })) {
try {
await navigator.share({ url, title });
return;
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error('Share failed', e);
}
}
try {
await navigator.clipboard.writeText(url);
toast.success("Link copied to clipboard");
} catch (e) {
console.error('Clipboard failed', e);
toast.error("Failed to copy link");
}
};
const processPageContent = (content: any): string => {
if (!content) return '';
if (typeof content === 'string') return content;
let markdown = '';
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
try {
// Determine content root
// Some versions might have content directly, others wrapped in { pages: { [id]: { containers: ... } } }
let root = content;
if (content.pages) {
// Try to find the page by ID or take the first one
const pageIdKey = `page-${page.id}`;
if (content.pages[pageIdKey]) {
root = content.pages[pageIdKey];
} else {
// Fallback: take first key
const keys = Object.keys(content.pages);
if (keys.length > 0) root = content.pages[keys[0]];
}
}
// Traverse containers
if (root.containers && Array.isArray(root.containers)) {
root.containers.forEach((container: any) => {
if (container.widgets && Array.isArray(container.widgets)) {
container.widgets.forEach((widget: any) => {
if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) {
markdown += widget.props.content + '\n\n';
}
// Future: Handle other widgets if needed
});
}
});
} else if (root.widgets && Array.isArray(root.widgets)) { // Fallback for simple structure
root.widgets.forEach((widget: any) => {
if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) {
markdown += widget.props.content + '\n\n';
}
});
}
} catch (e) {
console.error('Error parsing page content:', e);
return JSON.stringify(content, null, 2); // Fallback to raw JSON
}
// URL Resolution logic is handled by markdown-text widget content usually containing relative URLs
// If we need to process them:
// markdown = markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => { ... });
// For now returning raw markdown as user requested "mind url" likely meant for PDF which runs server side.
// Client side export usually keeps links as is unless they are purely internal IDs.
return markdown;
};
const getSlug = (text: string) => text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
const handleExportMarkdown = () => {
try {
let content = processPageContent(page.content);
// Generate TOC
const lines = content.split('\n');
let toc = '# Table of Contents\n\n';
let hasHeadings = false;
lines.forEach(line => {
// Determine header level
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
hasHeadings = true;
const level = match[1].length;
const text = match[2];
const slug = getSlug(text);
const indent = ' '.repeat(level - 1);
toc += `${indent}- [${text}](#${slug})\n`;
}
});
if (hasHeadings) {
content = `${toc}\n---\n\n${content}`;
}
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Markdown downloaded");
} catch (e) {
console.error("Markdown export failed", e);
toast.error("Failed to export Markdown");
}
};
const handleEmbed = async () => {
// Embed logic: Copy iframe code
// Route: /embed/:id is currently for posts. But maybe it works for pages if we update the backend?
// Wait, current serving index.ts handleGetEmbed fetches from 'posts' table only.
// User asked for "embed (HTML)".
// Assuming we should point to a route that RENDERS the page.
// If we don't have a dedicated page embed route yet, we might need one.
// However, the user said "see dedicated app route ( @[src/main-embed.tsx] )".
// This suggests the frontend app handles it.
// The backend route `/embed/:id` serves the HTML that bootstraps `main-embed.tsx`.
// So we just need to use that URL. BUT `handleGetEmbed` in backend fetches from `posts`.
// We probably need `handleGetEmbedPage` or update `handleGetEmbed` to support pages.
// For now, let's assume valid URL structure is `/embed/page/:id` which we plan to add or `/embed/:id` if we unify.
// Let's use `/embed/page/${page.id}` in the snippet and ensure backend supports it.
const embedUrl = `${baseUrl}/embed/page/${page.id}`;
const iframeCode = `<iframe src="${embedUrl}" width="100%" height="600" frameborder="0" allowfullscreen></iframe>`;
try {
await navigator.clipboard.writeText(iframeCode);
toast.success("Embed code copied to clipboard");
} catch (e) {
toast.error("Failed to copy embed code");
}
};
const handleExportAstro = async () => {
try {
// Re-use markdown export logic
let content = processPageContent(page.content);
const slug = getSlug(page.title || 'page');
// Generate TOC (same as markdown export)
const lines = content.split('\n');
let toc = '# Table of Contents\n\n';
let hasHeadings = false;
lines.forEach(line => {
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
hasHeadings = true;
const level = match[1].length;
const text = match[2];
const id = getSlug(text);
const indent = ' '.repeat(level - 1);
toc += `${indent}- [${text}](#${id})\n`;
}
});
if (hasHeadings) {
content = `${toc}\n---\n\n${content}`;
}
// Construct Front Matter
const safeTitle = (page.title || 'Untitled').replace(/"/g, '\\"');
const safeSlug = (page.slug || slug).replace(/"/g, '\\"');
const dateStr = new Date().toISOString().split('T')[0];
// Note: Page interface in PageActions might need update to include tags if we want them
// For now, using standard props we have
const frontMatter = `---
title: "${safeTitle}"
slug: "${safeSlug}"
date: "${dateStr}"
author: "${page.owner}"
draft: ${!page.visible}
---
`;
const finalContent = frontMatter + content;
const blob = new Blob([finalContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${slug}.astro`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Astro export downloaded");
} catch (e) {
console.error("Astro export failed", e);
toast.error("Failed to export Astro");
}
};
const handleExportPdf = async () => {
setIsGeneratingPdf(true);
toast.info("Generating PDF...");
try {
const link = document.createElement('a');
link.href = `${baseUrl}/api/render/pdf/page/${page.id}`;
link.target = "_blank";
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
console.error(e);
toast.error("Failed to download PDF");
} finally {
setIsGeneratingPdf(false);
}
};
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Share Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Share2 className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Share</T></span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Share & Export</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="h-4 w-4 mr-2" />
<span>Copy Link</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<Download className="h-4 w-4 mr-2" />
<span>Export Markdown</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf} disabled={isGeneratingPdf}>
<FileText className="h-4 w-4 mr-2" />
<span>{isGeneratingPdf ? 'Generating PDF...' : 'Export PDF'}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportAstro}>
<FileText className="mr-2 h-4 w-4" />
<span><T>Export Astro</T> (Beta)</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleEmbed}>
<LinkIcon className="h-4 w-4 mr-2" />
<span><T>Embed (HTML)</T></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Owner Controls */}
{isOwner && (
<>
<Button
variant="outline"
size="sm"
onClick={handleToggleVisibility}
disabled={loading}
className={cn(!page.visible && "text-muted-foreground")}
>
{page.visible ? (
<>
<Eye className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Visible</T></span>}
</>
) : (
<>
<EyeOff className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Hidden</T></span>}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleTogglePublic}
disabled={loading}
className={cn(!page.is_public && "text-muted-foreground")}
>
{showLabels ? (
<>
<span className="hidden md:inline"><T>{page.is_public ? 'Public' : 'Private'}</T></span>
<span className="md:hidden"><T>{page.is_public ? 'Pub' : 'Priv'}</T></span>
</>
) : (
<span className="text-xs">{page.is_public ? 'Pub' : 'Priv'}</span>
)}
</Button>
{onToggleEditMode && (
<Button
variant={isEditMode ? "default" : "outline"}
size="sm"
onClick={(e) => { e.stopPropagation(); onToggleEditMode(); }}
className={isEditMode ? "bg-primary text-white" : ""}
>
{isEditMode ? (
<>
<Eye className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>View</T></span>}
</>
) : (
<>
<Edit3 className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Edit</T></span>}
</>
)}
</Button>
)}
{/* Categorization - New All-in-One Component */}
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowCategoryManager(true); }}
className={cn("text-muted-foreground hover:text-foreground", page.meta?.categoryId && "text-primary border-primary")}
title={translate("Manage Categories")}
>
<FolderTree className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
</Button>
<CategoryManager
isOpen={showCategoryManager}
onClose={() => setShowCategoryManager(false)}
currentPageId={page.id}
currentPageMeta={page.meta}
onPageMetaUpdate={handleMetaUpdate}
/>
{/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */}
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowPagePicker(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Set Parent Page")}
>
<GitMerge className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Parent</T></span>}
</Button>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handleParentUpdate}
currentValue={page.parent}
forbiddenIds={[page.id]}
/>
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowCreationWizard(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Add Child Page")}
>
<FilePlus className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Add Child</T></span>}
</Button>
<PageCreationWizard
isOpen={showCreationWizard}
onClose={() => setShowCreationWizard(false)}
parentId={page.id}
/>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
);
};