diff --git a/src/App.tsx b/src/App.tsx index 4142d92..e361a2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,9 @@ import { TechStackListPage, TechStackCreatePage, TechStackEditPage, + TestimonialListPage, + TestimonialCreatePage, + TestimonialEditPage, LoginPage, // RegisterPage, // Keep this commented out if you aren't using it yet } from "@/pages"; @@ -42,6 +45,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/src/components/layout/AdminLayout.tsx b/src/components/layout/AdminLayout.tsx index a9c16a4..fc3197d 100644 --- a/src/components/layout/AdminLayout.tsx +++ b/src/components/layout/AdminLayout.tsx @@ -7,6 +7,7 @@ import { User, Briefcase, Layers, + MessageSquareQuote, } from "lucide-react"; import { signOut } from "@/lib/auth-client"; import { useAuth } from "@/contexts/AuthContext"; @@ -98,6 +99,21 @@ const AdminLayout = () => { Tags +
  • + + `flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ + isActive + ? "bg-purple-100 text-purple-700" + : "text-gray-600 hover:bg-gray-100 hover:text-gray-900" + }` + } + > + + Testimonials + +
  • [...testimonialKeys.all, "list"] as const, + details: () => [...testimonialKeys.all, "detail"] as const, + detail: (id: number) => [...testimonialKeys.details(), id] as const, +}; + +// Fetch all testimonials +export const useTestimonials = () => { + return useQuery({ + queryKey: testimonialKeys.lists(), + queryFn: async () => { + const response = await apiService.getAllTestimonials(); + if (!response.success) { + throw new Error(response.error || "Failed to fetch testimonials"); + } + return response.data || []; + }, + }); +}; + +// Fetch single testimonial +export const useTestimonial = (id: number) => { + return useQuery({ + queryKey: testimonialKeys.detail(id), + queryFn: async () => { + const response = await apiService.getTestimonialById(id); + if (!response.success) { + throw new Error(response.error || "Failed to fetch testimonial"); + } + return response.data; + }, + enabled: !!id && id > 0, + }); +}; + +// Create testimonial mutation +export const useCreateTestimonial = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: CreateTestimonialRequest) => { + const response = await apiService.createTestimonial(data); + if (!response.success) { + throw new Error(response.error || "Failed to create testimonial"); + } + return response.data!; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: testimonialKeys.lists() }); + }, + }); +}; + +// Update testimonial mutation +export const useUpdateTestimonial = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: number; + data: UpdateTestimonialRequest; + }) => { + const response = await apiService.updateTestimonial(id, data); + if (!response.success) { + throw new Error(response.error || "Failed to update testimonial"); + } + return response.data!; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: testimonialKeys.lists() }); + queryClient.invalidateQueries({ + queryKey: testimonialKeys.detail(variables.id), + }); + }, + }); +}; + +// Delete testimonial mutation +export const useDeleteTestimonial = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const response = await apiService.deleteTestimonial(id); + if (!response.success) { + throw new Error(response.error || "Failed to delete testimonial"); + } + return response.data!; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: testimonialKeys.lists() }); + }, + }); +}; diff --git a/src/pages/BlogCreatePage.tsx b/src/pages/BlogCreatePage.tsx index 3a15463..ad3aeac 100644 --- a/src/pages/BlogCreatePage.tsx +++ b/src/pages/BlogCreatePage.tsx @@ -91,14 +91,34 @@ const BlogCreatePage = () => { alert("Please enter a blog title"); return false; } + if (formData.title.length > 150) { + alert("Blog title cannot exceed 150 characters"); + return false; + } + if (!formData.author.trim()) { alert("Please enter the author name"); return false; } + if (formData.author.length > 50) { + alert("Author name cannot exceed 50 characters"); + return false; + } + + if (formData.excerpt && formData.excerpt.length > 300) { + alert("Excerpt cannot exceed 300 characters"); + return false; + } + if (!formData.time_read.trim()) { alert("Please enter the reading time"); return false; } + if (formData.time_read.length > 50) { + alert("Reading time cannot exceed 50 characters"); + return false; + } + if ( !formData.content || !formData.content.content || @@ -107,14 +127,17 @@ const BlogCreatePage = () => { alert("Please add content to your blog"); return false; } + if (!formData.thumbnail) { alert("Please upload a thumbnail image"); return false; } + if (!formData.tag_id) { alert("Please select a tag"); return false; } + return true; }; diff --git a/src/pages/BlogEditPage.tsx b/src/pages/BlogEditPage.tsx index e360060..61644bb 100644 --- a/src/pages/BlogEditPage.tsx +++ b/src/pages/BlogEditPage.tsx @@ -174,14 +174,34 @@ const BlogEditPage = () => { alert("Please enter a blog title"); return false; } + if (formData.title.length > 150) { + alert("Blog title cannot exceed 150 characters"); + return false; + } + if (!formData.author.trim()) { alert("Please enter the author name"); return false; } + if (formData.author.length > 50) { + alert("Author name cannot exceed 50 characters"); + return false; + } + + if (formData.excerpt && formData.excerpt.length > 300) { + alert("Excerpt cannot exceed 300 characters"); + return false; + } + if (!formData.time_read.trim()) { alert("Please enter the reading time"); return false; } + if (formData.time_read.length > 50) { + alert("Reading time cannot exceed 50 characters"); + return false; + } + if ( !formData.content || !formData.content.content || @@ -190,10 +210,12 @@ const BlogEditPage = () => { alert("Please add content to your blog"); return false; } + if (!formData.tag_id) { alert("Please select a tag"); return false; } + return true; }; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2a6962f..3d22477 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -108,4 +108,4 @@ export default function LoginPage() { ); -} +} \ No newline at end of file diff --git a/src/pages/ProjectCreatePage.tsx b/src/pages/ProjectCreatePage.tsx index 4b2aced..cf6dc8c 100644 --- a/src/pages/ProjectCreatePage.tsx +++ b/src/pages/ProjectCreatePage.tsx @@ -110,22 +110,50 @@ const ProjectCreatePage = () => { alert("Please enter a project title"); return false; } + if (formData.title.length > 100) { + alert("Project title cannot exceed 100 characters"); + return false; + } + if (!formData.description.trim()) { alert("Please enter a project description"); return false; } + + if (formData.description.length > 2000) { + alert("Description is too long (max 2000 characters)"); + return false; + } + if (!formData.owner.trim()) { alert("Please enter the project owner"); return false; } - if (!formData.category.trim()) { + if (formData.owner.length > 50) { + alert("Owner name cannot exceed 50 characters"); + return false; + } + + if (formData.category.trim() === "") { alert("Please select a category"); return false; } - if (!formData.scope.trim()) { + + if (formData.scope.trim() === "") { alert("Please select a scope"); return false; } + + if (formData.url && formData.url.length > 255) { + alert("Project URL is too long (max 255 characters)"); + return false; + } + + if (formData.testimonial && formData.testimonial.length > 500) { + alert("Testimonial cannot exceed 500 characters"); + return false; + } + return true; }; diff --git a/src/pages/ProjectEditPage.tsx b/src/pages/ProjectEditPage.tsx index 8fbf286..e33fc35 100644 --- a/src/pages/ProjectEditPage.tsx +++ b/src/pages/ProjectEditPage.tsx @@ -151,22 +151,49 @@ const ProjectEditPage = () => { alert("Please enter a project title"); return false; } + if (formData.title.length > 100) { + alert("Project title cannot exceed 100 characters"); + return false; + } + if (!formData.description.trim()) { alert("Please enter a project description"); return false; } + if (formData.description.length > 2000) { + alert("Description is too long (max 2000 characters)"); + return false; + } + if (!formData.owner.trim()) { alert("Please enter the project owner"); return false; } + if (formData.owner.length > 50) { + alert("Owner name cannot exceed 50 characters"); + return false; + } + if (!formData.category.trim()) { alert("Please select a category"); return false; } + if (!formData.scope.trim()) { alert("Please select a scope"); return false; } + + if (formData.url && formData.url.length > 255) { + alert("Project URL is too long (max 255 characters)"); + return false; + } + + if (formData.testimonial && formData.testimonial.length > 500) { + alert("Testimonial cannot exceed 500 characters"); + return false; + } + return true; }; diff --git a/src/pages/TechStackCreatePage.tsx b/src/pages/TechStackCreatePage.tsx index 19aef70..9a5a45c 100644 --- a/src/pages/TechStackCreatePage.tsx +++ b/src/pages/TechStackCreatePage.tsx @@ -49,6 +49,24 @@ const TechStackCreatePage = () => { alert("Please enter a tech stack name"); return false; } + if (formData.tech_stack_name.length > 50) { + alert("Tech stack name cannot exceed 50 characters"); + return false; + } + + if ( + formData.tech_stack_description && + formData.tech_stack_description.length > 500 + ) { + alert("Description cannot exceed 500 characters"); + return false; + } + + if (!formData.icon_url) { + alert("Please upload an icon"); + return false; + } + return true; }; diff --git a/src/pages/TechStackEditPage.tsx b/src/pages/TechStackEditPage.tsx index a338aff..9cca534 100644 --- a/src/pages/TechStackEditPage.tsx +++ b/src/pages/TechStackEditPage.tsx @@ -83,6 +83,26 @@ const TechStackEditPage = () => { alert("Please enter a tech stack name"); return false; } + if (formData.tech_stack_name.length > 50) { + alert("Tech stack name cannot exceed 50 characters"); + return false; + } + + if ( + formData.tech_stack_description && + formData.tech_stack_description.length > 500 + ) { + alert("Description cannot exceed 500 characters"); + return false; + } + + // Icon is optional in edit (might be already there), but if cleared it might be an issue? + // The state `icon_url` holds the current URL. + if (!formData.icon_url) { + alert("Please upload an icon"); + return false; + } + return true; }; diff --git a/src/pages/TestimonialCreatePage.tsx b/src/pages/TestimonialCreatePage.tsx new file mode 100644 index 0000000..e542638 --- /dev/null +++ b/src/pages/TestimonialCreatePage.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import MetaTags from "@/components/MetaTags"; +import { useCreateTestimonial } from "@/hooks/useTestimonials"; +import { sanitizeText } from "@/utils/sanitizeInput"; + +const TestimonialCreatePage = () => { + const navigate = useNavigate(); + const createTestimonialMutation = useCreateTestimonial(); + + const [formData, setFormData] = useState({ + full_name: "", + role: "", + description: "", + }); + + const validateForm = () => { + if (!formData.full_name.trim()) { + alert("Please enter the full name"); + return false; + } + if (formData.full_name.length > 100) { + alert("Full name cannot exceed 100 characters"); + return false; + } + + if (!formData.role.trim()) { + alert("Please enter the role/position"); + return false; + } + if (formData.role.length > 100) { + alert("Role/Position cannot exceed 100 characters"); + return false; + } + + if (!formData.description.trim()) { + alert("Please enter the description"); + return false; + } + if (formData.description.length > 500) { + alert("Description cannot exceed 500 characters"); + return false; + } + + return true; + }; + + const handleCreateTestimonial = async () => { + if (!validateForm()) return; + + try { + await createTestimonialMutation.mutateAsync({ + full_name: sanitizeText(formData.full_name), + role: sanitizeText(formData.role), + description: sanitizeText(formData.description), + }); + alert("Testimonial created successfully!"); + navigate("/testimonials"); + } catch (error) { + alert( + error instanceof Error + ? error.message + : "Failed to create testimonial", + ); + } + }; + + const isLoading = createTestimonialMutation.isPending; + + return ( +
    + +
    +

    + Create Testimonial +

    +

    Add a new client testimonial

    +
    + +
    +
    +
    + + + setFormData((prev) => ({ + ...prev, + full_name: e.target.value, + })) + } + placeholder="e.g., John Doe" + maxLength={100} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +

    + {formData.full_name.length}/100 characters +

    +
    + +
    + + + setFormData((prev) => ({ ...prev, role: e.target.value })) + } + placeholder="e.g., CEO at Company" + maxLength={100} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +

    + {formData.role.length}/100 characters +

    +
    + +
    + +