583 lines
24 KiB
TypeScript
583 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;
|
|
onMetaUpdated?: () => void;
|
|
className?: string;
|
|
showLabels?: boolean;
|
|
}
|
|
|
|
export const PageActions = ({
|
|
page,
|
|
isOwner,
|
|
isEditMode = false,
|
|
onToggleEditMode,
|
|
onPageUpdate,
|
|
onDelete,
|
|
onMetaUpdated,
|
|
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 = async (newMeta: any) => {
|
|
// Update local state immediately for responsive UI
|
|
onPageUpdate({ ...page, meta: newMeta });
|
|
|
|
// Persist to database
|
|
try {
|
|
const { updatePageMeta } = await import('@/lib/db');
|
|
await updatePageMeta(page.id, newMeta);
|
|
invalidatePageCache();
|
|
|
|
// Trigger parent refresh to get updated category_paths
|
|
if (onMetaUpdated) {
|
|
onMetaUpdated();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update page meta:', error);
|
|
toast.error(translate('Failed to update categories'));
|
|
}
|
|
};
|
|
|
|
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}
|
|
filterByType="pages"
|
|
defaultMetaType="pages"
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
};
|