client: lint and minor scroll button/file explorer adjustments

This commit is contained in:
Max Leiter 2022-03-25 14:31:10 -07:00
parent 887ecfabbc
commit 0815d43ee8
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
10 changed files with 454 additions and 427 deletions

View file

@ -23,7 +23,7 @@
transition: var(--transition); transition: var(--transition);
border-radius: var(--radius); border-radius: var(--radius);
margin: 0; margin: 0;
padding: 2px var(--gap); padding: 0 0;
} }
.content li:hover, .content li:hover,
@ -32,9 +32,11 @@
} }
.content li a { .content li a {
display: block; display: flex;
height: 100%; align-items: center;
width: min-content; text-align: center;
padding: var(--gap-half) var(--gap);
color: var(--dark-gray);
} }
.button { .button {

View file

@ -1,8 +1,8 @@
import { Button, Link, Text, Popover } from '@geist-ui/core' import { Button, Link, Text, Popover } from '@geist-ui/core'
import FileIcon from '@geist-ui/icons/fileText' import FileIcon from '@geist-ui/icons/fileText'
import CodeIcon from '@geist-ui/icons/fileLambda' import CodeIcon from '@geist-ui/icons/fileFunction'
import styles from './dropdown.module.css' import styles from './dropdown.module.css'
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
import ChevronDown from '@geist-ui/icons/chevronDown' import ChevronDown from '@geist-ui/icons/chevronDown'
import ShiftBy from "@components/shift-by" import ShiftBy from "@components/shift-by"
@ -20,6 +20,16 @@ const FileDropdown = ({
}) => { }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
const onOpen = useCallback(() => {
setExpanded(true)
}, [])
const onClose = useCallback(() => {
setExpanded(false)
// contentRef.current?.focus()
}, [])
useEffect(() => { useEffect(() => {
const newItems = files.map(file => { const newItems = files.map(file => {
const extension = file.title.split('.').pop() const extension = file.title.split('.').pop()
@ -40,24 +50,24 @@ const FileDropdown = ({
const content = useCallback(() => (<ul className={styles.content}> const content = useCallback(() => (<ul className={styles.content}>
{items.map(item => ( {items.map(item => (
<li key={item.id} onClick={() => setExpanded(false)}> <li key={item.id} onClick={onClose}>
<Link color={false} href={`#${item.title}`}> <a href={`#${item.title}`}>
<ShiftBy y={5}><span className={styles.fileIcon}> <ShiftBy y={5}><span className={styles.fileIcon}>
{item.icon}</span></ShiftBy> {item.icon}</span></ShiftBy>
<span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span> <span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span>
</Link> </a>
</li> </li>
))} ))}
</ul> </ul>
), [items]) ), [items, onClose])
// a list of files with an icon and a title // a list of files with an icon and a title
return ( return (
<Button auto onClick={() => setExpanded(!expanded)} className={styles.button} iconRight={<ChevronDown />}> <Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />}>
<Popover content={content} visible={expanded} trigger="click" hideArrow={true}> <Popover tabIndex={0} content={content} visible={expanded} trigger="click" hideArrow={true}>
{files.length} {files.length === 1 ? 'file' : 'files'} Jump to {files.length} {files.length === 1 ? 'file' : 'files'}
</Popover> </Popover>
</Button> </Button >
) )
} }

View file

@ -54,7 +54,7 @@ const PostPage = ({ post }: Props) => {
{/* {!isLoading && <PostFileExplorer files={post.files} />} */} {/* {!isLoading && <PostFileExplorer files={post.files} />} */}
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}> <span className={styles.title}>
<Text h2>{post.title}</Text> <Text h3>{post.title}</Text>
<div className={styles.badges}> <div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
<Badge type="secondary"><Tooltip text={formattedTime}>{time}</Tooltip></Badge> <Badge type="secondary"><Tooltip text={formattedTime}>{time}</Tooltip></Badge>

View file

@ -11,9 +11,13 @@
align-items: center; align-items: center;
} }
.header .title h3 {
margin: 0;
padding: 0;
}
.header .title .badges > * { .header .title .badges > * {
margin-left: var(--gap); margin-left: var(--gap);
margin-bottom: var(--gap-quarter);
} }
.header .buttons { .header .buttons {
@ -42,6 +46,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
width: 80%; width: 100%;
} }
} }

View file

@ -14,13 +14,15 @@ const ScrollToTop = () => {
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll)
}, []) }, [])
const onClick = () => { const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// blur the button
e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}>
<Tooltip text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}> <Tooltip hideArrow text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}>
<Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto > <Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto >
<Spacer height={2 / 3} inline width={0} /> <Spacer height={2 / 3} inline width={0} />
<ChevronUp /> <ChevronUp />

View file

@ -1,141 +1,141 @@
export const allowedFileTypes = [ export const allowedFileTypes = [
'application/json', "application/json",
'application/x-javascript', "application/x-javascript",
'application/xhtml+xml', "application/xhtml+xml",
'application/xml', "application/xml",
'text/xml', "text/xml",
'text/plain', "text/plain",
'text/html', "text/html",
'text/csv', "text/csv",
'text/tab-separated-values', "text/tab-separated-values",
'text/x-c', "text/x-c",
'text/x-c++', "text/x-c++",
'text/x-csharp', "text/x-csharp",
'text/x-java', "text/x-java",
'text/x-javascript', "text/x-javascript",
'text/x-php', "text/x-php",
'text/x-python', "text/x-python",
'text/x-ruby', "text/x-ruby",
'text/x-scala', "text/x-scala",
'text/x-swift', "text/x-swift",
'text/x-typescript', "text/x-typescript",
'text/x-vb', "text/x-vb",
'text/x-vbscript', "text/x-vbscript",
'text/x-yaml', "text/x-yaml",
'text/x-c++', "text/x-c++",
'text/x-c#', "text/x-c#",
'text/mathml', "text/mathml",
'text/x-markdown', "text/x-markdown",
'text/markdown', "text/markdown"
] ]
export const allowedFileNames = [ export const allowedFileNames = [
'Makefile', "Makefile",
'README', "README",
'Dockerfile', "Dockerfile",
'Jenkinsfile', "Jenkinsfile",
'LICENSE', "LICENSE",
'.env', ".env",
'.gitignore', ".gitignore",
'.gitattributes', ".gitattributes",
'.env.example', ".env.example",
'.env.development', ".env.development",
'.env.production', ".env.production",
'.env.test', ".env.test",
'.env.staging', ".env.staging",
'.env.development.local', ".env.development.local",
'yarn.lock', "yarn.lock",
'.bash', ".bash",
'.bashrc', ".bashrc",
'.bash_profile', ".bash_profile",
'.bash_logout', ".bash_logout",
'.profile', ".profile",
'.fish_prompt', ".fish_prompt",
'.zshrc', ".zshrc",
'.zsh', ".zsh",
'.zprofile', ".zprofile",
'go', "go"
] ]
export const allowedFileExtensions = [ export const allowedFileExtensions = [
'json', "json",
'js', "js",
'jsx', "jsx",
'ts', "ts",
'tsx', "tsx",
'c', "c",
'cpp', "cpp",
'c++', "c++",
'c#', "c#",
'java', "java",
'php', "php",
'py', "py",
'rb', "rb",
'scala', "scala",
'swift', "swift",
'vb', "vb",
'vbscript', "vbscript",
'yaml', "yaml",
'less', "less",
'stylus', "stylus",
'styl', "styl",
'sass', "sass",
'scss', "scss",
'lock', "lock",
'md', "md",
'markdown', "markdown",
'txt', "txt",
'html', "html",
'htm', "htm",
'css', "css",
'csv', "csv",
'log', "log",
'sql', "sql",
'xml', "xml",
'webmanifest', "webmanifest",
'vue', "vue",
'vuex', "vuex",
'rs', "rs",
'zig', "zig"
] ]
export const codeFileExtensions = [ export const codeFileExtensions = [
'json', "json",
'js', "js",
'jsx', "jsx",
'ts', "ts",
'tsx', "tsx",
'c', "c",
'cpp', "cpp",
'c++', "c++",
'c#', "c#",
'java', "java",
'php', "php",
'py', "py",
'rb', "rb",
'scala', "scala",
'swift', "swift",
'vb', "vb",
'vbscript', "vbscript",
'yaml', "yaml",
'less', "less",
'stylus', "stylus",
'styl', "styl",
'sass', "sass",
'scss', "scss",
'html', "html",
'htm', "htm",
'css', "css",
'asm', "asm",
's', "s",
'sh', "sh",
'bat', "bat",
'cmd', "cmd",
'sql', "sql",
'xml', "xml",
'rust', "rust",
'h', "h",
'zig', "zig",
'rs', "rs",
'go' "go"
] ]

View file

@ -1,24 +1,24 @@
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => { const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query const { id } = req.query
const file = await fetch(`${process.env.API_URL}/files/html/${id}`, { const file = await fetch(`${process.env.API_URL}/files/html/${id}`, {
headers: { headers: {
"x-secret-key": process.env.SECRET_KEY || "", "x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}` Authorization: `Bearer ${req.cookies["drift-token"]}`
} }
}) })
if (file.ok) { if (file.ok) {
const json = await file.text() const json = await file.text()
const data = json const data = json
// serve the file raw as plain text // serve the file raw as plain text
res.setHeader("Content-Type", "text/plain; charset=utf-8") res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400") res.setHeader("Cache-Control", "s-maxage=86400")
res.status(200).write(data, "utf-8") res.status(200).write(data, "utf-8")
res.end() res.end()
} else { } else {
res.status(404).send("File not found") res.status(404).send("File not found")
} }
} }
export default getRawFile export default getRawFile

View file

@ -11,8 +11,7 @@ const renderMarkdown: NextApiHandler = async (req, res) => {
Authorization: `Bearer ${req.cookies["drift-token"]}` Authorization: `Bearer ${req.cookies["drift-token"]}`
} }
}) })
if (file.status if (file.status !== 200) {
!== 200) {
return res.status(404).json({ error: "File not found" }) return res.status(404).json({ error: "File not found" })
} }

View file

@ -16,26 +16,26 @@ app.use("/posts", posts)
app.use("/users", users) app.use("/users", users)
app.use("/files", files) app.use("/files", files)
app.get('/welcome', secretKey, (req, res) => { app.get("/welcome", secretKey, (req, res) => {
const introContent = process.env.WELCOME_CONTENT; const introContent = process.env.WELCOME_CONTENT
const introTitle = process.env.WELCOME_TITLE; const introTitle = process.env.WELCOME_TITLE
if (!introContent || !introTitle) { if (!introContent || !introTitle) {
return res.status(500).json({ error: 'Missing welcome content' }); return res.status(500).json({ error: "Missing welcome content" })
} }
return res.json({ return res.json({
title: introTitle, title: introTitle,
content: introContent, content: introContent,
rendered: markdown(introContent) rendered: markdown(introContent)
}); })
}) })
app.use(errors()) app.use(errors())
app.use( app.use(
errorhandler({ errorhandler({
debug: process.env.ENV !== "production", debug: process.env.ENV !== "production",
log: true log: true
}) })
) )

View file

@ -12,275 +12,285 @@ import { Op } from "sequelize"
export const posts = Router() export const posts = Router()
const postVisibilitySchema = (value: string) => { const postVisibilitySchema = (value: string) => {
if (value === "public" || value === "private" || if (
value === "unlisted" || value === "protected") { value === "public" ||
return value value === "private" ||
} else { value === "unlisted" ||
throw new Error("Invalid post visibility") value === "protected"
} ) {
return value
} else {
throw new Error("Invalid post visibility")
}
} }
posts.post( posts.post(
"/create", "/create",
jwt, jwt,
celebrate({ celebrate({
body: { body: {
title: Joi.string().required().allow("", null), title: Joi.string().required().allow("", null),
files: Joi.any().required(), files: Joi.any().required(),
visibility: Joi.string() visibility: Joi.string()
.custom(postVisibilitySchema, "valid visibility") .custom(postVisibilitySchema, "valid visibility")
.required(), .required(),
userId: Joi.string().required(), userId: Joi.string().required(),
password: Joi.string().optional() password: Joi.string().optional()
} }
}), }),
async (req, res, next) => { async (req, res, next) => {
try { try {
let hashedPassword: string = "" let hashedPassword: string = ""
if (req.body.visibility === "protected") { if (req.body.visibility === "protected") {
hashedPassword = crypto hashedPassword = crypto
.createHash("sha256") .createHash("sha256")
.update(req.body.password) .update(req.body.password)
.digest("hex") .digest("hex")
} }
const newPost = new Post({ const newPost = new Post({
title: req.body.title, title: req.body.title,
visibility: req.body.visibility, visibility: req.body.visibility,
password: hashedPassword password: hashedPassword
}) })
await newPost.save() await newPost.save()
await newPost.$add("users", req.body.userId) await newPost.$add("users", req.body.userId)
const newFiles = await Promise.all( const newFiles = await Promise.all(
req.body.files.map(async (file) => { req.body.files.map(async (file) => {
const html = getHtmlFromFile(file) const html = getHtmlFromFile(file)
const newFile = new File({ const newFile = new File({
title: file.title || "", title: file.title || "",
content: file.content, content: file.content,
sha: crypto sha: crypto
.createHash("sha256") .createHash("sha256")
.update(file.content) .update(file.content)
.digest("hex") .digest("hex")
.toString(), .toString(),
html html
}) })
await newFile.$set("user", req.body.userId) await newFile.$set("user", req.body.userId)
await newFile.$set("post", newPost.id) await newFile.$set("post", newPost.id)
await newFile.save() await newFile.save()
return newFile return newFile
}) })
) )
await Promise.all( await Promise.all(
newFiles.map((file) => { newFiles.map((file) => {
newPost.$add("files", file.id) newPost.$add("files", file.id)
newPost.save() newPost.save()
}) })
) )
res.json(newPost) res.json(newPost)
} catch (e) { } catch (e) {
next(e) next(e)
} }
} }
) )
posts.get("/", secretKey, async (req, res, next) => { posts.get("/", secretKey, async (req, res, next) => {
try { try {
const posts = await Post.findAll({ const posts = await Post.findAll({
attributes: ["id", "title", "visibility", "createdAt"] attributes: ["id", "title", "visibility", "createdAt"]
}) })
res.json(posts) res.json(posts)
} catch (e) { } catch (e) {
next(e) next(e)
} }
}) })
posts.get("/mine", jwt, posts.get(
celebrate({ "/mine",
query: { jwt,
page: Joi.number().integer().min(1).default(1).optional() celebrate({
} query: {
}), page: Joi.number().integer().min(1).default(1).optional()
async (req: UserJwtRequest, res, next) => { }
if (!req.user) { }),
return res.status(401).json({ error: "Unauthorized" }) async (req: UserJwtRequest, res, next) => {
} if (!req.user) {
return res.status(401).json({ error: "Unauthorized" })
}
const { page } = req.query const { page } = req.query
const parsedPage = page ? parseInt(page as string) : 1 const parsedPage = page ? parseInt(page as string) : 1
try { try {
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
include: [ include: [
{ {
model: Post, model: Post,
as: "posts", as: "posts",
include: [ include: [
{ {
model: File, model: File,
as: "files", as: "files",
attributes: ["id", "title", "createdAt"] attributes: ["id", "title", "createdAt"]
}, }
], ],
attributes: ["id", "title", "visibility", "createdAt"] attributes: ["id", "title", "visibility", "createdAt"]
} }
] ]
}) })
if (!user) { if (!user) {
return res.status(404).json({ error: "User not found" }) return res.status(404).json({ error: "User not found" })
} }
return res.json( return res.json(
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice((parsedPage - 1) * 10, parsedPage * 10) user.posts
) ?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
} catch (error) { .slice((parsedPage - 1) * 10, parsedPage * 10)
next(error) )
} } catch (error) {
}) next(error)
}
posts.get("/search", }
jwt,
celebrate({
query: {
q: Joi.string().required()
}
}),
async (req, res, next) => {
const { q } = req.query
if (typeof q !== "string") {
return res.status(400).json({ error: "Invalid query" })
}
try {
const posts = await Post.findAll({
where: {
[Op.or]: [
{ title: { [Op.like]: `%${q}%` } },
{ "$files.title$": { [Op.like]: `%${q}%` } },
{ "$files.content$": { [Op.like]: `%${q}%` } }
]
},
include: [
{
model: File,
as: "files",
attributes: ["id", "title"]
}
],
attributes: ["id", "title", "visibility", "createdAt"],
order: [["createdAt", "DESC"]]
})
res.json(posts)
} catch (e) {
next(e)
}
}
) )
posts.get( posts.get(
"/:id", "/search",
celebrate({ jwt,
params: { celebrate({
id: Joi.string().required() query: {
} q: Joi.string().required()
}), }
async (req: UserJwtRequest, res, next) => { }),
try { async (req, res, next) => {
const post = await Post.findByPk(req.params.id, { const { q } = req.query
include: [ if (typeof q !== "string") {
{ return res.status(400).json({ error: "Invalid query" })
model: File, }
as: "files",
attributes: [
"id",
"title",
"content",
"sha",
"createdAt",
"updatedAt"
]
},
{
model: User,
as: "users",
attributes: ["id", "username"]
}
]
})
if (!post) { try {
return res.status(404).json({ error: "Post not found" }) const posts = await Post.findAll({
} where: {
[Op.or]: [
{ title: { [Op.like]: `%${q}%` } },
{ "$files.title$": { [Op.like]: `%${q}%` } },
{ "$files.content$": { [Op.like]: `%${q}%` } }
]
},
include: [
{
model: File,
as: "files",
attributes: ["id", "title"]
}
],
attributes: ["id", "title", "visibility", "createdAt"],
order: [["createdAt", "DESC"]]
})
// if public or unlisted, cache res.json(posts)
if (post.visibility === "public" || post.visibility === "unlisted") { } catch (e) {
res.set("Cache-Control", "public, max-age=4800") next(e)
} }
}
)
if (post.visibility === "public" || post?.visibility === "unlisted") { posts.get(
secretKey(req, res, () => { "/:id",
res.json(post) celebrate({
}) params: {
} else if (post.visibility === "private") { id: Joi.string().required()
jwt(req as UserJwtRequest, res, () => { }
res.json(post) }),
}) async (req: UserJwtRequest, res, next) => {
} else if (post.visibility === "protected") { try {
const { password } = req.query const post = await Post.findByPk(req.params.id, {
if (!password || typeof password !== "string") { include: [
return jwt(req as UserJwtRequest, res, () => { {
res.json(post) model: File,
}) as: "files",
} attributes: [
const hash = crypto "id",
.createHash("sha256") "title",
.update(password) "content",
.digest("hex") "sha",
.toString() "createdAt",
if (hash !== post.password) { "updatedAt"
return res.status(400).json({ error: "Incorrect password." }) ]
} },
{
model: User,
as: "users",
attributes: ["id", "username"]
}
]
})
res.json(post) if (!post) {
} return res.status(404).json({ error: "Post not found" })
} catch (e) { }
next(e)
} // if public or unlisted, cache
} if (post.visibility === "public" || post.visibility === "unlisted") {
res.set("Cache-Control", "public, max-age=4800")
}
if (post.visibility === "public" || post?.visibility === "unlisted") {
secretKey(req, res, () => {
res.json(post)
})
} else if (post.visibility === "private") {
jwt(req as UserJwtRequest, res, () => {
res.json(post)
})
} else if (post.visibility === "protected") {
const { password } = req.query
if (!password || typeof password !== "string") {
return jwt(req as UserJwtRequest, res, () => {
res.json(post)
})
}
const hash = crypto
.createHash("sha256")
.update(password)
.digest("hex")
.toString()
if (hash !== post.password) {
return res.status(400).json({ error: "Incorrect password." })
}
res.json(post)
}
} catch (e) {
next(e)
}
}
) )
function getHtmlFromFile(file: any) { function getHtmlFromFile(file: any) {
const renderAsMarkdown = [ const renderAsMarkdown = [
"markdown", "markdown",
"md", "md",
"mdown", "mdown",
"mkdn", "mkdn",
"mkd", "mkd",
"mdwn", "mdwn",
"mdtxt", "mdtxt",
"mdtext", "mdtext",
"text", "text",
"" ""
] ]
const fileType = () => { const fileType = () => {
const pathParts = file.title.split(".") const pathParts = file.title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : "" const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language return language
} }
const type = fileType() const type = fileType()
let contentToRender: string = file.content || "" let contentToRender: string = file.content || ""
if (!renderAsMarkdown.includes(type)) { if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type} contentToRender = `~~~${type}
${file.content} ${file.content}
~~~` ~~~`
} else { } else {
contentToRender = "\n" + file.content contentToRender = "\n" + file.content
} }
const html = markdown(contentToRender) const html = markdown(contentToRender)
return html return html
} }