parent
f83cd701e9
commit
78e4c38c97
23 changed files with 481 additions and 184 deletions
@ -1,29 +0,0 @@ |
|||||||
--- |
|
||||||
const { ...props } = Astro.props; |
|
||||||
|
|
||||||
export type Props = astroHTML.JSX.HTMLAttributes & {}; |
|
||||||
--- |
|
||||||
|
|
||||||
<div role='status'> |
|
||||||
<svg |
|
||||||
aria-hidden='true' |
|
||||||
xmlns='http://www.w3.org/2000/svg' |
|
||||||
fill='none' |
|
||||||
viewBox='0 0 24 24' |
|
||||||
class:list={[`animate-spin h-5 w-5`, props.class]} |
|
||||||
{...props} |
|
||||||
> |
|
||||||
<circle |
|
||||||
class='stroke-[4px] opacity-25' |
|
||||||
cx='12' |
|
||||||
cy='12' |
|
||||||
r='10' |
|
||||||
stroke='currentColor'></circle> |
|
||||||
<path |
|
||||||
class='opacity-75' |
|
||||||
fill='currentColor' |
|
||||||
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' |
|
||||||
></path> |
|
||||||
</svg> |
|
||||||
<span class='sr-only'>Loading</span> |
|
||||||
</div> |
|
@ -1,26 +0,0 @@ |
|||||||
export default function Spinner({ className }: { className?: string }) { |
|
||||||
return ( |
|
||||||
<div role="status"> |
|
||||||
<svg |
|
||||||
className={`h-5 w-5 animate-spin ${className}`} |
|
||||||
xmlns="http://www.w3.org/2000/svg" |
|
||||||
fill="none" |
|
||||||
viewBox="0 0 24 24" |
|
||||||
> |
|
||||||
<circle |
|
||||||
className="stroke-[4px] opacity-25" |
|
||||||
cx="12" |
|
||||||
cy="12" |
|
||||||
r="10" |
|
||||||
stroke="currentColor" |
|
||||||
></circle> |
|
||||||
<path |
|
||||||
className="opacity-75" |
|
||||||
fill="currentColor" |
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
|
||||||
></path> |
|
||||||
</svg> |
|
||||||
<span class="sr-only">Loading</span> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -0,0 +1,212 @@ |
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; |
||||||
|
import SpinnerIcon from '../../icons/spinner.svg'; |
||||||
|
import CheckIcon from '../../icons/check.svg'; |
||||||
|
import ResetIcon from '../../icons/reset.svg'; |
||||||
|
import CloseIcon from '../../icons/close.svg'; |
||||||
|
|
||||||
|
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||||
|
import { useLoadTopic } from '../../hooks/use-load-topic'; |
||||||
|
import { httpGet } from '../../lib/http'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { |
||||||
|
isTopicDone, |
||||||
|
ResourceType, |
||||||
|
toggleMarkTopicDone, |
||||||
|
} from '../../lib/user-resource-progress'; |
||||||
|
import { useKeydown } from '../../hooks/use-keydown'; |
||||||
|
|
||||||
|
export function TopicDetail() { |
||||||
|
const [isActive, setIsActive] = useState(false); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [topicHtml, setTopicHtml] = useState(''); |
||||||
|
|
||||||
|
const [isDone, setIsDone] = useState<boolean>(); |
||||||
|
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); |
||||||
|
|
||||||
|
const isGuest = useMemo(() => !isLoggedIn(), []); |
||||||
|
const topicRef = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
// Details of the currently loaded topic
|
||||||
|
const [topicId, setTopicId] = useState(''); |
||||||
|
const [resourceId, setResourceId] = useState(''); |
||||||
|
const [resourceType, setResourceType] = useState<ResourceType>('roadmap'); |
||||||
|
|
||||||
|
const toggleResourceProgress = (isDone: boolean) => { |
||||||
|
setIsUpdatingProgress(true); |
||||||
|
toggleMarkTopicDone({ topicId, resourceId, resourceType }, isDone) |
||||||
|
.then(() => { |
||||||
|
setIsDone(isDone); |
||||||
|
setIsActive(false); |
||||||
|
}) |
||||||
|
.catch(err => { |
||||||
|
alert(err.message); |
||||||
|
console.error(err); |
||||||
|
}) |
||||||
|
.finally(() => { |
||||||
|
setIsUpdatingProgress(false); |
||||||
|
}); |
||||||
|
|
||||||
|
console.log('toggle', isDone); |
||||||
|
}; |
||||||
|
|
||||||
|
// Load the topic status when the topic detail is active
|
||||||
|
useEffect(() => { |
||||||
|
if (!topicId || !resourceId || !resourceType) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsUpdatingProgress(true); |
||||||
|
isTopicDone({ topicId, resourceId, resourceType }) |
||||||
|
.then((status: boolean) => { |
||||||
|
setIsUpdatingProgress(false); |
||||||
|
setIsDone(status); |
||||||
|
}) |
||||||
|
.catch(console.error); |
||||||
|
}, [topicId, resourceId, resourceType]); |
||||||
|
|
||||||
|
// Close the topic detail when user clicks outside the topic detail
|
||||||
|
useOutsideClick(topicRef, () => { |
||||||
|
setIsActive(false); |
||||||
|
}); |
||||||
|
|
||||||
|
useKeydown('Escape', () => { |
||||||
|
setIsActive(false); |
||||||
|
}); |
||||||
|
|
||||||
|
// Load the topic detail when the topic detail is active
|
||||||
|
useLoadTopic(({ topicId, resourceType, resourceId }) => { |
||||||
|
setIsLoading(true); |
||||||
|
setIsActive(true); |
||||||
|
|
||||||
|
setTopicId(topicId); |
||||||
|
setResourceType(resourceType); |
||||||
|
setResourceId(resourceId); |
||||||
|
|
||||||
|
const topicPartial = topicId.replaceAll(':', '/'); |
||||||
|
const topicUrl = |
||||||
|
resourceType === 'roadmap' |
||||||
|
? `/${resourceId}/${topicPartial}` |
||||||
|
: `/best-practices/${resourceId}/${topicPartial}`; |
||||||
|
|
||||||
|
httpGet<string>( |
||||||
|
topicUrl, |
||||||
|
{}, |
||||||
|
{ |
||||||
|
headers: { |
||||||
|
Accept: 'text/html', |
||||||
|
}, |
||||||
|
} |
||||||
|
) |
||||||
|
.then(({ response }) => { |
||||||
|
if (!response) { |
||||||
|
setError('Topic not found.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// It's full HTML with page body, head etc.
|
||||||
|
// We only need the inner HTML of the #main-content
|
||||||
|
const node = new DOMParser().parseFromString(response, 'text/html'); |
||||||
|
const topicHtml = node?.getElementById('main-content')?.outerHTML || ''; |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
setTopicHtml(topicHtml); |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
if (!isActive) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div |
||||||
|
ref={topicRef} |
||||||
|
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6" |
||||||
|
> |
||||||
|
{isLoading && ( |
||||||
|
<div className="flex w-full justify-center"> |
||||||
|
<img |
||||||
|
src={SpinnerIcon} |
||||||
|
alt="Loading" |
||||||
|
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isLoading && !error && ( |
||||||
|
<> |
||||||
|
{/* Actions for the topic */} |
||||||
|
<div className="mb-2"> |
||||||
|
{isGuest && ( |
||||||
|
<button |
||||||
|
data-popup="login-popup" |
||||||
|
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
||||||
|
onClick={() => setIsActive(false)} |
||||||
|
> |
||||||
|
<img alt="Check" src={CheckIcon} /> |
||||||
|
<span className="ml-2">Mark as Done</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isGuest && ( |
||||||
|
<> |
||||||
|
{isUpdatingProgress && ( |
||||||
|
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black"> |
||||||
|
<img |
||||||
|
alt="Check" |
||||||
|
class="h-4 w-4 animate-spin" |
||||||
|
src={SpinnerIcon} |
||||||
|
/> |
||||||
|
<span className="ml-2">Updating Status..</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
{!isUpdatingProgress && !isDone && ( |
||||||
|
<button |
||||||
|
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
||||||
|
onClick={() => toggleResourceProgress(true)} |
||||||
|
> |
||||||
|
<img alt="Check" class="h-4 w-4" src={CheckIcon} /> |
||||||
|
<span className="ml-2">Mark as Done</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isUpdatingProgress && isDone && ( |
||||||
|
<button |
||||||
|
className="inline-flex items-center rounded-md border border-red-600 bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700" |
||||||
|
onClick={() => toggleResourceProgress(false)} |
||||||
|
> |
||||||
|
<img alt="Check" class="h-4" src={ResetIcon} /> |
||||||
|
<span className="ml-2">Mark as Pending</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="close-topic" |
||||||
|
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900" |
||||||
|
onClick={() => setIsActive(false)} |
||||||
|
> |
||||||
|
<img alt="Close" class="h-5 w-5" src={CloseIcon} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Topic Content */} |
||||||
|
<div |
||||||
|
id="topic-content" |
||||||
|
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5" |
||||||
|
dangerouslySetInnerHTML={{ __html: topicHtml }} |
||||||
|
></div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function useKeydown(keyName: string, callback: any) { |
||||||
|
useEffect(() => { |
||||||
|
const listener = (event: any) => { |
||||||
|
if (event.key.toLowerCase() === keyName.toLowerCase()) { |
||||||
|
callback(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
window.addEventListener('keydown', listener); |
||||||
|
return () => { |
||||||
|
window.removeEventListener('keydown', listener); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import { useEffect } from 'preact/hooks'; |
||||||
|
import type {ResourceType} from "../components/TopicDetail/TopicDetail"; |
||||||
|
|
||||||
|
type CallbackType = (data: { |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceId: string; |
||||||
|
topicId: string; |
||||||
|
}) => void; |
||||||
|
|
||||||
|
export function useLoadTopic(callback: CallbackType) { |
||||||
|
useEffect(() => { |
||||||
|
function handleTopicClick(e: any) { |
||||||
|
const { resourceType, resourceId, topicId } = e.detail; |
||||||
|
|
||||||
|
callback({ |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
topicId, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
window.addEventListener(`roadmap.topic.click`, handleTopicClick); |
||||||
|
window.addEventListener(`best-practice.topic.click`, handleTopicClick); |
||||||
|
|
||||||
|
return () => { |
||||||
|
window.removeEventListener(`roadmap.topic.click`, handleTopicClick); |
||||||
|
window.removeEventListener(`best-practice.topic.click`, handleTopicClick); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function useOutsideClick(ref: any, callback: any) { |
||||||
|
useEffect(() => { |
||||||
|
const listener = (event: any) => { |
||||||
|
const isClickedOutside = !ref?.current?.contains(event.target); |
||||||
|
if (isClickedOutside) { |
||||||
|
callback(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
document.addEventListener('mousedown', listener); |
||||||
|
document.addEventListener('touchstart', listener); |
||||||
|
|
||||||
|
return () => { |
||||||
|
document.removeEventListener('mousedown', listener); |
||||||
|
document.removeEventListener('touchstart', listener); |
||||||
|
}; |
||||||
|
}, [ref]); |
||||||
|
} |
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 208 B |
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 3.4 KiB |
@ -1,34 +0,0 @@ |
|||||||
import { httpGet, httpPatch } from './http'; |
|
||||||
|
|
||||||
export async function toggleMarkResourceDoneApi({ |
|
||||||
resourceId, |
|
||||||
resourceType, |
|
||||||
topicId, |
|
||||||
}: { |
|
||||||
resourceId: string; |
|
||||||
resourceType: 'roadmap' | 'best-practice'; |
|
||||||
topicId: string; |
|
||||||
}) { |
|
||||||
return await httpPatch<{ |
|
||||||
status: 'ok'; |
|
||||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`, { |
|
||||||
resourceId, |
|
||||||
resourceType, |
|
||||||
topicId, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
export async function getUserResourceProgressApi({ |
|
||||||
resourceId, |
|
||||||
resourceType, |
|
||||||
}: { |
|
||||||
resourceId: string; |
|
||||||
resourceType: 'roadmap' | 'best-practice'; |
|
||||||
}) { |
|
||||||
return await httpGet<{ |
|
||||||
done: string[]; |
|
||||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, { |
|
||||||
resourceId, |
|
||||||
resourceType, |
|
||||||
}); |
|
||||||
} |
|
@ -0,0 +1,112 @@ |
|||||||
|
import { httpGet, httpPatch } from './http'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from './jwt'; |
||||||
|
|
||||||
|
export type ResourceType = 'roadmap' | 'best-practice'; |
||||||
|
|
||||||
|
type TopicMeta = { |
||||||
|
topicId: string; |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export async function isTopicDone(topic: TopicMeta): Promise<boolean> { |
||||||
|
const { topicId, resourceType, resourceId } = topic; |
||||||
|
const doneItems = await getUserResourceProgress(resourceType, resourceId); |
||||||
|
|
||||||
|
if (!doneItems) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return doneItems.includes(topicId); |
||||||
|
} |
||||||
|
|
||||||
|
export async function toggleMarkTopicDone( |
||||||
|
topic: TopicMeta, |
||||||
|
isDone: boolean |
||||||
|
): Promise<void> { |
||||||
|
const { topicId, resourceType, resourceId } = topic; |
||||||
|
|
||||||
|
const { response, error } = await httpPatch<{ done: string[] }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`, |
||||||
|
{ |
||||||
|
topicId, |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
isDone, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response?.done) { |
||||||
|
throw new Error(error?.message || 'Something went wrong'); |
||||||
|
} |
||||||
|
|
||||||
|
setUserResourceProgress(resourceType, resourceId, response.done); |
||||||
|
} |
||||||
|
export async function getUserResourceProgress( |
||||||
|
resourceType: 'roadmap' | 'best-practice', |
||||||
|
resourceId: string |
||||||
|
): Promise<string[]> { |
||||||
|
const progressKey = `${resourceType}-${resourceId}-progress`; |
||||||
|
const rawProgress = localStorage.getItem(progressKey); |
||||||
|
const progress = JSON.parse(rawProgress || 'null'); |
||||||
|
|
||||||
|
const progressTimestamp = progress?.timestamp; |
||||||
|
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10); |
||||||
|
const isProgressExpired = diff > 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
console.log(progressKey); |
||||||
|
|
||||||
|
if (!progress || isProgressExpired) { |
||||||
|
return loadFreshProgress(resourceType, resourceId); |
||||||
|
} |
||||||
|
|
||||||
|
return progress.done; |
||||||
|
} |
||||||
|
|
||||||
|
async function loadFreshProgress( |
||||||
|
resourceType: ResourceType, |
||||||
|
resourceId: string |
||||||
|
) { |
||||||
|
const { response, error } = await httpGet<{ done: string[] }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, |
||||||
|
{ |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
if (error.status === 401) { |
||||||
|
Cookies.remove(TOKEN_COOKIE_NAME); |
||||||
|
window.location.reload(); |
||||||
|
|
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
console.error(error); |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
if (!response?.done) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
setUserResourceProgress(resourceType, resourceId, response.done); |
||||||
|
|
||||||
|
return response.done; |
||||||
|
} |
||||||
|
|
||||||
|
export function setUserResourceProgress( |
||||||
|
resourceType: 'roadmap' | 'best-practice', |
||||||
|
resourceId: string, |
||||||
|
done: string[] |
||||||
|
): void { |
||||||
|
localStorage.setItem( |
||||||
|
`${resourceType}-${resourceId}-progress`, |
||||||
|
JSON.stringify({ |
||||||
|
done, |
||||||
|
timestamp: new Date().getTime(), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue