mirror of
https://github.com/jordienr/zenblog.git
synced 2026-08-24 10:14:46 -05:00
Merge pull request #29 from jordienr/codex/add-google-signin-option
add google sign in
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="">
|
||||
<div className="sr-only">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { sendViewEvent } from "@/analytics";
|
||||
import { ContentRenderer } from "@/cms/ContentRenderer";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { getBlog, getPost } from "app/pub/queries";
|
||||
import { FadeIn } from "app/ui/fade-in";
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { subdomain, slug },
|
||||
}: {
|
||||
params: { subdomain: string; slug: string };
|
||||
}): Promise<Metadata> {
|
||||
console.log(subdomain);
|
||||
const { data: blog } = await getBlog(subdomain);
|
||||
const post = await getPost(subdomain, slug);
|
||||
|
||||
if (!blog) {
|
||||
return {
|
||||
title: "A zenblog blog",
|
||||
description: "Start writing your blog today",
|
||||
openGraph: {
|
||||
title: "A zenblog blog",
|
||||
description: "Start writing your blog today",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${post?.title} - ${blog?.title}` || "A zenblog blog",
|
||||
icons: {
|
||||
icon:
|
||||
`data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${blog?.emoji}</text></svg>` ||
|
||||
`data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎑</text></svg>`,
|
||||
},
|
||||
description: blog?.description || "Start writing your blog today",
|
||||
openGraph: {
|
||||
title: `${post?.title} - ${blog?.title}` || "A zenblog blog",
|
||||
description: blog?.description || "Start writing your blog today",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: `https://zenblog.com/api/public/v1/og?title=${post?.title}&emoji=${blog?.emoji}&url=${blog?.title}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Post = async ({
|
||||
params: { slug, subdomain },
|
||||
}: {
|
||||
params: { slug: string; subdomain: string };
|
||||
}) => {
|
||||
const { data: blog } = await getBlog(subdomain);
|
||||
const post = await getPost(subdomain, slug);
|
||||
|
||||
if (!blog) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendViewEvent({
|
||||
blog_id: blog?.id,
|
||||
post_slug: slug,
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return <div>Post not found</div>;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
|
||||
function getBlogUrl() {
|
||||
if (!baseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_BASE_URL");
|
||||
}
|
||||
const url = new URL(baseUrl);
|
||||
if (url.hostname.startsWith("www.")) {
|
||||
url.hostname = url.hostname.slice(4);
|
||||
}
|
||||
url.hostname = `${subdomain}.${url.hostname}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
const url = getBlogUrl();
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<nav className="mx-auto max-w-xl border-b border-zinc-100 px-4 py-2 text-sm">
|
||||
<Link
|
||||
href={url}
|
||||
className="flex items-center font-medium tracking-tight text-zinc-700"
|
||||
>
|
||||
<span className="mr-2 text-base">{blog?.emoji}</span>
|
||||
{blog?.title}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="mx-auto max-w-xl py-4">
|
||||
<div className="p-4">
|
||||
<p className="font-mono text-sm font-medium text-zinc-400">
|
||||
{formatDate(post.published_at || "")}
|
||||
</p>
|
||||
<h1 className="text-2xl font-medium">{post.title}</h1>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{post.cover_image && (
|
||||
<img
|
||||
className="rounded-lg"
|
||||
src={post.cover_image}
|
||||
height="400"
|
||||
width={(16 / 9) * 400}
|
||||
loading="lazy"
|
||||
alt={post.title || "Cover image"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-auto p-4">
|
||||
<ContentRenderer content={post.html_content || ""} />
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function Icon() {
|
||||
return (
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎯</text></svg>"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { getBlog } from "../queries";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<{
|
||||
params: { subdomain: string };
|
||||
}>) {
|
||||
const { subdomain } = params;
|
||||
const { data: blog } = await getBlog(subdomain);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<main className="min-h-[90vh]">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from "react";
|
||||
import { getBlog, getPosts } from "../queries";
|
||||
import { Metadata } from "next";
|
||||
import { BlogHomePage } from "../themes/blog-home";
|
||||
import { Theme } from "app/types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { subdomain },
|
||||
}: {
|
||||
params: { subdomain: string };
|
||||
}): Promise<Metadata> {
|
||||
console.log(subdomain);
|
||||
const { data: blog } = await getBlog(subdomain);
|
||||
|
||||
return {
|
||||
title: `${blog?.title}` || "A zenblog blog",
|
||||
icons: {
|
||||
icon:
|
||||
`data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${blog?.emoji}</text></svg>` ||
|
||||
`data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎑</text></svg>`,
|
||||
},
|
||||
description: blog?.description || "Start writing your blog today",
|
||||
openGraph: {
|
||||
title: `${blog?.title} - Zenblog` || "A zenblog blog",
|
||||
description: blog?.description || "Start writing your blog today",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function HostedBlog({
|
||||
params: { subdomain },
|
||||
}: {
|
||||
params: { subdomain: string };
|
||||
}) {
|
||||
const { data: blog, error: blogError } = await getBlog(subdomain);
|
||||
const { data: posts, error: postsError } = await getPosts(
|
||||
subdomain,
|
||||
blog?.order
|
||||
);
|
||||
|
||||
if (blogError || postsError) {
|
||||
return (
|
||||
<div className="flex-center p-12">
|
||||
<h1>Blog not found</h1>
|
||||
<p>{blogError?.message}</p>
|
||||
<p>{postsError?.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BlogHomePage theme={blog.theme as Theme} blog={blog} posts={posts} />;
|
||||
}
|
||||
|
||||
export default HostedBlog;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createClient } from "app/supa";
|
||||
|
||||
export async function getBlog(subdomain: string) {
|
||||
const supa = createClient();
|
||||
|
||||
const res = await supa
|
||||
.from("blogs")
|
||||
.select(
|
||||
"id, title, emoji, description, order, theme, twitter, instagram, website"
|
||||
)
|
||||
.eq("slug", subdomain)
|
||||
.single();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getPosts(subdomain: string, sort: string = "desc") {
|
||||
const supa = createClient();
|
||||
const res = await supa
|
||||
.from("posts_v5")
|
||||
.select("title, slug, published_at, cover_image, excerpt")
|
||||
.eq("blog_slug", subdomain)
|
||||
.eq("published", true)
|
||||
.order("published_at", { ascending: sort === "asc" });
|
||||
|
||||
return res as {
|
||||
data: {
|
||||
title: string;
|
||||
slug: string;
|
||||
published_at: string;
|
||||
cover_image: string;
|
||||
excerpt: string;
|
||||
}[];
|
||||
error: any;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPost(subdomain: string, slug: string) {
|
||||
const supa = createClient();
|
||||
const { data: post } = await supa
|
||||
.from("posts_v5")
|
||||
.select("title, cover_image, published_at, created_at, html_content")
|
||||
.eq("slug", slug)
|
||||
.eq("published", true)
|
||||
.eq("blog_slug", subdomain)
|
||||
.single();
|
||||
|
||||
return post;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Blog, Theme } from "app/types";
|
||||
import { DefaultHome } from "./default/home";
|
||||
import { DirectoryHome } from "./directory/home";
|
||||
import { NewsroomHome } from "./newsroom/home";
|
||||
import { GardenHome } from "./garden/home";
|
||||
import { InstrumentHome } from "./instrument/home";
|
||||
|
||||
export function BlogHomePage({
|
||||
theme,
|
||||
blog,
|
||||
posts,
|
||||
disableLinks = false,
|
||||
}: {
|
||||
theme: Theme;
|
||||
blog: Blog;
|
||||
posts: {
|
||||
title: string;
|
||||
slug: string;
|
||||
published_at: string;
|
||||
cover_image: string;
|
||||
excerpt: string;
|
||||
}[];
|
||||
disableLinks?: boolean;
|
||||
}) {
|
||||
const props = {
|
||||
blog,
|
||||
posts,
|
||||
disableLinks,
|
||||
};
|
||||
|
||||
if (theme === "directory") {
|
||||
return <DirectoryHome {...props} />;
|
||||
} else if (theme === "default") {
|
||||
return <DefaultHome {...props} />;
|
||||
} else if (theme === "newsroom") {
|
||||
return <NewsroomHome {...props} />;
|
||||
} else if (theme === "garden") {
|
||||
return <GardenHome {...props} />;
|
||||
} else if (theme === "instrument") {
|
||||
return <InstrumentHome {...props} />;
|
||||
} else {
|
||||
return <DefaultHome {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { formatPostDate } from "app/utils/dates";
|
||||
import { FadeIn } from "app/ui/fade-in";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export function BlogPostItem({
|
||||
post,
|
||||
disableLinks,
|
||||
index,
|
||||
}: {
|
||||
post: {
|
||||
title: string;
|
||||
slug: string;
|
||||
published_at: string;
|
||||
excerpt: string;
|
||||
};
|
||||
disableLinks?: boolean;
|
||||
index: number;
|
||||
}) {
|
||||
const date = formatPostDate(post.published_at);
|
||||
const [rightText, setRightText] = useState(date);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseEnter={() => setRightText("Read more →")}
|
||||
onMouseLeave={() => setRightText(date)}
|
||||
>
|
||||
<FadeIn delay={index * 0.05} key={post.slug}>
|
||||
<Link
|
||||
className="group grid p-2 transition-all hover:bg-zinc-50 sm:rounded-lg"
|
||||
key={post.slug}
|
||||
href={disableLinks ? "#" : `/${post.slug}`}
|
||||
>
|
||||
<div className="flex-wrap items-center justify-between sm:flex">
|
||||
<span className="text-zinc-700 group-hover:text-zinc-950">
|
||||
{post.title}
|
||||
</span>
|
||||
<motion.div
|
||||
key={rightText}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="hidden gap-2 text-right font-mono text-xs tracking-tight text-zinc-400 sm:block"
|
||||
>
|
||||
{rightText}
|
||||
</motion.div>
|
||||
</div>
|
||||
{post.excerpt && (
|
||||
<p className="font-mono text-xs text-zinc-500">{post.excerpt}</p>
|
||||
)}
|
||||
</Link>
|
||||
</FadeIn>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Blog } from "app/types";
|
||||
import { SocialLinks } from "app/ui/SocialLinks";
|
||||
import { ZenblogFooter } from "app/ui/zenblog-footer";
|
||||
import React from "react";
|
||||
import { BlogPostItem } from "./blog-post-item";
|
||||
|
||||
export function DefaultHome({
|
||||
posts,
|
||||
blog,
|
||||
disableLinks,
|
||||
}: {
|
||||
posts: {
|
||||
title: string;
|
||||
slug: string;
|
||||
published_at: string;
|
||||
cover_image: string;
|
||||
excerpt: string;
|
||||
}[];
|
||||
blog: Blog;
|
||||
disableLinks?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-2 py-8">
|
||||
<div className="mb-8 grid p-2">
|
||||
<h2 className="flex flex-col gap-2 font-medium">
|
||||
<span className="text-xl">{blog?.emoji}</span>
|
||||
<span className="text-lg text-zinc-800">{blog?.title}</span>
|
||||
</h2>
|
||||
{blog?.description && (
|
||||
<p className="font-mono text-sm text-zinc-500">{blog?.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SocialLinks
|
||||
className="mb-2 mt-2 gap-1 border-b pb-2 font-mono text-xs"
|
||||
linkClassName="px-2 py-1 text-zinc-500 hover:text-zinc-800 hover:bg-zinc-100 transition-all rounded-lg hover:cursor-default"
|
||||
links={blog}
|
||||
/>
|
||||
|
||||
{posts?.length === 0 && (
|
||||
<>
|
||||
<div className="">
|
||||
<h2 className="font-medium">No posts yet</h2>
|
||||
<p className="font-mono text-zinc-500">
|
||||
Check back later, see you soon!
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="divide-y sm:divide-y-0">
|
||||
{posts?.map((post, index) => (
|
||||
<BlogPostItem
|
||||
key={post.slug}
|
||||
post={post}
|
||||
index={index}
|
||||
disableLinks={disableLinks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ZenblogFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Blog } from "app/types";
|
||||
import { FadeIn } from "app/ui/fade-in";
|
||||
import { formatPostDate } from "app/utils/dates";
|
||||
import Link from "next/link";
|
||||
import { Bricolage_Grotesque } from "next/font/google";
|
||||
import { ZenblogFooter } from "app/ui/zenblog-footer";
|
||||
import { SocialLinks } from "app/ui/SocialLinks";
|
||||
|
||||
const h1Font = Bricolage_Grotesque({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
weight: "500",
|
||||
});
|
||||
|
||||
export function DirectoryHome({
|
||||
blog,
|
||||
posts,
|
||||
disableLinks,
|
||||
}: {
|
||||
blog: Blog;
|
||||
posts: {
|
||||
title: string;
|
||||
slug: string;
|
||||
published_at: string;
|
||||
cover_image: string;
|
||||
excerpt: string;
|
||||
}[];
|
||||
disableLinks?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen tracking-tight">
|
||||
<div className="mx-auto max-w-4xl p-3 py-12">
|
||||
<h2 className="inline rounded-full bg-white font-mono text-sm font-medium text-orange-500">
|
||||
{blog.title}
|
||||
</h2>
|
||||
<h1
|
||||
className={`mt-2 text-2xl font-medium md:text-3xl ${h1Font.className}`}
|
||||
>
|
||||
{blog.description}
|
||||
</h1>
|
||||
<SocialLinks
|
||||
className="mt-6"
|
||||
linkClassName="hover:bg-orange-50 hover:text-orange-600"
|
||||
links={blog}
|
||||
/>
|
||||
</div>
|
||||
{posts.length === 0 && (
|
||||
<div className="mx-auto max-w-4xl p-3 py-12">
|
||||
<h2 className="font-medium">No posts yet</h2>
|
||||
<p className="font-mono text-zinc-500">
|
||||
Check back later, see you soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mx-auto grid max-w-5xl gap-6 px-3 md:grid-cols-3">
|
||||
{posts.map((post, idx) => (
|
||||
<FadeIn key={post.slug} delay={idx * 0.1}>
|
||||
<Link
|
||||
href={disableLinks ? "#" : `/${post.slug}`}
|
||||
className="group block rounded-lg"
|
||||
>
|
||||
{post.cover_image ? (
|
||||
<div className="overflow-hidden rounded-md">
|
||||
<img
|
||||
width={300}
|
||||
height={300}
|
||||
className="mx-auto h-[240px] w-full rounded-md border bg-white object-cover transition-all group-hover:opacity-80"
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[240px] w-full items-center justify-center bg-zinc-50 text-2xl">
|
||||
⛩️
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 ">
|
||||
<h3 className="font-medium group-hover:text-orange-500">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="flex gap-4 font-mono text-xs text-zinc-500">
|
||||
<span>{formatPostDate(post.published_at)}</span>
|
||||
<span className="opacity-0 transition-all group-hover:opacity-80">
|
||||
Read more →
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
<ZenblogFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { BlogHomeProps } from "app/types";
|
||||
import { SocialLinks } from "app/ui/SocialLinks";
|
||||
import { ZenblogFooter } from "app/ui/zenblog-footer";
|
||||
import { formatPostDate } from "app/utils/dates";
|
||||
import { Cormorant_Garamond } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
|
||||
const serif = Cormorant_Garamond({
|
||||
display: "swap",
|
||||
weight: "500",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
function Separator() {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center text-center text-5xl text-emerald-500 ${serif.className}`}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="27"
|
||||
viewBox="0 0 165 27"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 26C3.66666 10.3333 13.8333 2.5 30.5 2.5C43.8333 2.5 61.3333 5.66667 83 12C107.333 17.3333 124 20 133 20C142.333 20 148.833 18.8333 152.5 16.5C156.167 13.8333 158.833 8.5 160.5 0.5L165 1.5C162 15.8333 154 23.6667 141 25C138.667 25.3333 134 25.5 127 25.5C120.333 25.5 109.167 23 93.5 18C88.5 17 80 15.1667 68 12.5C53.6667 9.16667 41.8333 7.5 32.5 7.5C23.5 7.5 17 8.83334 13 11.5C9.33333 14.1667 6.66667 19.3333 5 27L0 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GardenHome({ blog, posts, disableLinks }: BlogHomeProps) {
|
||||
function toLink(href: string) {
|
||||
if (disableLinks) {
|
||||
return "#";
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-screen max-w-3xl bg-white">
|
||||
<header>
|
||||
<div className="mx-auto max-w-3xl p-3 py-8 text-center text-sm">
|
||||
<span className="text-xl">{blog.emoji}</span>
|
||||
<h1
|
||||
className={`text-3xl font-medium tracking-tight ${serif.className}`}
|
||||
>
|
||||
{blog?.title}
|
||||
</h1>
|
||||
<p className="py-1 text-lg tracking-tight text-gray-500">
|
||||
{blog?.description}
|
||||
</p>
|
||||
<SocialLinks
|
||||
className={`mt-4 justify-center gap-1 font-mono`}
|
||||
linkClassName="hover:text-emerald-600 hover:bg-emerald-50 transition-all rounded-lg px-2 py-1 text-gray-500 hover:cursor-default"
|
||||
links={blog}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<Separator />
|
||||
<div className="p-3">
|
||||
<h2 className={`${serif.className} px-4 text-2xl italic`}>Blog </h2>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="py-8">
|
||||
<h2 className={`text-2xl font-medium ${serif.className}`}>
|
||||
No posts yet
|
||||
</h2>
|
||||
<p className="text-gray-500">Check back later, see you soon!</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="divide mt-4 grid gap-0 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={toLink(`/${post.slug}`)}
|
||||
className="group flex gap-3 rounded-lg p-2 transition-all hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex h-24 w-full max-w-32 justify-center overflow-hidden rounded-lg border bg-white">
|
||||
{post.cover_image ? (
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
height={128}
|
||||
width={128}
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center text-2xl">🍃</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col leading-5">
|
||||
<h3 className="font-medium">{post.title}</h3>
|
||||
<p className="text-xs text-gray-500">{post.excerpt}</p>
|
||||
<p className="mt-4 justify-self-end text-right align-bottom font-mono text-xs text-gray-300 group-hover:text-gray-400">
|
||||
{formatPostDate(post.published_at)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ZenblogFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { BlogHomeProps } from "app/types";
|
||||
import { formatPostDate } from "app/utils/dates";
|
||||
import Link from "next/link";
|
||||
import { Instrument_Serif, Instrument_Sans } from "next/font/google";
|
||||
import { SocialLinks } from "app/ui/SocialLinks";
|
||||
|
||||
const serif = Instrument_Serif({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
});
|
||||
|
||||
const sans = Instrument_Sans({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export function InstrumentHome({ posts, blog, disableLinks }: BlogHomeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen bg-zinc-900 py-8 text-white ${sans.className}`}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="max-w-xl">
|
||||
<header className="p-6">
|
||||
<h1 className={`${serif.className} text-2xl`}>{blog.title}</h1>
|
||||
<p className=" text-zinc-300">{blog.description}</p>
|
||||
<SocialLinks
|
||||
links={blog}
|
||||
className="mt-4 text-sm text-white"
|
||||
linkClassName="p-0 mr-4 hover:bg-transparent hover:text-orange-500 py-2 text-zinc-300"
|
||||
/>
|
||||
</header>
|
||||
<div className="m-3 flex gap-1">
|
||||
<div className="h-1 w-60 -skew-x-[30deg] bg-orange-600"></div>
|
||||
<div className="h-1 w-2 -skew-x-[30deg] bg-orange-600"></div>
|
||||
<div className="h-1 w-2 -skew-x-[30deg] bg-orange-600"></div>
|
||||
</div>
|
||||
<main>
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
className="group block cursor-default p-4 px-6 transition-all hover:bg-zinc-800/50"
|
||||
href={disableLinks ? "#" : `/${post.slug}`}
|
||||
>
|
||||
<div className="flex justify-between ">
|
||||
<h2 className="group-hover:text-orange-500">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="font-mono text-xs text-zinc-400">
|
||||
{formatPostDate(post.published_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{post.excerpt && (
|
||||
<p className="text-zinc-400">{post.excerpt}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<footer className="mt-6 border-t border-zinc-800 p-6">
|
||||
<p className="font-mono italic text-zinc-300">
|
||||
Powered by{" "}
|
||||
<a className="underline" href="https://zenblog.com">
|
||||
zenblog
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { BlogHomeProps } from "app/types";
|
||||
import { FadeIn } from "app/ui/fade-in";
|
||||
import { ZenblogFooter } from "app/ui/zenblog-footer";
|
||||
import { formatPostDate } from "app/utils/dates";
|
||||
import Link from "next/link";
|
||||
|
||||
export function NewsroomHome({ posts, blog, disableLinks }: BlogHomeProps) {
|
||||
const latestPost = posts[0];
|
||||
const postsWithoutLatest = posts.slice(1);
|
||||
|
||||
return (
|
||||
<div className="bg-white text-stone-700">
|
||||
<header className="bg-stone-800 text-white">
|
||||
<div className="mx-auto flex max-w-3xl p-3 text-sm">
|
||||
<h2 className="font-medium">{blog?.title}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-3xl p-3 pt-6 text-xl font-medium">Blog</div>
|
||||
|
||||
{posts?.length === 0 && (
|
||||
<>
|
||||
<div className="">
|
||||
<h2 className="font-medium">No posts yet</h2>
|
||||
<p className="font-mono text-stone-500">
|
||||
Check back later, see you soon!
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-100/80 p-4">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Link
|
||||
href={disableLinks ? "#" : `/${latestPost?.slug}`}
|
||||
className="grid overflow-hidden rounded-2xl border bg-white transition-all md:grid-cols-6"
|
||||
>
|
||||
{latestPost?.cover_image && (
|
||||
<img
|
||||
className="col-span-4 w-full object-cover"
|
||||
src={latestPost?.cover_image}
|
||||
alt={latestPost?.title}
|
||||
/>
|
||||
)}
|
||||
<div className="col-span-2 flex flex-col justify-end p-4">
|
||||
<div>
|
||||
<span className="rounded-full text-xs font-medium text-stone-400">
|
||||
LATEST
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium">{latestPost?.title}</h2>
|
||||
<p className="mt-auto font-mono text-sm text-stone-600">
|
||||
{formatPostDate(latestPost?.published_at || "")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||||
{postsWithoutLatest?.map((post, index) => (
|
||||
<FadeIn delay={index * 0.05} key={post.slug}>
|
||||
<Link
|
||||
className="group grid overflow-hidden rounded-xl border bg-white transition-all hover:bg-stone-50"
|
||||
key={post.slug}
|
||||
href={disableLinks ? "#" : `/${post.slug}`}
|
||||
>
|
||||
{post.cover_image ? (
|
||||
<img
|
||||
className="h-40 w-full object-cover"
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-40 w-full items-center justify-center bg-stone-800 text-4xl">
|
||||
📰
|
||||
</div>
|
||||
)}
|
||||
<div className="grid p-2 px-3">
|
||||
<span className="text-zinc-800 group-hover:text-zinc-950">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="font-mono text-xs tracking-tight text-stone-400 sm:text-sm">
|
||||
{formatPostDate(post.published_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ZenblogFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import posthog from "posthog-js";
|
||||
import { IS_DEV } from "./is-dev";
|
||||
import { isPreviewOrDevDeployment } from "@/lib/runtime-env";
|
||||
|
||||
type EventId = "feedback";
|
||||
|
||||
@@ -9,7 +9,7 @@ export function posthogCapture(
|
||||
) {
|
||||
posthog.capture(eventId, {
|
||||
...payload,
|
||||
is_dev: IS_DEV,
|
||||
is_dev: isPreviewOrDevDeployment(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ export function posthogCaptureException(
|
||||
) {
|
||||
posthog.captureException(error, {
|
||||
...payload,
|
||||
is_dev: IS_DEV,
|
||||
is_dev: isPreviewOrDevDeployment(),
|
||||
});
|
||||
}
|
||||
|
||||
export function posthogIdentify({ email }: { email: string }) {
|
||||
posthog.identify(email, {
|
||||
email,
|
||||
is_dev: IS_DEV,
|
||||
is_dev: isPreviewOrDevDeployment(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const PRODUCTION_HOSTNAMES = new Set([ "zenblog.com", "www.zenblog.com" ]);
|
||||
|
||||
export function isProductionDeployment() {
|
||||
if (typeof window === "undefined") {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
return PRODUCTION_HOSTNAMES.has(window.location.hostname);
|
||||
}
|
||||
|
||||
export function isPreviewOrDevDeployment() {
|
||||
return !isProductionDeployment();
|
||||
}
|
||||
@@ -5,3 +5,11 @@ export const isPublicPath = (path: string) => {
|
||||
path.match(new RegExp(`^${x}$`.replace("*$", "($|/)")))
|
||||
);
|
||||
};
|
||||
|
||||
export const getOAuthRedirectUrl = (path = "/sign-in") => {
|
||||
if (typeof window === "undefined") {
|
||||
return path;
|
||||
}
|
||||
|
||||
return new URL(path, window.location.origin).toString();
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updateSession } from "./utils/supabase/middleware";
|
||||
|
||||
// inspired by https://github.com/vercel/platforms/blob/main/middleware.ts
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
@@ -16,24 +14,6 @@ export const config = {
|
||||
],
|
||||
};
|
||||
|
||||
const invalidSubdomains = [
|
||||
"www",
|
||||
"localhost:3000",
|
||||
"localhost:8082",
|
||||
"zenblog",
|
||||
"127",
|
||||
];
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
const subdomain = req.headers.get("host")?.split(".")[0];
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
if (!subdomain || invalidSubdomains.includes(subdomain)) {
|
||||
return await updateSession(req);
|
||||
}
|
||||
|
||||
const newPath = `/pub/${subdomain}${path}`;
|
||||
const url = new URL(newPath, req.url);
|
||||
|
||||
return NextResponse.rewrite(url);
|
||||
return await updateSession(req);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useEffect, useState } from "react";
|
||||
import { UserProvider } from "@/utils/supabase/browser";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import posthog from "posthog-js";
|
||||
import { isPreviewOrDevDeployment } from "@/lib/runtime-env";
|
||||
|
||||
// Fonts
|
||||
const inter = Inter({
|
||||
@@ -43,7 +44,16 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||
defaults: "2025-05-24",
|
||||
// Enable debug mode in development
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === "development") posthog.debug();
|
||||
const isPreviewOrDev = isPreviewOrDevDeployment();
|
||||
|
||||
posthog.setPersonPropertiesForFlags(
|
||||
{
|
||||
is_dev: isPreviewOrDev,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
if (isPreviewOrDev) posthog.debug();
|
||||
},
|
||||
});
|
||||
console.log("PostHog initialized", posthog);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase";
|
||||
import { isProductionDeployment } from "@/lib/runtime-env";
|
||||
import { getOAuthRedirectUrl } from "@/lib/utils/auth";
|
||||
import { useUser } from "@/utils/supabase/browser";
|
||||
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
|
||||
import { CornerUpLeft, Loader2 } from "lucide-react";
|
||||
@@ -17,8 +19,15 @@ export default function SignIn() {
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const [isGoogleSignInEnabled, setIsGoogleSignInEnabled] = useState(false);
|
||||
const [isTurnstileEnabled, setIsTurnstileEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTurnstileEnabled(isProductionDeployment());
|
||||
setIsGoogleSignInEnabled(
|
||||
window.localStorage.getItem("google-signin") === "true"
|
||||
);
|
||||
|
||||
if (user) {
|
||||
router.push("/blogs");
|
||||
return;
|
||||
@@ -30,6 +39,22 @@ export default function SignIn() {
|
||||
});
|
||||
}, [router, supabase, user]);
|
||||
|
||||
async function onGoogleAuth() {
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
redirectTo: getOAuthRedirectUrl("/sign-in"),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || "Error signing in with Google");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -38,19 +63,21 @@ export default function SignIn() {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
if (!captchaToken) {
|
||||
if (isTurnstileEnabled && !captchaToken) {
|
||||
toast.error("Please complete the captcha");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
captchaToken,
|
||||
},
|
||||
options: isTurnstileEnabled
|
||||
? {
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -87,6 +114,16 @@ export default function SignIn() {
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
{isGoogleSignInEnabled ? (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Button type="button" variant="white" onClick={onGoogleAuth}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
<p className="text-center text-xs uppercase tracking-[0.2em] text-slate-400">
|
||||
Or use email
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input required type="email" name="email" />
|
||||
@@ -103,12 +140,14 @@ export default function SignIn() {
|
||||
</Label>
|
||||
<Input required type="password" name="password" />
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
/>
|
||||
{isTurnstileEnabled ? (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Button type="submit">Sign in</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import Spinner from "@/components/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { isProductionDeployment } from "@/lib/runtime-env";
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase";
|
||||
import { getOAuthRedirectUrl } from "@/lib/utils/auth";
|
||||
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
|
||||
import { CornerUpLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -17,8 +19,15 @@ export default function SignIn() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
const router = useRouter();
|
||||
const [isGoogleSignInEnabled, setIsGoogleSignInEnabled] = useState(false);
|
||||
const [isTurnstileEnabled, setIsTurnstileEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTurnstileEnabled(isProductionDeployment());
|
||||
setIsGoogleSignInEnabled(
|
||||
window.localStorage.getItem("google-signin") === "true"
|
||||
);
|
||||
|
||||
supabase.auth.getSession().then((res) => {
|
||||
if (res.data.session?.user) {
|
||||
router.push("/blogs");
|
||||
@@ -26,6 +35,23 @@ export default function SignIn() {
|
||||
});
|
||||
}, [router, supabase]);
|
||||
|
||||
async function onGoogleAuth() {
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
redirectTo: getOAuthRedirectUrl("/sign-in"),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Error signing in with Google", error);
|
||||
toast.error(error.message || "Error signing in with Google");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -35,7 +61,7 @@ export default function SignIn() {
|
||||
const email = form.email.value;
|
||||
const password = form.password.value;
|
||||
|
||||
if (!captchaToken) {
|
||||
if (isTurnstileEnabled && !captchaToken) {
|
||||
toast.error("Please complete the captcha");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -45,9 +71,11 @@ export default function SignIn() {
|
||||
const { data, error } = await sb.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
captchaToken,
|
||||
},
|
||||
options: isTurnstileEnabled
|
||||
? {
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -75,7 +103,9 @@ export default function SignIn() {
|
||||
Please, <span className="underline">check your email</span> 📧 to
|
||||
confirm your account.
|
||||
</p>
|
||||
<p className="">You can close this tab.</p>
|
||||
<p className="">
|
||||
After signing in, add a payment method to start your 7-day trial.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +119,16 @@ export default function SignIn() {
|
||||
</div>
|
||||
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<h1 className="text-2xl font-medium">Create your account</h1>
|
||||
{isGoogleSignInEnabled ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button type="button" variant="white" onClick={onGoogleAuth}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
<p className="text-center text-xs uppercase tracking-[0.2em] text-slate-400">
|
||||
Or create an account with email
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input required type="email" name="email" />
|
||||
@@ -97,12 +137,14 @@ export default function SignIn() {
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input required type="password" name="password" />
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
/>
|
||||
{isTurnstileEnabled ? (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
/>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function getHostedBlogUrl(slug: string) {
|
||||
const url = new URL(process.env.NEXT_PUBLIC_BASE_URL || "");
|
||||
// add blog slug as subdomain
|
||||
// remove www
|
||||
if (url.hostname.startsWith("www.")) {
|
||||
url.hostname = url.hostname.slice(4);
|
||||
}
|
||||
url.hostname = `${slug}.${url.hostname}`;
|
||||
return url;
|
||||
}
|
||||
Reference in New Issue
Block a user