Skip to content

Commit

Permalink
initialize code stepper
Browse files Browse the repository at this point in the history
  • Loading branch information
Wattenberger committed Aug 9, 2022
1 parent 83dfb38 commit 83657f2
Show file tree
Hide file tree
Showing 10 changed files with 645 additions and 107 deletions.
19 changes: 5 additions & 14 deletions blocks.config.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
[
{
"type": "file",
"id": "file-block",
"title": "Example File Block",
"description": "A basic file block",
"entry": "blocks/example-file-block/index.tsx",
"matches": ["*"],
"example_path": "https://github.com/facebook/react/blob/main/packages/react-dom/index.js"
},
{
"type": "folder",
"id": "folder-block",
"title": "Example Folder Block",
"description": "A basic folder block",
"entry": "blocks/example-folder-block.tsx",
"id": "code-stepper",
"title": "Code Stepper",
"description": "Step through versions of code: each version should have the same filename, followed by a number.",
"entry": "blocks/code-stepper/index.tsx",
"matches": ["*"],
"example_path": "https://github.com/githubocto/flat"
"example_path": "https://github.com/cs50/lectures/tree/python/2022/2/src2"
}
]
132 changes: 132 additions & 0 deletions blocks/code-stepper/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState } from "react";
import { getTrackBackground, Range } from "react-range";
import { tw } from "twind";
import { useInterval } from "./hooks";

export const Timeline = ({
files = [],
activeFileIndex = 0,
setActiveFileIndex,
}: {
files: File[];
activeFileIndex: number;
setActiveFileIndex: (index: number) => void;
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const activeFile = files[activeFileIndex];

useInterval(
() => {
const nextFileIndex = activeFileIndex + 1;
if (nextFileIndex >= files.length - 1) setIsPlaying(false);
setActiveFileIndex(nextFileIndex)
},
isPlaying ? 500 : null
);

return (
<div className={tw`w-full px-3 pt-7 flex items-center gap-3`}>
<button
onClick={() => {
if (activeFileIndex === files.length - 1) {
setActiveFileIndex(0);
}
setIsPlaying(!isPlaying);
}}
>
{isPlaying ? (
<svg viewBox="0 0 24 24" width="24" height="24" strokeLinecap="round" className={tw`text-[#0969da]`} fill="currentColor">
<path
d="M 9.5 9.5 L 9.5 14.5"
strokeWidth="2"
stroke="currentColor"
></path>
<path
d="M 14.5 9.5 L 14.5 14.5"
strokeWidth="2"
stroke="currentColor"
></path>
<path
fillRule="evenodd"
d="M12 2.5a9.5 9.5 0 100 19 9.5 9.5 0 000-19zM1 12C1 5.925 5.925 1 12 1s11 4.925 11 11-4.925 11-11 11S1 18.075 1 12z"
></path>
</svg>
) : (
<svg viewBox="0 0 24 24" width="24" height="24" className={tw`text-[#0969da]`} fill="currentColor">
<path d="M9.5 15.584V8.416a.5.5 0 01.77-.42l5.576 3.583a.5.5 0 010 .842l-5.576 3.584a.5.5 0 01-.77-.42z"></path>
<path
fillRule="evenodd"
d="M12 2.5a9.5 9.5 0 100 19 9.5 9.5 0 000-19zM1 12C1 5.925 5.925 1 12 1s11 4.925 11 11-4.925 11-11 11S1 18.075 1 12z"
></path>
</svg>
)}
</button>
<div className={tw`flex-1 flex items-center`}>
<Range
step={1}
min={0}
max={files.length - 1}
values={[activeFileIndex]}
onChange={(values) => {
if (isPlaying) {
setIsPlaying(false);
}
setActiveFileIndex(values[0]);
}}
renderMark={({ props, index }) => (
<div
{...props}
className={tw`w-[12px] h-[12px]`}
style={{
...props.style,
backgroundColor: index * 1 < activeFileIndex ? "#0969da" : "#d0d7de",
border: "1px solid white",
borderRadius: "100%",
}}
/>
)}
renderTrack={({ props, children }) => (
<div
{...props}
className={tw(
`bg-gray-200 h-1 w-full`,

)}
style={{
...props.style,
background: getTrackBackground({
values: [activeFileIndex],
colors: ["#0969da", "#d0d7de"],
min: 0,
max: files.length - 1,
}),
}}
>
{children}
</div>
)}
renderThumb={({ props }) => (
<div
{...props}
className={tw`w-4 h-4 rounded-full bg-[#0969da] flex items-center justify-center relative focus:outline-none`}
style={{
...props.style,
}}
>
<div
className={tw`absolute -top-3 w-2 h-2 bg-[#0969da] transform rotate-45`}
></div>
<div
className={tw`bg-[#0969da] absolute -top-8 text-white p-1 text-xs`}
>
<span className={tw`font-mono`}>
{(activeFile?.path || "").split("/").pop()}
</span>
</div>
</div>
)}
/>
</div>
</div >
);
};
21 changes: 21 additions & 0 deletions blocks/code-stepper/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useRef } from "react";

export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef<undefined | (() => void)>();

// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);

// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current && savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
Empty file added blocks/code-stepper/index.css
Empty file.
181 changes: 181 additions & 0 deletions blocks/code-stepper/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { FolderBlockProps, getLanguageFromFilename } from "@githubnext/blocks";
import { Box, FormControl, Select } from "@primer/react";
import { tw } from "twind";
import { useEffect, useState } from "react";
import { Endpoints } from "@octokit/types";
import { Timeline } from "./Timeline";
import { motion } from "framer-motion";
import SyntaxHighlighter from "react-syntax-highlighter";
import style from "react-syntax-highlighter/dist/esm/styles/hljs/github-gist";
import "./index.css"

export type RawTree =
Endpoints["GET /repos/{owner}/{repo}/git/trees/{tree_sha}"]["response"]["data"];
export type Tree = RawTree["tree"];
export type File = RawTree["tree"][0] & {
content: string,
}
export default ({ tree, context, onRequestGitHubData }: FolderBlockProps) => {
const [files, setFiles] = useState<File[]>([]);
const [activeFileIndex, setActiveFileIndex] = useState<number>(0);
const getLessonNameFromLessonPath = (lessonPath: string) => {
const filename = lessonPath.split("/").pop()?.split(".")[0] || "";
// remove trailing numbers
return filename.replace(/\d+$/, "");
}
const allLessonNames = files.map(file => file.path
? getLessonNameFromLessonPath(file.path)
: "");
const uniqueLessonNames = [...new Set(allLessonNames)];
const [lessonName, setLessonName] = useState<string>(uniqueLessonNames[0]);
const filesInLesson = files.filter(file => file.path?.startsWith(`${context.path}/${lessonName}`));

const activeFile = filesInLesson[activeFileIndex];
const lines = activeFile?.content?.split("\n");
const language = activeFile?.path ? getLanguageFromFilename(activeFile?.path.split("/").pop() || "") : ""
const languageCommentMarker = languageCommentMarkersMap[language] || "#"
const numberOfCommentLines = lines?.findIndex(line => !line.startsWith(languageCommentMarker)) || 0

useEffect(() => {
setActiveFileIndex(0);
}, [lessonName]);

useEffect(() => {
setLessonName(uniqueLessonNames[0]);
}, [uniqueLessonNames.join(",")]);

const onFetchFileContents = async (path) => {
const url = `/repos/${context.owner}/${context.repo}/contents/${path}`
console.log(url)
const data = await onRequestGitHubData(url, {
ref: context.sha,
});
const decodedData = atob(data.content);
return decodedData;
}
const onUpdateFiles = async () => {
const immediateFiles = tree.filter(item => (
item.type === "blob"
&& item.path?.startsWith(`${context.path}/`)
&& item.path?.slice(context.path.length + 1).split("/").length === 1
))
// .slice(0, 3)
console.log(immediateFiles, tree, context)
const files = await Promise.all(immediateFiles.map(async item => {
const data = await onFetchFileContents(item.path);
return { ...item, content: data }
}))
const sortedFiles = files.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
})
setFiles(sortedFiles)
}
useEffect(() => { onUpdateFiles() }, [context.path])

return (
<Box p={4}>
<Box
borderColor="border.default"
borderWidth={1}
borderStyle="solid"
borderRadius={6}
overflow="hidden"
>
<Box
display="flex"
background="canvas.subtle"
borderColor="border.default"
borderBottomWidth={1}
borderStyle="solid"
p={3}
>
<FormControl
sx={{ width: "20em" }}
>
<FormControl.Label>Lesson</FormControl.Label>
<Select value={lessonName} onChange={e => setLessonName(e.target.value)}
>
{uniqueLessonNames.map(name => (
<Select.Option key={name} value={name}> {name} </Select.Option>
))}
</Select>
</FormControl>
{!!activeFile && filesInLesson.length > 1 && (
<Box px={2} width="100%">
<Timeline
files={filesInLesson}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
</Box>
)}
</Box>

{!!activeFile && (
<pre className={tw`p-6`}>
{lines.map((line, index) => {
if (numberOfCommentLines > index) return (
<motion.div
key={line || `line-${index}`}
layout
className={tw(
`flex min-h-[1em] -mx-4 -my-2`,
"bg-[#ddf4ff] px-4 py-3 text-[#0550ae]"
)}
>
<Box className={tw`w-[2em] mr-1 text-[#0550ae]`}>
{index + 1}
</Box>
{line.slice(1).trim()}
</motion.div>
)
const numberOfMatchingPreviousLines = lines.slice(0, index).filter(previousLine => previousLine === line).length;
return (
<motion.div
key={`line--${line}--${numberOfMatchingPreviousLines}`}
layout
className={tw(
`flex min-h-[1.3em] py-[0.1em]`,
)}
initial={{ x: -30, opacity: 0.5 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.3 }}
exit={{ x: -30, opacity: 0.5 }}
>
<Box className={tw`w-[2em] mr-1`} color="fg.subtle">
<motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
{index + 1}
</motion.span>
</Box>
<SyntaxHighlighter
language={syntaxHighlighterLanguageMap[language] || "javascript"}
lineNumberStyle={{ opacity: 0.45 }}
className={tw(`!p-0`)}
wrapLines
wrapLongLines
style={style}
>
{line}
</SyntaxHighlighter>
</motion.div>
)
})}
</pre>
)}
</Box>
</Box>
);
}

const syntaxHighlighterLanguageMap = {
JavaScript: "javascript",
TypeScript: "typescript",
} as Record<string, string>;

const languageCommentMarkersMap = {
JavaScript: "//",
TypeScript: "//",
Python: "#",
}
3 changes: 0 additions & 3 deletions blocks/example-file-block/index.css

This file was deleted.

Loading

0 comments on commit 83657f2

Please sign in to comment.