Skip to content

Commit

Permalink
Create activities from events
Browse files Browse the repository at this point in the history
  • Loading branch information
hulloitskai committed Aug 14, 2023
1 parent 43251b9 commit d503994
Show file tree
Hide file tree
Showing 75 changed files with 4,637 additions and 203 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ ADMIN_EMAILS=opencal.me # comma-delimited: opencal.me,email@example.com,...
# == Contact
CONTACT_EMAIL=

# == Mapbox
MAPBOX_ACCESS_TOKEN=

# == Announcements
# ANNOUNCEMENT=...

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ WORKDIR /app
ENV BUNDLE_WITHOUT="development test" RAILS_ENV=production RAILS_LOG_TO_STDOUT=true NODE_ENV=$RAILS_ENV

# Copy dependency lists
COPY Gemfile Gemfile.lock package.json yarn.lock requirements.txt ./
COPY Gemfile Gemfile.lock package.json yarn.lock ./

# Install dependencies
RUN bundle install && yarn install
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ gem "rack-cors", "~> 2.0"
# Load events from Google Calendar
gem "google_calendar", "~> 0.6.4"

# Parse HTML with Nokogiri
gem "nokogiri", "~> 1.15"

group :development, :test do
# Auto-detect and warn about N+1 queries
gem "bullet"
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ DEPENDENCIES
listen (~> 3.8)
mailjet (~> 1.7)
nanoid (~> 2.0)
nokogiri (~> 1.15)
omniauth (~> 2.1)
omniauth-google-oauth2 (~> 1.1)
omniauth-rails_csrf_protection (~> 1.0)
Expand Down
61 changes: 61 additions & 0 deletions app/components/ActivityCreateButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { FC } from "react";
import type { ButtonProps } from "@mantine/core";
import {
ActivityCreateButtonActivityFragment,
CreateActivityMutationDocument,
} from "~/helpers/graphql";

export type ActivityCreateButtonProps = Omit<
ButtonProps,
"loading" | "onClick"
> & {
readonly googleEventId: string;
readonly onCreate: (activity: ActivityCreateButtonActivityFragment) => void;
};

const ActivityCreateButton: FC<ActivityCreateButtonProps> = ({
googleEventId,
onCreate,
children,
...otherProps
}) => {
// == Mutation
const onError = useApolloAlertCallback("Failed to create activity");
const [runMutation, { loading }] = useMutation(
CreateActivityMutationDocument,
{
onCompleted: ({ payload: { activity, errors } }) => {
if (activity) {
showNotice({ message: "Activity created successfully." });
onCreate(activity);
} else {
invariant(errors, "Missing input field errors");
const formErrors = parseFormErrors(errors);
showFormErrorsAlert(formErrors, "Couldn't create activity");
}
},
onError,
},
);

return (
<Button
leftIcon={<AddIcon />}
onClick={() => {
runMutation({
variables: {
input: {
googleEventId,
},
},
});
}}
{...{ loading }}
{...otherProps}
>
{children ?? "Create Activity"}
</Button>
);
};

export default ActivityCreateButton;
8 changes: 4 additions & 4 deletions app/components/ActivityStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ const ActivityStatusBadge: FC<ActivityStatusBadgeProps> = ({
<Anchor
href="https://twitter.com/scottific"
target="_blank"
weight={700}
weight={600}
>
scott
</Anchor>{" "}
&{" "}
<Anchor href="https://twitter.com/hulloitskai" weight={700}>
<Anchor href="https://twitter.com/hulloitskai" weight={600}>
kai
</Anchor>{" "}
with
Expand All @@ -98,7 +98,7 @@ const ActivityStatusBadge: FC<ActivityStatusBadgeProps> = ({
>
<Stack spacing={6} align="center">
<Text size="sm" lh={1.4}>
Did you know this website is{" "}
Did you know OpenCal is{" "}
<Text span inherit weight={600}>
open source
</Text>
Expand All @@ -114,7 +114,7 @@ const ActivityStatusBadge: FC<ActivityStatusBadgeProps> = ({
h="unset"
py={4}
>
Take me to the code!
take me to the code!
</Button>
</Stack>
</HoverCard.Dropdown>
Expand Down
6 changes: 3 additions & 3 deletions app/components/Empty.tsx → app/components/EmptyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { CardProps, Text } from "@mantine/core";

import EmptyIcon from "~icons/heroicons/inbox-20-solid";

export type EmptyProps = Omit<CardProps, "children"> & {
export type EmptyCardProps = Omit<CardProps, "children"> & {
readonly itemLabel: string;
};

const Empty: FC<EmptyProps> = ({ itemLabel, ...otherProps }) => (
const EmptyCard: FC<EmptyCardProps> = ({ itemLabel, ...otherProps }) => (
<Card withBorder py="lg" {...otherProps}>
<Flex direction="column" align="center">
<Box sx={({ colors }) => ({ color: colors.gray[6], lineHeight: 1.1 })}>
Expand All @@ -20,4 +20,4 @@ const Empty: FC<EmptyProps> = ({ itemLabel, ...otherProps }) => (
</Card>
);

export default Empty;
export default EmptyCard;
154 changes: 154 additions & 0 deletions app/components/GoogleEventCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { FC, JSXElementConstructor } from "react";
import Linkify from "linkify-react";
import humanizeDuration from "humanize-duration";
import RightArrowIcon from "~icons/heroicons/arrow-right-20-solid";

import { Text, MantineProvider } from "@mantine/core";
import type { BoxProps, TextProps } from "@mantine/core";

import type {
ActivityCreateButtonActivityFragment,
GoogleEventCardEventFragment,
} from "~/helpers/graphql";

import ActivityCreateButton from "./ActivityCreateButton";

export type GoogleEventCardProps = Omit<BoxProps, "children"> & {
readonly event: GoogleEventCardEventFragment;
readonly onCreateActivity: (
activity: ActivityCreateButtonActivityFragment,
) => void;
};

const durationHumanizer = humanizeDuration.humanizer({
language: "shortEn",
languages: {
shortEn: {
y: () => "y",
mo: () => "mo",
w: () => "w",
d: () => "d",
h: () => "h",
m: () => "m",
s: () => "s",
ms: () => "ms",
},
},
spacer: "",
delimiter: " ",
});

const GoogleEventCard: FC<GoogleEventCardProps> = ({
event: {
id: eventId,
title,
description,
start,
durationSeconds,
activity,
viewerIsOrganizer,
},
onCreateActivity,
...otherProps
}) => {
return (
<Card radius="md" withBorder {...otherProps}>
<Group align="start">
<Text weight={500} sx={{ flexGrow: 1 }}>
{title}
</Text>
<MantineProvider
inherit
theme={{
components: {
Text: {
defaultProps: {
size: "sm",
color: "dimmed",
lh: 1.4,
},
},
},
}}
>
<Group spacing={6} noWrap>
<Box>
<Time format={{ month: "short", day: "numeric" }}>{start}</Time>
</Box>
<Box>
<Time
format={{
hour: "numeric",
minute: "numeric",
hour12: true,
}}
>
{start}
</Time>
</Box>
<Text
span
color="gray.4"
sx={({ fontFamilyMonospace }) => ({
fontFamily: fontFamilyMonospace,
})}
>
{" / "}
</Text>{" "}
<Text>{durationHumanizer(durationSeconds * 1000)}</Text>
</Group>
</MantineProvider>
</Group>
{!!description && (
<Linkify<TextProps, JSXElementConstructor<TextProps>>
as={Text}
options={{
render: ({ content, attributes }) => (
<Anchor
target="_blank"
rel="noopener noreferrer nofollow"
{...attributes}
>
{content}
</Anchor>
),
}}
size="sm"
color="dimmed"
lineClamp={4}
sx={{ whiteSpace: "pre-wrap" }}
>
{description}
</Linkify>
)}
<Space h={6} />
{activity ? (
<Button
component={Link}
href={activity.url}
leftIcon={<RightArrowIcon />}
radius="md"
>
Go To Activity
</Button>
) : (
<Tooltip
label="right now, you must be the event organizer to create an activity :("
withArrow
disabled={viewerIsOrganizer}
>
<Box display="inline-block">
<ActivityCreateButton
googleEventId={eventId}
onCreate={onCreateActivity}
disabled={!viewerIsOrganizer}
radius="md"
/>
</Box>
</Tooltip>
)}
</Card>
);
};

export default GoogleEventCard;
65 changes: 65 additions & 0 deletions app/components/GoogleEvents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { FC } from "react";
import type { BoxProps } from "@mantine/core";

import { GoogleEventsQueryDocument } from "~/helpers/graphql";

import GoogleEventCard, { GoogleEventCardProps } from "./GoogleEventCard";

export type GoogleEventsProps = Omit<BoxProps, "children"> &
Pick<GoogleEventCardProps, "onCreateActivity">;

const GoogleEvents: FC<GoogleEventsProps> = ({
onCreateActivity,
...otherProps
}) => {
// == Search
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 200);

// == Query
const onError = useApolloAlertCallback("Failed to load events");
const { data } = useQuery(GoogleEventsQueryDocument, {
variables: {
query: debouncedSearch,
},
onError,
});
const { googleEvents: events } = data?.viewer ?? {};

// == Markup
return (
<Stack spacing={8} {...otherProps}>
<Title order={2} size="h3">
Your Events
</Title>
<Stack spacing="xs">
<TextInput
variant="filled"
size="md"
placeholder="Search events..."
value={search}
radius="md"
onChange={({ target }) => setSearch(target.value)}
/>
{events ? (
!isEmpty(events) ? (
events.map(event => (
<GoogleEventCard
key={event.id}
{...{ event, onCreateActivity }}
/>
))
) : (
<EmptyCard itemLabel="events" radius="md" />
)
) : (
[...new Array(3)].map((value, index) => (
<Skeleton key={index} radius="md" h={64} />
))
)}
</Stack>
</Stack>
);
};

export default GoogleEvents;
Loading

0 comments on commit d503994

Please sign in to comment.