generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Failing after 2m44s
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Failing after 2m44s
This commit is contained in:
83
src/components/content/ContentPreviewDialog.tsx
Normal file
83
src/components/content/ContentPreviewDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Text, VStack, HStack, Badge, Separator } from "@chakra-ui/react";
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/overlays/dialog";
|
||||
import { LuCalendar, LuGlobe, LuFileText } from "react-icons/lu";
|
||||
|
||||
interface ContentItem {
|
||||
id: number;
|
||||
title: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ContentPreviewDialogProps {
|
||||
item: ContentItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (details: { open: boolean }) => void;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "published": return "green";
|
||||
case "draft": return "gray";
|
||||
case "scheduled": return "blue";
|
||||
case "review": return "orange";
|
||||
default: return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
export function ContentPreviewDialog({ item, open, onOpenChange }: ContentPreviewDialogProps) {
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} size="lg">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody pb={8}>
|
||||
<VStack gap={4} align="stretch">
|
||||
<HStack gap={4}>
|
||||
<Badge colorPalette={getStatusColor(item.status)} size="lg" variant="solid">
|
||||
{item.status.toUpperCase()}
|
||||
</Badge>
|
||||
<HStack color="fg.muted">
|
||||
<LuGlobe />
|
||||
<Text>{item.platform}</Text>
|
||||
</HStack>
|
||||
<HStack color="fg.muted">
|
||||
<LuCalendar />
|
||||
<Text>{item.date}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Box p={4} bg="bg.subtle" borderRadius="md">
|
||||
<HStack mb={2} color="fg.muted">
|
||||
<LuFileText />
|
||||
<Text fontWeight="medium">Content Preview</Text>
|
||||
</HStack>
|
||||
<Text>
|
||||
This is a preview of the content for "{item.title}". In a real scenario, this would show the full post text, images, or video script generated by the system.
|
||||
</Text>
|
||||
<Text mt={4} fontStyle="italic" color="fg.muted">
|
||||
Generated content will appear here...
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</DialogBody>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
111
src/components/content/ContentTable.tsx
Normal file
111
src/components/content/ContentTable.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Table, Badge, HStack, IconButton } from "@chakra-ui/react";
|
||||
import { LuEye, LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { ContentPreviewDialog } from "./ContentPreviewDialog";
|
||||
|
||||
const MOCK_CONTENT = [
|
||||
{ id: 1, title: "The Future of AI in Marketing", platform: "LinkedIn", status: "published", date: "2024-03-10" },
|
||||
{ id: 2, title: "5 Tips for Better Sleep", platform: "Twitter", status: "draft", date: "2024-03-12" },
|
||||
{ id: 3, title: "Product Launch Announcement", platform: "Instagram", status: "scheduled", date: "2024-03-15" },
|
||||
{ id: 4, title: "Weekly Tech Roundup", platform: "LinkedIn", status: "review", date: "2024-03-18" },
|
||||
{ id: 5, title: "Customer Success Story", platform: "Blog", status: "draft", date: "2024-03-20" },
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "published": return "green";
|
||||
case "draft": return "gray";
|
||||
case "scheduled": return "blue";
|
||||
case "review": return "orange";
|
||||
default: return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
export function ContentTable() {
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const handleAction = (action: string, item: any) => {
|
||||
if (action === 'View') {
|
||||
setSelectedItem(item);
|
||||
setIsPreviewOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toaster.create({
|
||||
title: `${action} Action`,
|
||||
description: `You clicked ${action} for ${item.title}`,
|
||||
type: "info",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel">
|
||||
<Table.Root striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Title</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Platform</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Status</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Date</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Actions</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{MOCK_CONTENT.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell fontWeight="medium">{item.title}</Table.Cell>
|
||||
<Table.Cell>{item.platform}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge colorPalette={getStatusColor(item.status)} variant="solid">
|
||||
{item.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.date}</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<HStack justify="flex-end" gap={2}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="View"
|
||||
onClick={() => handleAction('View', item)}
|
||||
>
|
||||
<LuEye />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Edit"
|
||||
onClick={() => handleAction('Edit', item)}
|
||||
>
|
||||
<LuPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
colorPalette="red"
|
||||
aria-label="Delete"
|
||||
onClick={() => handleAction('Delete', item)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
</IconButton>
|
||||
</HStack>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
|
||||
<ContentPreviewDialog
|
||||
item={selectedItem}
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={(e) => setIsPreviewOpen(e.open)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
340
src/components/generate/GenerateWizard.tsx
Normal file
340
src/components/generate/GenerateWizard.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, Steps, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge } from "@chakra-ui/react";
|
||||
import { LuSparkles, LuArrowRight, LuCheck, LuHash } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState, useEffect } from "react";
|
||||
import { GeneratedContentResult } from "./GeneratedContentResult";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
// Platform Data (can also be fetched from backend if dynamic)
|
||||
const PLATFORMS = [
|
||||
{ id: 'twitter', name: 'Twitter/X', icon: '𝕏' },
|
||||
{ id: 'linkedin', name: 'LinkedIn', icon: '💼' },
|
||||
{ id: 'instagram', name: 'Instagram', icon: '📸' },
|
||||
{ id: 'medium', name: 'Medium/Blog', icon: '📝' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ title: "Topic & Niche", description: "Define what to create" },
|
||||
{ title: "Platforms", description: "Where to post" },
|
||||
{ title: "Review", description: "Generate content" },
|
||||
];
|
||||
|
||||
interface Niche {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function GenerateWizard() {
|
||||
const { data: session } = useSession();
|
||||
const searchParams = useSearchParams();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [topic, setTopic] = useState("");
|
||||
const [trendDescription, setTrendDescription] = useState("");
|
||||
const [trendKeywords, setTrendKeywords] = useState<string[]>([]);
|
||||
const [trendSource, setTrendSource] = useState("");
|
||||
const [selectedNiche, setSelectedNiche] = useState("");
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const [niches, setNiches] = useState<Niche[]>([]);
|
||||
const [isLoadingNiches, setIsLoadingNiches] = useState(false);
|
||||
|
||||
const [generatedBundle, setGeneratedBundle] = useState<any>(null);
|
||||
|
||||
// Read trend data from URL params (from trends page)
|
||||
useEffect(() => {
|
||||
const topicParam = searchParams.get('topic');
|
||||
const descParam = searchParams.get('description');
|
||||
const keywordsParam = searchParams.get('keywords');
|
||||
const sourceParam = searchParams.get('source');
|
||||
|
||||
if (topicParam) setTopic(decodeURIComponent(topicParam));
|
||||
if (descParam) setTrendDescription(decodeURIComponent(descParam));
|
||||
if (sourceParam) setTrendSource(sourceParam);
|
||||
if (keywordsParam) {
|
||||
try {
|
||||
const parsed = JSON.parse(keywordsParam);
|
||||
if (Array.isArray(parsed)) setTrendKeywords(parsed);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse keywords:', e);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch niches on mount
|
||||
useEffect(() => {
|
||||
async function fetchNiches() {
|
||||
setIsLoadingNiches(true);
|
||||
try {
|
||||
const headers: HeadersInit = {};
|
||||
if (session?.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
|
||||
// Using a relative path which the proxy should handle
|
||||
const res = await fetch('/api/backend/content-generation/niches', { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setNiches(data);
|
||||
} else {
|
||||
console.error("API returned non-array for niches:", data);
|
||||
setNiches([]);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch niches", res.status);
|
||||
setNiches([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching niches:", error);
|
||||
} finally {
|
||||
setIsLoadingNiches(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
fetchNiches();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const togglePlatform = (id: string) => {
|
||||
setSelectedPlatforms(prev =>
|
||||
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (activeStep === 0 && !topic) {
|
||||
toaster.create({
|
||||
title: "Topic Required",
|
||||
description: "Please enter a topic to proceed.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (activeStep === 1 && selectedPlatforms.length === 0) {
|
||||
toaster.create({
|
||||
title: "Platform Required",
|
||||
description: "Select at least one platform.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActiveStep((prev) => Math.min(prev + 1, STEPS.length - 1));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prev) => Math.max(prev - 1, 0));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const payload = {
|
||||
topic,
|
||||
description: trendDescription || undefined,
|
||||
keywords: trendKeywords.length > 0 ? trendKeywords : undefined,
|
||||
niche: selectedNiche, // The service expects 'niche' as string ID
|
||||
platforms: selectedPlatforms,
|
||||
includeResearch: true,
|
||||
includeHashtags: true,
|
||||
brandVoice: "friendly-expert", // Default for now, can be added to UI
|
||||
count: 1
|
||||
};
|
||||
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (session?.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backend/content-generation/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Generation failed");
|
||||
|
||||
const data = await response.json();
|
||||
setGeneratedBundle(data);
|
||||
|
||||
toaster.create({
|
||||
title: "Content Generated",
|
||||
description: "Your content is ready!",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toaster.create({
|
||||
title: "Error",
|
||||
description: "Failed to generate content. Please try again.",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (generatedBundle) {
|
||||
return <GeneratedContentResult bundle={generatedBundle} onReset={() => {
|
||||
setGeneratedBundle(null);
|
||||
setActiveStep(0);
|
||||
setTopic("");
|
||||
setSelectedPlatforms([]);
|
||||
}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={8}>
|
||||
<Steps.Root index={activeStep} count={STEPS.length}>
|
||||
<Steps.List>
|
||||
{STEPS.map((step, index) => (
|
||||
<Steps.Item key={index} title={step.title} icon={<Box>{index + 1}</Box>}>
|
||||
{step.description}
|
||||
</Steps.Item>
|
||||
))}
|
||||
</Steps.List>
|
||||
</Steps.Root>
|
||||
</Box>
|
||||
|
||||
<Box p={6} borderWidth="1px" borderRadius="lg" bg="bg.panel">
|
||||
{activeStep === 0 && (
|
||||
<VStack align="stretch" gap={6}>
|
||||
<Heading size="md">1. Start with a Topic</Heading>
|
||||
|
||||
{/* Show trend info if coming from trends page */}
|
||||
{(trendSource || trendDescription || trendKeywords.length > 0) && (
|
||||
<Card.Root bg="blue.50/10" borderColor="blue.500" borderWidth="1px">
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={2}>
|
||||
<HStack>
|
||||
<Badge colorPalette="blue">📊 Trend'den Geldi</Badge>
|
||||
{trendSource && <Badge variant="outline">{trendSource}</Badge>}
|
||||
</HStack>
|
||||
{trendDescription && (
|
||||
<Text fontSize="sm" color="fg.muted">{trendDescription}</Text>
|
||||
)}
|
||||
{trendKeywords.length > 0 && (
|
||||
<HStack flexWrap="wrap" gap={1}>
|
||||
{trendKeywords.map((kw, i) => (
|
||||
<Badge key={i} size="sm" variant="subtle">
|
||||
<LuHash /> {kw}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text mb={2} fontWeight="medium">What do you want to post about?</Text>
|
||||
<Input
|
||||
placeholder="e.g. 5 ways to use AI for marketing..."
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
size="lg"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text mb={3} fontWeight="medium">Select a Niche (Optional)</Text>
|
||||
{/* Fallback mock niches if fetch fails/empty for demo purposes */}
|
||||
<SimpleGrid columns={{ base: 2, md: 3 }} gap={4}>
|
||||
{(niches.length > 0 ? niches : [
|
||||
{ id: 'personal-finance', name: 'Personal Finance' },
|
||||
{ id: 'productivity', name: 'Productivity' },
|
||||
{ id: 'ai-tech', name: 'AI & Tech' },
|
||||
{ id: 'marketing', name: 'Marketing' }
|
||||
]).map((niche) => (
|
||||
<Card.Root
|
||||
key={niche.id}
|
||||
cursor="pointer"
|
||||
onClick={() => setSelectedNiche(niche.id)}
|
||||
borderColor={selectedNiche === niche.id ? "blue.500" : "border"}
|
||||
borderWidth={selectedNiche === niche.id ? "2px" : "1px"}
|
||||
bg={selectedNiche === niche.id ? "blue.50/10" : "bg.subtle"}
|
||||
_hover={{ borderColor: "blue.500" }}
|
||||
>
|
||||
<Card.Body p={4}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="medium" fontSize="sm">{niche.name}</Text>
|
||||
{selectedNiche === niche.id && <LuCheck color="var(--chakra-colors-blue-500)" />}
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && (
|
||||
<VStack align="stretch" gap={6}>
|
||||
<Heading size="md">2. Select Platforms</Heading>
|
||||
<Text mb={2} color="fg.muted">Where should this content be posted?</Text>
|
||||
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4}>
|
||||
{PLATFORMS.map((platform) => (
|
||||
<Card.Root
|
||||
key={platform.id}
|
||||
cursor="pointer"
|
||||
onClick={() => togglePlatform(platform.id)}
|
||||
borderColor={selectedPlatforms.includes(platform.id) ? "blue.500" : "border"}
|
||||
borderWidth={selectedPlatforms.includes(platform.id) ? "2px" : "1px"}
|
||||
bg={selectedPlatforms.includes(platform.id) ? "blue.50/10" : "bg.subtle"}
|
||||
_hover={{ borderColor: "blue.500" }}
|
||||
>
|
||||
<Card.Body p={4} textAlign="center">
|
||||
<VStack>
|
||||
<Text fontSize="2xl">{platform.icon}</Text>
|
||||
<Text fontWeight="medium">{platform.name}</Text>
|
||||
{selectedPlatforms.includes(platform.id) && <LuCheck color="var(--chakra-colors-blue-500)" />}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{activeStep === 2 && (
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Heading size="md">3. Ready to Generate?</Heading>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={2}>
|
||||
<Text><strong>Topic:</strong> {topic}</Text>
|
||||
<Text><strong>Niche:</strong> {niches?.find((n) => n.id === selectedNiche)?.name || selectedNiche || "General"}</Text>
|
||||
<Text><strong>Platforms:</strong> {selectedPlatforms.map(p => PLATFORMS.find(pl => pl.id === p)?.name).join(", ")}</Text>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end" mt={8} gap={4}>
|
||||
<Button variant="outline" onClick={handleBack} disabled={activeStep === 0 || isGenerating}>
|
||||
Back
|
||||
</Button>
|
||||
{activeStep === STEPS.length - 1 ? (
|
||||
<Button onClick={handleGenerate} loading={isGenerating} loadingText="Generating...">
|
||||
Generate <LuSparkles />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleNext}>
|
||||
Next <LuArrowRight />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
429
src/components/generate/GeneratedContentResult.tsx
Normal file
429
src/components/generate/GeneratedContentResult.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Tabs, SimpleGrid, Progress, Icon } from "@chakra-ui/react";
|
||||
import { LuCopy, LuCheck, LuFileText, LuBrainCircuit, LuLayers, LuSparkles, LuSearch, LuZap, LuDownload, LuHash } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
|
||||
// Matches backend GeneratedContentBundle
|
||||
interface GeneratedContentBundle {
|
||||
id: string;
|
||||
topic: string;
|
||||
niche?: any;
|
||||
research?: {
|
||||
summary: string;
|
||||
keyFindings: { finding: string; confidence: string }[];
|
||||
statistics: { value: string; context: string }[];
|
||||
quotes: { text: string; author: string }[];
|
||||
};
|
||||
platforms: {
|
||||
platform: string;
|
||||
content: string;
|
||||
hashtags: string[];
|
||||
characterCount: number;
|
||||
postingRecommendation: string;
|
||||
// mediaRecommendations is string[] in backend
|
||||
mediaRecommendations: string[];
|
||||
}[];
|
||||
variations?: {
|
||||
original: string;
|
||||
variations: { type: string; content: string }[];
|
||||
}[];
|
||||
seo?: {
|
||||
score: number;
|
||||
keywords: string[];
|
||||
suggestions: string[];
|
||||
meta: { title: string; description: string };
|
||||
};
|
||||
neuro?: {
|
||||
score: number;
|
||||
triggersUsed: string[];
|
||||
emotionProfile: string[];
|
||||
improvements: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
bundle: GeneratedContentBundle;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
toaster.create({
|
||||
title: "Copied to clipboard",
|
||||
type: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleCopyWithHashtags = (content: string, hashtags: string[], id: string) => {
|
||||
const fullText = `${content}\n\n${hashtags.join(' ')}`;
|
||||
navigator.clipboard.writeText(fullText);
|
||||
setCopiedId(id + '-full');
|
||||
toaster.create({ title: "Copied with hashtags", type: "success", duration: 2000 });
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleDownloadText = (content: string, platform: string) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${bundle.topic}-${platform}.txt`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!bundle || !bundle.platforms || bundle.platforms.length === 0) {
|
||||
return (
|
||||
<VStack gap={4} py={10}>
|
||||
<Text color="fg.muted">No content generated. Please try again.</Text>
|
||||
<Button variant="outline" onClick={onReset}>Try Again</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" gap={6}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="lg">Generated Results</Heading>
|
||||
<Button variant="outline" onClick={onReset}>Create New</Button>
|
||||
</HStack>
|
||||
|
||||
<Tabs.Root defaultValue="content">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="content">
|
||||
<LuFileText /> Content
|
||||
</Tabs.Trigger>
|
||||
{bundle.research && (
|
||||
<Tabs.Trigger value="research">
|
||||
<LuBrainCircuit /> Deep Research
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
{bundle.variations && bundle.variations.length > 0 && (
|
||||
<Tabs.Trigger value="variations">
|
||||
<LuLayers /> Variations
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
{bundle.seo && (
|
||||
<Tabs.Trigger value="seo">
|
||||
<LuSearch /> SEO
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
{bundle.neuro && (
|
||||
<Tabs.Trigger value="neuro">
|
||||
<LuZap /> Neuro
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
{/* CONTENT TAB */}
|
||||
<Tabs.Content value="content">
|
||||
<Tabs.Root defaultValue={bundle.platforms[0]?.platform}>
|
||||
<Tabs.List>
|
||||
{bundle.platforms.map(p => (
|
||||
<Tabs.Trigger key={p.platform} value={p.platform}>
|
||||
{p.platform.charAt(0).toUpperCase() + p.platform.slice(1)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{bundle.platforms.map((item) => (
|
||||
<Tabs.Content key={item.platform} value={item.platform}>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<HStack justify="space-between">
|
||||
<Badge colorPalette="blue" variant="subtle">
|
||||
{item.characterCount} chars
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCopy(item.content, item.platform)}
|
||||
>
|
||||
{copiedId === item.platform ? <LuCheck /> : <LuCopy />}
|
||||
{copiedId === item.platform ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box
|
||||
p={4}
|
||||
bg="bg.subtle"
|
||||
borderRadius="md"
|
||||
whiteSpace="pre-wrap"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
>
|
||||
{item.content}
|
||||
</Box>
|
||||
|
||||
<Box mt={2}>
|
||||
<Text fontWeight="medium" mb={1} fontSize="sm">Hashtags:</Text>
|
||||
<Text color="blue.500" fontSize="sm">{item.hashtags.join(" ")}</Text>
|
||||
</Box>
|
||||
|
||||
{item.postingRecommendation && (
|
||||
<Box mt={2} p={2} bg="blue.50/10" borderRadius="md">
|
||||
<HStack>
|
||||
<LuSparkles color="var(--chakra-colors-blue-500)" />
|
||||
<Text fontSize="sm" color="fg.muted">{item.postingRecommendation}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<HStack mt={3} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyWithHashtags(item.content, item.hashtags, item.platform)}
|
||||
>
|
||||
<LuHash /> Copy + Hashtags
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadText(item.content, item.platform)}
|
||||
>
|
||||
<LuDownload /> Download
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* RESEARCH TAB */}
|
||||
<Tabs.Content value="research">
|
||||
<VStack align="stretch" gap={6}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="md">Executive Summary</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Text>{bundle.research?.summary}</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Key Findings</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={3}>
|
||||
{bundle.research?.keyFindings.map((finding, idx) => (
|
||||
<HStack key={idx} align="start">
|
||||
<LuCheck color="green" style={{ marginTop: '4px' }} />
|
||||
<Text fontSize="sm">{finding.finding}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Statistics</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={3}>
|
||||
{bundle.research?.statistics.map((stat, idx) => (
|
||||
<Box key={idx}>
|
||||
<Text fontWeight="bold" color="blue.500">{stat.value}</Text>
|
||||
<Text fontSize="sm" color="fg.muted">{stat.context}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* VARIATIONS TAB */}
|
||||
<Tabs.Content value="variations">
|
||||
<VStack align="stretch" gap={4}>
|
||||
{bundle.variations?.map((varSet, i) => (
|
||||
<Box key={i}>
|
||||
{varSet.variations.map((variation, j) => (
|
||||
<Card.Root key={j} mb={4}>
|
||||
<Card.Header>
|
||||
<Badge>{variation.type}</Badge>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Box
|
||||
p={3}
|
||||
bg="bg.subtle"
|
||||
borderRadius="md"
|
||||
whiteSpace="pre-wrap"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
>
|
||||
{variation.content}
|
||||
</Box>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
mt={2}
|
||||
onClick={() => handleCopy(variation.content, `var-${i}-${j}`)}
|
||||
>
|
||||
<LuCopy /> Copy
|
||||
</Button>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* SEO TAB */}
|
||||
<Tabs.Content value="seo">
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">SEO Score</Heading>
|
||||
<Badge
|
||||
colorPalette={bundle.seo?.score && bundle.seo.score >= 70 ? 'green' : bundle.seo?.score && bundle.seo.score >= 50 ? 'yellow' : 'red'}
|
||||
size="lg"
|
||||
>
|
||||
{bundle.seo?.score || 0}/100
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Progress.Root value={bundle.seo?.score || 0} max={100} size="lg">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Keywords</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<HStack flexWrap="wrap" gap={2}>
|
||||
{bundle.seo?.keywords?.map((kw, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorPalette="blue">
|
||||
{kw}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Suggestions</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={2}>
|
||||
{bundle.seo?.suggestions?.map((sug, idx) => (
|
||||
<HStack key={idx}>
|
||||
<LuCheck color="green" />
|
||||
<Text fontSize="sm">{sug}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* NEURO TAB */}
|
||||
<Tabs.Content value="neuro">
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">Neuro Score</Heading>
|
||||
<Badge
|
||||
colorPalette={bundle.neuro?.score && bundle.neuro.score >= 70 ? 'green' : bundle.neuro?.score && bundle.neuro.score >= 50 ? 'yellow' : 'red'}
|
||||
size="lg"
|
||||
>
|
||||
{bundle.neuro?.score || 0}/100
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Progress.Root value={bundle.neuro?.score || 0} max={100} size="lg">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Triggers Used</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<HStack flexWrap="wrap" gap={2}>
|
||||
{bundle.neuro?.triggersUsed?.map((trigger, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorPalette="purple">
|
||||
{trigger}
|
||||
</Badge>
|
||||
))}
|
||||
{(!bundle.neuro?.triggersUsed || bundle.neuro.triggersUsed.length === 0) && (
|
||||
<Text fontSize="sm" color="fg.muted">No triggers detected</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Improvements</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={2}>
|
||||
{bundle.neuro?.improvements?.map((imp, idx) => (
|
||||
<HStack key={idx}>
|
||||
<LuZap color="orange" />
|
||||
<Text fontSize="sm">{imp}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Heading size="sm">Emotion Profile</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<HStack flexWrap="wrap" gap={2}>
|
||||
{bundle.neuro?.emotionProfile?.map((emotion, idx) => (
|
||||
<Badge key={idx} variant="outline" colorPalette="green">
|
||||
{emotion}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
24
src/components/layout/dashboard/DashboardLayout.tsx
Normal file
24
src/components/layout/dashboard/DashboardLayout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { Sidebar } from '../sidebar/Sidebar';
|
||||
import { Header } from '../header/header';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<Flex minH="100vh" bg="bg.canvas">
|
||||
<Sidebar />
|
||||
<Box flex="1" ml="250px" transition="margin-left 0.2s">
|
||||
<Header />
|
||||
<Box as="main" p={8}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +1,50 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Link as ChakraLink,
|
||||
Stack,
|
||||
VStack,
|
||||
Button,
|
||||
MenuItem,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||
import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/overlays/popover";
|
||||
import { RxHamburgerMenu } from "react-icons/rx";
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import HeaderLink from "./header-link";
|
||||
import MobileHeaderLink from "./mobile-header-link";
|
||||
import LocaleSwitcher from "@/components/ui/locale-switcher";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
MenuContent,
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/overlays/menu";
|
||||
import { Avatar } from "@/components/ui/data-display/avatar";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { authConfig } from "@/config/auth";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
import { LuLogIn } from "react-icons/lu";
|
||||
import { Flex, Text, HStack, Box } from '@chakra-ui/react';
|
||||
import { ColorModeButton } from '@/components/ui/color-mode';
|
||||
import { Avatar } from '@/components/ui/data-display/avatar';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isAuthenticated = !!session;
|
||||
const isLoading = status === "loading";
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsSticky(window.scrollY >= 10);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
if (authConfig.isAuthRequired) {
|
||||
router.replace("/signin");
|
||||
}
|
||||
};
|
||||
|
||||
// Render user menu or login button based on auth state
|
||||
const renderAuthSection = () => {
|
||||
if (isLoading) {
|
||||
return <Skeleton boxSize="10" rounded="full" />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||
<MenuTrigger rounded="full" focusRing="none">
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - show login button
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Render mobile auth section
|
||||
const renderMobileAuthSection = () => {
|
||||
if (isLoading) {
|
||||
return <Skeleton height="10" width="full" />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
<Button
|
||||
variant="surface"
|
||||
size="sm"
|
||||
width="full"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("auth.sign-out")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
width="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
const getPageTitle = (path: string) => {
|
||||
if (path === '/home') return 'Dashboard';
|
||||
if (path.startsWith('/content')) return 'Content Management';
|
||||
if (path.startsWith('/schedule')) return 'Schedule & Calendar';
|
||||
if (path.startsWith('/analytics')) return 'Analytics & Insights';
|
||||
if (path.startsWith('/settings')) return 'Settings';
|
||||
return 'Dashboard';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
as="nav"
|
||||
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
|
||||
_dark={{
|
||||
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
|
||||
}}
|
||||
shadow={isSticky ? "sm" : "none"}
|
||||
backdropFilter="blur(12px) saturate(180%)"
|
||||
border="1px solid"
|
||||
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
|
||||
borderBottomRadius={isSticky ? "xl" : "none"}
|
||||
transition="all 0.4s ease-in-out"
|
||||
px={{ base: 4, md: 8 }}
|
||||
py="3"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
w="full"
|
||||
>
|
||||
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
|
||||
{/* Logo */}
|
||||
<HStack>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/home"
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color={{ base: "primary.500", _dark: "primary.300" }}
|
||||
focusRing="none"
|
||||
textDecor="none"
|
||||
transition="all 0.3s ease-in-out"
|
||||
_hover={{
|
||||
color: { base: "primary.900", _dark: "primary.50" },
|
||||
}}
|
||||
>
|
||||
{"FCS "}
|
||||
</ChakraLink>
|
||||
</HStack>
|
||||
<Flex
|
||||
as="header"
|
||||
h="64px"
|
||||
bg="bg.panel"
|
||||
borderBottomWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={8}
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={99}
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="fg.default">
|
||||
{getPageTitle(pathname || '')}
|
||||
</Text>
|
||||
|
||||
{/* DESKTOP NAVIGATION */}
|
||||
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<HeaderLink key={index} item={item} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<ColorModeButton colorPalette="gray" />
|
||||
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
||||
<LocaleSwitcher />
|
||||
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
|
||||
{renderAuthSection()}
|
||||
</ClientOnly>
|
||||
</Box>
|
||||
|
||||
{/* MOBILE NAVIGATION */}
|
||||
<Stack display={{ base: "inline-flex", lg: "none" }}>
|
||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as="span">
|
||||
<IconButton aria-label="Open menu" variant="ghost">
|
||||
<RxHamburgerMenu />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||
<PopoverBody>
|
||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<MobileHeaderLink key={item.label} item={item} />
|
||||
))}
|
||||
<LocaleSwitcher />
|
||||
{renderMobileAuthSection()}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</ClientOnly>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Login Modal */}
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
</>
|
||||
<HStack gap={4}>
|
||||
<ColorModeButton />
|
||||
<HStack gap={3}>
|
||||
<Box textAlign="right" display={{ base: 'none', md: 'block' }}>
|
||||
<Text textStyle="sm" fontWeight="medium">John Doe</Text>
|
||||
<Text textStyle="xs" color="fg.muted">Admin</Text>
|
||||
</Box>
|
||||
<Avatar name="John Doe" src="https://bit.ly/dan-abramov" size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/components/layout/sidebar/Sidebar.tsx
Normal file
102
src/components/layout/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Flex, VStack, Text, Icon, Link as ChakraLink, Separator } from '@chakra-ui/react';
|
||||
import { Link, usePathname } from '@/i18n/navigation';
|
||||
import { LuLayoutDashboard, LuFileText, LuCalendar, LuTrendingUp, LuSettings, LuLogOut, LuSparkles } from 'react-icons/lu';
|
||||
|
||||
import { NAV_ITEMS as CONFIG_NAV_ITEMS } from '@/config/navigation';
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'/home': LuLayoutDashboard,
|
||||
'/generate': LuSparkles,
|
||||
'/content': LuFileText,
|
||||
'/schedule': LuCalendar,
|
||||
'/analytics': LuTrendingUp,
|
||||
'/settings': LuSettings,
|
||||
};
|
||||
|
||||
const NAV_ITEMS = CONFIG_NAV_ITEMS.map(item => ({
|
||||
name: item.label.charAt(0).toUpperCase() + item.label.slice(1),
|
||||
href: item.href,
|
||||
icon: ICON_MAP[item.href] || LuFileText
|
||||
}));
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="250px"
|
||||
h="100vh"
|
||||
bg="bg.panel"
|
||||
borderRightWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
position="fixed"
|
||||
left={0}
|
||||
top={0}
|
||||
zIndex={100}
|
||||
py={6}
|
||||
px={4}
|
||||
>
|
||||
<Flex align="center" gap={3} px={2} mb={8}>
|
||||
<Box w={8} h={8} bg="primary.solid" borderRadius="lg" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text color="white" fontWeight="bold">CH</Text>
|
||||
</Box>
|
||||
<Text fontSize="xl" fontWeight="bold">Content Hunter</Text>
|
||||
</Flex>
|
||||
|
||||
<VStack gap={2} align="stretch">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||
return (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
px={3}
|
||||
py={2.5}
|
||||
borderRadius="md"
|
||||
textDecoration="none"
|
||||
bg={isActive ? 'primary.subtle' : 'transparent'}
|
||||
color={isActive ? 'primary.solid' : 'fg.muted'}
|
||||
fontWeight={isActive ? 'semibold' : 'medium'}
|
||||
_hover={{
|
||||
bg: isActive ? 'primary.subtle' : 'bg.subtle',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon as={item.icon} boxSize={5} />
|
||||
<Text>{item.name}</Text>
|
||||
</ChakraLink>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
|
||||
<Box mt="auto">
|
||||
<Separator mb={4} />
|
||||
<ChakraLink
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
px={3}
|
||||
py={2.5}
|
||||
borderRadius="md"
|
||||
textDecoration="none"
|
||||
color="fg.muted"
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
bg: 'bg.subtle',
|
||||
color: 'fg.error',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon as={LuLogOut} boxSize={5} />
|
||||
<Text>Logout</Text>
|
||||
</ChakraLink>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user