Skip to content

Commit

Permalink
feat: experiment with a simpler upload page
Browse files Browse the repository at this point in the history
  • Loading branch information
aalemayhu committed Nov 24, 2023
1 parent b8dfc1e commit b07ec1a
Show file tree
Hide file tree
Showing 15 changed files with 378 additions and 3 deletions.
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import FavoritesPage from './pages/FavoritesPage';
import { PageLayout } from './components/Layout/PageLayout';
import DeleteAccountPage from './pages/DeleteAccountPage';
import { getErrorMessage } from './components/errors/helpers/getErrorMessage';
import SimplePage from './pages/SimplePage';

const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const SearchPage = lazy(() => import('./pages/SearchPage'));
Expand Down Expand Up @@ -50,6 +51,7 @@ function App() {
<BrowserRouter>
<PageLayout error={apiError}>
<Routes>
<Route path="/simple" element={<SimplePage />} />
<Route
path="/favorites"
element={<FavoritesPage setError={handledError} />}
Expand Down
5 changes: 5 additions & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { isSimplePage } from './shared/canShowNavbar';

const StyledFooter = styled.footer`
flex-shrink: 0;
Expand All @@ -18,6 +19,10 @@ const Header = styled.p`
`;

function Footer() {
if (isSimplePage()) {
return null;
}

return (
<StyledFooter>
<div className="columns">
Expand Down
1 change: 0 additions & 1 deletion src/components/NavigationBar/helpers/isLoginPage.ts
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
export const isLoginPage = (path: string) => path.includes('/login');
6 changes: 4 additions & 2 deletions src/components/shared/canShowNavbar.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isLoginPage } from '../NavigationBar/helpers/isLoginPage';
const isLoginPage = (path: string) => path.includes('/login');
export const isSimplePage = () => window.location.pathname.endsWith('simple');

export const canShowNavbar = (path: string) => !isLoginPage(path);
export const canShowNavbar = (path: string) =>
!isLoginPage(path) && !isSimplePage();
42 changes: 42 additions & 0 deletions src/pages/SimplePage/SimplePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useContext, useEffect, useState } from 'react';

import { ErrorPresenter } from '../../components/errors/ErrorPresenter';
import { Main, PageContainer } from '../../components/styled';
import StoreContext from '../../store/StoreContext';
import UploadForm from './components/UploadForm/UploadForm';
import {
InfoMessage,
UploadContainer
} from './styled';

export function SimplePage() {
const store = useContext(StoreContext);
const [errorMessage, setErrorMessage] = useState<Error | null>(null);

// Make sure the defaults are set if not present to ensure backwards compatability
useEffect(() => {
store.syncLocalStorage();
}, [store]);

if (errorMessage) {
return <ErrorPresenter error={errorMessage} />;
}

return (
<PageContainer>
<UploadContainer>
<Main>
<div className="container">
<UploadForm
setErrorMessage={(error) => setErrorMessage(error as Error)}
/>
<InfoMessage>
All files uploaded here are automatically deleted after 21
minutes.
</InfoMessage>
</div>
</Main>
</UploadContainer>
</PageContainer>
);
}
59 changes: 59 additions & 0 deletions src/pages/SimplePage/components/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { getDownloadFileName } from '../../DownloadsPage/helpers/getDownloadFileName';

interface Props {
downloadLink: string | null | undefined;
deckName: string | undefined;
uploading: boolean;
}

function DownloadButton(props: Props) {
const { downloadLink, deckName, uploading } = props;
const isDownloadable = downloadLink && deckName;
const downloadRef = useRef<HTMLAnchorElement>(null);

const className = `button cta
${isDownloadable ? 'is-primary' : 'is-light'}
${uploading ? 'is-loading' : ''}`;

const isReady = downloadLink && !uploading;

useEffect(() => {
if (isReady) {
downloadRef.current?.click();
}
}, [isReady, downloadRef]);

return (
<div>
<button
type="button"
className={className}
onClick={(event) => {
if (!isDownloadable) {
event?.preventDefault();
}
downloadRef.current?.click();
}}
disabled={!isDownloadable}
>
Download
</button>
{downloadLink && (
<a
hidden
target="_blank"
aria-label="download link"
href={downloadLink}
download={getDownloadFileName(deckName ?? 'Untitled')}
ref={downloadRef}
rel="noreferrer"
>
{downloadLink}
</a>
)}
</div>
);
}

export default DownloadButton;
15 changes: 15 additions & 0 deletions src/pages/SimplePage/components/DropParagraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import styled from 'styled-components';

const DropParagraph = styled.div<{ hover: boolean }>`
border: 1.3px dashed;
border-radius: 3px;
border-color: ${(props) => (props.hover ? '#5997f5' : 'lightgray')};
padding: 4rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
align-items: center;
grid-gap: 1rem;
`;

export default DropParagraph;
25 changes: 25 additions & 0 deletions src/pages/SimplePage/components/UploadForm/UploadForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';

import UploadForm from './UploadForm';

describe('UploadForm', () => {
test('download button is light by default', () => {
const { container } = render(
<UploadForm setErrorMessage={(error) => fail(error)} />
);
expect(container.querySelector('.button.cta.is-light')).toBeInTheDocument();
});

test('no null classes', () => {
const { container } = render(
<UploadForm setErrorMessage={(error) => fail(error)} />
);
expect(container.querySelector('.null')).toBeNull();
});

test('download button is disabled', () => {
render(<UploadForm setErrorMessage={(error) => fail(error)} />);
expect(document.querySelector('.button.cta')).toBeDisabled();
});
});
130 changes: 130 additions & 0 deletions src/pages/SimplePage/components/UploadForm/UploadForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { SyntheticEvent, useRef, useState } from 'react';
import { ErrorHandlerType } from '../../../../components/errors/helpers/getErrorMessage';
import handleRedirect from '../../../../lib/handleRedirect';
import getAcceptedContentTypes from '../../helpers/getAcceptedContentTypes';
import getHeadersFilename from '../../helpers/getHeadersFilename';
import DownloadButton from '../DownloadButton';
import DropParagraph from '../DropParagraph';
import { useDrag } from './hooks/useDrag';

interface UploadFormProps {
setErrorMessage: ErrorHandlerType;
}

function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
const [uploading, setUploading] = useState(false);
const [downloadLink, setDownloadLink] = useState<null | string>('');
const [deckName, setDeckName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const convertRef = useRef<HTMLButtonElement>(null);
const { dropHover } = useDrag({
onDrop: (event) => {
const { dataTransfer } = event;

if (dataTransfer && dataTransfer.files.length > 0) {
fileInputRef.current!.files = dataTransfer.files;
convertRef.current?.click();
}

event.preventDefault();
},
});

const handleSubmit = async (event: SyntheticEvent) => {
event.preventDefault();
setUploading(true);
try {
const storedFields = Object.entries(window.localStorage);
const element = event.currentTarget as HTMLFormElement;
const formData = new FormData(element);
storedFields.forEach((sf) => formData.append(sf[0], sf[1]));
const request = await window.fetch('/api/upload/file', {
method: 'post',
body: formData,
});
const contentType = request.headers.get('Content-Type');
const notOK = request.status !== 200;
if (request.redirected) {
return handleRedirect(request);
}

if (notOK) {
const text = await request.text();
setDownloadLink(null);
return setErrorMessage(text);
}
const fileNameHeader = getHeadersFilename(request.headers);
if (fileNameHeader) {
setDeckName(fileNameHeader);
} else {
const fallback =
contentType === 'application/zip'
? 'Your Decks.zip'
: 'Your deck.apkg';
setDeckName(fallback);
}
const blob = await request.blob();
setDownloadLink(window.URL.createObjectURL(blob));
setUploading(false);
} catch (error) {
setDownloadLink(null);
setErrorMessage(error as Error);
setUploading(false);
return false;
}
return true;
};

const fileSelected = () => {
convertRef.current?.click();
};

return (
<form
encType="multipart/form-data"
method="post"
onSubmit={(event) => {
handleSubmit(event);
}}
>
<div className="container">
<div>
<div className="field">
<DropParagraph hover={dropHover}>
<h1>Drag a file and Drop it here</h1>
<p className="my-2">
<i>or</i>
</p>
<label htmlFor="pakker">
<input
ref={fileInputRef}
className="file-input"
type="file"
name="pakker"
accept={getAcceptedContentTypes()}
required
multiple
onChange={() => fileSelected()}
/>
</label>
<span className="tag">Select</span>
</DropParagraph>
</div>
<DownloadButton
downloadLink={downloadLink}
deckName={deckName}
uploading={uploading}
/>
<button
aria-label="Upload file"
style={{ visibility: 'hidden' }}
ref={convertRef}
type="submit"
/>
</div>
</div>
</form>
);
}

export default UploadForm;
30 changes: 30 additions & 0 deletions src/pages/SimplePage/components/UploadForm/hooks/useDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';

interface UseDragInput {
onDrop: (event: DragEvent) => void;
}

export const useDrag = ({ onDrop }: UseDragInput) => {
const [dropHover, setDropHover] = useState(false);

useEffect(() => {
const body = document.getElementsByTagName('body')[0];
body.ondragover = (event) => {
setDropHover(true);
event.preventDefault();
};

body.ondragenter = (event) => {
event.preventDefault();
setDropHover(true);
};

body.ondragleave = () => {
setDropHover(false);
};

body.ondrop = onDrop;
}, []);

return { dropHover };
};
9 changes: 9 additions & 0 deletions src/pages/SimplePage/helpers/getAcceptedContentTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Function to get accepted content types
* For now this is a hardcoded string in the client but should be retrieved from the backend.
*
* @returns comma seperated string with supported file types
*/
export default function getAcceptedContentTypes(): string {
return '.zip,.html,.csv';
}
7 changes: 7 additions & 0 deletions src/pages/SimplePage/helpers/getHeadersFilename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import getHeadersFilename from './getHeadersFilename';

const mockedHeaders = new Headers();
mockedHeaders.get = () => 'My%20uber%20cool%20deck.apkg';
test('getHeadersFilename', () => {
expect(getHeadersFilename(mockedHeaders)).toBe('My uber cool deck.apkg');
});
9 changes: 9 additions & 0 deletions src/pages/SimplePage/helpers/getHeadersFilename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const getHeadersFilename = (headers: Response['headers']) => {
const filename = headers.get('File-Name');
if (!filename) {
return null;
}
return decodeURIComponent(filename);
};

export default getHeadersFilename;
3 changes: 3 additions & 0 deletions src/pages/SimplePage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SimplePage } from './SimplePage';

export default SimplePage;
Loading

0 comments on commit b07ec1a

Please sign in to comment.