The npm install command failed in my project. Analyze the following er
This commit is contained in:
parent
3696b98582
commit
5a067c16ac
20 changed files with 949 additions and 53 deletions
0
.modified
Normal file
0
.modified
Normal file
22
docs/blueprint.md
Normal file
22
docs/blueprint.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# **App Name**: ContentFlow
|
||||
|
||||
## Core Features:
|
||||
|
||||
- User Authentication: Login and authentication using email or social accounts (Firebase/Auth.js).
|
||||
- Briefing Form: Briefing form for users to input their content requirements; an AI chat with OpenRouter will come later.
|
||||
- Content Plan Generation: AI-powered tool to generate a content plan based on the user's brief via a webhook that connects to n8n and GPT.
|
||||
- Content Plan Editor: Display the AI-generated content plan for user review and edits.
|
||||
- Content Generation: AI tool to generate content based on the approved content plan via n8n.
|
||||
- Content Editor: Content preview and editor for the generated content, so the user can approve and modify.
|
||||
- Content Export: Export content plan and generated content as a PDF or ZIP file.
|
||||
- Storage: Firestore is used to store all brief data, plans, and generated content in a structured, user-specific hierarchy.
|
||||
|
||||
## Style Guidelines:
|
||||
|
||||
- Primary color: Deep indigo (#3F51B5), a professional and calming color to reflect the AI's ability to focus.
|
||||
- Background color: Very light grey (#F0F2F5), a neutral backdrop that keeps the interface feeling airy.
|
||||
- Accent color: Electric purple (#BF5AF2), adding a zing that evokes the spark of creativity.
|
||||
- Body and headline font: 'Inter' sans-serif for a modern, neutral look; suitable for both headlines and body text.
|
||||
- Code font: 'Source Code Pro' for any code snippets displayed.
|
||||
- Simple and modern icons from Headless UI and ShadCN to maintain a clean interface.
|
||||
- Subtle transitions and progress bars to provide feedback during AI operations and content loading.
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
|
|
@ -38,6 +38,7 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"firebase": "^11.9.1",
|
||||
"framer-motion": "^11.3.12",
|
||||
"genkit": "^1.13.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.3.3",
|
||||
|
|
@ -5958,6 +5959,33 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "11.18.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^11.18.1",
|
||||
"motion-utils": "^11.18.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
|
|
@ -7330,6 +7358,21 @@
|
|||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^11.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
|
||||
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"firebase": "^11.9.1",
|
||||
"framer-motion": "^11.3.12",
|
||||
"genkit": "^1.13.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.3.3",
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
// Flows will be imported for their side effects in this file.
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
import '@/ai/flows/generate-content.ts';
|
||||
import '@/ai/flows/generate-content-plan.ts';
|
||||
47
src/ai/flows/generate-content-plan.ts
Normal file
47
src/ai/flows/generate-content-plan.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use server';
|
||||
|
||||
/**
|
||||
* @fileOverview AI flow to generate a content plan based on user brief.
|
||||
*
|
||||
* - generateContentPlan - The main function to generate a content plan.
|
||||
* - GenerateContentPlanInput - The input type for the generateContentPlan function.
|
||||
* - GenerateContentPlanOutput - The output type for the generateContentPlan function.
|
||||
*/
|
||||
|
||||
import {ai} from '@/ai/genkit';
|
||||
import {z} from 'genkit';
|
||||
|
||||
const GenerateContentPlanInputSchema = z.object({
|
||||
brief: z.string().describe('The content brief provided by the user.'),
|
||||
});
|
||||
export type GenerateContentPlanInput = z.infer<typeof GenerateContentPlanInputSchema>;
|
||||
|
||||
const GenerateContentPlanOutputSchema = z.object({
|
||||
contentPlan: z.string().describe('The generated content plan.'),
|
||||
});
|
||||
export type GenerateContentPlanOutput = z.infer<typeof GenerateContentPlanOutputSchema>;
|
||||
|
||||
export async function generateContentPlan(input: GenerateContentPlanInput): Promise<GenerateContentPlanOutput> {
|
||||
return generateContentPlanFlow(input);
|
||||
}
|
||||
|
||||
const generateContentPlanPrompt = ai.definePrompt({
|
||||
name: 'generateContentPlanPrompt',
|
||||
input: {schema: GenerateContentPlanInputSchema},
|
||||
output: {schema: GenerateContentPlanOutputSchema},
|
||||
prompt: `You are an AI content planning assistant. Generate a detailed content plan based on the user's brief.
|
||||
|
||||
Brief: {{{brief}}}`,
|
||||
});
|
||||
|
||||
const generateContentPlanFlow = ai.defineFlow(
|
||||
{
|
||||
name: 'generateContentPlanFlow',
|
||||
inputSchema: GenerateContentPlanInputSchema,
|
||||
outputSchema: GenerateContentPlanOutputSchema,
|
||||
},
|
||||
async input => {
|
||||
const {output} = await generateContentPlanPrompt(input);
|
||||
return output!;
|
||||
}
|
||||
);
|
||||
49
src/ai/flows/generate-content.ts
Normal file
49
src/ai/flows/generate-content.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use server';
|
||||
|
||||
/**
|
||||
* @fileOverview Generates content based on an approved content plan.
|
||||
*
|
||||
* - generateContent - A function that generates content from a content plan.
|
||||
* - GenerateContentInput - The input type for the generateContent function.
|
||||
* - GenerateContentOutput - The return type for the generateContent function.
|
||||
*/
|
||||
|
||||
import {ai} from '@/ai/genkit';
|
||||
import {z} from 'genkit';
|
||||
|
||||
const GenerateContentInputSchema = z.object({
|
||||
contentPlan: z
|
||||
.string()
|
||||
.describe('The approved content plan to generate content from.'),
|
||||
});
|
||||
export type GenerateContentInput = z.infer<typeof GenerateContentInputSchema>;
|
||||
|
||||
const GenerateContentOutputSchema = z.object({
|
||||
generatedContent: z
|
||||
.string()
|
||||
.describe('The content generated based on the content plan.'),
|
||||
});
|
||||
export type GenerateContentOutput = z.infer<typeof GenerateContentOutputSchema>;
|
||||
|
||||
export async function generateContent(input: GenerateContentInput): Promise<GenerateContentOutput> {
|
||||
return generateContentFlow(input);
|
||||
}
|
||||
|
||||
const prompt = ai.definePrompt({
|
||||
name: 'generateContentPrompt',
|
||||
input: {schema: GenerateContentInputSchema},
|
||||
output: {schema: GenerateContentOutputSchema},
|
||||
prompt: `You are an AI content generator. Generate content based on the following content plan:\n\nContent Plan: {{{contentPlan}}}`,
|
||||
});
|
||||
|
||||
const generateContentFlow = ai.defineFlow(
|
||||
{
|
||||
name: 'generateContentFlow',
|
||||
inputSchema: GenerateContentInputSchema,
|
||||
outputSchema: GenerateContentOutputSchema,
|
||||
},
|
||||
async input => {
|
||||
const {output} = await prompt(input);
|
||||
return output!;
|
||||
}
|
||||
);
|
||||
143
src/app/dashboard/create/components/brief-form.tsx
Normal file
143
src/app/dashboard/create/components/brief-form.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { BotMessageSquare } from 'lucide-react';
|
||||
|
||||
const briefSchema = z.object({
|
||||
title: z.string().min(3, 'Title must be at least 3 characters long.'),
|
||||
goal: z.string().min(10, 'Content goal must be at least 10 characters long.'),
|
||||
audience: z.string().min(3, 'Target audience must be at least 3 characters long.'),
|
||||
points: z.string().min(10, 'Please provide some key points.'),
|
||||
tone: z.string().min(3, 'Tone of voice must be at least 3 characters long.'),
|
||||
keywords: z.string().optional(),
|
||||
});
|
||||
|
||||
export type BriefData = z.infer<typeof briefSchema>;
|
||||
|
||||
interface BriefFormProps {
|
||||
onSubmit: (data: BriefData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function BriefForm({ onSubmit, isLoading }: BriefFormProps) {
|
||||
const form = useForm<BriefData>({
|
||||
resolver: zodResolver(briefSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
goal: '',
|
||||
audience: '',
|
||||
points: '',
|
||||
tone: '',
|
||||
keywords: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Brief</CardTitle>
|
||||
<CardDescription>Fill out the details below to give the AI context for your content.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Q4 Social Media Campaign" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>What is the primary goal of this content?</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., To drive traffic to our new feature page" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="audience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Who is the target audience?</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Tech-savvy project managers, aged 25-40" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="points"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Key Talking Points</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="List the main ideas, arguments, or features to include. Use bullet points or short sentences." {...field} rows={5} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tone of Voice</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Professional yet approachable" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keywords to Include (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., AI content, productivity, automation" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Separate keywords with commas.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<BotMessageSquare className="mr-2 h-4 w-4" />
|
||||
Generate Content Plan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
src/app/dashboard/create/components/content-editor.tsx
Normal file
50
src/app/dashboard/create/components/content-editor.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { ArrowLeft, Download, Save } from 'lucide-react';
|
||||
|
||||
interface ContentEditorProps {
|
||||
content: string;
|
||||
setContent: (content: string) => void;
|
||||
onEditPlan: () => void;
|
||||
}
|
||||
|
||||
export function ContentEditor({ content, setContent, onEditPlan }: ContentEditorProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generated Content</CardTitle>
|
||||
<CardDescription>
|
||||
Your content is ready! Make final adjustments and export when you are happy with the result.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={20}
|
||||
className="text-base"
|
||||
placeholder="Final content will appear here..."
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" onClick={onEditPlan}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Edit Plan
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
52
src/app/dashboard/create/components/plan-editor.tsx
Normal file
52
src/app/dashboard/create/components/plan-editor.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { ArrowLeft, Download, Sparkles } from 'lucide-react';
|
||||
|
||||
interface PlanEditorProps {
|
||||
plan: string;
|
||||
setPlan: (plan: string) => void;
|
||||
onApprove: () => void;
|
||||
onEditBrief: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function PlanEditor({ plan, setPlan, onApprove, onEditBrief, isLoading }: PlanEditorProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Plan</CardTitle>
|
||||
<CardDescription>
|
||||
Here is the AI-generated plan. Review and make any necessary edits before generating the final content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={plan}
|
||||
onChange={(e) => setPlan(e.target.value)}
|
||||
rows={15}
|
||||
className="font-code text-sm"
|
||||
placeholder="Content plan will appear here..."
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" onClick={onEditBrief} disabled={isLoading}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Edit Brief
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" disabled={isLoading}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button onClick={onApprove} disabled={isLoading}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Approve & Generate Content
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
src/app/dashboard/create/components/stepper.tsx
Normal file
75
src/app/dashboard/create/components/stepper.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
type Step = 'brief' | 'plan' | 'content';
|
||||
const steps: Step[] = ['brief', 'plan', 'content'];
|
||||
const stepLabels: Record<Step, string> = {
|
||||
brief: 'Brief',
|
||||
plan: 'Content Plan',
|
||||
content: 'Generated Content',
|
||||
};
|
||||
|
||||
interface StepperProps {
|
||||
currentStep: Step;
|
||||
}
|
||||
|
||||
export function Stepper({ currentStep }: StepperProps) {
|
||||
const currentStepIndex = steps.indexOf(currentStep);
|
||||
|
||||
return (
|
||||
<nav aria-label="Progress">
|
||||
<ol role="list" className="flex items-center">
|
||||
{steps.map((step, stepIdx) => (
|
||||
<li key={step} className={cn('relative', { 'pr-8 sm:pr-20': stepIdx !== steps.length - 1 })}>
|
||||
{stepIdx < currentStepIndex ? (
|
||||
// Completed step
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-primary" />
|
||||
</div>
|
||||
<div
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-primary"
|
||||
>
|
||||
<Check className="h-5 w-5 text-primary-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">{stepLabels[step]}</span>
|
||||
</div>
|
||||
</>
|
||||
) : stepIdx === currentStepIndex ? (
|
||||
// Current step
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-border" />
|
||||
</div>
|
||||
<div
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-primary bg-background"
|
||||
aria-current="step"
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-primary" aria-hidden="true" />
|
||||
<span className="sr-only">{stepLabels[step]}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Upcoming step
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-border" />
|
||||
</div>
|
||||
<div
|
||||
className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-border bg-background"
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-transparent" aria-hidden="true" />
|
||||
<span className="sr-only">{stepLabels[step]}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="absolute -bottom-6 w-max text-center text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
{stepLabels[step]}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
124
src/app/dashboard/create/page.tsx
Normal file
124
src/app/dashboard/create/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { generateContentPlan } from '@/ai/flows/generate-content-plan';
|
||||
import { generateContent } from '@/ai/flows/generate-content';
|
||||
import { Stepper } from './components/stepper';
|
||||
import { BriefForm } from './components/brief-form';
|
||||
import { PlanEditor } from './components/plan-editor';
|
||||
import { ContentEditor } from './components/content-editor';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { BriefData } from './components/brief-form';
|
||||
|
||||
type Step = 'brief' | 'plan' | 'content';
|
||||
|
||||
export default function CreatePage() {
|
||||
const [step, setStep] = useState<Step>('brief');
|
||||
const [brief, setBrief] = useState<string>('');
|
||||
const [plan, setPlan] = useState<string>('');
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleBriefSubmit = async (data: BriefData) => {
|
||||
setIsLoading(true);
|
||||
const briefText = `Project Title: ${data.title}\nContent Goal: ${data.goal}\nTarget Audience: ${data.audience}\nKey Talking Points: ${data.points}\nTone of Voice: ${data.tone}\nKeywords: ${data.keywords}`;
|
||||
setBrief(briefText);
|
||||
|
||||
try {
|
||||
const result = await generateContentPlan({ brief: briefText });
|
||||
setPlan(result.contentPlan);
|
||||
setStep('plan');
|
||||
toast({
|
||||
title: 'Content Plan Generated!',
|
||||
description: 'Review and edit your new content plan.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error Generating Plan',
|
||||
description: 'Something went wrong. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlanApprove = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await generateContent({ contentPlan: plan });
|
||||
setContent(result.generatedContent);
|
||||
setStep('content');
|
||||
toast({
|
||||
title: 'Content Generated!',
|
||||
description: 'Your content is ready for final review.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error Generating Content',
|
||||
description: 'Something went wrong. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goBackToBrief = () => setStep('brief');
|
||||
const goBackToPlan = () => setStep('plan');
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, x: 50 },
|
||||
in: { opacity: 1, x: 0 },
|
||||
out: { opacity: 0, x: -50 },
|
||||
};
|
||||
|
||||
const pageTransition = {
|
||||
type: 'tween',
|
||||
ease: 'anticipate',
|
||||
duration: 0.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center border-b px-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-semibold">Create New Content</h1>
|
||||
<p className="text-sm text-muted-foreground">Follow the steps to generate your content.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Stepper currentStep={step} />
|
||||
<div className="relative mt-8">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-lg bg-background/80 backdrop-blur-sm">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||
<p className="mt-4 text-muted-foreground">AI is thinking...</p>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial="initial"
|
||||
animate="in"
|
||||
exit="out"
|
||||
variants={pageVariants}
|
||||
transition={pageTransition}
|
||||
>
|
||||
{step === 'brief' && <BriefForm onSubmit={handleBriefSubmit} isLoading={isLoading} />}
|
||||
{step === 'plan' && <PlanEditor plan={plan} setPlan={setPlan} onApprove={handlePlanApprove} onEditBrief={goBackToBrief} isLoading={isLoading} />}
|
||||
{step === 'content' && <ContentEditor content={content} setContent={setContent} onEditPlan={goBackToPlan} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/app/dashboard/layout.tsx
Normal file
14
src/app/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { AppSidebar } from '@/components/app/sidebar';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/app/dashboard/page.tsx
Normal file
96
src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { PlusCircle, MoreVertical } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
title: 'Q3 Marketing Campaign',
|
||||
description: 'A series of blog posts about sustainable tech.',
|
||||
status: 'In Progress',
|
||||
imageUrl: 'https://placehold.co/600x400.png',
|
||||
aiHint: 'technology marketing'
|
||||
},
|
||||
{
|
||||
title: 'New Feature Launch',
|
||||
description: 'Social media copy for the new analytics dashboard.',
|
||||
status: 'Completed',
|
||||
imageUrl: 'https://placehold.co/600x400.png',
|
||||
aiHint: 'social media'
|
||||
},
|
||||
{
|
||||
title: 'Website SEO Overhaul',
|
||||
description: 'Generating SEO-optimized articles for key pages.',
|
||||
status: 'Planning',
|
||||
imageUrl: 'https://placehold.co/600x400.png',
|
||||
aiHint: 'seo writing'
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<header className="flex h-16 shrink-0 items-center border-b px-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Welcome back! Here's your content overview.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/create">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create New Content
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold tracking-tight">Recent Projects</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{mockProjects.map((project) => (
|
||||
<Card key={project.title} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
width={600}
|
||||
height={400}
|
||||
className="mb-4 aspect-[3/2] w-full rounded-lg object-cover"
|
||||
data-ai-hint={project.aiHint}
|
||||
/>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CardDescription>{project.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow"></CardContent>
|
||||
<CardFooter>
|
||||
<Badge variant={project.status === 'Completed' ? 'secondary' : 'default'}>{project.status}</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,73 +8,73 @@ body {
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: 220 13% 95%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
--primary: 231 48% 48%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--accent: 283 84% 65%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 231 48% 48%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 224 71.4% 4.1%;
|
||||
--sidebar-primary: 231 48% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 14.3% 95.9%;
|
||||
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-ring: 231 48% 48%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card: 222 47% 11%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 222 47% 11%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--primary: 231 48% 58%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 283 84% 70%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 231 48% 58%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-background: 222 47% 11%;
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-primary: 231 48% 58%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 231 48% 58%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type {Metadata} from 'next';
|
||||
import './globals.css';
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Firebase Studio App',
|
||||
description: 'Generated by Firebase Studio',
|
||||
title: 'ContentFlow',
|
||||
description: 'Generate content with AI',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -12,13 +13,16 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></link>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Source+Code+Pro&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className="font-body antialiased">{children}</body>
|
||||
<body className="font-body antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,51 @@
|
|||
export default function Home() {
|
||||
return <></>;
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4">
|
||||
<Card className="mx-auto w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<Logo className="mx-auto mb-4 h-8" />
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">Welcome to ContentFlow</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="#" className="ml-auto inline-block text-sm underline" prefetch={false}>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" asChild>
|
||||
<Link href="/dashboard">Login</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
Login with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="#" className="underline" prefetch={false}>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
src/components/app/sidebar.tsx
Normal file
92
src/components/app/sidebar.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Home, PlusCircle, Settings, User, LogOut, BotMessageSquare } from 'lucide-react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||
{ href: '/dashboard/create', icon: PlusCircle, label: 'Create New' },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden h-screen w-64 flex-col border-r bg-card md:flex">
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2 font-semibold" prefetch={false}>
|
||||
<Logo className="h-6" />
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
|
||||
pathname === item.href && 'bg-muted text-primary'
|
||||
)}
|
||||
prefetch={false}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex w-full items-center justify-start gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="https://placehold.co/100x100.png" alt="@user" />
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">User</span>
|
||||
<span className="text-xs text-muted-foreground">user@example.com</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">User</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">user@example.com</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/" prefetch={false}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
32
src/components/logo.tsx
Normal file
32
src/components/logo.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Logo({ className, ...props }: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 64"
|
||||
className={cn('text-primary', className)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style={{ stopColor: 'hsl(var(--primary))' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'hsl(var(--accent))' }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
fontSize="52"
|
||||
fontWeight="bold"
|
||||
fill="url(#logo-gradient)"
|
||||
className="font-headline"
|
||||
>
|
||||
ContentFlow
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export default {
|
|||
fontFamily: {
|
||||
body: ['Inter', 'sans-serif'],
|
||||
headline: ['Inter', 'sans-serif'],
|
||||
code: ['monospace'],
|
||||
code: ['"Source Code Pro"', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue