mirror of
https://github.com/OwethuManagedServices/oms-website-nextjs.git
synced 2025-12-17 17:18:09 +00:00
create post completed
This commit is contained in:
75
components/BlogPostCard.tsx
Normal file
75
components/BlogPostCard.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FiCalendar, FiUser, FiArrowRight } from "react-icons/fi";
|
||||
|
||||
type BlogPostCardProps = {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
imageUrl: string;
|
||||
author: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({
|
||||
slug,
|
||||
title,
|
||||
excerpt,
|
||||
imageUrl,
|
||||
author,
|
||||
date,
|
||||
}) => {
|
||||
console.log("BlogPostCard Props:", { imageUrl });
|
||||
|
||||
return (
|
||||
<Link href={`/tech-talk/${slug}`} passHref>
|
||||
<div className="group bg-card border border-border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 ease-in-out flex flex-col h-full transform hover:-translate-y-1">
|
||||
{/* Image Container */}
|
||||
<div className="relative w-full h-48 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Optional: Subtle overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-5 flex flex-col flex-grow">
|
||||
<h3 className="text-xl font-semibold mb-2 text-foreground group-hover:text-primary transition-colors duration-200 line-clamp-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm mb-4 flex-grow line-clamp-3">
|
||||
{excerpt}
|
||||
</p>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="text-xs text-muted-foreground/80 flex flex-wrap items-center gap-x-4 gap-y-1 mb-4">
|
||||
<span className="flex items-center">
|
||||
<FiUser className="w-3 h-3 mr-1.5" />
|
||||
{author}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<FiCalendar className="w-3 h-3 mr-1.5" />
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Read More Link */}
|
||||
<div className="mt-auto pt-2 border-t border-border/50">
|
||||
<span className="inline-flex items-center text-sm font-medium text-primary group-hover:underline">
|
||||
Read More
|
||||
<FiArrowRight className="ml-1.5 w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostCard;
|
||||
243
components/CreatePostForm.tsx
Normal file
243
components/CreatePostForm.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
// Import useActionState from react instead of useFormState from react-dom
|
||||
import React, { useActionState } from "react";
|
||||
import { useFormStatus } from "react-dom"; // Keep this for useFormStatus
|
||||
import { createPost, CreatePostState } from "@/actions/posts";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Post"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreatePostForm() {
|
||||
const initialState: CreatePostState = { message: null, errors: {} };
|
||||
// Use useActionState from React
|
||||
const [state, dispatch] = useActionState(createPost, initialState);
|
||||
|
||||
// Helper to get default value or empty string
|
||||
const getPreviousInput = (
|
||||
key: keyof NonNullable<CreatePostState["previousInput"]>
|
||||
) => state.previousInput?.[key] ?? "";
|
||||
|
||||
return (
|
||||
// Remove encType="multipart/form-data"
|
||||
<form action={dispatch} className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
// Use previous input as default value
|
||||
defaultValue={getPreviousInput("title")}
|
||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||
aria-describedby="title-error"
|
||||
/>
|
||||
<div id="title-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.title &&
|
||||
state.errors.title.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="slug"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Slug (URL Path)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||
title="Lowercase letters, numbers, and hyphens only (e.g., my-cool-post)"
|
||||
// Use previous input as default value
|
||||
defaultValue={getPreviousInput("slug")}
|
||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||
aria-describedby="slug-error"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Lowercase letters, numbers, and hyphens only (e.g., my-cool-post).
|
||||
</p>
|
||||
<div id="slug-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.slug &&
|
||||
state.errors.slug.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content (Textarea - consider a Markdown editor later) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="content"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Content (Markdown supported)
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
rows={10}
|
||||
required
|
||||
// Use previous input as default value
|
||||
defaultValue={getPreviousInput("content")}
|
||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||
aria-describedby="content-error"
|
||||
></textarea>
|
||||
<div id="content-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.content &&
|
||||
state.errors.content.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excerpt (Optional) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="excerpt"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Excerpt (Optional short summary)
|
||||
</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
rows={3}
|
||||
// Use previous input as default value
|
||||
defaultValue={getPreviousInput("excerpt")}
|
||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||
aria-describedby="excerpt-error"
|
||||
></textarea>
|
||||
<div id="excerpt-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.excerpt &&
|
||||
state.errors.excerpt.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload (File Input) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="image"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Featured Image (Optional, Max 5MB)
|
||||
</label>
|
||||
<input
|
||||
type="file" // Changed type to file
|
||||
id="image"
|
||||
name="image"
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-secondary file:text-secondary-foreground hover:file:bg-secondary/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
aria-describedby="image-error"
|
||||
accept="image/jpeg, image/png, image/webp, image/gif" // Specify accepted types
|
||||
/>
|
||||
<div id="image-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.image &&
|
||||
state.errors.image.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Input (Optional) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Tags (Optional, comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
// Use previous input as default value
|
||||
defaultValue={getPreviousInput("tags")}
|
||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
|
||||
aria-describedby="tags-error"
|
||||
placeholder="e.g., cloud, ai, development"
|
||||
/>
|
||||
<div id="tags-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.tags &&
|
||||
state.errors.tags.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Published Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="published"
|
||||
name="published"
|
||||
type="checkbox"
|
||||
// Use previous input as default checked state
|
||||
defaultChecked={state.previousInput?.published ?? false}
|
||||
className="h-4 w-4 text-primary border-border rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="published"
|
||||
className="ml-2 block text-sm text-foreground"
|
||||
>
|
||||
Publish immediately
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* General Form Error */}
|
||||
<div id="form-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?._form &&
|
||||
state.errors._form.map((error: string) => (
|
||||
<p className="mt-1 text-sm text-destructive" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{state.message && !state.errors?._form && (
|
||||
// Display general message if no specific form error
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
state.errors ? "text-destructive" : "text-green-600"
|
||||
}`}
|
||||
>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -122,6 +122,12 @@ const HeaderClient = ({
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/tech-talk"
|
||||
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
||||
>
|
||||
Tech Talk
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className={`text-sm font-medium text-foreground/80 hover:text-primary transition`}
|
||||
|
||||
Reference in New Issue
Block a user