mirror of
https://github.com/Dokploy/website
synced 2025-06-26 18:16:01 +00:00
feat: add Ghost-powered blog with dynamic content and internationalization
This commit is contained in:
145
apps/website/app/[locale]/blog/[slug]/page.tsx
Normal file
145
apps/website/app/[locale]/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { getPost, getPosts } from "@/lib/ghost";
|
||||
import type { Post } from "@/lib/ghost";
|
||||
import type { Metadata, ResolvingMetadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
params: { locale: string; slug: string };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: Props,
|
||||
parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
const post = await getPost(params.slug);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: "Post Not Found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.custom_excerpt || post.excerpt,
|
||||
openGraph: post.feature_image
|
||||
? {
|
||||
images: [
|
||||
{
|
||||
url: post.feature_image,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: post.title,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getPosts();
|
||||
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { locale, slug } = params;
|
||||
const t = await getTranslations({ locale, namespace: "blog" });
|
||||
const post = await getPost(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="container mx-auto px-4 py-12 max-w-4xl">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t("backToBlog")}
|
||||
</Link>
|
||||
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
|
||||
<div className="flex items-center mb-6">
|
||||
{post.primary_author?.profile_image && (
|
||||
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
|
||||
<Image
|
||||
src={post.primary_author.profile_image}
|
||||
alt={post.primary_author.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{post.primary_author?.name || "Unknown Author"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formattedDate} • {post.reading_time} min read
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{post.feature_image && (
|
||||
<div className="relative h-96 w-full rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={post.feature_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="prose prose-lg max-w-none prose-headings:text-foreground prose-a:text-primary hover:prose-a:text-primary/80"
|
||||
dangerouslySetInnerHTML={{ __html: post.html }}
|
||||
/>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mt-12 pt-6 border-t border-border">
|
||||
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/blog/tag/${tag.slug}`}
|
||||
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
95
apps/website/app/[locale]/blog/page.tsx
Normal file
95
apps/website/app/[locale]/blog/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getPosts } from "@/lib/ghost";
|
||||
import type { Post } from "@/lib/ghost";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | Dokploy",
|
||||
description: "Latest news, updates, and articles from Dokploy",
|
||||
};
|
||||
|
||||
export const revalidate = 3600; // Revalidate the data at most every hour
|
||||
|
||||
export default async function BlogPage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "blog" });
|
||||
const posts = await getPosts();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<h1 className="text-4xl font-bold mb-8">{t("title")}</h1>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
{t("noPosts")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{posts.map((post: Post) => (
|
||||
<BlogPostCard key={post.id} post={post} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostCard({ post, locale }: { post: Post; locale: string }) {
|
||||
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`} className="group">
|
||||
<div className="bg-card rounded-lg overflow-hidden h-fit shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
|
||||
{post.feature_image && (
|
||||
<div className="relative h-48 w-full">
|
||||
<Image
|
||||
src={post.feature_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-2 transition-colors">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formattedDate} • {post.reading_time} min read
|
||||
</p>
|
||||
<p className=" text-primary/80 line-clamp-3 mb-4">
|
||||
{post.custom_excerpt || post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
{post.primary_author?.profile_image && (
|
||||
<div className="relative h-10 w-10 rounded-full overflow-hidden mr-3">
|
||||
<Image
|
||||
src={post.primary_author.profile_image}
|
||||
alt={post.primary_author.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{post.primary_author?.name || "Unknown Author"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
135
apps/website/app/[locale]/blog/tag/[tag]/page.tsx
Normal file
135
apps/website/app/[locale]/blog/tag/[tag]/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { getPostsByTag, getTags } from "@/lib/ghost";
|
||||
import type { Post } from "@/lib/ghost";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
params: { locale: string; tag: string };
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { tag, locale } = params;
|
||||
const t = await getTranslations({ locale, namespace: "blog" });
|
||||
|
||||
return {
|
||||
title: `${t("tagTitle", { tag })}`,
|
||||
description: t("tagDescription", { tag }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const tags = await getTags();
|
||||
|
||||
return tags.map((tag: { slug: string }) => ({
|
||||
tag: tag.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function TagPage({ params }: Props) {
|
||||
const { locale, tag } = params;
|
||||
const t = await getTranslations({ locale, namespace: "blog" });
|
||||
const posts = await getPostsByTag(tag);
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get the tag name from the first post
|
||||
const tagName =
|
||||
posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center mb-8 text-primary-600 hover:text-primary-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t("backToBlog")}
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{t("postsTaggedWith")}{" "}
|
||||
<span className="text-primary-600">"{tagName}"</span>
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t("foundPosts", { count: posts.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{posts.map((post: Post) => (
|
||||
<BlogPostCard key={post.id} post={post} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostCard({ post, locale }: { post: Post; locale: string }) {
|
||||
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`} className="group">
|
||||
<div className="dark:bg-gray-800 rounded-lg overflow-hidden shadow-lg transition-all duration-300 hover:shadow-xl">
|
||||
{post.feature_image && (
|
||||
<div className="relative h-48 w-full">
|
||||
<Image
|
||||
src={post.feature_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-2 group-hover:text-primary-500 transition-colors">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{formattedDate} • {post.reading_time} min read
|
||||
</p>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
{post.custom_excerpt || post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
{post.primary_author?.profile_image && (
|
||||
<div className="relative h-10 w-10 rounded-full overflow-hidden mr-3">
|
||||
<Image
|
||||
src={post.primary_author.profile_image}
|
||||
alt={post.primary_author.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{post.primary_author?.name || "Unknown Author"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,7 @@ function MobileNavigation() {
|
||||
<MobileNavLink href={linkT("docs.intro")} target="_blank">
|
||||
{t("navigation.docs")}
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/blog">{t("navigation.blog")}</MobileNavLink>
|
||||
<MobileNavLink href={linkT("docs.intro")} target="_blank">
|
||||
<Button className=" w-full" asChild>
|
||||
<Link
|
||||
@@ -166,6 +167,7 @@ export function Header() {
|
||||
<NavLink href={linkT("docs.intro")} target="_blank">
|
||||
{t("navigation.docs")}
|
||||
</NavLink>
|
||||
<NavLink href="/blog">{t("navigation.blog")}</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 md:gap-x-5">
|
||||
|
||||
72
apps/website/components/blog/BlogCard.tsx
Normal file
72
apps/website/components/blog/BlogCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Post } from "@/lib/ghost";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
interface BlogCardProps {
|
||||
post: Post;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function BlogCard({ post, locale }: BlogCardProps) {
|
||||
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg shadow-lg transition-all hover:shadow-xl">
|
||||
<div className="relative h-48 w-full">
|
||||
{post.feature_image ? (
|
||||
<Image
|
||||
src={post.feature_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-200">
|
||||
<span className="text-gray-400">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between bg-white p-6">
|
||||
<div className="flex-1">
|
||||
{post.primary_tag && (
|
||||
<p className="text-sm font-medium text-indigo-600">
|
||||
{post.primary_tag.name}
|
||||
</p>
|
||||
)}
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="mt-2 block">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-base text-gray-500">{post.excerpt}</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center">
|
||||
{post.primary_author?.profile_image && (
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={post.primary_author.profile_image}
|
||||
alt={post.primary_author.name}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{post.primary_author?.name || "Anonymous"}
|
||||
</p>
|
||||
<div className="flex space-x-1 text-sm text-gray-500">
|
||||
<time dateTime={post.published_at}>{formattedDate}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{post.reading_time} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
apps/website/components/navigation.tsx
Normal file
6
apps/website/components/navigation.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
const navigation = [
|
||||
{ name: "home", href: "/" },
|
||||
{ name: "features", href: "/features" },
|
||||
{ name: "pricing", href: "/pricing" },
|
||||
{ name: "blog", href: "/blog" },
|
||||
];
|
||||
127
apps/website/lib/ghost.ts
Normal file
127
apps/website/lib/ghost.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import GhostContentAPI from "@tryghost/content-api";
|
||||
|
||||
// Ghost API configuration
|
||||
const ghostConfig = {
|
||||
url: "http://testing-ghost-8423be-31-220-108-27.traefik.me",
|
||||
key: "d8c93e7121f36a95cd60921a04",
|
||||
version: "v5.0",
|
||||
};
|
||||
|
||||
// Initialize the Ghost API with your credentials
|
||||
const api = GhostContentAPI({
|
||||
url: ghostConfig.url,
|
||||
key: ghostConfig.key,
|
||||
version: ghostConfig.version,
|
||||
// @ts-ignore
|
||||
makeRequest: ({ url, method, params, headers }) => {
|
||||
const apiUrl = new URL(url);
|
||||
// @ts-ignore
|
||||
Object.keys(params).map((key) =>
|
||||
apiUrl.searchParams.set(key, encodeURIComponent(params[key])),
|
||||
);
|
||||
|
||||
return fetch(apiUrl.toString(), { method, headers })
|
||||
.then(async (res) => {
|
||||
// Check if the response was successful.
|
||||
if (!res.ok) {
|
||||
// You can handle HTTP errors here
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return { data: await res.json() };
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Fetch error:", error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
uuid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
html: string;
|
||||
feature_image: string | null;
|
||||
featured: boolean;
|
||||
visibility: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
published_at: string;
|
||||
custom_excerpt: string | null;
|
||||
excerpt: string;
|
||||
reading_time: number;
|
||||
primary_tag?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}>;
|
||||
primary_author?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
profile_image: string | null;
|
||||
bio: string | null;
|
||||
};
|
||||
authors?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
profile_image: string | null;
|
||||
bio: string | null;
|
||||
}>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function getPosts(options = {}): Promise<Post[]> {
|
||||
try {
|
||||
const result = (await api.posts.browse({
|
||||
limit: "all",
|
||||
include: ["tags", "authors"],
|
||||
})) as Post[];
|
||||
console.log(result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching posts:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPost(slug: string): Promise<Post | null> {
|
||||
try {
|
||||
const result = (await api.posts.read({ slug })) as Post;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching post:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTags() {
|
||||
try {
|
||||
const result = await api.tags.browse();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching tags:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPostsByTag(tag: string) {
|
||||
try {
|
||||
const result = await api.posts.browse({
|
||||
limit: "all",
|
||||
filter: `tag:${tag}`,
|
||||
include: ["tags", "authors"],
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching posts with tag ${tag}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
44
apps/website/lib/types/ghost-content-api.d.ts
vendored
Normal file
44
apps/website/lib/types/ghost-content-api.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
declare module "@tryghost/content-api" {
|
||||
interface GhostContentAPIOptions {
|
||||
url: string;
|
||||
key: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface BrowseOptions {
|
||||
limit?: string | number;
|
||||
page?: number;
|
||||
order?: string;
|
||||
filter?: string;
|
||||
include?: string | string[];
|
||||
fields?: string | string[];
|
||||
formats?: string | string[];
|
||||
}
|
||||
|
||||
interface ReadOptions {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
include?: string | string[];
|
||||
fields?: string | string[];
|
||||
formats?: string | string[];
|
||||
}
|
||||
|
||||
interface ApiObject {
|
||||
browse<T>(options?: BrowseOptions): Promise<T[]>;
|
||||
read<T>(options: ReadOptions): Promise<T>;
|
||||
}
|
||||
|
||||
interface GhostAPI {
|
||||
posts: ApiObject;
|
||||
tags: ApiObject;
|
||||
authors: ApiObject;
|
||||
pages: ApiObject;
|
||||
settings: {
|
||||
browse<T>(): Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
function GhostContentAPI(options: GhostContentAPIOptions): GhostAPI;
|
||||
|
||||
export default GhostContentAPI;
|
||||
}
|
||||
@@ -11,7 +11,11 @@
|
||||
"i18nButtonPlaceholder": "Language",
|
||||
"i18nFr": "Français",
|
||||
"i18nEn": "English",
|
||||
"i18nZh-Hans": "简体中文"
|
||||
"i18nZh-Hans": "简体中文",
|
||||
"blog": "Blog",
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"register": "Register"
|
||||
},
|
||||
"hero": {
|
||||
"cloud": "Introducing Dokploy Cloud",
|
||||
@@ -69,7 +73,7 @@
|
||||
},
|
||||
"callToAction": {
|
||||
"title": "Unlock Your Deployment Potential with Dokploy Cloud",
|
||||
"des": "Say goodbye to infrastructure hassles—Dokploy Cloud handles it all. Effortlessly deploy, manage Docker containers, and secure your traffic with Traefik. Focus on building, we’ll handle the rest.",
|
||||
"des": "Say goodbye to infrastructure hassles—Dokploy Cloud handles it all. Effortlessly deploy, manage Docker containers, and secure your traffic with Traefik. Focus on building, we'll handle the rest.",
|
||||
"button": "Get Started Now"
|
||||
},
|
||||
"faq": {
|
||||
@@ -173,7 +177,7 @@
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently asked questions",
|
||||
"description": "If you can’t find what you’re looking for, please send us an email to",
|
||||
"description": "If you can't find what you're looking for, please send us an email to",
|
||||
"q1": "How does Dokploy's Open Source plan work?",
|
||||
"a1": "You can host Dokploy UI on your own infrastructure and you will be responsible for the maintenance and updates.",
|
||||
"q2": "Do I need to provide my own server for the managed plan?",
|
||||
@@ -191,5 +195,17 @@
|
||||
"q8": "Is Dokploy open-source?",
|
||||
"a8": "Yes, Dokploy is fully open-source. You can contribute or modify it as needed for your projects."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"description": "Latest news, updates, and articles from Dokploy",
|
||||
"noPosts": "No posts found. Check back later for new content.",
|
||||
"readMore": "Read more",
|
||||
"backToBlog": "Back to Blog",
|
||||
"tags": "Tags",
|
||||
"postsTaggedWith": "Posts tagged with",
|
||||
"foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}",
|
||||
"tagTitle": "Posts tagged with {tag}",
|
||||
"tagDescription": "Browse all blog posts tagged with {tag}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"i18nButtonPlaceholder": "Langue",
|
||||
"i18nFr": "Français",
|
||||
"i18nEn": "English",
|
||||
"i18nZh-Hans": "简体中文"
|
||||
"i18nZh-Hans": "简体中文",
|
||||
"blog": "Blog",
|
||||
"home": "Accueil",
|
||||
"login": "Connexion",
|
||||
"register": "S'inscrire"
|
||||
},
|
||||
"hero": {
|
||||
"cloud": "Présentation de Dokploy Cloud",
|
||||
@@ -190,5 +194,17 @@
|
||||
"q8": "Dokploy est-il open-source ?",
|
||||
"a8": "Oui, Dokploy est entièrement open-source. Vous pouvez contribuer ou modifier selon vos besoins pour vos projets."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"description": "Dernières nouvelles, mises à jour et articles de Dokploy",
|
||||
"noPosts": "Aucun article trouvé. Revenez plus tard pour du nouveau contenu.",
|
||||
"readMore": "Lire la suite",
|
||||
"backToBlog": "Retour au Blog",
|
||||
"tags": "Tags",
|
||||
"postsTaggedWith": "Articles tagués avec",
|
||||
"foundPosts": "{count, plural, =0 {Aucun article trouvé} one {# article trouvé} other {# articles trouvés}}",
|
||||
"tagTitle": "Articles tagués avec {tag}",
|
||||
"tagDescription": "Parcourir tous les articles de blog tagués avec {tag}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"i18nButtonPlaceholder": "语言",
|
||||
"i18nFr": "Français",
|
||||
"i18nEn": "English",
|
||||
"i18nZh-Hans": "简体中文"
|
||||
"i18nZh-Hans": "简体中文",
|
||||
"blog": "博客",
|
||||
"home": "首页",
|
||||
"login": "登录",
|
||||
"register": "注册"
|
||||
},
|
||||
"hero": {
|
||||
"cloud": "隆重介绍 Dokploy 云",
|
||||
@@ -195,5 +199,17 @@
|
||||
"q8": "Dokploy 开源吗?",
|
||||
"a8": "是的,Dokploy 完全开源,您可以参与贡献或者是 Fork 后自行修改以用于您的私人需求。"
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"title": "博客",
|
||||
"description": "Dokploy 的最新消息、更新和文章",
|
||||
"noPosts": "未找到文章。稍后再来查看新内容。",
|
||||
"readMore": "阅读更多",
|
||||
"backToBlog": "返回博客",
|
||||
"tags": "标签",
|
||||
"postsTaggedWith": "标签为",
|
||||
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
|
||||
"tagTitle": "标签为 {tag} 的文章",
|
||||
"tagDescription": "浏览所有标签为 {tag} 的博客文章"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
domains: [
|
||||
"static.ghost.org",
|
||||
"testing-ghost-8423be-31-220-108-27.traefik.me",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
"@radix-ui/react-tabs": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tabler/icons-react": "3.21.0",
|
||||
"@tryghost/content-api": "^1.11.21",
|
||||
"@types/node": "20.4.6",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"axios": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"framer-motion": "^11.3.19",
|
||||
|
||||
Reference in New Issue
Block a user