Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/components/common/blog/BlogList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

// since this component has interactivity added this component as client component
import { useCallback, useMemo, useState, useTransition } from "react";
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { loadMoreBlogs } from "@/store/blogActions";
import { Blog, BlogResponse, BlogSectionProps, BlogTag } from "@/types/blog";
Expand Down Expand Up @@ -51,6 +51,10 @@ export default function BlogList({
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [isPending, startTransition] = useTransition();

// Ref to track if a fetch is currently in progress (prevents race conditions)
const isFetchingRef = useRef(false);

const t = useTranslations("Blog");
const { isMobile, isPad } = useDeviceDetail();

Expand Down Expand Up @@ -94,6 +98,15 @@ export default function BlogList({

const handleMoreBlogsCTA = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

// Prevent concurrent requests (race condition guard)
if (isFetchingRef.current || isPending) {
return;
}

// Mark fetch as in progress
isFetchingRef.current = true;

loadMoreBlogs(cursor, blogsToShow())
.then(({ posts: newBlogs, endCursor, error }) => {
if (error) {
Expand All @@ -118,11 +131,18 @@ export default function BlogList({
}
return true;
});

startTransition(() => {
setBlogsResponse((prev) => ({
data: [...prev.data, ...validNewBlogs],
error: null,
}));
setBlogsResponse((prev) => {
// Additional safety: check for duplicate IDs before merging
const existingIds = new Set(prev.data.map((b) => b.id));
const uniqueNewBlogs = validNewBlogs.filter((blog) => !existingIds.has(blog.id));

return {
data: [...prev.data, ...uniqueNewBlogs],
error: null,
};
});
setCursor(endCursor);
});
})
Expand All @@ -133,6 +153,10 @@ export default function BlogList({
error: error instanceof Error ? error.message : "Failed to load blogs",
}));
});
})
.finally(() => {
// Always reset the fetch flag, even on error
isFetchingRef.current = false;
});
};

Expand Down
25 changes: 24 additions & 1 deletion src/lib/calendar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ export function generateOutlookCalendarUrl(event: CalendarEvent): string {
return `${baseUrl}?${params.toString()}`;
}

/**
* Generate a globally unique identifier for calendar events
* Uses crypto.randomUUID() for guaranteed uniqueness
* @returns UUID string
*/
function generateEventUID(): string {
// Use Web Crypto API for cryptographically secure UUID
// Fallback to timestamp-based UUID if crypto is not available (older browsers)
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}

// Fallback: Generate RFC 4122 version 4 UUID manually
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

/**
* Generate ICS (iCalendar) file content for download
* @param event - Calendar event data
Expand All @@ -48,12 +68,15 @@ export function generateICSContent(event: CalendarEvent): string {
const startDate = formatDateForCalendar(event.startDateTime);
const endDate = formatDateForCalendar(event.endDateTime);

// Generate a globally unique UID to prevent calendar collisions
const uniqueUID = generateEventUID();

return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//React Kolkata//Event//EN",
"BEGIN:VEVENT",
`UID:${event.title.replace(/\s+/g, "-").toLowerCase()}-${startDate}@react-kolkata.dev`,
`UID:${uniqueUID}@react-kolkata.dev`,
`DTSTAMP:${now}`,
`DTSTART:${startDate}`,
`DTEND:${endDate}`,
Expand Down
27 changes: 23 additions & 4 deletions src/lib/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,25 +151,44 @@ function getRelativeTime(date: Date): string {
* Format event time range from two ISO datetime strings
* @param startDateTime - ISO datetime string
* @param endDateTime - ISO datetime string
* @param timezone - Optional timezone (e.g., "Asia/Kolkata"). If not provided, uses user's local timezone
* @returns Formatted time range (e.g., "11:00 AM – 1:00 PM IST")
*/
export function formatEventTimeRange(startDateTime: string, endDateTime: string): string {
export function formatEventTimeRange(
startDateTime: string,
endDateTime: string,
timezone?: string
): string {
const start = new Date(startDateTime);
const end = new Date(endDateTime);

// Determine which timezone to use
const timeZone = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;

// Format times in the specified timezone
const startTime = start.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZone,
});

const endTime = end.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZone,
});

return `${startTime} – ${endTime} IST`;
// Get timezone abbreviation (e.g., "IST", "PST", "EST")
const timezoneAbbr = new Intl.DateTimeFormat("en-IN", {
timeZone,
timeZoneName: "short",
})
.formatToParts(start)
.find((part) => part.type === "timeZoneName")?.value || "";

return `${startTime} – ${endTime} ${timezoneAbbr}`;
}

// Convenience functions for common use cases
Expand All @@ -183,5 +202,5 @@ export const formatBlogRelativeTime = (isoDateString: string) =>
// Event-specific convenience functions
export const formatEventDate = (isoDateString: string) =>
formatDate(isoDateString, DATE_FORMATS.BLOG_DATE);
export const formatEventTime = (startDateTime: string, endDateTime: string) =>
formatEventTimeRange(startDateTime, endDateTime);
export const formatEventTime = (startDateTime: string, endDateTime: string, timezone?: string) =>
formatEventTimeRange(startDateTime, endDateTime, timezone);
34 changes: 33 additions & 1 deletion src/modules/contributors/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,39 @@ const ContributorsSection = () => {
);

if (!response.ok) {
throw new Error(`Failed to fetch contributors: ${response.status}`);
// Handle specific HTTP error codes with meaningful messages
if (response.status === 403) {
// Check if it's a rate limit error
const rateLimitRemaining = response.headers.get("X-RateLimit-Remaining");
const rateLimitReset = response.headers.get("X-RateLimit-Reset");

if (rateLimitRemaining === "0" && rateLimitReset) {
const resetTime = new Date(parseInt(rateLimitReset) * 1000);
const resetTimeFormatted = resetTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
throw new Error(
`GitHub API rate limit exceeded. Please try again after ${resetTimeFormatted}.`
);
}
throw new Error(
"Access forbidden. This might be due to API rate limiting or access restrictions."
);
} else if (response.status === 404) {
throw new Error(
"Repository not found. Please check if the repository exists and is public."
);
} else if (response.status >= 500) {
throw new Error(
"GitHub servers are experiencing issues. Please try again later."
);
} else {
throw new Error(
`Unable to fetch contributors (Error ${response.status}). Please try again.`
);
}
}

const data = await response.json();
Expand Down
29 changes: 29 additions & 0 deletions src/store/blogActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,36 @@ export async function loadMoreBlogs(
variables: { postCount: count, cursor: cursor.toString() },
}),
});

// Check response status before attempting to parse JSON
if (!res.ok) {
// Attempt to parse error response for more details
let errorMessage = `Failed to fetch blogs (HTTP ${res.status})`;
try {
const errorData = await res.json();
if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors[0]?.message) {
errorMessage = errorData.errors[0].message;
}
} catch {
// If error response is not JSON, use status-based message
if (res.status === 429) {
errorMessage = "Too many requests. Please try again in a moment.";
} else if (res.status >= 500) {
errorMessage = "Blog service is temporarily unavailable. Please try again later.";
} else if (res.status === 403) {
errorMessage = "Access denied to blog service.";
}
}
throw new Error(errorMessage);
}

const { data }: { data: { publication: HashnodePublication } } = await res.json();

// Validate API response structure
if (!data || !data.publication || !data.publication.posts) {
throw new Error("Invalid response structure from blog API");
}

const { edges, pageInfo } = data.publication.posts;

const posts: Blog[] = edges.map((edge) => edge.node);
Expand Down
27 changes: 25 additions & 2 deletions src/utils/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,33 @@ export async function getInitialBlogs(): Promise<BlogFetchResponse> {
});

if (!res.ok) {
// This will be caught by the error boundary
throw new Error("Failed to fetch blogs");
// Provide meaningful error messages based on status code
let errorMessage = `Failed to fetch blogs (HTTP ${res.status})`;
try {
const errorData = await res.json();
if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors[0]?.message) {
errorMessage = errorData.errors[0].message;
}
} catch {
// If error response is not JSON, use status-based message
if (res.status === 429) {
errorMessage = "Too many requests. Please try again in a moment.";
} else if (res.status >= 500) {
errorMessage = "Blog service is temporarily unavailable. Please try again later.";
} else if (res.status === 403) {
errorMessage = "Access denied to blog service.";
}
}
throw new Error(errorMessage);
}

const { data }: HashnodeAPIResponse = await res.json();

// Validate API response structure
if (!data || !data.publication || !data.publication.posts) {
throw new Error("Invalid response structure from blog API");
}

const { edges, pageInfo } = data.publication.posts;
const posts: Blog[] = edges.map((edge) => edge.node);
const endCursor = pageInfo.hasNextPage ? pageInfo.endCursor : null;
Expand Down