Skip to content

Commit 6c4ef4b

Browse files
feat: Add sitemap editor
This commit adds a sitemap editor to the dashboard at `/dashboard/settings/sitemap`. It includes: - A new `Sitemap` collection to store sitemap data. - GraphQL queries and mutations to get and update the sitemap. - A new `/sitemap` route that generates the sitemap in XML format. - A sitemap editor UI with a dropdown to add pages and a toggle to automatically publish the latest blogs.
1 parent e1a3c61 commit 6c4ef4b

14 files changed

Lines changed: 4289 additions & 414 deletions

File tree

apps/web/.env

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# For persisting data
2-
# DB_CONNECTION_STRING=mongodb://your_connection_string
2+
DB_CONNECTION_STRING=mongodb://localhost:27017/courselit
33

44
# For sending emails
55
# EMAIL_USER=email_user
@@ -12,16 +12,16 @@
1212
# MEDIALIT_APIKEY=medialit_apikey
1313
# MEDIALIT_SERVER=medialit_server
1414

15-
# For carrying out tasks asynchronously.
15+
# For carrying out tasks asynchronously.
1616
#
1717
# Uncomment the next line if you have started the queue server.
18-
# # QUEUE_SERVER=http://localhost:4000
18+
QUEUE_SERVER=http://localhost:4000
1919

2020
# App secrets
21-
# AUTH_SECRET=long_random_string
21+
AUTH_SECRET=a-long-random-string-for-testing
2222

2323
# For setting up the admin user when the app first boots up
2424
# SUPER_ADMIN_EMAIL=your@email.com
2525

2626
# Sequence settings
27-
# SEQUENCE_DELAY_BETWEEN_MAILS = 86400000 # 1 day in milliseconds
27+
# SEQUENCE_DELAY_BETWEEN_MAILS = 86400000 # 1 day in milliseconds
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
'use client';
2+
3+
import { gql, useQuery, useMutation } from '@apollo/client';
4+
import { Button, Card, CardBody, CardHeader, Checkbox, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Snippet } from '@nextui-org/react';
5+
import { getAbsoluteUrl } from '@/__shared__/utils/url';
6+
import { useEffect, useState } from 'react';
7+
8+
interface SitemapItem {
9+
loc: string;
10+
lastmod?: string;
11+
}
12+
13+
interface Page {
14+
pageId: string;
15+
title: string;
16+
}
17+
18+
const GET_SITEMAP = gql`
19+
query Sitemap($domain: String!) {
20+
sitemap(domain: $domain) {
21+
_id
22+
items {
23+
loc
24+
lastmod
25+
}
26+
publishLatestBlogs
27+
}
28+
}
29+
`;
30+
31+
const UPDATE_SITEMAP = gql`
32+
mutation UpdateSitemap($domain: String!, $items: [SitemapItemInput!]!, $publishLatestBlogs: Boolean) {
33+
updateSitemap(domain: $domain, items: $items, publishLatestBlogs: $publishLatestBlogs) {
34+
_id
35+
}
36+
}
37+
`;
38+
39+
const GET_PAGES = gql`
40+
query pages {
41+
getPages(type: site) {
42+
pageId
43+
title
44+
}
45+
}
46+
`;
47+
48+
export default function SitemapEditor() {
49+
const [domain, setDomain] = useState('');
50+
const [items, setItems] = useState<SitemapItem[]>([]);
51+
const [publishLatestBlogs, setPublishLatestBlogs] = useState(false);
52+
53+
useEffect(() => {
54+
setDomain(window.location.hostname);
55+
}, []);
56+
57+
const { data, loading, refetch } = useQuery(GET_SITEMAP, {
58+
variables: { domain },
59+
skip: !domain,
60+
});
61+
62+
useEffect(() => {
63+
if (data?.sitemap) {
64+
setItems(data.sitemap.items.map((item: SitemapItem) => ({ ...item })));
65+
setPublishLatestBlogs(data.sitemap.publishLatestBlogs);
66+
}
67+
}, [data]);
68+
69+
const { data: pagesData } = useQuery(GET_PAGES);
70+
71+
const [updateSitemap] = useMutation(UPDATE_SITEMAP);
72+
73+
const handleAddItem = (page: Page | undefined) => {
74+
if (page) {
75+
setItems([...items, { loc: `/${page.pageId}`, lastmod: new Date().toISOString() }]);
76+
}
77+
};
78+
79+
const handleRemoveItem = (index: number) => {
80+
const newItems = [...items];
81+
newItems.splice(index, 1);
82+
setItems(newItems);
83+
};
84+
85+
const handleSaveChanges = async () => {
86+
await updateSitemap({
87+
variables: {
88+
domain,
89+
items: items.map(item => ({ loc: item.loc, lastmod: item.lastmod })),
90+
publishLatestBlogs,
91+
},
92+
});
93+
refetch();
94+
};
95+
96+
if (loading) return <p>Loading...</p>;
97+
98+
return (
99+
<Card>
100+
<CardHeader>Sitemap Editor</CardHeader>
101+
<CardBody>
102+
<Snippet>
103+
{getAbsoluteUrl('/sitemap')}
104+
</Snippet>
105+
106+
<div className="mt-4">
107+
{items.map((item, index) => (
108+
<div key={index} className="flex items-center justify-between mb-2">
109+
<Input
110+
value={item.loc}
111+
onChange={(e) => {
112+
const newItems = [...items];
113+
newItems[index].loc = e.target.value;
114+
setItems(newItems);
115+
}}
116+
/>
117+
<Button color="danger" onClick={() => handleRemoveItem(index)}>Remove</Button>
118+
</div>
119+
))}
120+
</div>
121+
122+
<div className="mt-4">
123+
<Dropdown>
124+
<DropdownTrigger>
125+
<Button>Add Page</Button>
126+
</DropdownTrigger>
127+
<DropdownMenu onAction={(key) => handleAddItem(pagesData?.getPages.find((page: Page) => page.pageId === key))}>
128+
{pagesData?.getPages.map((page: Page) => (
129+
<DropdownItem key={page.pageId}>{page.title}</DropdownItem>
130+
))}
131+
</DropdownMenu>
132+
</Dropdown>
133+
</div>
134+
135+
<div className="mt-4">
136+
<Checkbox
137+
isSelected={publishLatestBlogs}
138+
onValueChange={setPublishLatestBlogs}
139+
>
140+
Publish latest blogs to sitemap automatically
141+
</Checkbox>
142+
</div>
143+
144+
<div className="mt-t-4">
145+
<Button color="primary" onClick={handleSaveChanges}>Save Changes</Button>
146+
</div>
147+
</CardBody>
148+
</Card>
149+
);
150+
}

apps/web/app/sitemap/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { getSitemap } from '../../graphql/sitemap/logic';
2+
import { headers } from 'next/headers';
3+
import Page from '../../models/Page';
4+
5+
export async function GET() {
6+
const headersList = headers();
7+
const domain = headersList.get('host') || '';
8+
const sitemap = await getSitemap(domain);
9+
const blogs = sitemap.publishLatestBlogs ? await Page.find({ domain, type: 'blogPage' }) : [];
10+
11+
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
12+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
13+
${sitemap.items
14+
.map(
15+
(item) => `
16+
<url>
17+
<loc>${new URL(item.loc, `https://${domain}`).href}</loc>
18+
${item.lastmod ? `<lastmod>${item.lastmod}</lastmod>` : ''}
19+
</url>
20+
`
21+
)
22+
.join('')}
23+
${blogs
24+
.map(
25+
(blog) => `
26+
<url>
27+
<loc>${new URL(`/blog/${blog.slug}`, `https://${domain}`).href}</loc>
28+
<lastmod>${new Date(blog.updatedAt).toISOString()}</lastmod>
29+
</url>
30+
`
31+
)
32+
.join('')}
33+
</urlset>`;
34+
35+
return new Response(sitemapXml, {
36+
headers: {
37+
'Content-Type': 'application/xml',
38+
},
39+
});
40+
}

apps/web/components/admin/settings/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { Copy, Info } from "lucide-react";
8484
import { Input } from "@components/ui/input";
8585
import Resources from "@components/resources";
8686
import { AddressContext } from "@components/contexts";
87+
import SitemapEditor from "../../../app/(with-contexts)/dashboard/(sidebar)/settings/sitemap/page";
8788

8889
const {
8990
PAYMENT_METHOD_PAYPAL,
@@ -125,6 +126,7 @@ const Settings = (props: SettingsProps) => {
125126
SITE_MAILS_HEADER,
126127
SITE_CUSTOMISATIONS_SETTING_HEADER,
127128
SITE_APIKEYS_SETTING_HEADER,
129+
"Sitemap",
128130
].includes(props.selectedTab)
129131
? props.selectedTab
130132
: SITE_SETTINGS_SECTION_GENERAL;
@@ -696,6 +698,7 @@ const Settings = (props: SettingsProps) => {
696698
SITE_MAILS_HEADER,
697699
SITE_CUSTOMISATIONS_SETTING_HEADER,
698700
SITE_APIKEYS_SETTING_HEADER,
701+
"Sitemap",
699702
];
700703

701704
const copyToClipboard = (text: string) => {
@@ -1242,6 +1245,9 @@ const Settings = (props: SettingsProps) => {
12421245
]}
12431246
/>
12441247
</div>
1248+
<div>
1249+
<SitemapEditor />
1250+
</div>
12451251
</Tabbs>
12461252
</div>
12471253
);

apps/web/graphql/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import communities from "./communities";
1313
import paymentplans from "./paymentplans";
1414
import notifications from "./notifications";
1515
import themes from "./themes";
16+
import sitemap from "./sitemap";
1617

1718
const schema = new graphql.GraphQLSchema({
1819
query: new graphql.GraphQLObjectType({
1920
name: "RootQuery",
2021
fields: {
22+
...sitemap.queries,
2123
...users.queries,
2224
...lessons.queries,
2325
...courses.queries,
@@ -36,6 +38,7 @@ const schema = new graphql.GraphQLSchema({
3638
mutation: new graphql.GraphQLObjectType({
3739
name: "RootMutation",
3840
fields: {
41+
...sitemap.mutations,
3942
...users.mutations,
4043
...lessons.mutations,
4144
...courses.mutations,

apps/web/graphql/sitemap/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import queries from "./query";
2+
import mutations from "./mutation";
3+
4+
export default {
5+
queries,
6+
mutations,
7+
};

apps/web/graphql/sitemap/logic.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Sitemap from '../../../models/Sitemap';
2+
3+
export const getSitemap = async (domain: string) => {
4+
let sitemap = await Sitemap.findOne({ domain });
5+
if (!sitemap) {
6+
sitemap = await Sitemap.create({ domain });
7+
}
8+
return sitemap;
9+
};
10+
11+
export const updateSitemap = async (domain: string, items: any[], publishLatestBlogs: boolean) => {
12+
const sitemap = await Sitemap.findOneAndUpdate(
13+
{ domain },
14+
{ items, publishLatestBlogs },
15+
{ new: true, upsert: true }
16+
);
17+
return sitemap;
18+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { GraphQLString, GraphQLList, GraphQLBoolean, GraphQLNonNull } from 'graphql';
2+
import { updateSitemap } from './logic';
3+
import types from './types';
4+
5+
const mutations = {
6+
updateSitemap: {
7+
type: types.Sitemap,
8+
args: {
9+
domain: { type: new GraphQLNonNull(GraphQLString) },
10+
items: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(types.SitemapItemInput))) },
11+
publishLatestBlogs: { type: GraphQLBoolean },
12+
},
13+
resolve: (
14+
_: any,
15+
{
16+
domain,
17+
items,
18+
publishLatestBlogs,
19+
}: { domain: string; items: any[]; publishLatestBlogs: boolean }
20+
) => updateSitemap(domain, items, publishLatestBlogs),
21+
},
22+
};
23+
24+
export default mutations;

apps/web/graphql/sitemap/query.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { GraphQLString, GraphQLNonNull } from 'graphql';
2+
import { getSitemap } from './logic';
3+
import types from './types';
4+
5+
const queries = {
6+
sitemap: {
7+
type: types.Sitemap,
8+
args: {
9+
domain: {
10+
type: new GraphQLNonNull(GraphQLString),
11+
},
12+
},
13+
resolve: (_: any, { domain }: { domain: string }) => getSitemap(domain),
14+
},
15+
};
16+
17+
export default queries;

apps/web/graphql/sitemap/types.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
GraphQLObjectType,
3+
GraphQLString,
4+
GraphQLList,
5+
GraphQLBoolean,
6+
GraphQLInputObjectType,
7+
GraphQLNonNull,
8+
} from 'graphql';
9+
10+
const SitemapItem = new GraphQLObjectType({
11+
name: 'SitemapItem',
12+
fields: {
13+
loc: { type: new GraphQLNonNull(GraphQLString) },
14+
lastmod: { type: GraphQLString },
15+
},
16+
});
17+
18+
const Sitemap = new GraphQLObjectType({
19+
name: 'Sitemap',
20+
fields: {
21+
_id: { type: new GraphQLNonNull(GraphQLString) },
22+
domain: { type: new GraphQLNonNull(GraphQLString) },
23+
items: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(SitemapItem))) },
24+
publishLatestBlogs: { type: GraphQLBoolean },
25+
},
26+
});
27+
28+
const SitemapItemInput = new GraphQLInputObjectType({
29+
name: 'SitemapItemInput',
30+
fields: {
31+
loc: { type: new GraphQLNonNull(GraphQLString) },
32+
lastmod: { type: GraphQLString },
33+
},
34+
});
35+
36+
export default {
37+
Sitemap,
38+
SitemapItemInput,
39+
};

0 commit comments

Comments
 (0)