Merge pull request #4 from GRA0007/dev
Recently visited and browser extension
This commit is contained in:
commit
4aeb580548
25
crabfit-browser-extension/manifest.json
Normal file
25
crabfit-browser-extension/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "Crab Fit",
|
||||
"description": "Enter your availability to find a time that works for everyone!",
|
||||
"version": "1.0",
|
||||
"manifest_version": 2,
|
||||
|
||||
"author": "Ben Grant",
|
||||
"homepage_url": "https://crab.fit",
|
||||
|
||||
"browser_action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "res/icon16.png",
|
||||
"32": "res/icon32.png",
|
||||
"48": "res/icon48.png",
|
||||
"128": "res/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "res/icon16.png",
|
||||
"32": "res/icon32.png",
|
||||
"48": "res/icon48.png",
|
||||
"128": "res/icon128.png"
|
||||
}
|
||||
}
|
||||
22
crabfit-browser-extension/popup.html
Normal file
22
crabfit-browser-extension/popup.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
width: 360px;
|
||||
height: 500px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
iframe {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="https://crab.fit/create" allow="clipboard-write"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
BIN
crabfit-browser-extension/res/icon128.png
Normal file
BIN
crabfit-browser-extension/res/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
crabfit-browser-extension/res/icon16.png
Normal file
BIN
crabfit-browser-extension/res/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
BIN
crabfit-browser-extension/res/icon32.png
Normal file
BIN
crabfit-browser-extension/res/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
crabfit-browser-extension/res/icon48.png
Normal file
BIN
crabfit-browser-extension/res/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -15,6 +15,7 @@ const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft'
|
|||
|
||||
const Home = lazy(() => import('pages/Home/Home'));
|
||||
const Event = lazy(() => import('pages/Event/Event'));
|
||||
const Create = lazy(() => import('pages/Create/Create'));
|
||||
|
||||
const App = () => {
|
||||
const colortheme = useSettingsStore(state => state.theme);
|
||||
|
|
@ -113,6 +114,11 @@ const App = () => {
|
|||
<Suspense fallback={<Loading />}>
|
||||
<Home offline={offline} {...props} />
|
||||
</Suspense>
|
||||
)} />
|
||||
<Route path="/create" exact render={props => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Create offline={offline} {...props} />
|
||||
</Suspense>
|
||||
)} />
|
||||
<Route path="/:id" exact render={props => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export const HiddenInput = styled.input`
|
|||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
right: -1000px;
|
||||
left: -1000px;
|
||||
opacity: 0;
|
||||
|
||||
&:checked + label {
|
||||
color: ${props => props.theme.background};
|
||||
|
|
|
|||
248
crabfit-frontend/src/pages/Create/Create.tsx
Normal file
248
crabfit-frontend/src/pages/Create/Create.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
|
||||
import {
|
||||
TextField,
|
||||
CalendarField,
|
||||
TimeRangeField,
|
||||
SelectField,
|
||||
Button,
|
||||
Donate,
|
||||
Error,
|
||||
} from 'components';
|
||||
|
||||
import {
|
||||
StyledMain,
|
||||
CreateForm,
|
||||
TitleSmall,
|
||||
TitleLarge,
|
||||
P,
|
||||
OfflineMessage,
|
||||
ShareInfo,
|
||||
Footer,
|
||||
AboutSection,
|
||||
Recent,
|
||||
} from './createStyle';
|
||||
|
||||
import api from 'services';
|
||||
import { useRecentsStore } from 'stores';
|
||||
|
||||
import timezones from 'res/timezones.json';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
const Create = ({ offline }) => {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [createdEvent, setCreatedEvent] = useState(null);
|
||||
const [copied, setCopied] = useState(null);
|
||||
|
||||
const { push } = useHistory();
|
||||
|
||||
const recentsStore = useRecentsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
push('/');
|
||||
}
|
||||
document.title = 'Create a Crab Fit';
|
||||
}, [push]);
|
||||
|
||||
const onSubmit = async data => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { start, end } = JSON.parse(data.times);
|
||||
const dates = JSON.parse(data.dates);
|
||||
|
||||
if (dates.length === 0) {
|
||||
return setError(`You haven't selected any dates!`);
|
||||
}
|
||||
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8;
|
||||
if (start === end) {
|
||||
return setError(`The start and end times can't be the same`);
|
||||
}
|
||||
|
||||
let times = dates.reduce((times, date) => {
|
||||
let day = [];
|
||||
for (let i = start; i < (start > end ? 24 : end); i++) {
|
||||
if (isSpecificDates) {
|
||||
day.push(
|
||||
dayjs.tz(date, 'DDMMYYYY', data.timezone)
|
||||
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||
);
|
||||
} else {
|
||||
day.push(
|
||||
dayjs().tz(data.timezone)
|
||||
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||
);
|
||||
}
|
||||
}
|
||||
if (start > end) {
|
||||
for (let i = 0; i < end; i++) {
|
||||
if (isSpecificDates) {
|
||||
day.push(
|
||||
dayjs.tz(date, 'DDMMYYYY', data.timezone)
|
||||
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||
);
|
||||
} else {
|
||||
day.push(
|
||||
dayjs().tz(data.timezone)
|
||||
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...times, ...day];
|
||||
}, []);
|
||||
|
||||
if (times.length === 0) {
|
||||
return setError(`You don't have any time selected`);
|
||||
}
|
||||
|
||||
const response = await api.post('/event', {
|
||||
event: {
|
||||
name: data.name,
|
||||
times: times,
|
||||
},
|
||||
});
|
||||
setCreatedEvent(response.data);
|
||||
recentsStore.addRecent({
|
||||
id: response.data.id,
|
||||
created: response.data.created,
|
||||
name: response.data.name,
|
||||
});
|
||||
gtag('event', 'create_event', {
|
||||
'event_category': 'home',
|
||||
});
|
||||
} catch (e) {
|
||||
setError('An error ocurred while creating the event. Please try again later.');
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledMain>
|
||||
<TitleSmall>CREATE A</TitleSmall>
|
||||
<TitleLarge>CRAB FIT</TitleLarge>
|
||||
</StyledMain>
|
||||
|
||||
{createdEvent ? (
|
||||
<StyledMain>
|
||||
<OfflineMessage>
|
||||
<h2>Created {createdEvent.name}</h2>
|
||||
<ShareInfo
|
||||
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${createdEvent.id}`)
|
||||
.then(() => {
|
||||
setCopied('Copied!');
|
||||
setTimeout(() => setCopied(null), 1000);
|
||||
gtag('event', 'copy_link', {
|
||||
'event_category': 'event',
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error('Failed to copy', e))
|
||||
}
|
||||
title={!!navigator.clipboard ? 'Click to copy' : ''}
|
||||
>{copied ?? `https://crab.fit/${createdEvent.id}`}</ShareInfo>
|
||||
<ShareInfo>
|
||||
{/* eslint-disable-next-line */}
|
||||
Click the link above to copy it to your clipboard, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(`Scheduling ${createdEvent.name}`)}&body=${encodeURIComponent(`Visit this link to enter your availabilities: https://crab.fit/${createdEvent.id}`)}`} target="_blank">email</a>.
|
||||
</ShareInfo>
|
||||
<Footer>
|
||||
<span>Thank you for using Crab Fit. If you like it, consider donating.</span>
|
||||
<Donate />
|
||||
</Footer>
|
||||
</OfflineMessage>
|
||||
</StyledMain>
|
||||
) : (
|
||||
<>
|
||||
{!!recentsStore.recents.length && (
|
||||
<AboutSection id="recents">
|
||||
<StyledMain>
|
||||
<h2>Recently visited</h2>
|
||||
{recentsStore.recents.map(event => (
|
||||
<Recent href={`/${event.id}`} key={event.id}>
|
||||
<span className="name">{event.name}</span>
|
||||
<span className="date">Created {dayjs.unix(event.created).format('D MMMM, YYYY')}</span>
|
||||
</Recent>
|
||||
))}
|
||||
</StyledMain>
|
||||
</AboutSection>
|
||||
)}
|
||||
|
||||
<StyledMain>
|
||||
{offline ? (
|
||||
<OfflineMessage>
|
||||
<h1>🦀📵</h1>
|
||||
<P>You can't create a Crab Fit when you don't have an internet connection. Please make sure you're connected.</P>
|
||||
</OfflineMessage>
|
||||
) : (
|
||||
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
|
||||
<TextField
|
||||
label="Give your event a name!"
|
||||
subLabel="Or leave blank to generate one"
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
register={register}
|
||||
/>
|
||||
|
||||
<CalendarField
|
||||
label="What dates might work?"
|
||||
subLabel="Click and drag to select"
|
||||
name="dates"
|
||||
id="dates"
|
||||
required
|
||||
register={register}
|
||||
/>
|
||||
|
||||
<TimeRangeField
|
||||
label="What times might work?"
|
||||
subLabel="Click and drag to select a time range"
|
||||
name="times"
|
||||
id="times"
|
||||
required
|
||||
register={register}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label="And the timezone"
|
||||
name="timezone"
|
||||
id="timezone"
|
||||
register={register}
|
||||
options={timezones}
|
||||
required
|
||||
defaultOption="Select..."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Error onClose={() => setError(null)}>{error}</Error>
|
||||
)}
|
||||
|
||||
<Button type="submit" isLoading={isLoading} disabled={isLoading} buttonWidth="100%">Create</Button>
|
||||
</CreateForm>
|
||||
)}
|
||||
</StyledMain>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Create;
|
||||
106
crabfit-frontend/src/pages/Create/createStyle.ts
Normal file
106
crabfit-frontend/src/pages/Create/createStyle.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import styled from '@emotion/styled';
|
||||
|
||||
export const StyledMain = styled.div`
|
||||
width: 600px;
|
||||
margin: 10px auto;
|
||||
max-width: calc(100% - 30px);
|
||||
`;
|
||||
|
||||
export const CreateForm = styled.form`
|
||||
margin: 0 0 30px;
|
||||
`;
|
||||
|
||||
export const TitleSmall = styled.span`
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
font-family: 'Samurai Bob', sans-serif;
|
||||
font-weight: 400;
|
||||
color: ${props => props.theme.primaryDark};
|
||||
line-height: 1em;
|
||||
`;
|
||||
|
||||
export const TitleLarge = styled.h1`
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
color: ${props => props.theme.primary};
|
||||
font-family: 'Molot', sans-serif;
|
||||
font-weight: 400;
|
||||
text-shadow: 0 4px 0 ${props => props.theme.primaryDark};
|
||||
line-height: 1em;
|
||||
`;
|
||||
|
||||
export const P = styled.p`
|
||||
font-weight: 500;
|
||||
line-height: 1.6em;
|
||||
`;
|
||||
|
||||
export const Footer = styled.footer`
|
||||
margin: 60px auto 0;
|
||||
width: 250px;
|
||||
|
||||
& span {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const OfflineMessage = styled.div`
|
||||
text-align: center;
|
||||
margin: 50px 0 20px;
|
||||
`;
|
||||
|
||||
export const ShareInfo = styled.p`
|
||||
margin: 6px 0;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
padding: 10px 0;
|
||||
|
||||
${props => props.onClick && `
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${props.theme.primaryDark};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const AboutSection = styled.section`
|
||||
margin: 30px 0 0;
|
||||
background-color: ${props => props.theme.primaryBackground};
|
||||
padding: 10px 0;
|
||||
|
||||
& h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Recent = styled.a`
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& .name {
|
||||
font-weight: 700;
|
||||
color: ${props => props.theme.primaryDark};
|
||||
}
|
||||
& .date {
|
||||
font-weight: 400;
|
||||
font-size: .9em;
|
||||
opacity: .8;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover .name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
|
@ -34,7 +34,7 @@ import {
|
|||
} from './eventStyle';
|
||||
|
||||
import api from 'services';
|
||||
import { useSettingsStore } from 'stores';
|
||||
import { useSettingsStore, useRecentsStore } from 'stores';
|
||||
|
||||
import logo from 'res/logo.svg';
|
||||
import timezones from 'res/timezones.json';
|
||||
|
|
@ -47,6 +47,8 @@ const Event = (props) => {
|
|||
const timeFormat = useSettingsStore(state => state.timeFormat);
|
||||
const weekStart = useSettingsStore(state => state.weekStart);
|
||||
|
||||
const addRecent = useRecentsStore(state => state.addRecent);
|
||||
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { id } = props.match.params;
|
||||
const { offline } = props;
|
||||
|
|
@ -74,6 +76,11 @@ const Event = (props) => {
|
|||
const response = await api.get(`/event/${id}`);
|
||||
|
||||
setEvent(response.data);
|
||||
addRecent({
|
||||
id: response.data.id,
|
||||
created: response.data.created,
|
||||
name: response.data.name,
|
||||
});
|
||||
document.title = `${response.data.name} | Crab Fit`;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
@ -83,7 +90,7 @@ const Event = (props) => {
|
|||
};
|
||||
|
||||
fetchEvent();
|
||||
}, [id]);
|
||||
}, [id, addRecent]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPeople = async () => {
|
||||
|
|
|
|||
|
|
@ -33,9 +33,11 @@ import {
|
|||
StatNumber,
|
||||
StatLabel,
|
||||
OfflineMessage,
|
||||
Recent,
|
||||
} from './homeStyle';
|
||||
|
||||
import api from 'services';
|
||||
import { useRecentsStore } from 'stores';
|
||||
|
||||
import logo from 'res/logo.svg';
|
||||
import timezones from 'res/timezones.json';
|
||||
|
|
@ -58,6 +60,7 @@ const Home = ({ offline }) => {
|
|||
version: 'loading...',
|
||||
});
|
||||
const { push } = useHistory();
|
||||
const recentsStore = useRecentsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
|
|
@ -154,7 +157,23 @@ const Home = ({ offline }) => {
|
|||
<Links>
|
||||
<a href="#about">About</a> / <a href="#donate">Donate</a>
|
||||
</Links>
|
||||
</StyledMain>
|
||||
|
||||
{!!recentsStore.recents.length && (
|
||||
<AboutSection id="recents">
|
||||
<StyledMain>
|
||||
<h2>Recently visited</h2>
|
||||
{recentsStore.recents.map(event => (
|
||||
<Recent href={`/${event.id}`} key={event.id}>
|
||||
<span className="name">{event.name}</span>
|
||||
<span className="date">Created {dayjs.unix(event.created).format('D MMMM, YYYY')}</span>
|
||||
</Recent>
|
||||
))}
|
||||
</StyledMain>
|
||||
</AboutSection>
|
||||
)}
|
||||
|
||||
<StyledMain>
|
||||
{offline ? (
|
||||
<OfflineMessage>
|
||||
<h1>🦀📵</h1>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const StyledMain = styled.div`
|
|||
`;
|
||||
|
||||
export const CreateForm = styled.form`
|
||||
margin: 0 0 60px;
|
||||
`;
|
||||
|
||||
export const TitleSmall = styled.span`
|
||||
|
|
@ -45,7 +46,7 @@ export const Links = styled.nav`
|
|||
`;
|
||||
|
||||
export const AboutSection = styled.section`
|
||||
margin: 60px 0 0;
|
||||
margin: 30px 0 0;
|
||||
background-color: ${props => props.theme.primaryBackground};
|
||||
padding: 20px 0;
|
||||
`;
|
||||
|
|
@ -93,3 +94,30 @@ export const OfflineMessage = styled.div`
|
|||
text-align: center;
|
||||
margin: 50px 0 20px;
|
||||
`;
|
||||
|
||||
export const Recent = styled.a`
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& .name {
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
color: ${props => props.theme.primaryDark};
|
||||
}
|
||||
& .date {
|
||||
font-weight: 400;
|
||||
opacity: .8;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover .name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as Home } from './Home/Home';
|
||||
export { default as Event } from './Event/Event';
|
||||
export { default as Create } from './Create/Create';
|
||||
|
|
|
|||
|
|
@ -13,3 +13,22 @@ export const useSettingsStore = create(persist(
|
|||
}),
|
||||
{ name: 'crabfit-settings' },
|
||||
));
|
||||
|
||||
export const useRecentsStore = create(persist(
|
||||
set => ({
|
||||
recents: [],
|
||||
|
||||
addRecent: event => set(state => {
|
||||
const recents = state.recents.filter(e => e.id !== event.id);
|
||||
recents.unshift(event);
|
||||
recents.length = Math.min(recents.length, 5);
|
||||
return { recents };
|
||||
}),
|
||||
removeRecent: id => set(state => {
|
||||
const recents = state.recents.filter(e => e.id !== id);
|
||||
return { recents };
|
||||
}),
|
||||
clearRecents: () => set({ recents: [] }),
|
||||
}),
|
||||
{ name: 'crabfit-recent' },
|
||||
));
|
||||
|
|
|
|||
Loading…
Reference in a new issue