Skip to content

Commit 7f87bdd

Browse files
committed
feat: Add a certificates page, implement SEO, refine Supabase RLS policies for security and performance, and update project metadata to reflect Vite, React, and TypeScript.
1 parent 52fdec9 commit 7f87bdd

18 files changed

Lines changed: 813 additions & 106 deletions

app/about.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import React from 'react';
22
import { useTheme } from '../lib/ThemeContext';
33
import { useQuery } from '@tanstack/react-query';
4+
import { useNavigate } from 'react-router-dom';
45
import * as api from '../lib/api';
56
import { Navbar } from '../components/Navbar';
6-
import { Download, MapPin, Briefcase, GraduationCap, Mail, Github, Linkedin, Twitter, Instagram, Youtube, Award, Clock, Code2, Calendar } from 'lucide-react';
7+
import { Download, MapPin, Briefcase, GraduationCap, Mail, Github, Linkedin, Twitter, Instagram, Youtube, Award, Clock, Code2, Calendar, ArrowRight } from 'lucide-react';
78
import { motion } from 'framer-motion';
89
import ReactMarkdown from 'react-markdown';
910
import WakatimeStats from '../components/WakatimeStats';
1011
import { ensureFullUrl } from '../lib/utils';
12+
import { useDocumentMeta } from '../lib/useDocumentMeta';
1113

1214
const SocialIconMap: { [key: string]: React.ElementType } = {
1315
github: Github, linkedin: Linkedin, twitter: Twitter, instagram: Instagram, youtube: Youtube, mail: Mail,
1416
};
1517

1618
export default function AboutPage() {
1719
const { theme } = useTheme();
20+
const navigate = useNavigate();
21+
22+
useDocumentMeta({
23+
title: 'About',
24+
description: 'Learn more about Ilham Ramadhan — experience, education, certifications, and coding activity.',
25+
});
1826

1927
const { data: profile, isLoading: isLoadingProfile } = useQuery({ queryKey: ['profile'], queryFn: api.getProfile });
2028
const { data: experience, isLoading: isLoadingExp } = useQuery({ queryKey: ['resume', 'experience'], queryFn: () => api.getResume('experience') });
@@ -133,9 +141,19 @@ export default function AboutPage() {
133141

134142
{certificates && certificates.length > 0 && (
135143
<div className="mb-20">
136-
<h2 className="text-2xl font-bold mb-8 flex items-center gap-3"><div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg text-green-600 dark:text-green-400"><Award className="h-6 w-6" /></div>Certificates</h2>
144+
<div className="flex items-center justify-between mb-8">
145+
<h2 className="text-2xl font-bold flex items-center gap-3"><div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg text-green-600 dark:text-green-400"><Award className="h-6 w-6" /></div>Certificates</h2>
146+
{certificates.length > 3 && (
147+
<button
148+
onClick={() => navigate('/certificates')}
149+
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm flex items-center gap-1"
150+
>
151+
View All Certificates <ArrowRight className="h-4 w-4" />
152+
</button>
153+
)}
154+
</div>
137155
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
138-
{certificates.map((cert, idx) => (
156+
{certificates.slice(0, 3).map((cert, idx) => (
139157
<motion.div key={cert.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.1 }} className="bg-white border border-slate-200 dark:bg-slate-900/50 dark:border-white/5 rounded-xl overflow-hidden hover:border-indigo-500/30 transition-all shadow-sm dark:shadow-none group flex flex-col">
140158
{cert.file_url ? (
141159
<div className="h-48 bg-slate-100 dark:bg-slate-800 overflow-hidden relative">

app/blog-detail.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import React, { useState } from 'react';
1+
import React, { useState, useRef, useEffect, useCallback } from 'react';
22
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
33
import { useNavigate, useParams } from 'react-router-dom';
44
import { Navbar } from '../components/Navbar';
55
import { getBlogBySlug, addComment as addCommentAPI, getComments } from '../lib/api';
6+
import { useDocumentMeta } from '../lib/useDocumentMeta';
67
import { ArrowLeft, Calendar, Tag, MessageSquare, Send } from 'lucide-react';
78
import { toast } from 'sonner';
89
import { ImageCarousel } from '../components/ImageCarousel';
910
import ReactMarkdown from 'react-markdown';
1011

12+
const COMMENT_COOLDOWN = 30;
13+
1114
export default function BlogDetailPage() {
1215
const { slug } = useParams<{ slug: string }>();
1316
const navigate = useNavigate();
@@ -19,6 +22,12 @@ export default function BlogDetailPage() {
1922
enabled: !!slug,
2023
});
2124

25+
useDocumentMeta({
26+
title: blog?.title,
27+
description: blog?.excerpt || undefined,
28+
ogImage: blog?.images?.[0] || undefined,
29+
});
30+
2231
const { data: comments, isLoading: isLoadingComments } = useQuery({
2332
queryKey: ['comments', blog?.id],
2433
queryFn: () => getComments(blog!.id),
@@ -32,15 +41,33 @@ export default function BlogDetailPage() {
3241
queryClient.invalidateQueries({ queryKey: ['comments', blog?.id] });
3342
setCommentName('');
3443
setCommentText('');
44+
startCooldown();
3545
},
3646
onError: (error) => {
3747
toast.error(`Failed to add comment: ${error.message}`);
3848
}
3949
});
4050

41-
// Comment State
51+
// Comment State + Rate Limiting
4252
const [commentName, setCommentName] = useState('');
4353
const [commentText, setCommentText] = useState('');
54+
const [commentHoneypot, setCommentHoneypot] = useState('');
55+
const [cooldown, setCooldown] = useState(0);
56+
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
57+
58+
useEffect(() => {
59+
return () => { if (cooldownRef.current) clearInterval(cooldownRef.current); };
60+
}, []);
61+
62+
const startCooldown = useCallback(() => {
63+
setCooldown(COMMENT_COOLDOWN);
64+
cooldownRef.current = setInterval(() => {
65+
setCooldown(prev => {
66+
if (prev <= 1) { clearInterval(cooldownRef.current!); return 0; }
67+
return prev - 1;
68+
});
69+
}, 1000);
70+
}, []);
4471

4572
if (isLoading) {
4673
return (
@@ -61,6 +88,9 @@ export default function BlogDetailPage() {
6188

6289
const handleCommentSubmit = (e: React.FormEvent) => {
6390
e.preventDefault();
91+
// Honeypot
92+
if (commentHoneypot) return;
93+
if (cooldown > 0) { toast.error(`Please wait ${cooldown}s before posting again.`); return; }
6494
if (!commentName.trim() || !commentText.trim() || !blog.id) return;
6595

6696
addComment({ postId: blog.id, name: commentName, text: commentText });
@@ -106,6 +136,10 @@ export default function BlogDetailPage() {
106136
<h3 className="text-2xl font-bold mb-6 flex items-center gap-2"><MessageSquare className="h-6 w-6"/> Comments ({comments?.length || 0})</h3>
107137

108138
<form onSubmit={handleCommentSubmit} className="mb-8 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-white/5">
139+
{/* Honeypot */}
140+
<div className="absolute opacity-0 pointer-events-none" aria-hidden="true" tabIndex={-1}>
141+
<input type="text" value={commentHoneypot} onChange={(e) => setCommentHoneypot(e.target.value)} tabIndex={-1} autoComplete="off" />
142+
</div>
109143
<div className="mb-4">
110144
<label className="block text-sm font-medium mb-1">Name</label>
111145
<input
@@ -114,6 +148,7 @@ export default function BlogDetailPage() {
114148
onChange={(e) => setCommentName(e.target.value)}
115149
className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-white/10 focus:outline-none focus:ring-2 focus:ring-indigo-500"
116150
placeholder="Your Name"
151+
maxLength={100}
117152
required
118153
/>
119154
</div>
@@ -125,15 +160,16 @@ export default function BlogDetailPage() {
125160
className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-white/10 focus:outline-none focus:ring-2 focus:ring-indigo-500"
126161
placeholder="Share your thoughts..."
127162
rows={3}
163+
maxLength={2000}
128164
required
129165
/>
130166
</div>
131167
<button
132168
type="submit"
133-
disabled={isAddingComment}
169+
disabled={isAddingComment || cooldown > 0}
134170
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50"
135171
>
136-
{isAddingComment ? 'Posting...' : <><Send className="h-4 w-4"/> Post Comment</>}
172+
{isAddingComment ? 'Posting...' : cooldown > 0 ? `Wait ${cooldown}s` : <><Send className="h-4 w-4"/> Post Comment</>}
137173
</button>
138174
</form>
139175

app/blog.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import React, { useState } from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { useQuery } from '@tanstack/react-query';
44
import { Navbar } from '../components/Navbar';
5+
import { CollapsibleTagFilter } from '../components/CollapsibleTagFilter';
56
import { getBlogs, getBlogTags } from '../lib/api';
67
import { useDebounce } from '../lib/hooks';
8+
import { useDocumentMeta } from '../lib/useDocumentMeta';
79
import { Calendar, Search, Tag, ChevronLeft, ChevronRight } from 'lucide-react';
810

911
const ITEMS_PER_PAGE = 6;
@@ -16,6 +18,11 @@ export default function BlogPage() {
1618

1719
const debouncedSearchQuery = useDebounce(searchQuery, 300);
1820

21+
useDocumentMeta({
22+
title: 'Blog',
23+
description: 'Insights, thoughts, and tutorials on web development, design, and technology.',
24+
});
25+
1926
const { data: allBlogTags, isLoading: isLoadingTags } = useQuery({
2027
queryKey: ['blogTags'],
2128
queryFn: getBlogTags,
@@ -64,20 +71,15 @@ export default function BlogPage() {
6471
/>
6572
</div>
6673

67-
<div className="flex flex-wrap justify-center gap-2">
68-
{isLoadingTags ? <div className="h-6 w-20 animate-pulse bg-slate-200 dark:bg-slate-800 rounded-full" /> : allBlogTags?.map(tag => (
69-
<button
70-
key={tag}
71-
onClick={() => handleFilterChange(tag === selectedTag ? null : tag)}
72-
className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
73-
tag === selectedTag
74-
? 'bg-indigo-600 text-white border-indigo-600'
75-
: 'bg-white dark:bg-slate-900/50 text-slate-600 dark:text-slate-400 border-slate-200 dark:border-white/10 hover:border-indigo-500 hover:text-indigo-500'
76-
}`}
77-
>
78-
#{tag}
79-
</button>
80-
))}
74+
<div className="flex justify-center">
75+
<CollapsibleTagFilter
76+
tags={allBlogTags ?? []}
77+
selectedTag={selectedTag}
78+
onTagClick={handleFilterChange}
79+
maxVisible={6}
80+
isLoading={isLoadingTags}
81+
prefix="#"
82+
/>
8183
</div>
8284
</div>
8385

app/certificates.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import React, { useState } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import { Navbar } from '../components/Navbar';
4+
import { CollapsibleTagFilter } from '../components/CollapsibleTagFilter';
5+
import { getCertificatesPaginated, getCertificateIssuers } from '../lib/api';
6+
import { useDebounce } from '../lib/hooks';
7+
import { useDocumentMeta } from '../lib/useDocumentMeta';
8+
import { Award, Search, ChevronLeft, ChevronRight } from 'lucide-react';
9+
import { motion } from 'framer-motion';
10+
import ReactMarkdown from 'react-markdown';
11+
12+
const ITEMS_PER_PAGE = 9;
13+
14+
export default function CertificatesPage() {
15+
const [searchQuery, setSearchQuery] = useState('');
16+
const [selectedIssuer, setSelectedIssuer] = useState<string | null>(null);
17+
const [currentPage, setCurrentPage] = useState(1);
18+
19+
const debouncedSearchQuery = useDebounce(searchQuery, 300);
20+
21+
useDocumentMeta({
22+
title: 'Certificates',
23+
description: 'Browse all professional certifications and achievements.',
24+
});
25+
26+
const { data: allIssuers, isLoading: isLoadingIssuers } = useQuery({
27+
queryKey: ['certificateIssuers'],
28+
queryFn: getCertificateIssuers,
29+
});
30+
31+
const { data: certData, isLoading: isLoadingCerts, isError } = useQuery({
32+
queryKey: ['certificatesPaginated', currentPage, selectedIssuer, debouncedSearchQuery],
33+
queryFn: () => getCertificatesPaginated({
34+
page: currentPage,
35+
limit: ITEMS_PER_PAGE,
36+
query: debouncedSearchQuery,
37+
issuer: selectedIssuer,
38+
}),
39+
placeholderData: (previousData) => previousData,
40+
});
41+
42+
const certificates = certData?.data ?? [];
43+
const totalCerts = certData?.count ?? 0;
44+
const totalPages = Math.ceil(totalCerts / ITEMS_PER_PAGE);
45+
46+
const handlePageChange = (newPage: number) => {
47+
setCurrentPage(newPage);
48+
window.scrollTo({ top: 0, behavior: 'smooth' });
49+
};
50+
51+
const handleIssuerFilter = (issuer: string | null) => {
52+
setSelectedIssuer(issuer);
53+
setCurrentPage(1);
54+
};
55+
56+
return (
57+
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-white transition-colors duration-300">
58+
<Navbar />
59+
<div className="pt-32 pb-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
60+
{/* Header */}
61+
<div className="text-center mb-12">
62+
<h1 className="text-4xl md:text-5xl font-bold mb-4">Certificates</h1>
63+
<p className="text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
64+
Professional certifications, achievements, and credentials.
65+
</p>
66+
</div>
67+
68+
{/* Search & Filters */}
69+
<div className="max-w-4xl mx-auto mb-12 space-y-6">
70+
<div className="relative">
71+
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-500" />
72+
<input
73+
type="text"
74+
placeholder="Search certificates..."
75+
value={searchQuery}
76+
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
77+
className="w-full bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-white/10 rounded-full py-3 pl-12 pr-6 text-slate-900 dark:text-white focus:ring-2 focus:ring-indigo-500 outline-none backdrop-blur-sm shadow-sm"
78+
/>
79+
</div>
80+
81+
<div className="flex justify-center">
82+
<CollapsibleTagFilter
83+
tags={allIssuers ?? []}
84+
selectedTag={selectedIssuer}
85+
onTagClick={handleIssuerFilter}
86+
maxVisible={6}
87+
isLoading={isLoadingIssuers}
88+
/>
89+
</div>
90+
</div>
91+
92+
{/* Certificate Grid */}
93+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
94+
{isLoadingCerts && Array.from({ length: ITEMS_PER_PAGE }).map((_, i) => (
95+
<div key={i} className="rounded-xl bg-slate-200 dark:bg-slate-800/50 aspect-[4/3] animate-pulse" />
96+
))}
97+
{isError && <div className="col-span-full py-20 text-center text-red-500">Error loading certificates.</div>}
98+
{certificates.map((cert, idx) => (
99+
<motion.div
100+
key={cert.id}
101+
initial={{ opacity: 0, y: 20 }}
102+
whileInView={{ opacity: 1, y: 0 }}
103+
transition={{ delay: idx * 0.05 }}
104+
className="bg-white border border-slate-200 dark:bg-slate-900/50 dark:border-white/5 rounded-xl overflow-hidden hover:border-indigo-500/30 transition-all shadow-sm dark:shadow-none group flex flex-col"
105+
>
106+
{cert.file_url ? (
107+
<div className="h-48 bg-slate-100 dark:bg-slate-800 overflow-hidden relative">
108+
<img src={cert.file_url} alt={cert.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
109+
</div>
110+
) : (
111+
<div className="h-48 bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
112+
<Award className="h-12 w-12" />
113+
</div>
114+
)}
115+
<div className="p-6 flex-1 flex flex-col">
116+
<h3 className="text-lg font-bold mb-2 text-slate-900 dark:text-white">{cert.title}</h3>
117+
<p className="text-sm text-indigo-600 dark:text-indigo-400 mb-2">{cert.issued_by}</p>
118+
<p className="text-xs text-slate-500 mb-4">{cert.issued_date} {cert.expiry_date ? ` - ${cert.expiry_date}` : ''}</p>
119+
<div className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed mb-4 flex-1 prose prose-sm dark:prose-invert line-clamp-3">
120+
<ReactMarkdown>{cert.description || ''}</ReactMarkdown>
121+
</div>
122+
{cert.credential_url && (
123+
<a href={cert.credential_url} target="_blank" rel="noopener noreferrer" className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline mt-auto">
124+
View Credential &rarr;
125+
</a>
126+
)}
127+
</div>
128+
</motion.div>
129+
))}
130+
{certificates.length === 0 && !isLoadingCerts && (
131+
<div className="col-span-full py-20 text-center text-slate-500">
132+
No certificates found matching your criteria.
133+
</div>
134+
)}
135+
</div>
136+
137+
{/* Pagination */}
138+
{totalPages > 1 && (
139+
<div className="flex justify-center gap-2">
140+
<button
141+
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
142+
disabled={currentPage === 1}
143+
className="p-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed"
144+
>
145+
<ChevronLeft className="h-5 w-5" />
146+
</button>
147+
{Array.from({ length: totalPages }).map((_, i) => (
148+
<button
149+
key={i}
150+
onClick={() => handlePageChange(i + 1)}
151+
className={`w-10 h-10 rounded-lg font-medium transition-colors ${
152+
currentPage === i + 1
153+
? 'bg-indigo-600 text-white'
154+
: 'border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/5'
155+
}`}
156+
>
157+
{i + 1}
158+
</button>
159+
))}
160+
<button
161+
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
162+
disabled={currentPage === totalPages}
163+
className="p-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/5 disabled:opacity-50 disabled:cursor-not-allowed"
164+
>
165+
<ChevronRight className="h-5 w-5" />
166+
</button>
167+
</div>
168+
)}
169+
</div>
170+
</div>
171+
);
172+
}

0 commit comments

Comments
 (0)