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

View file

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

View file

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

View file

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

View file

@ -14,13 +14,15 @@ const ScrollToTop = () => {
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' })
}
return (
<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 >
<Spacer height={2 / 3} inline width={0} />
<ChevronUp />

View file

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

View file

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

View file

@ -11,8 +11,7 @@ const renderMarkdown: NextApiHandler = async (req, res) => {
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
if (file.status
!== 200) {
if (file.status !== 200) {
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("/files", files)
app.get('/welcome', secretKey, (req, res) => {
const introContent = process.env.WELCOME_CONTENT;
const introTitle = process.env.WELCOME_TITLE;
app.get("/welcome", secretKey, (req, res) => {
const introContent = process.env.WELCOME_CONTENT
const introTitle = process.env.WELCOME_TITLE
if (!introContent || !introTitle) {
return res.status(500).json({ error: 'Missing welcome content' });
}
if (!introContent || !introTitle) {
return res.status(500).json({ error: "Missing welcome content" })
}
return res.json({
title: introTitle,
content: introContent,
rendered: markdown(introContent)
});
return res.json({
title: introTitle,
content: introContent,
rendered: markdown(introContent)
})
})
app.use(errors())
app.use(
errorhandler({
debug: process.env.ENV !== "production",
log: true
})
errorhandler({
debug: process.env.ENV !== "production",
log: true
})
)

View file

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