From 9187ace07db038683812d0a4892a72870b731c36 Mon Sep 17 00:00:00 2001 From: Naufal Pinasthika Date: Wed, 18 Feb 2026 10:28:19 +0700 Subject: [PATCH 1/4] feat: input validation on create/edit project --- src/pages/ProjectCreatePage.tsx | 32 ++++++++++++++++++++++++++++++-- src/pages/ProjectEditPage.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) 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; }; From ffa2816b74040b73146c6551ca4aef3f34cb4ec6 Mon Sep 17 00:00:00 2001 From: Naufal Pinasthika Date: Wed, 25 Feb 2026 21:21:32 +0700 Subject: [PATCH 2/4] feat: validataion on remaining pages --- src/pages/BlogCreatePage.tsx | 23 +++++++++++++++++++++++ src/pages/BlogEditPage.tsx | 22 ++++++++++++++++++++++ src/pages/TechStackCreatePage.tsx | 18 ++++++++++++++++++ src/pages/TechStackEditPage.tsx | 20 ++++++++++++++++++++ 4 files changed, 83 insertions(+) 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/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; }; From e9725311f1cc446816e95ea5355515c9e86a4b8c Mon Sep 17 00:00:00 2001 From: Naufal Pinasthika Date: Sat, 28 Feb 2026 13:02:59 +0700 Subject: [PATCH 3/4] feat: testimonial section --- src/App.tsx | 6 + src/components/layout/AdminLayout.tsx | 16 ++ src/contexts/AuthContext.tsx | 11 +- src/hooks/useTestimonials.ts | 106 +++++++++++ src/pages/LoginPage.tsx | 14 ++ src/pages/TestimonialCreatePage.tsx | 172 ++++++++++++++++++ src/pages/TestimonialEditPage.tsx | 215 +++++++++++++++++++++++ src/pages/TestimonialListPage.tsx | 195 ++++++++++++++++++++ src/pages/index.ts | 3 + src/services/api.ts | 8 + src/services/api/index.ts | 7 + src/services/api/testimonials.service.ts | 45 +++++ src/services/api/types.ts | 20 +++ 13 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useTestimonials.ts create mode 100644 src/pages/TestimonialCreatePage.tsx create mode 100644 src/pages/TestimonialEditPage.tsx create mode 100644 src/pages/TestimonialListPage.tsx create mode 100644 src/services/api/testimonials.service.ts 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 + +
  • (undefined); export function AuthProvider({ children }: { children: ReactNode }) { const { data, isPending } = useSession(); + // TODO: REMOVE BYPASS WHEN BACKEND AUTH IS READY + const isBypassed = localStorage.getItem("admin_token") === "dev_token"; + + const user = isBypassed + ? { id: "dev-id", name: "Dev Admin", email: "admin@iit.dev", emailVerified: true } + : (data?.user ?? null); + + const isLoading = isBypassed ? false : isPending; + return ( {children} diff --git a/src/hooks/useTestimonials.ts b/src/hooks/useTestimonials.ts new file mode 100644 index 0000000..983927c --- /dev/null +++ b/src/hooks/useTestimonials.ts @@ -0,0 +1,106 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiService } from "@/services/api"; +import type { + CreateTestimonialRequest, + UpdateTestimonialRequest, +} from "@/services/api/types"; + +// Query keys +export const testimonialKeys = { + all: ["testimonials"] as const, + lists: () => [...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/LoginPage.tsx b/src/pages/LoginPage.tsx index 2a6962f..71ac395 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -25,6 +25,19 @@ export default function LoginPage() { setError(""); setIsLoading(true); + // TODO: REMOVE THIS BYPASS WHEN BACKEND AUTH IS IMPLEMENTED + // Currently backend has no auth/users table, so we bypass login for development. + setTimeout(() => { + console.warn("BYPASSING LOGIN: No backend auth available yet."); + // Mock successful login state + localStorage.setItem("admin_token", "dev_token"); + // Force reload to update AuthContext + window.location.href = "/"; + setIsLoading(false);admin/src/contexts + }, 1000); + + // ORIGINAL CODE (Commented out): + /* try { const result = await signIn.email({ email, @@ -42,6 +55,7 @@ export default function LoginPage() { } finally { setIsLoading(false); } + */ }; return ( 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 +

    +
    + +
    + +