Skip to content

Commit

Permalink
client: add downloading and viewing raw files (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
MaxLeiter committed Mar 12, 2022
1 parent 606e38e commit f9e9c6f
Show file tree
Hide file tree
Showing 20 changed files with 188 additions and 48 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions client/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
}

Expand Down
4 changes: 2 additions & 2 deletions client/components/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ 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) {
setError(err.message || "Something went wrong")
}
} 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) {
Expand Down
10 changes: 10 additions & 0 deletions client/components/document/document.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@
.textarea {
height: 100%;
}

.actionWrapper {
position: relative;
z-index: 1;
}

.actionWrapper .actions {
position: absolute;
right: 0;
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand 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} />
))}
Expand Down
48 changes: 43 additions & 5 deletions client/components/document/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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%'
Expand Down Expand Up @@ -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} />
Expand Down Expand Up @@ -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> */}
Expand Down
1 change: 1 addition & 0 deletions client/components/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 />
Expand Down
2 changes: 1 addition & 1 deletion client/components/my-posts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
}

Expand Down
44 changes: 23 additions & 21 deletions client/components/new-post/drag-and-drop/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/components/new-post/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion client/lib/hooks/use-signed-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
6 changes: 5 additions & 1 deletion client/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
},
];
},
};
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions client/pages/api/raw/[id].ts
Original file line number Diff line number Diff line change
@@ -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
30 changes: 27 additions & 3 deletions client/pages/post/[id].tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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>
Expand All @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions client/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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',
Expand Down
Loading

1 comment on commit f9e9c6f

@vercel
Copy link

@vercel vercel bot commented on f9e9c6f Mar 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.