Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add template for digests #47

Merged
merged 4 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions prisma/json-schema/json-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,10 @@
"isFeatured": {
"type": "boolean",
"default": false
},
"isTemplate": {
"type": "boolean",
"default": false
}
}
},
Expand Down Expand Up @@ -596,6 +600,10 @@
"string",
"null"
]
},
"isTemplate": {
"type": "boolean",
"default": false
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "digests" ADD COLUMN "isTemplate" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "digest_blocks" ADD COLUMN "isTemplate" BOOLEAN NOT NULL DEFAULT false;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ model Digest {
typefullyThreadUrl String?
hasSentNewsletter Boolean @default(false)
isFeatured Boolean @default(false)
isTemplate Boolean @default(false)

@@unique([slug, teamId])
@@map("digests")
Expand All @@ -212,6 +213,7 @@ model DigestBlock {
description String?
type DigestBlockType @default(BOOKMARK)
text String?
isTemplate Boolean @default(false)

@@unique([bookmarkId, digestId])
@@map("digest_blocks")
Expand Down
15 changes: 10 additions & 5 deletions src/app/(routes)/teams/[teamSlug]/digests/[digestId]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TemplateEdit } from '@/components/digests/templates/TemplateEdit';
import { DigestEditPage } from '@/components/pages/DigestEditPage';
import { TeamProvider } from '@/contexts/TeamContext';
import {
Expand Down Expand Up @@ -40,11 +41,15 @@ const page = async ({ params, searchParams }: TeamPageProps) => {

return (
<TeamProvider team={team}>
<DigestEditPage
teamLinksData={teamLinksData}
digest={digest}
team={team}
/>
{digest?.isTemplate ? (
<TemplateEdit template={digest} team={team} />
) : (
<DigestEditPage
teamLinksData={teamLinksData}
digest={digest}
team={team}
/>
)}
</TeamProvider>
);
};
Expand Down
2 changes: 2 additions & 0 deletions src/app/(routes)/teams/[teamSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const TeamPage = async ({ params, searchParams }: TeamPageProps) => {
digestsPage,
8
);
const { digests: templates } = await getTeamDigests(team.id, 1, 30, true);

return (
<Team
Expand All @@ -60,6 +61,7 @@ const TeamPage = async ({ params, searchParams }: TeamPageProps) => {
digests={digests}
digestsCount={digestsCount}
search={search}
templates={templates}
/>
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/app/(routes)/teams/[teamSlug]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TeamSettings } from '@/components/teams/TeamSettings';
import {
checkUserTeamBySlug,
getTeamDigests,
getTeamInvitations,
getTeamMembers,
getTeamSubscriptions,
Expand All @@ -26,6 +27,7 @@ const TeamSettingsPage = async ({ params }: TeamPageProps) => {
const members = await getTeamMembers(teamSlug);
const invitations = await getTeamInvitations(teamSlug);
const subscriptions = await getTeamSubscriptions(teamSlug);
const { digests: templates } = await getTeamDigests(team.id, 1, 30, true);

if (!user?.id) return notFound();
return (
Expand All @@ -35,6 +37,7 @@ const TeamSettingsPage = async ({ params }: TeamPageProps) => {
invitations={invitations}
user={user}
subscriptions={subscriptions}
templates={templates}
/>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const buttonVariants = cva(
'bg-transparent text-violet-700 ring-1 ring-transparent hover:bg-violet-100 hover:ring-violet-100',
destructiveGhost:
'bg-transparent text-red-700 ring-1 ring-transparent hover:bg-red-100 hover:ring-red-100',
link: 'ring-0 text-gray-700 text-sm underline hover:text-gray-400 font-light !px-0 !font-normal',
},
size: {
sm: 'py-1 px-4 text-sm',
Expand Down
92 changes: 61 additions & 31 deletions src/components/digests/DigestCreateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,54 @@ import useTransitionRefresh from '@/hooks/useTransitionRefresh';
import api from '@/lib/api';
import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/digests';
import { AxiosError, AxiosResponse } from 'axios';
import { ChangeEvent, useState } from 'react';
import { FormEvent } from 'react';
import { useMutation } from 'react-query';
import { routes } from '@/core/constants';
import { useRouter } from 'next/navigation';
import clsx from 'clsx';
import Button from '../Button';
import { Input } from '../Input';
import { Input, Select } from '../Input';
import { TeamDigestsResult } from '@/lib/queries';
import { Team } from '@prisma/client';
import { formatTemplateTitle } from './templates/TemplateItem';
import { useForm } from 'react-hook-form';

type Props = {
teamId: string;
teamSlug: string;
team: Team;
predictedDigestTitle: string | null;
templates?: TeamDigestsResult[];
};

export const DigestCreateInput = ({
teamId,
teamSlug,
team,
predictedDigestTitle,
templates,
}: Props) => {
const router = useRouter();
const { successToast, errorToast } = useCustomToast();
const [newDigestTiltle, setNewDigestTitle] = useState(
predictedDigestTitle ?? ''
);
const { isRefreshing, refresh } = useTransitionRefresh();
const methods = useForm<{ title: string; templateId?: string }>({
mode: 'onBlur',
defaultValues: {
title: predictedDigestTitle ?? '',
templateId: undefined,
},
});
const { handleSubmit, register, watch } = methods;

const { mutate: createDigest, isLoading } = useMutation<
AxiosResponse<ApiDigestResponseSuccess>,
AxiosError<ErrorResponse>,
string
{ title: string; templateId?: string }
>(
'create-digest',
(title: string) => api.post(`/teams/${teamId}/digests`, { title }),
({ title, templateId }) =>
api.post(`/teams/${team.id}/digests`, { title, templateId }),
{
onSuccess: (response) => {
const newDigest = response.data;
successToast('Your digest has been created!');
const route = routes.DIGEST_EDIT.replace(':slug', teamSlug).replace(
const route = routes.DIGEST_EDIT.replace(':slug', team.slug).replace(
':id',
newDigest.id
);
Expand All @@ -59,33 +69,53 @@ export const DigestCreateInput = ({
}
);

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (newDigestTiltle) {
createDigest(newDigestTiltle);
}
const onSubmit = (e: FormEvent) => {
handleSubmit((values) => {
createDigest(values);
})(e);
};

const hasTemplates = !!templates?.length;
const title = watch('title');
const templateId = watch('templateId');

return (
<div>
<form
onSubmit={handleSubmit}
className={clsx('w-full flex', isRefreshing && 'opacity-80')}
onSubmit={onSubmit}
className={clsx(
'w-full flex',
isRefreshing && 'opacity-80',
hasTemplates && 'flex-col gap-4'
)}
>
<Input
className="px-4 rounded-r-none"
type="text"
placeholder="Digest name"
value={newDigestTiltle}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setNewDigestTitle(e.target.value)
}
required
/>
<div className={clsx('flex-col flex w-full', hasTemplates && 'gap-2')}>
<Input
className={clsx('px-4', !hasTemplates && 'rounded-r-none')}
type="text"
placeholder="Digest name"
required
{...register('title')}
/>
{hasTemplates && (
<Select
options={[
...templates?.map((template) => ({
value: template?.id,
label: formatTemplateTitle(template?.title, team.slug),
})),
]}
{...register('templateId')}
/>
)}
</div>
<Button
className="py-2 px-4 bg-violet-600 text-white border-violet-600 !rounded-l-none ring-0"
className={clsx(
'py-2 px-4 bg-violet-600 text-white border-violet-600 ring-0',
hasTemplates ? '' : '!rounded-l-none'
)}
type="submit"
disabled={!newDigestTiltle || isLoading}
disabled={!title || (hasTemplates && !templateId) || isLoading}
isLoading={isLoading || isRefreshing}
>
Create
Expand Down
96 changes: 96 additions & 0 deletions src/components/digests/templates/CreateTemplateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import { FormEvent, useState } from 'react';
import Button from '../../Button';
import { Dialog, DialogContent, DialogTrigger } from '../../Dialog';
import { useRouter } from 'next/navigation';
import useCustomToast from '@/hooks/useCustomToast';
import { useMutation } from 'react-query';
import api from '@/lib/api';
import { Input } from '../../Input';
import { DigestBlock, Team } from '@prisma/client';

const CreateTemplateModal = ({
team,
digestBlocks,
}: {
team: Team;
digestBlocks: DigestBlock[];
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);

const router = useRouter();
const { errorToast, successToast } = useCustomToast();

const { mutate: saveTemplate, isLoading } = useMutation(
'save-digest-template',
(title: string) =>
api.post(`/teams/${team.id}/digests`, {
title: `${team?.slug}-template-${title}`,
digestBlocks,
isTemplate: 'true',
}),
{
onSuccess: () => {
successToast('Your template has been saved');
setIsDialogOpen(false);
router.refresh();
},
onError: (error: any) => {
if (error.response.data.error) {
errorToast(error.response.data.error);
} else {
errorToast('Something went wrong...');
}
},
}
);

const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const template = e.currentTarget.template.value;
if (!template) return;
saveTemplate(template);
};

if (!digestBlocks?.length) return null;
return (
<div className="py-4">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="link" size="sm">
save as template
</Button>
</DialogTrigger>
<DialogContent
containerClassName="w-full sm:max-w-md"
title="New Digest Template"
description="Save your digest text blocks as a template to reuse it as a layout for your next publications"
closeIcon
>
<form
className="w-full bg-gray-50 flex flex-col gap-4"
onSubmit={onSubmit}
>
<label htmlFor="template" className="hidden" aria-hidden="true">
template
</label>
<Input
type="text"
name="template"
id="template"
placeholder="Digest template name"
required
autoFocus
/>
<Button isLoading={isLoading} type="submit">
Save
</Button>
</form>
</DialogContent>
</Dialog>
</div>
);
};

export default CreateTemplateModal;
44 changes: 44 additions & 0 deletions src/components/digests/templates/SelectTemplateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Button from '@/components/Button';
import { Dialog, DialogTrigger, DialogContent } from '@/components/Dialog';
import { Digest, Team } from '@prisma/client';
import { useState } from 'react';
import { DigestCreateInput } from '../DigestCreateInput';
import { TeamDigestsResult } from '@/lib/queries';

const SelectTemplateModal = ({
team,
templates,
predictedDigestTitle,
}: {
templates: TeamDigestsResult[];
team: Team;
predictedDigestTitle: string | null;
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);

return (
<div className="flex justify-end">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="link">Use template</Button>
</DialogTrigger>
<DialogContent
containerClassName="w-full sm:max-w-md"
title="Create templated digest"
description="Create a new digest using one of your templates"
closeIcon
>
<div className="flex flex-col gap-4 w-full">
<DigestCreateInput
team={team}
predictedDigestTitle={predictedDigestTitle}
templates={templates}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
};

export default SelectTemplateModal;
Loading
Loading