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

pull/24/head
Max Leiter 3 years ago committed by GitHub
parent 606e38e192
commit f9e9c6fe06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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} />
}

@ -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) {

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

@ -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} />
))}

@ -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> */}

@ -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 />

@ -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} />
}

@ -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)
})
}))
if (docs.length === 1) {
if (docs[0].content === '') {
setDocs(newDocs)
return
}
reader.readAsText(file)
})
}
}, [docs, setDocs])
setDocs((oldDocs) => [...oldDocs, ...newDocs])
}, [setDocs, setToast, docs])
const validator = (file: File) => {
// TODO: make this configurable

@ -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',

@ -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}`

@ -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`,
},
];
},
};

@ -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",

@ -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

@ -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}

@ -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"

@ -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',

@ -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);
}
});

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

@ -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);
});

Loading…
Cancel
Save