client: add downloading and viewing raw files (#21)

This commit is contained in:
Max Leiter 2022-03-11 18:48:40 -08:00 committed by GitHub
parent 606e38e192
commit f9e9c6fe06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 189 additions and 49 deletions

View file

@ -1,13 +1,13 @@
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is (almost, no database yet) completely functional.
Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is (almost, no database yet) completely functional.
You can try a demo at https://drift.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time.
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
## Current status
Drit is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
- [x] creating and sharing private, public, unlisted posts
@ -17,7 +17,7 @@ Drit is a major work in progress. Below is a (rough) list of completed and envis
- [x] responsive UI
- [x] user auth
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
- [ ] downloading files (individually and entire posts)
- [x] downloading files (individually and entire posts)
- [ ] password protected posts
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
- [ ] non-node backend

View file

@ -3,9 +3,8 @@ import { useRouter } from "next/router";
const Link = (props: LinkProps) => {
const { basePath } = useRouter();
const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substr(1) : props.href;
const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href;
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
(href)
return <GeistLink {...props} href={href} />
}

View file

@ -35,7 +35,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
e.preventDefault()
if (signingIn) {
try {
const resp = await fetch('/api/auth/signin', reqOpts)
const resp = await fetch('/server-api/auth/signin', reqOpts)
const json = await resp.json()
handleJson(json)
} catch (err: any) {
@ -43,7 +43,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
}
} else {
try {
const resp = await fetch('/api/auth/signup', reqOpts)
const resp = await fetch('/server-api/auth/signup', reqOpts)
const json = await resp.json()
handleJson(json)
} catch (err: any) {

View file

@ -29,3 +29,13 @@
.textarea {
height: 100%;
}
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}

View file

@ -1,6 +1,7 @@
import { ButtonGroup, Button } from "@geist-ui/core"
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
import { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css'
// TODO: clean up
@ -122,11 +123,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
return (
<div style={{ position: 'relative', zIndex: 1 }}>
<ButtonGroup style={{
position: 'absolute',
right: 0,
}}>
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => (
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
))}

View file

@ -1,10 +1,11 @@
import { Button, Card, Input, Spacer, Tabs, Textarea } from "@geist-ui/core"
import { ChangeEvent, memo, useMemo, useRef, useState } from "react"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import styles from './document.module.css'
import MarkdownPreview from '../preview'
import { Trash } from '@geist-ui/icons'
import FormattingIcons from "../formatting-icons"
import { Trash, Download, ExternalLink } from '@geist-ui/icons'
import FormattingIcons from "./formatting-icons"
import Skeleton from "react-loading-skeleton"
// import Link from "next/link"
type Props = {
editable?: boolean
remove?: () => void
@ -14,9 +15,38 @@ type Props = {
setContent?: (content: string) => void
initialTab?: "edit" | "preview"
skeleton?: boolean
id?: string
}
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton }: Props) => {
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
<Tooltip text="Download">
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<Download />}
auto
aria-label="Download"
/>
</a>
</Tooltip>
<Tooltip text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<ExternalLink />}
auto
aria-label="Open raw file in new tab"
/>
</a>
</Tooltip>
</ButtonGroup>
</div>)
}
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
const height = editable ? "500px" : '100%'
@ -47,6 +77,13 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
}
}
}
const rawLink = useMemo(() => {
if (id) {
return `/file/raw/${id}`
}
}, [id])
if (skeleton) {
return <>
<Spacer height={1} />
@ -82,6 +119,7 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
</div>
<div className={styles.descriptionContainer}>
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
{rawLink && <DownloadButton rawLink={rawLink} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}

View file

@ -175,6 +175,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
auto
type="abort"
onClick={() => setExpanded(!expanded)}
aria-label="Menu"
>
<Spacer height={5 / 6} width={0} />
<MenuIcon />

View file

@ -9,7 +9,7 @@ const fetcher = (url: string) => fetch(url, {
}).then(r => r.json())
const MyPosts = () => {
const { data, error } = useSWR('/api/users/mine', fetcher)
const { data, error } = useSWR('/server-api/users/mine', fetcher)
return <PostList posts={data} error={error} />
}

View file

@ -94,35 +94,37 @@ const allowedFileExtensions = [
'webmanifest',
]
// TODO: this shouldn't need to know about docs
function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) {
function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStateAction<Document[]>>, docs: Document[] }) {
const { palette } = useTheme()
const onDrop = useCallback((acceptedFiles) => {
acceptedFiles.forEach((file: File) => {
const reader = new FileReader()
const { setToast } = useToasts()
const onDrop = useCallback(async (acceptedFiles) => {
const newDocs = await Promise.all(acceptedFiles.map((file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onabort = () => console.log('file reading was aborted')
reader.onerror = () => console.log('file reading has failed')
reader.onload = () => {
const content = reader.result as string
if (docs.length === 1 && docs[0].content === '') {
setDocs([{
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' })
reader.onload = () => {
const content = reader.result as string
resolve({
title: file.name,
content,
id: generateUUID()
}])
} else {
setDocs([...docs, {
title: file.name,
content,
id: generateUUID()
}])
})
}
}
reader.readAsText(file)
})
reader.readAsText(file)
})
}))
}, [docs, setDocs])
if (docs.length === 1) {
if (docs[0].content === '') {
setDocs(newDocs)
return
}
}
setDocs((oldDocs) => [...oldDocs, ...newDocs])
}, [setDocs, setToast, docs])
const validator = (file: File) => {
// TODO: make this configurable

View file

@ -31,7 +31,7 @@ const Post = () => {
const onSubmit = async (visibility: string) => {
setSubmitting(true)
const response = await fetch('/api/posts/create', {
const response = await fetch('/server-api/posts/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View file

@ -16,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
async function checkToken() {
const token = localStorage.getItem('drift-token')
if (token) {
const response = await fetch('/api/auth/verify-token', {
const response = await fetch('/server-api/auth/verify-token', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`

View file

@ -10,9 +10,13 @@ const nextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`,
},
{
source: "/file/raw/:id",
destination: `/api/raw/:id`,
},
];
},
};

View file

@ -12,6 +12,7 @@
"@fec/remark-a11y-emoji": "^3.1.0",
"@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1",
"client-zip": "^2.0.0",
"comlink": "^4.3.1",
"dotenv": "^16.0.0",
"next": "12.1.0",

View file

@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`)
if (file.ok) {
const data = await file.json()
const { title, content } = data
// serve the file raw as plain text
res.setHeader("Content-Type", "text/plain")
res.setHeader('Cache-Control', 's-maxage=86400');
if (download) {
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
} else {
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
}
res.status(200).send(content)
} else {
res.status(404).send("File not found")
}
}
export default getRawFile

View file

@ -1,4 +1,4 @@
import { Page, Text } from "@geist-ui/core";
import { Button, Page, Text } from "@geist-ui/core";
import Skeleton from 'react-loading-skeleton';
import { useRouter } from "next/router";
@ -19,7 +19,7 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
async function fetchPost() {
setIsLoading(true);
if (router.query.id) {
const post = await fetch(`/api/posts/${router.query.id}`, {
const post = await fetch(`/server-api/posts/${router.query.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
@ -46,6 +46,23 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
fetchPost()
}, [router, router.query.id])
const download = async () => {
const clientZip = require("client-zip")
const blob = await clientZip.downloadZip(post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
return (
<Page width={"100%"}>
<Head>
@ -62,10 +79,17 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
<Document skeleton={true} />
</>}
{!isLoading && post && <><Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
{!isLoading && post && <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
<Button auto onClick={download}>
Download as Zip
</Button>
</div>
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
<Document
key={id}
id={id}
content={content}
title={title}
editable={false}

View file

@ -480,6 +480,11 @@ character-reference-invalid@^1.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
client-zip@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/client-zip/-/client-zip-2.0.0.tgz#c93676c92ddb40c858da83517c27297a53874f8d"
integrity sha512-JFd4zdhxk5F01NmNnBq3+iMgJkkh0ku9NsI1wZlUjZ52inPJX92vR5TlSkjxRhmHJBPI7YqanD71wDEiKhdWtw==
clsx@^1.0.4:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"

View file

@ -2,7 +2,7 @@ import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as errorhandler from 'strong-error-handler';
import * as cors from 'cors';
import { posts, users, auth } from './routes';
import { posts, users, auth, files } from './routes';
export const app = express();
@ -17,6 +17,7 @@ app.use(cors(corsOptions));
app.use("/auth", auth)
app.use("/posts", posts)
app.use("/users", users)
app.use("/files", files)
app.use(errorhandler({
debug: process.env.ENV !== 'production',

View file

@ -0,0 +1,29 @@
import { Router } from 'express'
// import { Movie } from '../models/Post'
import { File } from '../../lib/models/File'
export const files = Router()
files.get("/raw/:id", async (req, res, next) => {
try {
const file = await File.findOne({
where: {
id: req.params.id
},
attributes: ["title", "content"],
})
// TODO: fix post inclusion
// if (file?.post.visibility === 'public' || file?.post.visibility === 'unlisted') {
res.setHeader("Cache-Control", "public, max-age=86400");
res.json(file);
// } else {
// TODO: should this be `private, `?
// res.setHeader("Cache-Control", "max-age=86400");
// res.json(file);
// }
}
catch (e) {
next(e);
}
});

View file

@ -1,3 +1,4 @@
export { auth } from './auth';
export { posts } from './posts';
export { users } from './users';
export { files } from './files';

View file

@ -69,7 +69,7 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
{
model: File,
as: "files",
attributes: ["id", "title", "content", "sha"],
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"],
},
{
model: User,
@ -80,8 +80,11 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
})
if (post?.visibility === 'public' || post?.visibility === 'unlisted') {
res.setHeader("Cache-Control", "public, max-age=86400");
res.json(post);
} else {
// TODO: should this be `private, `?
res.setHeader("Cache-Control", "max-age=86400");
jwt(req, res, () => {
res.json(post);
});