portosaurus 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portosaurus might be problematic. Click here for more details.
- package/.vscode/snippets.code-snippets +79 -0
- package/AGENTS.md +37 -0
- package/GG/config.js +233 -0
- package/GG/package.json +14 -0
- package/GG/static/.nojekyll +0 -0
- package/GG/static/docusaurus-snippet.css +3 -0
- package/GG/static/img/icon-bg.png +0 -0
- package/GG/static/img/icon-old.png +0 -0
- package/GG/static/img/icon.png +0 -0
- package/GG/static/img/project-blank.png +0 -0
- package/GG/static/img/social-card.jpeg +0 -0
- package/LICENSE +674 -0
- package/README.md +57 -0
- package/bin/portosaurus.js +136 -0
- package/package.json +36 -0
- package/src/config/iconMappings.js +329 -0
- package/src/config/metaTags.js +240 -0
- package/src/config/prism.js +179 -0
- package/src/config/sidebar.js +20 -0
- package/src/configLoader.js +99 -0
- package/src/index.js +79 -0
- package/src/pages/index.js +98 -0
- package/src/pages/notes.js +88 -0
- package/src/pages/tasks.js +251 -0
- package/src/theme/components/AboutSection/index.js +67 -0
- package/src/theme/components/AboutSection/styles.module.css +492 -0
- package/src/theme/components/ContactSection/index.js +87 -0
- package/src/theme/components/ContactSection/styles.module.css +327 -0
- package/src/theme/components/ExperienceSection/index.js +25 -0
- package/src/theme/components/ExperienceSection/styles.module.css +180 -0
- package/src/theme/components/HeroSection/index.js +63 -0
- package/src/theme/components/HeroSection/styles.module.css +471 -0
- package/src/theme/components/NoteIndex/index.js +119 -0
- package/src/theme/components/NoteIndex/styles.module.css +143 -0
- package/src/theme/components/ProjectsSection/index.js +529 -0
- package/src/theme/components/ProjectsSection/styles.module.css +830 -0
- package/src/theme/components/ScrollToTop/index.js +98 -0
- package/src/theme/components/ScrollToTop/styles.module.css +96 -0
- package/src/theme/components/SocialLinks/index.js +129 -0
- package/src/theme/components/SocialLinks/styles.module.css +55 -0
- package/src/theme/components/Tooltip/index.js +30 -0
- package/src/theme/components/Tooltip/styles.module.css +92 -0
- package/src/theme/css/bootstrap.css +6 -0
- package/src/theme/css/catppuccin.css +632 -0
- package/src/theme/css/custom.css +186 -0
- package/src/theme/css/tasks.css +868 -0
- package/src/theme/staticLink/.nojekyll +0 -0
- package/src/theme/staticLink/docusaurus-snippet.css +3 -0
- package/src/theme/staticLink/img/icon-bg.png +0 -0
- package/src/theme/staticLink/img/icon-old.png +0 -0
- package/src/theme/staticLink/img/icon.png +0 -0
- package/src/theme/staticLink/img/project-blank.png +0 -0
- package/src/theme/staticLink/img/social-card.jpeg +0 -0
- package/src/utils/HashNavigation.js +250 -0
- package/src/utils/appVersion.js +27 -0
- package/src/utils/cssUtils.js +99 -0
- package/src/utils/filterEnabledItems.js +21 -0
- package/src/utils/generateFavicon.js +256 -0
- package/src/utils/generateRobotsTxt.js +97 -0
- package/src/utils/iconExtractor.js +159 -0
- package/src/utils/imageDownloader.js +88 -0
- package/src/utils/imageProcessor.js +134 -0
- package/src/utils/linkShortner.js +0 -0
- package/src/utils/updateTitle.js +107 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import Slider from "react-slick";
|
|
3
|
+
import { FaGithub, FaGlobe, FaPlay, FaChevronLeft, FaChevronRight, FaStar } from 'react-icons/fa';
|
|
4
|
+
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
|
5
|
+
import styles from './styles.module.css';
|
|
6
|
+
|
|
7
|
+
// Import slick carousel css
|
|
8
|
+
import "slick-carousel/slick/slick.css";
|
|
9
|
+
import "slick-carousel/slick/slick-theme.css";
|
|
10
|
+
|
|
11
|
+
/// PART OF THIS COMPONENT IS AI GENERATED
|
|
12
|
+
|
|
13
|
+
export default function ProjectsSection({ id, className, title, subtitle }) {
|
|
14
|
+
const { siteConfig } = useDocusaurusContext();
|
|
15
|
+
|
|
16
|
+
const [projects, setProjects] = useState([]);
|
|
17
|
+
const sliderRef = useRef(null);
|
|
18
|
+
const [atBeginning, setAtBeginning] = useState(true);
|
|
19
|
+
const [atEnd, setAtEnd] = useState(false);
|
|
20
|
+
const [slidesToShow, setSlidesToShow] = useState(3);
|
|
21
|
+
const [currentSlide, setCurrentSlide] = useState(0);
|
|
22
|
+
const [totalPages, setTotalPages] = useState(1);
|
|
23
|
+
const activeDotRef = useRef(null);
|
|
24
|
+
const dotsContainerRef = useRef(null);
|
|
25
|
+
|
|
26
|
+
// Default Settings
|
|
27
|
+
const projectDefaults = {
|
|
28
|
+
title: "Future Project",
|
|
29
|
+
desc: "Coming soon...",
|
|
30
|
+
image: "img/project-blank.png",
|
|
31
|
+
state: "active",
|
|
32
|
+
tags: ["planned"]
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
const createPlaceholders = useCallback((count, existingProjects) => {
|
|
37
|
+
if (existingProjects.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
...existingProjects,
|
|
41
|
+
...Array.from({ length: count }, (_, i) => ({
|
|
42
|
+
...projectDefaults,
|
|
43
|
+
|
|
44
|
+
// Dummy card config
|
|
45
|
+
state: "n/a",
|
|
46
|
+
title: `Project ${existingProjects.length + i + 1}`,
|
|
47
|
+
description: projectDefaults.desc,
|
|
48
|
+
image: projectDefaults.image,
|
|
49
|
+
id: `placeholder-${i}`,
|
|
50
|
+
tags: null
|
|
51
|
+
}))
|
|
52
|
+
];
|
|
53
|
+
}, [projectDefaults]);
|
|
54
|
+
|
|
55
|
+
// Get current slidesToShow based on screen width
|
|
56
|
+
const getVisibleSlidesPerView = useCallback(() => {
|
|
57
|
+
if (typeof window === 'undefined') return 3;
|
|
58
|
+
|
|
59
|
+
const width = window.innerWidth;
|
|
60
|
+
if (width <= 600) return 1;
|
|
61
|
+
if (width <= 1024) return 2;
|
|
62
|
+
return 3;
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const prepareProjects = useCallback((projectList, slides) => {
|
|
66
|
+
if (!projectList?.length) return { projects: [], totalPages: 0 };
|
|
67
|
+
|
|
68
|
+
// Sort featured first
|
|
69
|
+
const sortedProjects = [...projectList].sort((a, b) =>
|
|
70
|
+
(a.featured ? -1 : 0) - (b.featured ? -1 : 0)
|
|
71
|
+
).map(project => {
|
|
72
|
+
|
|
73
|
+
// Apply defaults if value not null
|
|
74
|
+
const processedProject = {
|
|
75
|
+
...project,
|
|
76
|
+
description: project.desc === undefined ? projectDefaults.desc : project.desc,
|
|
77
|
+
image: project.image === undefined ? projectDefaults.image : project.image,
|
|
78
|
+
tags: project.tags === undefined ? [...projectDefaults.tags] : project.tags,
|
|
79
|
+
state: project.state === undefined ? projectDefaults.state : project.state
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Add ID
|
|
83
|
+
if (!processedProject.id) {
|
|
84
|
+
processedProject.id = processedProject.title
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^\w\s-]/g, '')
|
|
87
|
+
.replace(/\s+/g, '-')
|
|
88
|
+
.replace(/-+/g, '-');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return processedProject;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Calculate pagination and placeholder needs
|
|
95
|
+
const totalPages = Math.ceil(sortedProjects.length / slides);
|
|
96
|
+
const slotsPerPage = slides;
|
|
97
|
+
const totalSlots = totalPages * slotsPerPage;
|
|
98
|
+
const placeholderCount = totalSlots - sortedProjects.length;
|
|
99
|
+
|
|
100
|
+
// Return prepared data
|
|
101
|
+
return {
|
|
102
|
+
projects: placeholderCount > 0
|
|
103
|
+
? createPlaceholders(placeholderCount, sortedProjects)
|
|
104
|
+
: sortedProjects,
|
|
105
|
+
totalPages
|
|
106
|
+
};
|
|
107
|
+
}, [createPlaceholders, projectDefaults]);
|
|
108
|
+
|
|
109
|
+
// Load and set up projects on initial load and on resize
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const projectShelf = siteConfig.customFields?.projects;
|
|
112
|
+
const configuredProjects = projectShelf?.enable ? projectShelf?.projects || [] : [];
|
|
113
|
+
|
|
114
|
+
const handleLayout = () => {
|
|
115
|
+
const newSlidesToShow = getVisibleSlidesPerView();
|
|
116
|
+
|
|
117
|
+
if (newSlidesToShow !== slidesToShow || !projects.length) {
|
|
118
|
+
setSlidesToShow(newSlidesToShow);
|
|
119
|
+
const { projects: newProjects, totalPages: newTotalPages } =
|
|
120
|
+
prepareProjects(configuredProjects, newSlidesToShow);
|
|
121
|
+
|
|
122
|
+
setProjects(newProjects);
|
|
123
|
+
setTotalPages(newTotalPages);
|
|
124
|
+
setAtEnd(newProjects.length <= newSlidesToShow);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Initial setup
|
|
129
|
+
handleLayout();
|
|
130
|
+
|
|
131
|
+
// Resize handler
|
|
132
|
+
window.addEventListener('resize', handleLayout);
|
|
133
|
+
return () => window.removeEventListener('resize', handleLayout);
|
|
134
|
+
}, [siteConfig, getVisibleSlidesPerView, prepareProjects, slidesToShow, projects.length]);
|
|
135
|
+
|
|
136
|
+
// Method to go to a specific slide
|
|
137
|
+
const goToSlide = useCallback((index) => {
|
|
138
|
+
if (sliderRef.current) {
|
|
139
|
+
|
|
140
|
+
sliderRef.current.slickGoTo(index * slidesToShow);
|
|
141
|
+
setCurrentSlide(index);
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
}, [slidesToShow]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const scrollTimeout = setTimeout(() => {
|
|
148
|
+
|
|
149
|
+
if (activeDotRef.current && dotsContainerRef.current) {
|
|
150
|
+
const container = dotsContainerRef.current;
|
|
151
|
+
const activeDot = activeDotRef.current;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
|
|
155
|
+
// For first few
|
|
156
|
+
const adaptiveThreshold = Math.max(1, Math.floor(totalPages * 0.1));
|
|
157
|
+
if (currentSlide <= adaptiveThreshold) {
|
|
158
|
+
container.scrollTo({
|
|
159
|
+
left: 0,
|
|
160
|
+
behavior: 'smooth'
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// For last few
|
|
166
|
+
if (currentSlide >= totalPages - 2) {
|
|
167
|
+
const scrollMax = container.scrollWidth - container.clientWidth;
|
|
168
|
+
container.scrollTo({
|
|
169
|
+
left: scrollMax,
|
|
170
|
+
behavior: 'smooth'
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Center the active dot at mobile
|
|
176
|
+
const dotRect = activeDot.getBoundingClientRect();
|
|
177
|
+
const containerRect = container.getBoundingClientRect();
|
|
178
|
+
|
|
179
|
+
// Check if dot is within visible area with margins
|
|
180
|
+
const isOutsideLeft = dotRect.left < containerRect.left + 20;
|
|
181
|
+
const isOutsideRight = dotRect.right > containerRect.right - 20;
|
|
182
|
+
|
|
183
|
+
if (isOutsideLeft || isOutsideRight) {
|
|
184
|
+
const dotPosition = activeDot.offsetLeft;
|
|
185
|
+
const dotWidth = activeDot.clientWidth;
|
|
186
|
+
const containerWidth = container.clientWidth;
|
|
187
|
+
const scrollPosition = dotPosition - (containerWidth / 2) + (dotWidth / 2);
|
|
188
|
+
|
|
189
|
+
// Disable smooth scroll if not supported
|
|
190
|
+
if ('scrollBehavior' in document.documentElement.style) {
|
|
191
|
+
container.scrollTo({
|
|
192
|
+
left: Math.max(0, scrollPosition),
|
|
193
|
+
behavior: 'smooth'
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
container.scrollLeft = Math.max(0, scrollPosition);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.warn('Dot scrolling error:', error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, 50);
|
|
204
|
+
|
|
205
|
+
return () => clearTimeout(scrollTimeout);
|
|
206
|
+
}, [currentSlide, totalPages]);
|
|
207
|
+
|
|
208
|
+
// Carousel settings
|
|
209
|
+
const settings = useMemo(() => ({
|
|
210
|
+
dots: false,
|
|
211
|
+
infinite: false,
|
|
212
|
+
speed: 600,
|
|
213
|
+
slidesToShow: Math.min(projects.length, slidesToShow),
|
|
214
|
+
slidesToScroll: slidesToShow,
|
|
215
|
+
autoplay: false,
|
|
216
|
+
adaptiveHeight: false,
|
|
217
|
+
centerPadding: '20px',
|
|
218
|
+
centerMode: false,
|
|
219
|
+
variableWidth: false,
|
|
220
|
+
swipeToSlide: false,
|
|
221
|
+
focusOnSelect: false,
|
|
222
|
+
responsive: [
|
|
223
|
+
{
|
|
224
|
+
breakpoint: 1024,
|
|
225
|
+
settings: {
|
|
226
|
+
slidesToShow: Math.min(projects.length, 2),
|
|
227
|
+
slidesToScroll: 2,
|
|
228
|
+
dots: false,
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
breakpoint: 600,
|
|
233
|
+
settings: {
|
|
234
|
+
slidesToShow: 1,
|
|
235
|
+
slidesToScroll: 1,
|
|
236
|
+
dots: false,
|
|
237
|
+
arrows: false,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
className: styles.projectsCarousel,
|
|
242
|
+
beforeChange: (current, next) => {
|
|
243
|
+
setAtBeginning(next === 0);
|
|
244
|
+
setCurrentSlide(Math.floor(next / slidesToShow));
|
|
245
|
+
setAtEnd(next + Math.min(projects.length, slidesToShow) >= projects.length);
|
|
246
|
+
},
|
|
247
|
+
}), [projects, slidesToShow]);
|
|
248
|
+
|
|
249
|
+
// Navigation handlers
|
|
250
|
+
const goToNext = useCallback(() => {
|
|
251
|
+
if (!atEnd && sliderRef.current) {
|
|
252
|
+
sliderRef.current.slickNext();
|
|
253
|
+
}
|
|
254
|
+
}, [atEnd]);
|
|
255
|
+
|
|
256
|
+
const goToPrev = useCallback(() => {
|
|
257
|
+
if (!atBeginning && sliderRef.current) {
|
|
258
|
+
sliderRef.current.slickPrev();
|
|
259
|
+
}
|
|
260
|
+
}, [atBeginning]);
|
|
261
|
+
|
|
262
|
+
// Project link renderer
|
|
263
|
+
const renderProjectLink = useCallback((url, Icon, label, ariaLabel) => {
|
|
264
|
+
if (!url) return null;
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<a
|
|
268
|
+
href={url}
|
|
269
|
+
target="_blank"
|
|
270
|
+
rel="noopener noreferrer"
|
|
271
|
+
className={styles.projectLink}
|
|
272
|
+
aria-label={ariaLabel}
|
|
273
|
+
>
|
|
274
|
+
<Icon />
|
|
275
|
+
<span>{label}</span>
|
|
276
|
+
</a>
|
|
277
|
+
);
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
// Get state label and class
|
|
281
|
+
const getProjectStateInfo = useCallback((state) => {
|
|
282
|
+
switch(state?.toLowerCase()) {
|
|
283
|
+
// For projects currently in active development
|
|
284
|
+
case 'active':
|
|
285
|
+
return {
|
|
286
|
+
label: 'Active',
|
|
287
|
+
className: styles.stateActive,
|
|
288
|
+
};
|
|
289
|
+
// For finished projects
|
|
290
|
+
case 'completed':
|
|
291
|
+
return {
|
|
292
|
+
label: 'Completed',
|
|
293
|
+
className: styles.stateCompleted,
|
|
294
|
+
};
|
|
295
|
+
// For projects receiving updates/maintenance
|
|
296
|
+
case 'maintenance':
|
|
297
|
+
return {
|
|
298
|
+
label: 'Maintenance',
|
|
299
|
+
className: styles.stateMaintenance,
|
|
300
|
+
};
|
|
301
|
+
// For temporarily paused development
|
|
302
|
+
case 'paused':
|
|
303
|
+
return {
|
|
304
|
+
label: 'Paused',
|
|
305
|
+
className: styles.statePaused,
|
|
306
|
+
};
|
|
307
|
+
// For projects no longer maintained
|
|
308
|
+
case 'archived':
|
|
309
|
+
return {
|
|
310
|
+
label: 'Archived',
|
|
311
|
+
className: styles.stateArchived,
|
|
312
|
+
};
|
|
313
|
+
// For future projects in planning stage
|
|
314
|
+
case 'planned':
|
|
315
|
+
return {
|
|
316
|
+
label: 'Planned',
|
|
317
|
+
className: styles.statePlanned,
|
|
318
|
+
};
|
|
319
|
+
// Default state when not specified
|
|
320
|
+
case 'n/a':
|
|
321
|
+
default:
|
|
322
|
+
return {
|
|
323
|
+
label: 'N/A',
|
|
324
|
+
className: styles.stateNA,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
// Render navigation dots with proper CSS classes based on count
|
|
330
|
+
const renderNavigationDots = useCallback(() => {
|
|
331
|
+
if (totalPages <= 1) return null;
|
|
332
|
+
|
|
333
|
+
// Determine if we should use scrollable or centered layout
|
|
334
|
+
const fewDots = totalPages <= 5;
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<div
|
|
338
|
+
className={`${styles.navDotsContainer} ${fewDots ? styles.centerDots : styles.scrollDots}`}
|
|
339
|
+
role="tablist"
|
|
340
|
+
aria-label="Project carousel navigation"
|
|
341
|
+
>
|
|
342
|
+
{Array.from({ length: totalPages }, (_, i) => (
|
|
343
|
+
<button
|
|
344
|
+
key={i}
|
|
345
|
+
className={`${styles.navDot} ${currentSlide === i ? styles.activeDot : ''}`}
|
|
346
|
+
onClick={() => goToSlide(i)}
|
|
347
|
+
aria-label={`Go to slide ${i + 1} of ${totalPages}`}
|
|
348
|
+
aria-selected={currentSlide === i}
|
|
349
|
+
role="tab"
|
|
350
|
+
type="button"
|
|
351
|
+
ref={currentSlide === i ? activeDotRef : null}
|
|
352
|
+
/>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}, [currentSlide, totalPages, goToSlide]);
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div id={id} className={`${styles.projectsSection} ${className || ''}`} role="region" aria-label="Projects section">
|
|
360
|
+
<div className={styles.projectsContainer}>
|
|
361
|
+
<div className={styles.projectsHeader}>
|
|
362
|
+
<h2 className={styles.projectsTitle}>
|
|
363
|
+
{title || "My Projects"}
|
|
364
|
+
</h2>
|
|
365
|
+
<p className={styles.projectsSubtitle}>
|
|
366
|
+
{subtitle || "A collection of all my works, with featured projects highlighted"}
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{projects.length === 0 ? (
|
|
371
|
+
<div className={styles.noProjects}>
|
|
372
|
+
<p>No projects to display.</p>
|
|
373
|
+
</div>
|
|
374
|
+
) : (
|
|
375
|
+
<div className={styles.carouselContainer}>
|
|
376
|
+
{/* Desktop navigation buttons (sides) */}
|
|
377
|
+
<button
|
|
378
|
+
className={`${styles.carouselControl} ${styles.prevButton} ${styles.desktopOnly} ${atBeginning ? styles.disabledButton : ''}`}
|
|
379
|
+
onClick={goToPrev}
|
|
380
|
+
aria-label="View previous projects"
|
|
381
|
+
aria-disabled={atBeginning}
|
|
382
|
+
type="button"
|
|
383
|
+
disabled={atBeginning}
|
|
384
|
+
>
|
|
385
|
+
<FaChevronLeft aria-hidden="true" />
|
|
386
|
+
</button>
|
|
387
|
+
|
|
388
|
+
<div className={styles.carouselWrapper} aria-roledescription="carousel" aria-label="Projects carousel">
|
|
389
|
+
<Slider ref={sliderRef} {...settings}>
|
|
390
|
+
{projects.map((project, index) => (
|
|
391
|
+
<div
|
|
392
|
+
key={project.id || project.title + index}
|
|
393
|
+
className={styles.carouselSlide}
|
|
394
|
+
data-project-id={project.id}
|
|
395
|
+
aria-roledescription="slide"
|
|
396
|
+
aria-label={`Project ${index + 1} of ${projects.length}: ${project.title}`}
|
|
397
|
+
>
|
|
398
|
+
<div className={`${styles.carouselCard} ${project.featured ? styles.featuredCard : ''}`}>
|
|
399
|
+
{/* Project state badge */}
|
|
400
|
+
{project.state && (
|
|
401
|
+
<div
|
|
402
|
+
className={styles.projectStateBadge}
|
|
403
|
+
title={`Project status: ${getProjectStateInfo(project.state).label}`}
|
|
404
|
+
>
|
|
405
|
+
<span className={`${styles.projectStateLabel} ${getProjectStateInfo(project.state).className}`}>
|
|
406
|
+
{getProjectStateInfo(project.state).label}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
<div className={styles.projectImageContainer}>
|
|
412
|
+
{project.image && (
|
|
413
|
+
<img
|
|
414
|
+
src={project.image}
|
|
415
|
+
alt={project.title}
|
|
416
|
+
className={styles.projectImage}
|
|
417
|
+
loading="lazy"
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
{/* Featured badge */}
|
|
422
|
+
{project.featured && (
|
|
423
|
+
<div className={styles.featuredBadge} title="Featured Project" aria-label="Featured project">
|
|
424
|
+
<FaStar aria-hidden="true" />
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div className={styles.projectContent}>
|
|
430
|
+
<h3 className={styles.projectTitle}>{project.title}</h3>
|
|
431
|
+
|
|
432
|
+
{project.tags?.length > 0 && (
|
|
433
|
+
<div className={styles.projectTags}>
|
|
434
|
+
{project.tags.map(tag => (
|
|
435
|
+
<span key={tag} className={styles.projectTag}>{tag}</span>
|
|
436
|
+
))}
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
439
|
+
|
|
440
|
+
<p className={styles.projectDescription}>{project.description}</p>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<div className={styles.projectLinks}>
|
|
444
|
+
{renderProjectLink(
|
|
445
|
+
project.website,
|
|
446
|
+
FaGlobe,
|
|
447
|
+
"Website",
|
|
448
|
+
`Visit ${project.title} website`
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{renderProjectLink(
|
|
452
|
+
project.github,
|
|
453
|
+
FaGithub,
|
|
454
|
+
"Source",
|
|
455
|
+
`Repository with source code`
|
|
456
|
+
)}
|
|
457
|
+
|
|
458
|
+
{renderProjectLink(
|
|
459
|
+
project.Demo,
|
|
460
|
+
FaPlay,
|
|
461
|
+
"Demo",
|
|
462
|
+
`Live demo for ${project.title}`
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
))}
|
|
468
|
+
</Slider>
|
|
469
|
+
|
|
470
|
+
{/* Desktop navigation dots */}
|
|
471
|
+
<div className={styles.desktopDotsContainer}>
|
|
472
|
+
{renderNavigationDots()}
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
{/* Mobile navigation controls (bottom) */}
|
|
476
|
+
<div className={styles.mobileNavigationControls}>
|
|
477
|
+
{totalPages > 1 && (
|
|
478
|
+
<>
|
|
479
|
+
<button
|
|
480
|
+
className={`${styles.carouselControl} ${styles.prevButton} ${atBeginning ? styles.disabledButton : ''}`}
|
|
481
|
+
onClick={goToPrev}
|
|
482
|
+
aria-label="View previous projects"
|
|
483
|
+
aria-disabled={atBeginning}
|
|
484
|
+
type="button"
|
|
485
|
+
disabled={atBeginning}
|
|
486
|
+
>
|
|
487
|
+
<FaChevronLeft aria-hidden="true" />
|
|
488
|
+
</button>
|
|
489
|
+
|
|
490
|
+
{/* Mobile navigation dots */}
|
|
491
|
+
<div
|
|
492
|
+
className={styles.dotsScrollContainer}
|
|
493
|
+
ref={dotsContainerRef}
|
|
494
|
+
>
|
|
495
|
+
{renderNavigationDots()}
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<button
|
|
499
|
+
className={`${styles.carouselControl} ${styles.nextButton} ${atEnd ? styles.disabledButton : ''}`}
|
|
500
|
+
onClick={goToNext}
|
|
501
|
+
aria-label="View next projects"
|
|
502
|
+
aria-disabled={atEnd}
|
|
503
|
+
type="button"
|
|
504
|
+
disabled={atEnd}
|
|
505
|
+
>
|
|
506
|
+
<FaChevronRight aria-hidden="true" />
|
|
507
|
+
</button>
|
|
508
|
+
</>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
{/* Desktop navigation button (right side) */}
|
|
514
|
+
<button
|
|
515
|
+
className={`${styles.carouselControl} ${styles.nextButton} ${styles.desktopOnly} ${atEnd ? styles.disabledButton : ''}`}
|
|
516
|
+
onClick={goToNext}
|
|
517
|
+
aria-label="View next projects"
|
|
518
|
+
aria-disabled={atEnd}
|
|
519
|
+
type="button"
|
|
520
|
+
disabled={atEnd}
|
|
521
|
+
>
|
|
522
|
+
<FaChevronRight />
|
|
523
|
+
</button>
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
}
|