Update CMS client configuration and refactor content organization

The code changes involve a significant update to the configuration of our CMS client. The nature of retrieving content items has been refactored to be more granular, allowing for the identification and fetching of content from specified collections rather than general categories. These modifications improve the efficiency and specificity of content queries. Furthermore, other changes were made to provide a better alignment of our content structure, including the reorganization of content files and renaming of image paths in various components for consistency.
This commit is contained in:
giancarlo
2024-04-10 15:52:26 +08:00
parent 006c4d430f
commit 44373c0372
39 changed files with 176 additions and 84 deletions

View File

@@ -1,3 +1,5 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
@@ -8,13 +10,18 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post';
const getPostBySlug = cache(async (slug: string) => {
const client = await createCmsClient();
return client.getContentItemBySlug({ slug, collection: 'posts' });
});
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata | undefined> {
const cms = await createCmsClient();
const post = await cms.getContentItemById(params.slug);
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
@@ -29,7 +36,7 @@ export async function generateMetadata({
title,
description,
type: 'article',
publishedTime: publishedAt.toDateString(),
publishedTime: publishedAt?.toDateString(),
url: post.url,
images: image
? [
@@ -49,8 +56,7 @@ export async function generateMetadata({
}
async function BlogPost({ params }: { params: { slug: string } }) {
const cms = await createCmsClient();
const post = await cms.getContentItemById(params.slug);
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();

View File

@@ -12,7 +12,7 @@ export const Post: React.FC<{
<div>
<PostHeader post={post} />
<div className={'mx-auto flex max-w-2xl flex-col space-y-6'}>
<div className={'mx-auto flex max-w-2xl flex-col space-y-6 py-8'}>
<article className={styles.HTML}>
<ContentRenderer content={content} />
</article>

View File

@@ -15,12 +15,18 @@ export const generateMetadata = async () => {
};
};
async function BlogPage() {
async function BlogPage({ searchParams }: { searchParams: { page: string } }) {
const { t } = await createI18nServerInstance();
const cms = await createCmsClient();
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const limit = 10;
const offset = page * limit;
const posts = await cms.getContentItems({
categories: ['blog'],
collection: 'posts',
limit,
offset,
});
return (

View File

@@ -14,7 +14,7 @@ import { DocsCards } from '../_components/docs-cards';
const getPageBySlug = cache(async (slug: string) => {
const client = await createCmsClient();
return client.getContentItemById(slug);
return client.getContentItemBySlug({ slug, collection: 'documentation' });
});
interface PageParams {

View File

@@ -40,11 +40,14 @@ const Node: React.FC<{
level: number;
activePath: string;
}> = ({ node, level, activePath }) => {
const pathPrefix = `/docs`;
const url = `${pathPrefix}/${node.url}`;
return (
<>
<DocsNavLink
label={node.title}
url={node.url}
url={url}
level={level}
activePath={activePath}
/>

View File

@@ -6,9 +6,11 @@ async function DocsLayout({ children }: React.PropsWithChildren) {
const cms = await createCmsClient();
const pages = await cms.getContentItems({
categories: ['documentation'],
collection: 'documentation',
});
console.log(pages);
return (
<div className={'flex'}>
<DocsNavigation pages={buildDocumentationTree(pages)} />

View File

@@ -19,7 +19,7 @@ async function DocsPage() {
const { t } = await createI18nServerInstance();
const docs = await client.getContentItems({
categories: ['documentation'],
collection: 'documentation',
});
// Filter out any docs that have a parentId, as these are children of other docs

View File

@@ -76,7 +76,7 @@ function Home() {
}
width={3069}
height={1916}
src={`/assets/images/dashboard-demo.webp`}
src={`/images/dashboard-demo.webp`}
alt={`App Image`}
/>
</div>
@@ -135,7 +135,7 @@ function Home() {
<RightFeatureContainer>
<Image
className="rounded-2xl"
src={'/assets/images/sign-in.webp'}
src={'/images/sign-in.webp'}
width={'626'}
height={'683'}
alt={'Sign In'}
@@ -147,7 +147,7 @@ function Home() {
<LeftFeatureContainer>
<Image
className="rounded-2xl"
src={'/assets/images/dashboard.webp'}
src={'/images/dashboard.webp'}
width={'887'}
height={'743'}
alt={'Dashboard'}

View File

@@ -66,9 +66,9 @@ export const metadata = {
description: appConfig.description,
},
icons: {
icon: '/assets/images/favicon/favicon.ico',
icon: '/images/favicon/favicon.ico',
shortcut: '/shortcut-icon.png',
apple: '/assets/images/favicon/apple-touch-icon.png',
apple: '/images/favicon/apple-touch-icon.png',
other: {
rel: 'apple-touch-icon-precomposed',
url: '/apple-touch-icon-precomposed.png',

View File

@@ -1,8 +0,0 @@
---
title: hello
description: hello
categories: ['blog']
tags: []
---
ddfdsfjsdnfjdks

View File

@@ -0,0 +1,44 @@
---
title: "A Comprehensive Starter Kit for Indie Hackers"
description: "A comprehensive starter kit tailored specifically for SaaS and indie hackers, providing insights and resources to navigate the complexities of SaaS entrepreneurship."
categories: ['blog']
tags: []
image: "/images/posts/saas-starter-blog-post.webp"
publishedAt: 2024-04-10
---
In the dynamic world of entrepreneurship, the Software as a Service (SaaS) model has emerged as a beacon of opportunity for indie hackers individuals or small teams with big dreams of creating impactful software solutions. With the right tools and strategies, launching a successful SaaS startup is within reach for anyone with passion, dedication, and a clear vision. To empower aspiring entrepreneurs on this journey, we've curated a comprehensive starter kit tailored specifically for SaaS and indie hackers.
## 1. Idea Generation and Validation
Every successful SaaS startup begins with a compelling idea. To kickstart your journey, leverage idea generation techniques such as problem identification, market research, and brainstorming sessions. Validate your idea by seeking feedback from potential users and analyzing market demand through surveys, interviews, and competitor analysis.
## 2. MVP Development
The Minimal Viable Product (MVP) approach is a cornerstone of SaaS startup development. Focus on building a basic version of your product with core features that address the identified problem. Utilize prototyping tools, development frameworks, and outsourcing platforms to expedite MVP development while maintaining quality and cost-effectiveness.
## 3. Platform Selection
Choosing the right platform for your SaaS startup is crucial for scalability, flexibility, and long-term success. Evaluate factors such as hosting options, scalability features, security measures, and integration capabilities when selecting a platform. Popular choices include cloud-based solutions like Amazon Web Services (AWS), Microsoft Azure, and Google Cloud Platform (GCP).
## 4. Product Design and User Experience
A seamless user experience is paramount for SaaS success. Invest in intuitive product design, responsive UI/UX, and user-centric features to enhance customer satisfaction and retention. Leverage prototyping tools, usability testing, and user feedback loops to iterate and improve your product design continuously.
## 5. Marketing and Growth Strategies
Effective marketing and growth strategies are essential for acquiring, retaining, and monetizing customers. Develop a robust marketing plan encompassing content marketing, social media engagement, search engine optimization (SEO), email campaigns, and influencer partnerships. Leverage analytics tools and A/B testing to optimize your marketing efforts and maximize ROI.
## 6. Customer Support and Feedback Loop
Providing exceptional customer support is key to building a loyal customer base and fostering long-term relationships. Implement multi-channel support options, such as live chat, email support, and knowledge bases, to address customer inquiries promptly and effectively. Establish a feedback loop to gather insights from users and prioritize product enhancements accordingly.
## 7. Monetization Strategies
Explore diverse monetization strategies to generate revenue from your SaaS product. Options include subscription-based models, tiered pricing plans, usage-based billing, and freemium offerings. Conduct pricing experiments, competitor analysis, and customer surveys to determine the optimal pricing strategy for your target market.
## 8. Legal and Compliance Considerations
Ensure legal compliance and protect your SaaS startup from potential risks and liabilities. Consult legal experts to draft robust terms of service, privacy policies, and data protection agreements. Familiarize yourself with relevant regulations such as GDPR, CCPA, and PCI DSS to safeguard user data and maintain regulatory compliance.
Launching a SaaS startup as an indie hacker is a challenging yet rewarding journey. By leveraging the insights and resources provided in this comprehensive starter kit, you can navigate the complexities of SaaS entrepreneurship with confidence and drive sustainable growth for your venture. Embrace experimentation, iteration, and continuous learning as you embark on this exciting entrepreneurial adventure. Remember, the path to success is paved with resilience, determination, and a relentless pursuit of excellence.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Before

Width:  |  Height:  |  Size: 677 B

After

Width:  |  Height:  |  Size: 677 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -9,22 +9,22 @@
"start_url": "/",
"icons": [
{
"src": "/assets/images/favicon/favicon-16x16.png",
"src": "/images/favicon/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/assets/images/favicon/favicon-32x32.png",
"src": "/images/favicon/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/assets/images/favicon/android-chrome-192x192.png",
"src": "/images/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/images/favicon/android-chrome-512x512.png",
"src": "/images/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}

View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -6,7 +6,6 @@ export namespace Cms {
url: string;
description: string | undefined;
content: unknown;
author: string;
publishedAt: Date;
image: string | undefined;
slug: string;
@@ -30,6 +29,7 @@ export namespace Cms {
}
export interface GetContentItemsOptions {
collection: string;
limit?: number;
offset?: number;
categories?: string[];
@@ -65,10 +65,12 @@ export abstract class CmsClient {
/**
* Retrieves a content item by its ID and type.
* @param id - The ID of the content item.
* @returns A promise that resolves to the content item, or undefined if not found.
*/
abstract getContentItemById(id: string): Promise<Cms.ContentItem | undefined>;
abstract getContentItemBySlug(params: {
slug: string;
collection: string;
}): Promise<Cms.ContentItem | undefined>;
/**
* Retrieves categories based on the provided options.

View File

@@ -9,8 +9,15 @@ const reader = createReader('.', config);
type EntryProps = Entry<(typeof config)['collections']['posts']>;
export class KeystaticClient implements CmsClient {
async getContentItems(options?: Cms.GetContentItemsOptions) {
const docs = await reader.collections.posts.all();
async getContentItems(options: Cms.GetContentItemsOptions) {
const collection =
options.collection as keyof (typeof config)['collections'];
if (!reader.collections[collection]) {
throw new Error(`Collection ${collection} not found`);
}
const docs = await reader.collections[collection].all();
const startOffset = options?.offset ?? 0;
const endOffset = startOffset + (options?.limit ?? 10);
@@ -44,21 +51,26 @@ export class KeystaticClient implements CmsClient {
(item) => item.entry.parent === item.slug,
);
console.log(item);
return this.mapPost(item, children);
}),
);
}
async getContentItemById(id: string) {
const doc = await reader.collections.posts.read(id);
async getContentItemBySlug(params: { slug: string; collection: string }) {
const collection =
params.collection as keyof (typeof config)['collections'];
if (!reader.collections[collection]) {
throw new Error(`Collection ${collection} not found`);
}
const doc = await reader.collections[collection].read(params.slug);
if (!doc) {
return Promise.resolve(undefined);
}
return this.mapPost({ entry: doc, slug: id }, []);
return this.mapPost({ entry: doc, slug: params.slug }, []);
}
async getCategories() {
@@ -96,7 +108,6 @@ export class KeystaticClient implements CmsClient {
slug: item.slug,
description: item.entry.description,
publishedAt,
author: item.entry.author,
content,
image: item.entry.image ?? undefined,
categories:

View File

@@ -27,7 +27,6 @@ function createKeyStaticConfig(path: string) {
tags: fields.array(fields.text({ label: 'Tag' })),
description: fields.text({ label: 'Description' }),
publishedAt: fields.date({ label: 'Published At' }),
author: fields.text({ label: 'Author' }),
parent: fields.relationship({
label: 'Parent',
collection: 'posts',
@@ -52,6 +51,46 @@ function createKeyStaticConfig(path: string) {
}),
},
}),
documentation: collection({
label: 'Documentation',
slugField: 'title',
path: `${path}/documentation/**`,
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
content: fields.document({
label: 'Content',
formatting: true,
dividers: true,
links: true,
images: {
directory: 'public/site/images',
publicPath: '/site/images',
schema: {
title: fields.text({
label: 'Caption',
description:
'The text to display under the image in a caption.',
}),
},
},
}),
image: fields.image({
label: 'Image',
directory: 'public/site/images',
publicPath: '/site/images',
}),
description: fields.text({ label: 'Description' }),
publishedAt: fields.date({ label: 'Published At' }),
order: fields.number({ label: 'Order' }),
parent: fields.relationship({
label: 'Parent',
collection: 'documentation',
}),
categories: fields.array(fields.text({ label: 'Category' })),
tags: fields.array(fields.text({ label: 'Tag' })),
},
}),
},
});
}

View File

@@ -25,7 +25,7 @@ export class WordpressClient implements CmsClient {
*
* @param {Cms.GetContentItemsOptions} options - The options to customize the retrieval of content items.
*/
async getContentItems(options?: Cms.GetContentItemsOptions) {
async getContentItems(options: Cms.GetContentItemsOptions) {
const queryParams = new URLSearchParams({
_embed: 'true',
});
@@ -70,20 +70,21 @@ export class WordpressClient implements CmsClient {
}
const endpoints = [
`/wp-json/wp/v2/pages?${queryParams.toString()}`,
`/wp-json/wp/v2/posts?${queryParams.toString()}`,
`/wp-json/wp/v2/pages?${queryParams.toString()}`,
];
const urls = endpoints.map((endpoint) => `${this.apiUrl}${endpoint}`);
const endpoint =
options.collection === 'posts' ? endpoints[0] : endpoints[1];
const responses = await Promise.all(
urls.map((url) =>
fetch(url).then((value) => value.json() as Promise<WP_REST_API_Post[]>),
),
).then((values) => values.flat().filter(Boolean));
const url = `${this.apiUrl}${endpoint}`;
return await Promise.all(
responses.map(async (item: WP_REST_API_Post) => {
const posts = await fetch(url).then(
(value) => value.json() as Promise<WP_REST_API_Post[]>,
);
return Promise.all(
posts.map(async (item: WP_REST_API_Post) => {
let parentId: string | undefined;
if (!item) {
@@ -94,7 +95,6 @@ export class WordpressClient implements CmsClient {
parentId = item.parent.toString();
}
const author = await this.getAuthor(item.author);
const categories = await this.getCategoriesByIds(item.categories ?? []);
const tags = await this.getTagsByIds(item.tags ?? []);
const image = item.featured_media ? this.getFeaturedMedia(item) : '';
@@ -109,7 +109,6 @@ export class WordpressClient implements CmsClient {
url: item.link,
slug: item.slug,
publishedAt: new Date(item.date),
author: author?.name,
categories: categories,
tags: tags,
parentId,
@@ -120,34 +119,35 @@ export class WordpressClient implements CmsClient {
);
}
async getContentItemById(slug: string) {
async getContentItemBySlug({
slug,
collection,
}: {
slug: string;
collection: string;
}) {
const searchParams = new URLSearchParams({
_embed: 'true',
slug,
});
const endpoints = [
`/wp-json/wp/v2/pages?${searchParams.toString()}`,
`/wp-json/wp/v2/posts?${searchParams.toString()}`,
`/wp-json/wp/v2/pages?${searchParams.toString()}`,
];
const promises = endpoints.map((endpoint) =>
fetch(this.apiUrl + endpoint).then(
(res) => res.json() as Promise<WP_REST_API_Post[]>,
),
const endpoint = collection === 'posts' ? endpoints[0] : endpoints[1];
const responses = await fetch(this.apiUrl + endpoint).then(
(res) => res.json() as Promise<WP_REST_API_Post[]>,
);
const responses = await Promise.all(promises).then((values) =>
values.filter(Boolean),
);
const item = responses[0] ? responses[0][0] : undefined;
const item = responses[0];
if (!item) {
return;
}
const author = await this.getAuthor(item.author);
const categories = await this.getCategoriesByIds(item.categories ?? []);
const tags = await this.getTagsByIds(item.tags ?? []);
const image = item.featured_media ? this.getFeaturedMedia(item) : '';
@@ -163,7 +163,6 @@ export class WordpressClient implements CmsClient {
content: item.content.rendered,
slug: item.slug,
publishedAt: new Date(item.date),
author: author?.name,
categories,
tags,
parentId: item.parent?.toString(),
@@ -313,18 +312,6 @@ export class WordpressClient implements CmsClient {
}));
}
private async getAuthor(id: number) {
const response = await fetch(`${this.apiUrl}/wp-json/wp/v2/users/${id}`);
if (!response.ok) {
return undefined;
}
const data = await response.json();
return { name: data.name };
}
private getFeaturedMedia(post: WP_REST_API_Post) {
const embedded = post._embedded ?? {
'wp:featuredmedia': [],

View File

@@ -31,11 +31,11 @@ function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
return {
password: <AtSign className={'s-[18px]'} />,
phone: <Phone className={'s-[18px]'} />,
google: '/assets/images/google.webp',
facebook: '/assets/images/facebook.webp',
twitter: '/assets/images/twitter.webp',
github: '/assets/images/github.webp',
microsoft: '/assets/images/microsoft.webp',
apple: '/assets/images/apple.webp',
google: '/images/oauth/google.webp',
facebook: '/images/oauth/facebook.webp',
twitter: '/images/oauth/twitter.webp',
github: '/images/oauth/github.webp',
microsoft: '/images/oauth/microsoft.webp',
apple: '/images/oauth/apple.webp',
};
}