mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-08 18:26:57 +02:00
feat: showcase roadmap (#7791)
* wip * wip * fix: status issue * feat: update UI * wip * wip: showcase status * wip: showcase listing * feat: update showcase status * chore: update roadmap content json (#7738) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * Fix issue in sticky top ad * Add preloading of ad image * feat(backend): update unit testing node resources (#7743) * feat: container orchestration * Update container-orchestration@Yq8kVoRf20aL_o4VZU5--.md Simplified content and added working links to resources for better clarity and learning. * Update container-orchestration@Yq8kVoRf20aL_o4VZU5--.md Replace content and added working links to resources for better clarity and learning. * Update container-orchestration@Yq8kVoRf20aL_o4VZU5--.md * Update src/data/roadmaps/devops/content/container-orchestration@Yq8kVoRf20aL_o4VZU5--.md --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com> * docs: fix typos and improve grammar in documentation (#7747) Corrects typos and grammatical errors in various markdown files to enhance clarity and readability. * feat: add PearAI code editor Added PearAI to the list of AI Code Editors (An Open Source Option for developers!) * chore: update roadmap content json (#7751) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * feat: center of mass explain video (#7754) video addition explaining COM better * Ad new changelog entry * Update C# link to correct URL (#7757) * Add engineering manager roadmap * chore: update roadmap content json (#7758) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * Update frontend FAQs (#7764) Tweaked the first two Qs * Update DevOps skills (#7763) Added internal refs * Add ref to DevOps roadmap in guide (#7762) Added roadmap ref. * Add engineering manager roadmap * Update engineering manager roadmap content * Update engineering manager roadmap * Add content to engineering manager roadmap * chore: update roadmap content json (#7768) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * fix: postgresql link (#7766) * fix(typo): comma todo-list-api.md (#7772) * Add new link of Redis in FullStack (#7771) * chore: update roadmap content json (#7778) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * Add content to vue.js performance (#7777) * Update performance@f7N4pAp_jBlT8_8owAcbG.md * Update src/data/roadmaps/vue/content/performance@f7N4pAp_jBlT8_8owAcbG.md --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com> * Update resources for Authentication (#7745) * Update authentication-vs-authorization@WG7DdsxESm31VcLFfkVTz.md replaced a wrong article with one about biometrics * Update understand-common-exploit-frameworks@Lg7mz4zeCToEzZBFxYuaU.md link redirects to a Thai gambling game site * Add resource for rest-assured (#7737) ## Content I’ve added a beginner-friendly article, A Guide to REST-assured, from Baeldung to the REST Assured section. If there’s anything that doesn’t meet the format, please feel free to comment. Thanks😊. ## Issue Fixed #7736 * Add UX design resource (#7710) * Update conceptual-design@r6D07cN0Mg4YXsiRSrl1_.md I have added an article by Dan Nessler on How to apply a design thinking, HCD, UX or any creative process from scratch which is a how-to article aims at providing designers, creative thinkers or even project managers with a tool to set up, frame, organise, structure, run or manage design challenges, and projects: The Double Diamond revamped. * Update conceptual-design@r6D07cN0Mg4YXsiRSrl1_.md I have added an article by Dan Nessler on How to apply a design thinking, HCD, UX or any creative process from scratch which is a how-to article aims at providing designers, creative thinkers or even project managers with a tool to set up, frame, organise, structure, run or manage design challenges, and projects: The Double Diamond revamped. * Add user personas resource to UX design (#7709) Added more resources from IxD Foundation and NN group. Co-authored-by: Shivam Kumar <85393390+TinyTijil@users.noreply.github.com> * Add linked in content (#7695) * Update linkedin@6UR59TigEZ0NaixbaUIqn.md * Update src/data/roadmaps/devrel/content/linkedin@6UR59TigEZ0NaixbaUIqn.md --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com> * Added article on AuthN vs AuthZ (#7694) Added a guide on the difference between authentication and authorization, since these terms are often confused. * Add a video to the Decentralization section (#7692) * adding a video to the Decentralization section * adding a video to the Decentralization section * Fixes typo in 104-proc-priorities.md (#7684) Old: renice +5 New: renice -5 From my research, after reading the topic in the Linux roadmap, it didnt make sense that increasing the priority of a process was made by +5, the topic said that a negative number makes the priority higher, so do many articles on the internet. * Add bastion host and file integrity checker idea * Add pomodoro timer project idea * Add project idea for quiz app * chore: update roadmap content json (#7785) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * fix typo in dockerhub alternatives (#7780) Co-authored-by: Fabio Stabile <fabio.stabile@mia-platform.eu> * Add content to engineering manager roadmap (#7779) * Update system-design-and-architecture@iX4HPgoiEbc_gze1A01n4.md * Update src/data/roadmaps/engineering-manager/content/system-design-and-architecture@iX4HPgoiEbc_gze1A01n4.md * Update src/data/roadmaps/engineering-manager/content/system-design-and-architecture@iX4HPgoiEbc_gze1A01n4.md --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com> * Add project idea for stories feature * Add weather app project idea * Update project ideas * Add engineering manager roadmap content * Update engineering manager roadmap content * Add DevOps best practices guide * Add AI Engineer introduction video (#7788) * Added Introduction Video * Changed formatting * Update src/data/roadmaps/ai-engineer/content/introduction@_hYN0gEi9BL24nptEtXWU.md --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com> * chore: update roadmap content json (#7789) Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> * Add devops automation tools * Add featuring functionality --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: kamranahmedse <4921183+kamranahmedse@users.noreply.github.com> Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com> Co-authored-by: Rogério Ferreira de Souza <rogeriofrsouza@gmail.com> Co-authored-by: Jawher Kl <kalleljawher4@gmail.com> Co-authored-by: garyellow <gary20011110@gmail.com> Co-authored-by: Nang <nathanang2000@gmail.com> Co-authored-by: FormerlyWD <156501761+FormerlyWD@users.noreply.github.com> Co-authored-by: dudi vaichledere <117650526+dudi-w@users.noreply.github.com> Co-authored-by: Ed Lan <165309301+Edlan01@users.noreply.github.com> Co-authored-by: elias_sisay <87943132+eliassisay@users.noreply.github.com> Co-authored-by: feelsgoodfrog <gudrb963@gmail.com> Co-authored-by: Gustavo Martins Pereira <gustavo.martins.pereira.main@gmail.com> Co-authored-by: Maksymilian <maxsapa@gmail.com> Co-authored-by: b4haa7 <69992780+88BahaaAdel88@users.noreply.github.com> Co-authored-by: Wick Dynex <1328032567@qq.com> Co-authored-by: Shivam Kumar <85393390+kshivam14@users.noreply.github.com> Co-authored-by: Shivam Kumar <85393390+TinyTijil@users.noreply.github.com> Co-authored-by: Yanbo Wang <yanbotravelaroundworld@gmail.com> Co-authored-by: Lisa Dziuba <lisa@flawlessapp.io> Co-authored-by: Karamoko Israël Abdelaziz Axel <72276211+karamokoisrael@users.noreply.github.com> Co-authored-by: duds <xaviduds@gmail.com> Co-authored-by: Fabio Stabile <93452841+fabioS24@users.noreply.github.com> Co-authored-by: Fabio Stabile <fabio.stabile@mia-platform.eu> Co-authored-by: Naresh Thakur <122244033+thinklikeacto@users.noreply.github.com> Co-authored-by: Gustaf <79180496+GGyll@users.noreply.github.com>
This commit is contained in:
@@ -23,11 +23,16 @@ export const allowedCustomRoadmapType = ['role', 'skill'] as const;
|
|||||||
export type AllowedCustomRoadmapType =
|
export type AllowedCustomRoadmapType =
|
||||||
(typeof allowedCustomRoadmapType)[number];
|
(typeof allowedCustomRoadmapType)[number];
|
||||||
|
|
||||||
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
|
export const allowedShowcaseStatus = [
|
||||||
|
'submitted',
|
||||||
|
'approved',
|
||||||
|
'rejected',
|
||||||
|
'rejected_with_reason',
|
||||||
|
] as const;
|
||||||
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
|
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
|
||||||
|
|
||||||
export interface RoadmapDocument {
|
export interface RoadmapDocument {
|
||||||
_id?: string;
|
_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
@@ -51,14 +56,22 @@ export interface RoadmapDocument {
|
|||||||
edges: any[];
|
edges: any[];
|
||||||
|
|
||||||
isDiscoverable?: boolean;
|
isDiscoverable?: boolean;
|
||||||
showcaseStatus?: AllowedShowcaseStatus;
|
|
||||||
ratings: {
|
ratings: {
|
||||||
average: number;
|
average: number;
|
||||||
|
totalCount: number;
|
||||||
breakdown: {
|
breakdown: {
|
||||||
[key: number]: number;
|
[key: number]: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
showcaseStatus?: AllowedShowcaseStatus;
|
||||||
|
showcaseRejectedReason?: string;
|
||||||
|
showcaseRejectedAt?: Date;
|
||||||
|
showcaseSubmittedAt?: Date;
|
||||||
|
showcaseApprovedAt?: Date;
|
||||||
|
|
||||||
|
hasMigratedContent?: boolean;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getUrlParams } from '../../lib/browser';
|
import { getUrlParams } from '../../lib/browser';
|
||||||
import { type AppError, type FetchError, httpGet } from '../../lib/http';
|
|
||||||
import { RoadmapHeader } from './RoadmapHeader';
|
import { RoadmapHeader } from './RoadmapHeader';
|
||||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||||
import { currentRoadmap } from '../../stores/roadmap';
|
import { currentRoadmap } from '../../stores/roadmap';
|
||||||
import { RestrictedPage } from './RestrictedPage';
|
import { RestrictedPage } from './RestrictedPage';
|
||||||
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
|
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
import { httpGet, type FetchError } from '../../lib/query-http';
|
||||||
|
import { useCustomRoadmap } from '../../hooks/use-custom-roadmap';
|
||||||
|
|
||||||
export const allowedLinkTypes = [
|
export const allowedLinkTypes = [
|
||||||
'video',
|
'video',
|
||||||
@@ -71,43 +74,24 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
|
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
|
||||||
const [error, setError] = useState<AppError | FetchError | undefined>();
|
|
||||||
|
|
||||||
async function getRoadmap() {
|
const { data, error } = useCustomRoadmap({
|
||||||
setIsLoading(true);
|
id,
|
||||||
|
secret,
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
|
||||||
const roadmapUrl = slug
|
useEffect(() => {
|
||||||
? new URL(
|
if (!data) {
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
|
|
||||||
)
|
|
||||||
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
|
|
||||||
|
|
||||||
if (secret) {
|
|
||||||
roadmapUrl.searchParams.set('secret', secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { response, error } = await httpGet<GetRoadmapResponse>(
|
|
||||||
roadmapUrl.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error || !response) {
|
|
||||||
setError(error);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.title = `${response.title} - roadmap.sh`;
|
document.title = `${data.title} - roadmap.sh`;
|
||||||
|
setRoadmap(data);
|
||||||
setRoadmap(response);
|
currentRoadmap.set(data);
|
||||||
currentRoadmap.set(response);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
hideRoadmapLoader();
|
||||||
|
}, [data]);
|
||||||
useEffect(() => {
|
|
||||||
getRoadmap().finally(() => {
|
|
||||||
hideRoadmapLoader();
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
|
@@ -11,6 +11,8 @@ import { RoadmapActionButton } from './RoadmapActionButton';
|
|||||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||||
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
|
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
|
||||||
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
|
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
|
||||||
|
import { ShowcaseStatus } from './Showcase/ShowcaseStatus.tsx';
|
||||||
|
import { ShowcaseAlert } from './Showcase/ShowcaseAlert.tsx';
|
||||||
|
|
||||||
type RoadmapHeaderProps = {};
|
type RoadmapHeaderProps = {};
|
||||||
|
|
||||||
@@ -73,122 +75,132 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
|||||||
: '/images/default-avatar.png';
|
: '/images/default-avatar.png';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b">
|
<>
|
||||||
<div className="container relative py-5 sm:py-12">
|
<div className="relative border-b">
|
||||||
{!$canManageCurrentRoadmap && <CustomRoadmapAlert />}
|
{$currentRoadmap && $canManageCurrentRoadmap && (
|
||||||
|
<ShowcaseAlert currentRoadmap={$currentRoadmap} />
|
||||||
{creator?.name && (
|
|
||||||
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
|
|
||||||
<img
|
|
||||||
alt={creator.name}
|
|
||||||
src={avatarUrl}
|
|
||||||
className="h-5 w-5 rounded-full"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Created by
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{creator?.name}
|
|
||||||
</span>
|
|
||||||
{team && (
|
|
||||||
<>
|
|
||||||
from
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{team?.name}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="mb-3 mt-4 sm:mb-4">
|
|
||||||
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
|
|
||||||
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 sm:gap-0">
|
<div className="container relative py-5 sm:py-12">
|
||||||
<div className="flex justify-stretch gap-1 sm:gap-2">
|
{!$canManageCurrentRoadmap && <CustomRoadmapAlert />}
|
||||||
<a
|
|
||||||
href="/community"
|
|
||||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
|
||||||
aria-label="Back to All Roadmaps"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
<span className="hidden sm:inline"> Discover more</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ShareRoadmapButton
|
{creator?.name && (
|
||||||
roadmapId={roadmapId!}
|
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
|
||||||
description={description!}
|
<img
|
||||||
pageUrl={`https://roadmap.sh/r/${roadmapSlug}`}
|
alt={creator.name}
|
||||||
allowEmbed={true}
|
src={avatarUrl}
|
||||||
/>
|
className="h-5 w-5 rounded-full"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Created by
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{creator?.name}
|
||||||
|
</span>
|
||||||
|
{team && (
|
||||||
|
<>
|
||||||
|
from
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{team?.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 mt-4 sm:mb-4">
|
||||||
|
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{$canManageCurrentRoadmap && (
|
<div className="flex justify-between gap-2 sm:gap-0">
|
||||||
<>
|
<div className="flex justify-stretch gap-1 sm:gap-2">
|
||||||
{isSharing && $currentRoadmap && (
|
<a
|
||||||
<ShareOptionsModal
|
href="/community"
|
||||||
roadmapSlug={$currentRoadmap?.slug}
|
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||||
isDiscoverable={$currentRoadmap.isDiscoverable}
|
aria-label="Back to All Roadmaps"
|
||||||
description={$currentRoadmap?.description}
|
>
|
||||||
visibility={$currentRoadmap?.visibility}
|
←
|
||||||
teamId={$currentRoadmap?.teamId}
|
<span className="hidden sm:inline"> Discover more</span>
|
||||||
roadmapId={$currentRoadmap?._id!}
|
</a>
|
||||||
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
|
|
||||||
sharedTeamMemberIds={
|
<ShareRoadmapButton
|
||||||
$currentRoadmap?.sharedTeamMemberIds || []
|
roadmapId={roadmapId!}
|
||||||
}
|
description={description!}
|
||||||
onClose={() => setIsSharing(false)}
|
pageUrl={`https://roadmap.sh/r/${roadmapSlug}`}
|
||||||
onShareSettingsUpdate={(settings) => {
|
allowEmbed={true}
|
||||||
currentRoadmap.set({
|
/>
|
||||||
...$currentRoadmap,
|
</div>
|
||||||
...settings,
|
<div className="flex items-center gap-2">
|
||||||
});
|
{$canManageCurrentRoadmap && (
|
||||||
|
<>
|
||||||
|
{isSharing && $currentRoadmap && (
|
||||||
|
<ShareOptionsModal
|
||||||
|
roadmapSlug={$currentRoadmap?.slug}
|
||||||
|
isDiscoverable={$currentRoadmap.isDiscoverable}
|
||||||
|
description={$currentRoadmap?.description}
|
||||||
|
visibility={$currentRoadmap?.visibility}
|
||||||
|
teamId={$currentRoadmap?.teamId}
|
||||||
|
roadmapId={$currentRoadmap?._id!}
|
||||||
|
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
|
||||||
|
sharedTeamMemberIds={
|
||||||
|
$currentRoadmap?.sharedTeamMemberIds || []
|
||||||
|
}
|
||||||
|
onClose={() => setIsSharing(false)}
|
||||||
|
onShareSettingsUpdate={(settings) => {
|
||||||
|
currentRoadmap.set({
|
||||||
|
...$currentRoadmap,
|
||||||
|
...settings,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{$currentRoadmap && (
|
||||||
|
<ShowcaseStatus currentRoadmap={$currentRoadmap} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RoadmapActionButton
|
||||||
|
onUpdateSharing={() => setIsSharing(true)}
|
||||||
|
onCustomize={() => {
|
||||||
|
window.location.href = `${
|
||||||
|
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||||
|
}/${$currentRoadmap?._id}`;
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const confirmation = window.confirm(
|
||||||
|
'Are you sure you want to delete this roadmap?',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResource().finally(() => null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<RoadmapActionButton
|
{showcaseStatus === 'approved' && (
|
||||||
onUpdateSharing={() => setIsSharing(true)}
|
<CustomRoadmapRatings
|
||||||
onCustomize={() => {
|
roadmapSlug={roadmapSlug!}
|
||||||
window.location.href = `${
|
ratings={ratings!}
|
||||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
canManage={$canManageCurrentRoadmap}
|
||||||
}/${$currentRoadmap?._id}`;
|
unseenRatingCount={unseenRatingCount || 0}
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
const confirmation = window.confirm(
|
|
||||||
'Are you sure you want to delete this roadmap?',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResource().finally(() => null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{((ratings?.average || 0) > 0 || showcaseStatus === 'visible') && (
|
|
||||||
<CustomRoadmapRatings
|
|
||||||
roadmapSlug={roadmapSlug!}
|
|
||||||
ratings={ratings!}
|
|
||||||
canManage={$canManageCurrentRoadmap}
|
|
||||||
unseenRatingCount={unseenRatingCount || 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<RoadmapHint
|
<RoadmapHint
|
||||||
roadmapTitle={title!}
|
roadmapTitle={title!}
|
||||||
hasTNSBanner={false}
|
hasTNSBanner={false}
|
||||||
roadmapId={roadmapId!}
|
roadmapId={roadmapId!}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
88
src/components/CustomRoadmap/Showcase/ShowcaseAlert.tsx
Normal file
88
src/components/CustomRoadmap/Showcase/ShowcaseAlert.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { EyeIcon, FlagIcon, FrownIcon, SmileIcon } from 'lucide-react';
|
||||||
|
import { cn } from '../../../lib/classname';
|
||||||
|
import type { GetRoadmapResponse } from '../CustomRoadmap';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SubmitShowcaseWarning } from './SubmitShowcaseWarning';
|
||||||
|
|
||||||
|
type ShowcaseAlertProps = {
|
||||||
|
currentRoadmap: GetRoadmapResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShowcaseAlert(props: ShowcaseAlertProps) {
|
||||||
|
const { currentRoadmap } = props;
|
||||||
|
|
||||||
|
const [showRejectedReason, setShowRejectedReason] = useState(false);
|
||||||
|
|
||||||
|
const { showcaseStatus } = currentRoadmap;
|
||||||
|
if (!showcaseStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showcaseStatusMap = {
|
||||||
|
submitted: {
|
||||||
|
icon: EyeIcon,
|
||||||
|
label:
|
||||||
|
'We are currently reviewing your roadmap, please wait for our response.',
|
||||||
|
className: 'bg-blue-100 text-blue-600 border-blue-200',
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
icon: SmileIcon,
|
||||||
|
label: 'Hooray! Your roadmap is now visible on the community page.',
|
||||||
|
className: 'text-green-600 bg-green-100 border-green-300',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
icon: FrownIcon,
|
||||||
|
label: 'Sorry, we are unable to feature your roadmap at this time.',
|
||||||
|
className: 'text-red-600 bg-red-100 border-red-300',
|
||||||
|
},
|
||||||
|
rejected_with_reason: {
|
||||||
|
icon: FlagIcon,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Your roadmap could not be featured at this time{' '}
|
||||||
|
<button
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-red-800"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRejectedReason(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
click here to see why
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
className: 'text-red-800 bg-red-200 border-red-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const showcaseStatusDetails = showcaseStatusMap[showcaseStatus];
|
||||||
|
if (!showcaseStatusDetails) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon: Icon, label, className } = showcaseStatusDetails;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showRejectedReason && (
|
||||||
|
<SubmitShowcaseWarning
|
||||||
|
onClose={() => {
|
||||||
|
setShowRejectedReason(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'z-10 border-b -mb-4',
|
||||||
|
showcaseStatusDetails.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="container relative flex items-center justify-center py-2 text-sm">
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 stroke-[2.5]" />
|
||||||
|
<div>{label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
42
src/components/CustomRoadmap/Showcase/ShowcaseStatus.tsx
Normal file
42
src/components/CustomRoadmap/Showcase/ShowcaseStatus.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { SubmitShowcaseWarning } from './SubmitShowcaseWarning';
|
||||||
|
import type { GetRoadmapResponse } from '../CustomRoadmap';
|
||||||
|
import { SendIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
type ShowcaseStatusProps = {
|
||||||
|
currentRoadmap: GetRoadmapResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShowcaseStatus(props: ShowcaseStatusProps) {
|
||||||
|
const { currentRoadmap } = props;
|
||||||
|
|
||||||
|
const { showcaseStatus } = currentRoadmap;
|
||||||
|
const [showSubmitWarning, setShowSubmitWarning] = useState(false);
|
||||||
|
|
||||||
|
if (!currentRoadmap || showcaseStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showSubmitWarning && (
|
||||||
|
<SubmitShowcaseWarning
|
||||||
|
onClose={() => {
|
||||||
|
setShowSubmitWarning(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:pl-1.5 sm:pr-3 sm:text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSubmitWarning(true);
|
||||||
|
}}
|
||||||
|
disabled={!!showcaseStatus}
|
||||||
|
>
|
||||||
|
<SendIcon className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Apply to be Featured</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
122
src/components/CustomRoadmap/Showcase/SubmitShowcaseWarning.tsx
Normal file
122
src/components/CustomRoadmap/Showcase/SubmitShowcaseWarning.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { Modal } from '../../Modal';
|
||||||
|
import { queryClient } from '../../../stores/query-client';
|
||||||
|
import { httpPost } from '../../../lib/query-http';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { currentRoadmap } from '../../../stores/roadmap';
|
||||||
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
type SubmitShowcaseWarningProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SubmitShowcaseWarning(props: SubmitShowcaseWarningProps) {
|
||||||
|
const { onClose } = props;
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const $currentRoadmap = useStore(currentRoadmap);
|
||||||
|
|
||||||
|
const submit = useMutation(
|
||||||
|
{
|
||||||
|
mutationFn: async () => {
|
||||||
|
return httpPost(`/v1-submit-for-showcase/${$currentRoadmap?._id}`, {});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['get-roadmap'],
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error?.message || 'Something went wrong');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
showcaseStatus,
|
||||||
|
showcaseRejectedReason,
|
||||||
|
showcaseRejectedAt,
|
||||||
|
updatedAt,
|
||||||
|
} = $currentRoadmap || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{showcaseStatus === 'rejected_with_reason'
|
||||||
|
? 'Rejected with Reason'
|
||||||
|
: 'Feature Your Roadmap'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
{showcaseStatus === 'rejected_with_reason' && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'block rounded-md bg-red-100 px-2 py-1.5 text-red-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showcaseRejectedReason}
|
||||||
|
</span>
|
||||||
|
<span className="block mt-3">
|
||||||
|
Feel free to make changes to your roadmap and resubmit.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!showcaseStatus && (
|
||||||
|
<>
|
||||||
|
We will review your roadmap and if accepted, we will make it
|
||||||
|
public and show it on the community roadmap listing.{' '}
|
||||||
|
<span className="mt-4 block font-medium">
|
||||||
|
Are you sure to submit?
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center text-sm hover:bg-gray-300"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submit.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={submit.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const updatedAtDate =
|
||||||
|
updatedAt && DateTime.fromJSDate(new Date(updatedAt));
|
||||||
|
const showcaseRejectedAtDate =
|
||||||
|
showcaseRejectedAt &&
|
||||||
|
DateTime.fromJSDate(new Date(showcaseRejectedAt));
|
||||||
|
|
||||||
|
if (
|
||||||
|
showcaseRejectedAtDate &&
|
||||||
|
updatedAtDate &&
|
||||||
|
updatedAtDate < showcaseRejectedAtDate
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
'You need to make changes to your roadmap before resubmitting.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submit.isPending
|
||||||
|
? 'Submitting...'
|
||||||
|
: showcaseStatus === 'rejected_with_reason'
|
||||||
|
? 'Resubmit'
|
||||||
|
: 'Submit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,8 +2,8 @@ export function SkeletonRoadmapHeader() {
|
|||||||
return (
|
return (
|
||||||
<div className="border-b">
|
<div className="border-b">
|
||||||
<div className="container relative py-5 sm:py-12">
|
<div className="container relative py-5 sm:py-12">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="-mb-0.5 flex items-center gap-1.5">
|
||||||
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
|
<div className="h-5 w-5 animate-pulse rounded-full bg-gray-300" />
|
||||||
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
|
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 mt-4 sm:mb-4">
|
<div className="mb-3 mt-4 sm:mb-4">
|
||||||
@@ -12,7 +12,7 @@ export function SkeletonRoadmapHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 sm:gap-0">
|
<div className="flex justify-between gap-2 sm:gap-0">
|
||||||
<div className='flex gap-1 sm:gap-2'>
|
<div className="flex gap-1 sm:gap-2">
|
||||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
||||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
|
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -27,9 +27,7 @@ export function DashboardAiRoadmaps(props: DashboardAiRoadmapsProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 mt-6 flex items-center justify-between gap-2">
|
<div className="mb-2 mt-6 flex items-center justify-between gap-2">
|
||||||
<h2 className="text-xs uppercase text-gray-400">
|
<h2 className="text-xs uppercase text-gray-400">My AI Roadmaps</h2>
|
||||||
My AI Roadmaps
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/ai/explore"
|
href="/ai/explore"
|
||||||
@@ -62,6 +60,7 @@ export function DashboardAiRoadmaps(props: DashboardAiRoadmapsProps) {
|
|||||||
<>
|
<>
|
||||||
{roadmaps.map((roadmap) => (
|
{roadmaps.map((roadmap) => (
|
||||||
<a
|
<a
|
||||||
|
key={roadmap.id}
|
||||||
href={`/ai/${roadmap.slug}`}
|
href={`/ai/${roadmap.slug}`}
|
||||||
className="relative truncate rounded-md border bg-white p-2.5 text-left text-sm shadow-sm hover:border-gray-400 hover:bg-gray-50"
|
className="relative truncate rounded-md border bg-white p-2.5 text-left text-sm shadow-sm hover:border-gray-400 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
|
@@ -133,14 +133,6 @@ export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-1.5">
|
<div className="flex flex-col sm:flex-row items-center gap-1.5">
|
||||||
<span className="group relative normal-case">
|
<span className="group relative normal-case">
|
||||||
<Tooltip
|
|
||||||
position={'bottom-left'}
|
|
||||||
additionalClass={
|
|
||||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Ask us to feature it once you're done!
|
|
||||||
</Tooltip>
|
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black"
|
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -151,14 +143,6 @@ export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="group relative normal-case">
|
<span className="group relative normal-case">
|
||||||
<Tooltip
|
|
||||||
position={'bottom-left'}
|
|
||||||
additionalClass={
|
|
||||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Up-to-date and maintained by the official team
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
<a
|
||||||
href="/roadmaps"
|
href="/roadmaps"
|
||||||
className="inline-block rounded-md bg-gray-300 px-3.5 py-1.5 text-sm text-black sm:py-1.5 sm:text-sm"
|
className="inline-block rounded-md bg-gray-300 px-3.5 py-1.5 text-sm text-black sm:py-1.5 sm:text-sm"
|
||||||
|
@@ -105,7 +105,7 @@ export function PayToBypass(props: PayToBypassProps) {
|
|||||||
id={roadmapCountId}
|
id={roadmapCountId}
|
||||||
name={roadmapCountId}
|
name={roadmapCountId}
|
||||||
required
|
required
|
||||||
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||||
placeholder="How many roadmaps you will be generating (daily, or monthly)?"
|
placeholder="How many roadmaps you will be generating (daily, or monthly)?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export function PayToBypass(props: PayToBypassProps) {
|
|||||||
id={usageId}
|
id={usageId}
|
||||||
name={usageId}
|
name={usageId}
|
||||||
required
|
required
|
||||||
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||||
placeholder="How will you be using this"
|
placeholder="How will you be using this"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ export function PayToBypass(props: PayToBypassProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
id={feedbackId}
|
id={feedbackId}
|
||||||
name={feedbackId}
|
name={feedbackId}
|
||||||
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||||
placeholder="Do you have any feedback?"
|
placeholder="Do you have any feedback?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,7 @@ export function PayToBypass(props: PayToBypassProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="disbaled:opacity-60 w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed"
|
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
41
src/hooks/use-custom-roadmap.ts
Normal file
41
src/hooks/use-custom-roadmap.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadmap';
|
||||||
|
import { httpGet, type FetchError } from '../lib/query-http';
|
||||||
|
import { queryClient } from '../stores/query-client';
|
||||||
|
|
||||||
|
type UseCustomRoadmapOptions = {
|
||||||
|
slug?: string;
|
||||||
|
id?: string;
|
||||||
|
secret?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCustomRoadmap(options: UseCustomRoadmapOptions) {
|
||||||
|
const { slug, id, secret } = options;
|
||||||
|
|
||||||
|
return useQuery<GetRoadmapResponse, FetchError>(
|
||||||
|
{
|
||||||
|
queryKey: [
|
||||||
|
'get-roadmap',
|
||||||
|
{
|
||||||
|
slug,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
const roadmapUrl = slug
|
||||||
|
? new URL(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
|
||||||
|
)
|
||||||
|
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
roadmapUrl.searchParams.set('secret', secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpGet(roadmapUrl.toString());
|
||||||
|
},
|
||||||
|
enabled: !!(slug || id),
|
||||||
|
},
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
}
|
146
src/lib/query-http.ts
Normal file
146
src/lib/query-http.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import fp from '@fingerprintjs/fingerprintjs';
|
||||||
|
import { TOKEN_COOKIE_NAME, removeAuthToken } from './jwt.ts';
|
||||||
|
|
||||||
|
type HttpOptionsType = RequestInit;
|
||||||
|
|
||||||
|
type AppResponse = Record<string, any>;
|
||||||
|
|
||||||
|
export interface FetchError extends Error {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppError = {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
errors?: { message: string; location: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiReturn<ResponseType> = ResponseType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around fetch to make it easy to handle errors
|
||||||
|
*
|
||||||
|
* @param url
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export async function httpCall<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
const fullUrl = url.startsWith('http')
|
||||||
|
? url
|
||||||
|
: `${import.meta.env.PUBLIC_API_URL}${url}`;
|
||||||
|
try {
|
||||||
|
const fingerprintPromise = await fp.load();
|
||||||
|
const fingerprint = await fingerprintPromise.get();
|
||||||
|
|
||||||
|
const isMultiPartFormData = options?.body instanceof FormData;
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
|
||||||
|
fp: fingerprint.visitorId,
|
||||||
|
...(options?.headers ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isMultiPartFormData) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
|
||||||
|
|
||||||
|
const data = doesAcceptHtml ? await response.text() : await response.json();
|
||||||
|
|
||||||
|
// Logout user if token is invalid
|
||||||
|
if (data?.status === 401) {
|
||||||
|
removeAuthToken();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return null as unknown as ApiReturn<ResponseType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data.errors) {
|
||||||
|
const error = new Error() as FetchError;
|
||||||
|
error.message = data.message;
|
||||||
|
error.status = response?.status;
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
throw new Error('An unexpected error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as ResponseType;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpPost<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
body: Record<string, any>,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
return httpCall<ResponseType>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpGet<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
queryParams?: Record<string, any>,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
const searchParams = new URLSearchParams(queryParams).toString();
|
||||||
|
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
|
||||||
|
|
||||||
|
return httpCall<ResponseType>(queryUrl, {
|
||||||
|
credentials: 'include',
|
||||||
|
method: 'GET',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpPatch<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
body: Record<string, any>,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
return httpCall<ResponseType>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpPut<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
body: Record<string, any>,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
return httpCall<ResponseType>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpDelete<ResponseType = AppResponse>(
|
||||||
|
url: string,
|
||||||
|
options?: HttpOptionsType,
|
||||||
|
): Promise<ApiReturn<ResponseType>> {
|
||||||
|
return httpCall<ResponseType>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
11
src/stores/query-client.ts
Normal file
11
src/stores/query-client.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({}),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
enabled: !import.meta.env.SSR,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Reference in New Issue
Block a user