parent
45a7aad669
commit
10883454f5
33 changed files with 194992 additions and 42 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,43 @@ |
||||
--- |
||||
import Loader from "../Loader.astro"; |
||||
import ShareIcons from "../ShareIcons.astro"; |
||||
import "./InteractiveRoadmap.css"; |
||||
|
||||
export interface Props { |
||||
jsonUrl: string; |
||||
roadmapId: string; |
||||
description: string; |
||||
roadmapPermalink: string; |
||||
jsonUrl: string; |
||||
dimensions: { |
||||
width: number; |
||||
height: number; |
||||
}; |
||||
} |
||||
|
||||
const { jsonUrl } = Astro.props; |
||||
const { roadmapId, jsonUrl, dimensions, description, roadmapPermalink } = |
||||
Astro.props; |
||||
--- |
||||
|
||||
<link rel="preload" href="/fonts/balsamiq.woff2" as="font" type="font/woff2" crossorigin slot="after-header" /> |
||||
<link |
||||
rel="preload" |
||||
href="/fonts/balsamiq.woff2" |
||||
as="font" |
||||
type="font/woff2" |
||||
crossorigin |
||||
slot="after-header" |
||||
/> |
||||
|
||||
<script> |
||||
import { wireframeJSONToSVG } from 'roadmap-renderer'; |
||||
<div class='bg-gray-50 py-4 sm:py-10'> |
||||
<div class="max-w-[1000px] container relative"> |
||||
<div |
||||
id="roadmap-svg" |
||||
style={`--aspect-ratio:${dimensions.width}/${dimensions.height}`} |
||||
data-roadmap-id={roadmapId} |
||||
data-json-url={jsonUrl} |
||||
> |
||||
<Loader /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
|
||||
</script> |
||||
<script src="./roadmap.js"></script> |
||||
|
@ -0,0 +1,102 @@ |
||||
import { wireframeJSONToSVG } from "roadmap-renderer"; |
||||
import { Topic } from "./topic"; |
||||
import { Sharer } from "./sharer"; |
||||
|
||||
/** |
||||
* @typedef {{ roadmapId: string, jsonUrl: string }} RoadmapConfig |
||||
*/ |
||||
|
||||
export class Roadmap { |
||||
/** |
||||
* @param {RoadmapConfig} config |
||||
*/ |
||||
constructor() { |
||||
this.roadmapId = ""; |
||||
this.jsonUrl = ""; |
||||
|
||||
this.containerId = "roadmap-svg"; |
||||
|
||||
this.init = this.init.bind(this); |
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this); |
||||
this.fetchRoadmapSvg = this.fetchRoadmapSvg.bind(this); |
||||
this.handleRoadmapClick = this.handleRoadmapClick.bind(this); |
||||
this.prepareConfig = this.prepareConfig.bind(this); |
||||
} |
||||
|
||||
get containerEl() { |
||||
return document.getElementById(this.containerId); |
||||
} |
||||
|
||||
prepareConfig() { |
||||
const dataset = this.containerEl.dataset; |
||||
|
||||
this.roadmapId = dataset.roadmapId; |
||||
this.jsonUrl = dataset.jsonUrl; |
||||
} |
||||
|
||||
/** |
||||
* @param { string } jsonUrl |
||||
* @returns {Promise<SVGElement>} |
||||
*/ |
||||
fetchRoadmapSvg(jsonUrl) { |
||||
if (!jsonUrl) { |
||||
console.error("jsonUrl not defined in frontmatter"); |
||||
return null; |
||||
} |
||||
|
||||
return fetch(jsonUrl) |
||||
.then(function (res) { |
||||
return res.json(); |
||||
}) |
||||
.then(function (json) { |
||||
return wireframeJSONToSVG(json, { |
||||
fontURL: "/fonts/balsamiq.woff2", |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
onDOMLoaded() { |
||||
this.prepareConfig(); |
||||
|
||||
this.fetchRoadmapSvg(this.jsonUrl) |
||||
.then((svg) => { |
||||
document.getElementById(this.containerId).replaceChildren(svg); |
||||
}) |
||||
.catch(console.error); |
||||
} |
||||
|
||||
handleRoadmapClick(e) { |
||||
const targetGroup = e.target.closest("g") || {}; |
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ""; |
||||
if (!groupId) { |
||||
return; |
||||
} |
||||
|
||||
e.stopImmediatePropagation(); |
||||
|
||||
window.dispatchEvent( |
||||
new CustomEvent("topic.click", { |
||||
detail: { |
||||
topicId: groupId, |
||||
roadmapId: this.roadmapId, |
||||
}, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
init() { |
||||
window.addEventListener("DOMContentLoaded", this.onDOMLoaded); |
||||
window.addEventListener("click", this.handleRoadmapClick); |
||||
} |
||||
} |
||||
|
||||
const roadmap = new Roadmap(); |
||||
roadmap.init(); |
||||
|
||||
// Initialize the topic loader
|
||||
const topic = new Topic(); |
||||
topic.init(); |
||||
|
||||
// Handles the share icons on the roadmap page
|
||||
const sharer = new Sharer(); |
||||
sharer.init(); |
@ -0,0 +1,25 @@ |
||||
export class Sharer { |
||||
constructor() { |
||||
this.init = this.init.bind(this); |
||||
this.onScroll = this.onScroll.bind(this); |
||||
|
||||
this.shareIconsId = "page-share-icons"; |
||||
} |
||||
|
||||
get shareIconsEl() { |
||||
return document.getElementById(this.shareIconsId); |
||||
} |
||||
|
||||
onScroll() { |
||||
if (window.scrollY < 100 || window.innerWidth < 1050) { |
||||
this.shareIconsEl.classList.add("hidden"); |
||||
return null; |
||||
} |
||||
|
||||
this.shareIconsEl.classList.remove("hidden"); |
||||
} |
||||
|
||||
init() { |
||||
window.addEventListener("scroll", this.onScroll, { passive: true }); |
||||
} |
||||
} |
@ -0,0 +1,204 @@ |
||||
export class Topic { |
||||
constructor() { |
||||
this.overlayId = 'topic-overlay'; |
||||
this.contentId = 'topic-content'; |
||||
this.loaderId = 'topic-loader'; |
||||
this.topicBodyId = 'topic-body'; |
||||
this.topicActionsId = 'topic-actions'; |
||||
this.markTopicDoneId = 'mark-topic-done'; |
||||
this.markTopicPendingId = 'mark-topic-pending'; |
||||
this.closeTopicId = 'close-topic'; |
||||
|
||||
this.activeRoadmapId = null; |
||||
this.activeTopicId = null; |
||||
|
||||
this.handleTopicClick = this.handleTopicClick.bind(this); |
||||
|
||||
this.close = this.close.bind(this); |
||||
this.resetDOM = this.resetDOM.bind(this); |
||||
this.populate = this.populate.bind(this); |
||||
this.handleOverlayClick = this.handleOverlayClick.bind(this); |
||||
this.markAsDone = this.markAsDone.bind(this); |
||||
this.markAsPending = this.markAsPending.bind(this); |
||||
this.queryRoadmapElementsByTopicId = this.queryRoadmapElementsByTopicId.bind(this); |
||||
|
||||
this.init = this.init.bind(this); |
||||
} |
||||
|
||||
get loaderEl() { |
||||
return document.getElementById(this.loaderId); |
||||
} |
||||
|
||||
get markTopicDoneEl() { |
||||
return document.getElementById(this.markTopicDoneId); |
||||
} |
||||
|
||||
get markTopicPendingEl() { |
||||
return document.getElementById(this.markTopicPendingId); |
||||
} |
||||
|
||||
get topicActionsEl() { |
||||
return document.getElementById(this.topicActionsId); |
||||
} |
||||
|
||||
get contentEl() { |
||||
return document.getElementById(this.contentId); |
||||
} |
||||
|
||||
get overlayEl() { |
||||
return document.getElementById(this.overlayId); |
||||
} |
||||
|
||||
resetDOM(hideOverlay = false) { |
||||
if (hideOverlay) { |
||||
this.overlayEl.classList.add('hidden'); |
||||
} else { |
||||
this.overlayEl.classList.remove('hidden'); |
||||
} |
||||
|
||||
this.loaderEl.classList.remove('hidden'); // Show loader
|
||||
this.topicActionsEl.classList.add('hidden'); // Hide Actions
|
||||
this.contentEl.replaceChildren(''); // Remove content
|
||||
} |
||||
|
||||
close() { |
||||
this.resetDOM(true); |
||||
|
||||
this.activeRoadmapId = null; |
||||
this.activeTopicId = null; |
||||
} |
||||
|
||||
/** |
||||
* @param {string | HTMLElement} html |
||||
*/ |
||||
populate(html) { |
||||
this.contentEl.replaceChildren(html); |
||||
this.loaderEl.classList.add('hidden'); |
||||
this.topicActionsEl.classList.remove('hidden'); |
||||
|
||||
const normalizedGroup = (this.activeTopicId || '').replace(/^\d+-/, ''); |
||||
const isDone = localStorage.getItem(normalizedGroup) === 'done'; |
||||
|
||||
if (isDone) { |
||||
this.markTopicDoneEl.classList.add('hidden'); |
||||
this.markTopicPendingEl.classList.remove('hidden'); |
||||
} else { |
||||
this.markTopicDoneEl.classList.remove('hidden'); |
||||
this.markTopicPendingEl.classList.add('hidden'); |
||||
} |
||||
} |
||||
|
||||
fetchTopicHtml(roadmapId, topicId) { |
||||
const topicPartial = topicId.replace(/^\d+-/, '').replaceAll(/:/g, '/'); |
||||
const fullUrl = `/${roadmapId}/${topicPartial}/`; |
||||
|
||||
return fetch(fullUrl) |
||||
.then((res) => { |
||||
return res.text(); |
||||
}) |
||||
.then((topicHtml) => { |
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(topicHtml, 'text/html'); |
||||
|
||||
return node.getElementById('main-content'); |
||||
}); |
||||
} |
||||
|
||||
handleTopicClick(e) { |
||||
const { roadmapId, topicId } = e.detail; |
||||
if (!topicId || !roadmapId) { |
||||
console.log('Missing topic or roadmap: ', e.detail); |
||||
return; |
||||
} |
||||
|
||||
this.activeRoadmapId = roadmapId; |
||||
this.activeTopicId = topicId; |
||||
|
||||
if (/^ext_link/.test(topicId)) { |
||||
window.open(`https://${topicId.replace('ext_link:', '')}`); |
||||
return; |
||||
} |
||||
|
||||
this.resetDOM(); |
||||
this.fetchTopicHtml(roadmapId, topicId) |
||||
.then((content) => { |
||||
this.populate(content); |
||||
}) |
||||
.catch((e) => { |
||||
console.error(e); |
||||
this.populate('Error loading the content!'); |
||||
}); |
||||
} |
||||
|
||||
queryRoadmapElementsByTopicId(topicId) { |
||||
const elements = document.querySelectorAll(`[data-group-id$="-${topicId}"]`); |
||||
const matchingElements = []; |
||||
|
||||
elements.forEach((element) => { |
||||
const foundGroupId = element?.dataset?.groupId || ''; |
||||
const validGroupRegex = new RegExp(`^\\d+-${topicId}$`); |
||||
|
||||
if (validGroupRegex.test(foundGroupId)) { |
||||
matchingElements.push(element); |
||||
} |
||||
}); |
||||
|
||||
return matchingElements; |
||||
} |
||||
|
||||
markAsDone(topicId) { |
||||
const updatedTopicId = topicId.replace(/^\d+-/, ''); |
||||
localStorage.setItem(updatedTopicId, 'done'); |
||||
|
||||
this.queryRoadmapElementsByTopicId(updatedTopicId).forEach((item) => { |
||||
item?.classList?.add('done'); |
||||
}); |
||||
} |
||||
|
||||
markAsPending(topicId) { |
||||
const updatedTopicId = topicId.replace(/^\d+-/, ''); |
||||
|
||||
localStorage.removeItem(updatedTopicId); |
||||
this.queryRoadmapElementsByTopicId(updatedTopicId).forEach((item) => { |
||||
item?.classList?.remove('done'); |
||||
}); |
||||
} |
||||
|
||||
handleOverlayClick(e) { |
||||
const isClickedInsideTopic = e.target.closest(`#${this.topicBodyId}`); |
||||
|
||||
if (!isClickedInsideTopic) { |
||||
this.close(); |
||||
return; |
||||
} |
||||
|
||||
const isClickedDone = e.target.id === this.markTopicDoneId || e.target.closest(`#${this.markTopicDoneId}`); |
||||
if (isClickedDone) { |
||||
this.markAsDone(this.activeTopicId); |
||||
this.close(); |
||||
} |
||||
|
||||
const isClickedPending = e.target.id === this.markTopicPendingId || e.target.closest(`#${this.markTopicPendingId}`); |
||||
if (isClickedPending) { |
||||
this.markAsPending(this.activeTopicId); |
||||
this.close(); |
||||
} |
||||
|
||||
const isClickedClose = e.target.id === this.closeTopicId || e.target.closest(`#${this.closeTopicId}`); |
||||
if (isClickedClose) { |
||||
this.close(); |
||||
} |
||||
} |
||||
|
||||
init() { |
||||
window.addEventListener('topic.click', this.handleTopicClick); |
||||
window.addEventListener('click', this.handleOverlayClick); |
||||
window.addEventListener('keydown', (e) => { |
||||
if (e.key.toLowerCase() === 'escape') { |
||||
this.close(); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
@ -0,0 +1,7 @@ |
||||
--- |
||||
import Icon from "./Icon.astro"; |
||||
--- |
||||
|
||||
<div class="flex justify-center w-full"> |
||||
<Icon icon="spinner" /> |
||||
</div> |
@ -0,0 +1,28 @@ |
||||
--- |
||||
import Icon from "./Icon.astro"; |
||||
|
||||
export interface Props { |
||||
pageUrl: string; |
||||
description: string; |
||||
} |
||||
|
||||
const { pageUrl, description } = Astro.props; |
||||
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?text=${description}&url=${pageUrl}`; |
||||
const fbUrl = `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${pageUrl}`; |
||||
const hnUrl = `https://news.ycombinator.com/submitlink?t=${description}&u=${pageUrl}`; |
||||
const redditUrl = `https://www.reddit.com/submit?title=${description}&url=${pageUrl}`; |
||||
--- |
||||
|
||||
<a href={twitterUrl} target="_blank" class="text-gray-500 hover:text-gray-700"> |
||||
<Icon icon="twitter" /> |
||||
</a> |
||||
<a href={fbUrl} target="_blank" class="text-gray-500 hover:text-gray-700"> |
||||
<Icon icon="facebook" /> |
||||
</a> |
||||
<a href={hnUrl} target="_blank" class="text-gray-500 hover:text-gray-700"> |
||||
<Icon icon="hackernews" /> |
||||
</a> |
||||
<a href={redditUrl} target="_blank" class="text-gray-500 hover:text-gray-700"> |
||||
<Icon icon="reddit" /> |
||||
</a> |
@ -0,0 +1,25 @@ |
||||
@tailwind base; |
||||
@tailwind components; |
||||
@tailwind utilities; |
||||
|
||||
@layer components { |
||||
.container { |
||||
@apply max-w-[830px] px-4 mx-auto; |
||||
} |
||||
} |
||||
|
||||
.bg-stripes { |
||||
background-image: linear-gradient(45deg, var(--stripes-color) 12.5%, transparent 12.5%, transparent 50%, var(--stripes-color) 50%, var(--stripes-color) 62.5%, transparent 62.5%, transparent 100%); |
||||
background-size: 5.66px 5.66px |
||||
} |
||||
|
||||
.sponsor-footer { |
||||
text-align: center; |
||||
font-weight: 600; |
||||
font-size: 9px; |
||||
letter-spacing: 0.5px; |
||||
text-transform: uppercase; |
||||
padding: 3px 10px; |
||||
display: block; |
||||
background: repeating-linear-gradient(-45deg, transparent, transparent 5px, hsla(0, 0%, 0%, .025) 5px, hsla(0, 0%, 0%, .025) 10px) hsla(203, 11%, 95%, .4); |
||||
} |
Loading…
Reference in new issue