diff --git a/apps/docs/content/docs/core/manual-installation.mdx b/apps/docs/content/docs/core/manual-installation.mdx index e8a3c5f..a4cd347 100644 --- a/apps/docs/content/docs/core/manual-installation.mdx +++ b/apps/docs/content/docs/core/manual-installation.mdx @@ -167,7 +167,7 @@ install_dokploy() { --restart always \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ - -v /var/run/docker.sock:/var/run/docker.sock \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p 80:80/tcp \ -p 443:443/tcp \ -p 443:443/udp \ @@ -183,7 +183,7 @@ install_dokploy() { # --network dokploy-network \ # --mount type=bind,source=/etc/dokploy/traefik/traefik.yml,target=/etc/traefik/traefik.yml \ # --mount type=bind,source=/etc/dokploy/traefik/dynamic,target=/etc/dokploy/traefik/dynamic \ - # --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ + # --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly \ # --publish mode=host,published=443,target=443 \ # --publish mode=host,published=80,target=80 \ # --publish mode=host,published=443,target=443,protocol=udp \ diff --git a/apps/docs/content/docs/core/troubleshooting.mdx b/apps/docs/content/docs/core/troubleshooting.mdx index 5b2cbb7..383f29a 100644 --- a/apps/docs/content/docs/core/troubleshooting.mdx +++ b/apps/docs/content/docs/core/troubleshooting.mdx @@ -472,7 +472,7 @@ docker run -d \ --restart always \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ - -v /var/run/docker.sock:/var/run/docker.sock \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p 80:80/tcp \ -p 443:443/tcp \ -p 443:443/udp \ @@ -486,15 +486,14 @@ docker service rm dokploy-traefik # Create a new dokploy-traefik service docker service create \ --name dokploy-traefik \ - --constraint 'node.role == manager' \ - --replicas 1 \ + --constraint 'node.role==manager' \ --network dokploy-network \ - --publish published=80,target=80,mode=host \ - --publish published=443,target=443,mode=host \ --mount type=bind,source=/etc/dokploy/traefik/traefik.yml,target=/etc/traefik/traefik.yml \ --mount type=bind,source=/etc/dokploy/traefik/dynamic,target=/etc/dokploy/traefik/dynamic \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ - --label traefik.enable=true \ + --publish mode=host,published=443,target=443 \ + --publish mode=host,published=80,target=80 \ + --publish mode=host,published=443,target=443,protocol=udp \ traefik:v3.6.1 ``` diff --git a/apps/website/app/[locale]/[...rest]/page.tsx b/apps/website/app/[locale]/[...rest]/page.tsx deleted file mode 100644 index 4583936..0000000 --- a/apps/website/app/[locale]/[...rest]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { notFound } from "next/navigation"; - -export default function CatchAll() { - notFound(); -} diff --git a/apps/website/app/api/github-stars/route.ts b/apps/website/app/api/github-stars/route.ts new file mode 100644 index 0000000..57c7a4d --- /dev/null +++ b/apps/website/app/api/github-stars/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +// Cache the result for 5 minutes to avoid rate limiting +let cachedStars: { count: number; timestamp: number } | null = null; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const owner = searchParams.get("owner"); + const repo = searchParams.get("repo"); + + if (!owner || !repo) { + return NextResponse.json( + { error: "Owner and repo parameters are required" }, + { status: 400 }, + ); + } + + // Check if we have a valid cached result + if ( + cachedStars && + Date.now() - cachedStars.timestamp < CACHE_DURATION + ) { + return NextResponse.json( + { stargazers_count: cachedStars.count }, + { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } + + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Dokploy-Website", + }, + }, + ); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch repository data" }, + { status: response.status }, + ); + } + + const data = await response.json(); + const starCount = data.stargazers_count; + + // Cache the result + cachedStars = { + count: starCount, + timestamp: Date.now(), + }; + + return NextResponse.json( + { stargazers_count: starCount }, + { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } catch (error) { + console.error("Error fetching GitHub stars:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + diff --git a/apps/website/app/api/og/route.ts b/apps/website/app/api/og/route.ts index 9883471..53cac07 100644 --- a/apps/website/app/api/og/route.ts +++ b/apps/website/app/api/og/route.ts @@ -21,7 +21,6 @@ export async function GET(request: NextRequest) { return new Response("Post not found", { status: 404 }); } - console.log("Found post:", post.title); const formattedDate = new Date(post.published_at).toLocaleDateString( "en-US", @@ -44,8 +43,6 @@ export async function GET(request: NextRequest) { readingTime: post.reading_time, }); - console.log("Successfully generated OG image"); - return new Response(ogImage, { headers: { "Content-Type": "image/png", diff --git a/apps/website/app/blog/[slug]/page.tsx b/apps/website/app/blog/[slug]/page.tsx index 1907a88..03e63cf 100644 --- a/apps/website/app/blog/[slug]/page.tsx +++ b/apps/website/app/blog/[slug]/page.tsx @@ -17,7 +17,6 @@ import { CodeBlock } from "./components/CodeBlock"; import { H1, H2, H3 } from "./components/Headings"; import { TableOfContents } from "./components/TableOfContents"; import { ZoomableImage } from "./components/ZoomableImage"; -import { useTranslations } from "@/lib/intl"; type Props = { params: { slug: string }; @@ -72,7 +71,6 @@ export async function generateMetadata( export default async function BlogPostPage({ params }: Props) { const { slug } = await params; - const t = useTranslations("blog"); const post = await getPost(slug); const allPosts = await getPosts(); @@ -233,7 +231,7 @@ export default async function BlogPostPage({ params }: Props) { clipRule="evenodd" /> - {t("backToBlog")} + Back to Blog
@@ -313,7 +311,7 @@ export default async function BlogPostPage({ params }: Props) { {post.tags && post.tags.length > 0 && (
-

{t("tags")}

+

Tags

{post.tags.map((tag) => ( 0 && (
-

{t("relatedPosts")}

+

Related Posts

{relatedPosts.map((relatedPost) => { const relatedPostDate = new Date( diff --git a/apps/website/app/blog/page.tsx b/apps/website/app/blog/page.tsx index 195108a..e389b29 100644 --- a/apps/website/app/blog/page.tsx +++ b/apps/website/app/blog/page.tsx @@ -5,7 +5,6 @@ import type { Metadata } from "next"; import Link from "next/link"; import { BlogPostCard } from "./components/BlogPostCard"; import { SearchAndFilter } from "./components/SearchAndFilter"; -import { useTranslations } from "@/lib/intl"; interface Tag { id: string; @@ -14,7 +13,7 @@ interface Tag { } export const metadata: Metadata = { - title: "Blog | Dokploy", + title: "Blog", description: "Latest news, updates, and articles from Dokploy", }; @@ -24,7 +23,6 @@ export default async function BlogPage({ searchParams: { [key: string]: string | string[] | undefined }; }) { const searchParams2 = await searchParams; - const t = useTranslations("blog"); const posts = await getPosts(); const tags = (await getTags()) as Tag[]; const search = @@ -61,21 +59,21 @@ export default async function BlogPage({
- + - {filteredPosts.length === 0 ? ( -
-

- {search || selectedTag ? t("noResults") : t("noPosts")} -

-
- ) : ( + {filteredPosts.length === 0 ? ( +
+

+ {search || selectedTag ? "No posts found matching your criteria" : "No posts available"} +

+
+ ) : (
{filteredPosts.map((post: Post) => ( diff --git a/apps/website/app/blog/tag/[tag]/page.tsx b/apps/website/app/blog/tag/[tag]/page.tsx index a1aeb84..1fd1913 100644 --- a/apps/website/app/blog/tag/[tag]/page.tsx +++ b/apps/website/app/blog/tag/[tag]/page.tsx @@ -4,7 +4,6 @@ import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { useTranslations } from "@/lib/intl"; type Props = { params: { tag: string }; @@ -12,11 +11,21 @@ type Props = { export async function generateMetadata({ params }: Props): Promise { const { tag } = await params; - const t = useTranslations("blog"); + const posts = await getPostsByTag(tag); + + if (!posts || posts.length === 0) { + return { + title: "Tag Not Found", + description: "The requested tag could not be found", + }; + } + + const tagName = + posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag; return { - title: `${t("tagTitle", { tag })}`, - description: t("tagDescription", { tag }), + title: `${tagName} Posts`, + description: `Browse all posts tagged with ${tagName}`, }; } @@ -27,7 +36,6 @@ export async function generateStaticParams() { export default async function TagPage({ params }: Props) { const { tag } = await params; - const t = useTranslations("blog"); const posts = await getPostsByTag(tag); if (!posts || posts.length === 0) { @@ -55,16 +63,16 @@ export default async function TagPage({ params }: Props) { clipRule="evenodd" /> - {t("backToBlog")} + Back to Blog

- {t("postsTaggedWith")}{" "} + Posts tagged with{" "} "{tagName}"

- {t("foundPosts", { count: posts.length })} + {posts.length} {posts.length === 1 ? 'post' : 'posts'} found

diff --git a/apps/website/app/[locale]/_changelog/page.tsx b/apps/website/app/changelog/page.tsx similarity index 51% rename from apps/website/app/[locale]/_changelog/page.tsx rename to apps/website/app/changelog/page.tsx index f6b1b6d..8acfde8 100644 --- a/apps/website/app/[locale]/_changelog/page.tsx +++ b/apps/website/app/changelog/page.tsx @@ -1,6 +1,11 @@ -// import { ScrollArea } from "@/components/ui/scroll-area"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Changelog", + description: + "Stay updated with the latest changes, improvements, and features in Dokploy", +}; -// Datos de ejemplo del changelog const changelogEntries = [ { date: "2023-11-01", @@ -57,7 +62,7 @@ const changelogEntries = [ }, ]; -const Comp = () => { +export default function ChangelogPage() { return (
@@ -77,7 +82,7 @@ const Comp = () => {
-
+

Changelog

{changelogEntries.map((entry, index) => ( @@ -115,64 +120,5 @@ const Comp = () => {
); -}; +} -// export default function Changelog() { -// return ( -//
-//
-// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -//
-//
-//

Changelog

-//
-// {changelogEntries.map((entry, index) => ( -//
-//
-// -// {entry.date} -// -//

{entry.title}

-//
-//
-//
-// {`Imagen -//
- -//
    -// {entry.changes.map((change, changeIndex) => ( -//
  • -// {change} -//
  • -// ))} -//
-//
-//
-// ))} -//
-//
-//
-// ); -// } diff --git a/apps/website/app/contact/layout.tsx b/apps/website/app/contact/layout.tsx new file mode 100644 index 0000000..fab9665 --- /dev/null +++ b/apps/website/app/contact/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Contact Us", + description: + "Get in touch with our team. We're here to help with any questions about Dokploy.", +}; + +export default function ContactLayout({ children }: { children: ReactNode }) { + return <>{children}; +} + diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 6ac6010..95991aa 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -12,27 +12,30 @@ type Props = { children: ReactNode; }; -// export const metadata: Metadata = { -// metadataBase: new URL("https://dokploy.com"), -// title: "Dokploy - Deploy your applications with ease", -// description: "Deploy your applications with ease using Dokploy", -// icons: { -// icon: "icon.svg", -// apple: "apple-touch-icon.png", -// }, -// openGraph: { -// title: "Dokploy - Deploy your applications with ease", -// description: "Deploy your applications with ease using Dokploy", -// images: "favicon.ico", -// type: "website", -// }, -// twitter: { -// card: "summary_large_image", -// title: "Dokploy - Deploy your applications with ease", -// description: "Deploy your applications with ease using Dokploy", -// images: ["/og.png"], -// }, -// }; +export const metadata: Metadata = { + metadataBase: new URL("https://dokploy.com"), + title: { + default: "Dokploy - Deploy your applications with ease", + template: "%s | Dokploy", + }, + description: "Deploy your applications with ease using Dokploy", + icons: { + icon: "icon.svg", + apple: "apple-touch-icon.png", + }, + openGraph: { + title: "Dokploy - Deploy your applications with ease", + description: "Deploy your applications with ease using Dokploy", + images: "/og.png", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Dokploy - Deploy your applications with ease", + description: "Deploy your applications with ease using Dokploy", + images: ["/og.png"], + }, +}; const inter = Inter({ subsets: ["latin"], display: "swap", diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index 37c0216..7210ade 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -7,6 +7,14 @@ import { Pricing } from "@/components/pricing"; import { SecondaryFeaturesSections } from "@/components/secondary-features"; import { Sponsors } from "@/components/sponsors"; import { StatsSection } from "@/components/stats"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: { + absolute: "Dokploy - Deploy your applications with ease", + }, + description: "Open-source self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases", +}; export default function Home() { return ( diff --git a/apps/website/app/[locale]/privacy/page.tsx b/apps/website/app/privacy/page.tsx similarity index 90% rename from apps/website/app/[locale]/privacy/page.tsx rename to apps/website/app/privacy/page.tsx index a87d611..6859800 100644 --- a/apps/website/app/[locale]/privacy/page.tsx +++ b/apps/website/app/privacy/page.tsx @@ -1,6 +1,14 @@ -export default function Home() { +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Privacy Policy", + description: + "Learn about how Dokploy collects, uses, and safeguards your personal information when you use our website and services.", +}; + +export default function PrivacyPage() { return ( -
+

Privacy

@@ -97,7 +105,7 @@ export default function Home() { please contact us at:

- Email: + Email:{" "} ); } + diff --git a/apps/website/app/[locale]/terms/page.tsx b/apps/website/app/terms/page.tsx similarity index 93% rename from apps/website/app/[locale]/terms/page.tsx rename to apps/website/app/terms/page.tsx index ddaa103..e7d53ed 100644 --- a/apps/website/app/[locale]/terms/page.tsx +++ b/apps/website/app/terms/page.tsx @@ -1,6 +1,14 @@ -export default function Home() { +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Terms and Conditions", + description: + "Read the terms and conditions for using Dokploy's website and services.", +}; + +export default function TermsPage() { return ( -

+

Terms and Conditions

@@ -8,7 +16,7 @@ export default function Home() {

Welcome to Dokploy! These Terms and Conditions outline the rules and - regulations for the use of Dokploy’s website and services. + regulations for the use of Dokploy's website and services.

By accessing or using our services, you agree to be bound by the @@ -180,7 +188,7 @@ export default function Home() { These Terms & Conditions are governed by applicable laws based on the user's location. Any disputes arising under these terms will be resolved in accordance with the legal jurisdiction relevant to the - user’s location, unless otherwise required by applicable law. + user's location, unless otherwise required by applicable law.

@@ -191,7 +199,7 @@ export default function Home() { reach us at:

- Email: + Email:{" "} ); } + diff --git a/apps/website/components/CallToAction.tsx b/apps/website/components/CallToAction.tsx index 294eff7..f638f81 100644 --- a/apps/website/components/CallToAction.tsx +++ b/apps/website/components/CallToAction.tsx @@ -1,10 +1,8 @@ import { Container } from "@/components/Container"; -import { useTranslations } from "@/lib/intl"; import Link from "next/link"; import { Button } from "./ui/button"; export function CallToAction() { - const t = useTranslations("HomePage"); return (

- {t("callToAction.title")} + Unlock Your Deployment Potential with Dokploy Cloud

- {t("callToAction.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.

- +
diff --git a/apps/website/components/Faqs.tsx b/apps/website/components/Faqs.tsx index 6340ead..be0bc43 100644 --- a/apps/website/components/Faqs.tsx +++ b/apps/website/components/Faqs.tsx @@ -4,82 +4,80 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { useTranslations } from "@/lib/intl"; import { Container } from "./Container"; const faqs = [ { - question: "faq.q1", - answer: "faq.a1", + question: "What is Dokploy?", + answer: "Dokploy is a stable, easy-to-use deployment solution designed to simplify the application management process. Think of Dokploy as a free alternative self-hostable solution to platforms like Heroku, Vercel, and Netlify.", }, { - question: "faq.q11", - answer: "faq.a11", + question: "How does Dokploy's Open Source plan work?", + answer: "You can host Dokploy UI on your own infrastructure and you will be responsible for the maintenance and updates.", }, { - question: "faq.q12", - answer: "faq.a12", + question: "Do I need to provide my own server for the managed plan?", + answer: "Yes, in the managed plan, you provide your own server (e.g., Hetzner, Hostinger, AWS, etc.) VPS, and we manage the Dokploy UI infrastructure for you.", }, { - question: "faq.q13", - answer: "faq.a13", + question: "What happens if I need more than one server?", + answer: "The first server costs $4.50/month, if you buy more than one it will be $3.50/month per server.", }, { - question: "faq.q14", - answer: "faq.a14", + question: "Is there a limit on the number of deployments?", + answer: "No, there is no limit on the number of deployments in any of the plans.", }, { - question: "faq.q15", - answer: "faq.a15", + question: "What happens if I exceed my purchased server limit?", + answer: "The most recently added servers will be deactivated. You won't be able to create services on inactive servers until they are reactivated.", }, { - question: "faq.q17", - answer: "faq.a17", + question: "What kind of support do you offer?", + answer: "We offer community support for the open source version and priority support for paid plans (via Discord or Email at support@dokploy.com).", }, { - question: "faq.q18", - answer: "faq.a18", + question: "What's the catch on the Paid Plan?", + answer: "Nothing, once you link your server (VPS) to your account, you can deploy unlimited applications, databases, and users, and you get unlimited updates, deployments, backups, and more.", }, { - question: "faq.q2", - answer: "faq.a2", + question: "Why Choose Dokploy?", + answer: "Dokploy offers simplicity, flexibility, and speed in application deployment and management.", }, { - question: "faq.q4", - answer: "faq.a4", + question: "Is it open source?", + answer: "Yes, Dokploy is open source and free to use.", }, { - question: "faq.q5", - answer: "faq.a5", + question: "What types of languages can I deploy with Dokploy?", + answer: "Dokploy does not restrict programming languages. You are free to choose your preferred language and framework.", }, { - question: "faq.q6", - answer: "faq.a6", + question: "How do I request a feature or report a bug?", + answer: "To request a feature or report a bug, please create an issue on our GitHub repository or ask in our Discord channel.", }, { - question: "faq.q7", - answer: "faq.a7", + question: "Do you track the usage of Dokploy?", + answer: "No, we don't track any usage data.", }, { - question: "faq.q8", - answer: "faq.a8", + question: "Are there any user forums or communities where I can interact with other users?", + answer: "Yes, we have active GitHub discussions and Discord where you can share ideas, ask for help, and connect with other users.", }, { - question: "faq.q16", - answer: "faq.a16", + question: "Do you offer a refunds?", + answer: "We do not offer refunds. However, you can cancel your subscription at any time. Feel free to try our open-source version for free before making a purchase.", }, { - question: "faq.q9", - answer: "faq.a9", + question: "What types of applications can I deploy with Dokploy?", + answer: "You can deploy any application that can be Dockerized, with no limits. Dokploy supports builds from Git repositories, Dockerfiles, Nixpacks, and Buildpacks like Heroku and Paketo.", }, { - question: "faq.q10", - answer: "faq.a10", + question: "How does Dokploy handle database management?", + answer: "Dokploy supports multiple database systems including Postgres, MySQL, MariaDB, MongoDB, and Redis, providing tools for easy deployment and management and backups directly from the dashboard.", }, ]; export function Faqs() { - const t = useTranslations("HomePage"); return (
- {t("faq.title")} + Frequently asked questions

- {t("faq.des")} + If you can't find what you're looking for, please submit an issue through our GitHub repository or ask questions on our Discord.

@@ -104,12 +102,12 @@ export function Faqs() { collapsible className="w-full max-w-3xl mx-auto" > - {faqs.map((column, columnIndex) => ( + {faqs.map((faq, columnIndex) => ( - {t(column.question)} + {faq.question} - {t(column.answer)} + {faq.answer} ))} diff --git a/apps/website/components/GithubStars.tsx b/apps/website/components/GithubStars.tsx index 1fc9579..f8b8bd9 100644 --- a/apps/website/components/GithubStars.tsx +++ b/apps/website/components/GithubStars.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; type GithubStarsProps = { @@ -10,17 +11,73 @@ type GithubStarsProps = { count?: string; }; +// Function to format star count (e.g., 26400 -> "26.4k") +function formatStarCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return count.toString(); +} + +// Extract owner and repo from GitHub URL +function extractRepoInfo(url: string): { owner: string; repo: string } | null { + try { + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); + if (match) { + return { owner: match[1], repo: match[2].replace(/\.git$/, "") }; + } + } catch (error) { + console.error("Error extracting repo info:", error); + } + return null; +} + export function GithubStars({ className, repoUrl = "https://github.com/dokploy/dokploy", label = "GitHub Stars", - count = "26.4k", + count: defaultCount = "26.4k", }: GithubStarsProps) { + const [starCount, setStarCount] = useState(defaultCount); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchStarCount = async () => { + const repoInfo = extractRepoInfo(repoUrl); + if (!repoInfo) { + setIsLoading(false); + return; + } + + try { + const response = await fetch( + `/api/github-stars?owner=${encodeURIComponent(repoInfo.owner)}&repo=${encodeURIComponent(repoInfo.repo)}`, + ); + + if (response.ok) { + const data = await response.json(); + const formattedCount = formatStarCount(data.stargazers_count); + setStarCount(formattedCount); + } + } catch (error) { + console.error("Error fetching GitHub stars:", error); + // Keep default count on error + } finally { + setIsLoading(false); + } + }; + + fetchStarCount(); + }, [repoUrl]); + return ( Stars - {count} + + {isLoading ? "..." : starCount} + {/* subtle ring on hover */} diff --git a/apps/website/components/Header.tsx b/apps/website/components/Header.tsx index ba853dd..1989b12 100644 --- a/apps/website/components/Header.tsx +++ b/apps/website/components/Header.tsx @@ -154,7 +154,7 @@ function MobileNavigation() { export function Header() { return ( -
+