Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Unit tests
run: npm test

# Also validates every MDX post compiles and every static route
# (articles, share images, sitemap) prerenders.
- name: Build
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# TechCoder

TechCoder is a technology and lifestyle platform: clear, trustworthy writing on
the technology shaping how we build, work, and live, without the noise.

- **Domain:** [techcoder.tech](https://techcoder.tech)
- **What you'll find:** technical articles, AI news and explainers, honest
gadget and product reviews, and programming guides and tutorials.
- **Who it's for:** developers, students, and curious tech enthusiasts who want
reliable, well-explained takes on technology. Readers can optionally sign in
to like articles and join the discussion.

## Repository

The application lives in [`morya-techcoder/`](./morya-techcoder/) — see
[`morya-techcoder/README.md`](./morya-techcoder/README.md) to get started.
9 changes: 9 additions & 0 deletions morya-techcoder/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ NEXT_PUBLIC_SITE_URL=
# on a real deployment — storage is `local`, so it would expose an
# unauthenticated filesystem write API. See docs/keystatic.md.
ENABLE_KEYSTATIC=

# Reader accounts, likes and responses (Supabase). Both values are public and
# safe in the browser — Row Level Security protects the data. Without them the
# site works; sign-in, likes and responses are simply hidden.
# Hosted project: Dashboard → Project Settings → API Keys.
# The app never uses the service-role/secret key — do not add it here.
# Rebuild after changing these: they're inlined into the client bundle and CSP.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
20 changes: 15 additions & 5 deletions morya-techcoder/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
**TechCoder** is an MDX-based tech publishing platform: Next.js 16 (App
Router) + React 19 + Tailwind CSS v4, with [Keystatic](https://keystatic.com)
(a Git-based CMS) as a visual editor on top of plain `.mdx`/`.json` files.
Static site generation, no database.
Static site generation. Content is never stored in a database. Supabase backs
only the optional reader accounts, likes and responses.

**This file is a map, not the manual.** Full, current documentation lives in
[`docs/`](./docs/README.md) — read [docs/README.md](./docs/README.md) first,
Expand All @@ -19,7 +20,10 @@ misleading.
## Fast orientation

- Content lives in Git: `content/posts/*.mdx` (articles) and
`content/authors/*.json` (bylines). No database.
`content/authors/*.json` (bylines). Never move it into a database. The only
database is Supabase, for optional reader accounts, likes and responses
([docs/auth-and-engagement.md](./docs/auth-and-engagement.md)). It loads
client-side, so articles stay static.
- One-way data flow: `content/loader.ts` (data) → server components → client
components (props only). Never import `content/loader.ts` into a client
component — it's `server-only`.
Expand Down Expand Up @@ -80,8 +84,8 @@ short version, because they're easy to reintroduce without realizing it:
- An author slug on a post is resolved to a full record in `lib/author.ts`;
unset falls back to `DEFAULT_AUTHOR_SLUG` (`team-techcoder`).
- CI (`.github/workflows/ci.yml` at the Git root) runs `npm run lint`,
`npm run typecheck` (`next typegen && tsc --noEmit`) and `npm run build`.
Run all three before finishing a change.
`npm run typecheck` (`next typegen && tsc --noEmit`), `npm test` and
`npm run build`. Run them all before finishing a change.
- Reading time is always computed from the body (`calcReadingTime`, 238 wpm)
— never add it as a frontmatter field.
- `npm run dev` after a large dependency change (e.g. bumping
Expand All @@ -96,4 +100,10 @@ welcome email (`RESEND_AUDIENCE_ID` is optional, for older Resend
workspaces), `NEXT_PUBLIC_SITE_URL` sets the public origin when not on Vercel, and
`ENABLE_KEYSTATIC=true` re-enables `/keystatic` in a local production build
(`next build && next start`) — never set it on a real deployment while
storage is `local`.
storage is `local`. `NEXT_PUBLIC_SUPABASE_URL` +
`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` enable reader accounts. Without them
that UI hides itself. Never add the Supabase service-role/secret key: every
write runs as the signed-in reader under RLS. Schema changes go in a new file
in `supabase/migrations/` (apply with `npx supabase db push`), and
`npx supabase test db --linked` must still pass. There is no local Supabase
stack.
8 changes: 5 additions & 3 deletions morya-techcoder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
A developer-first publication — technical blogs, AI news, and programming
tutorials — built with Next.js 16, React 19, and Tailwind CSS v4. Content is
authored in MDX and edited visually with [Keystatic](https://keystatic.com), a
Git-based CMS. No database.
Git-based CMS. Content never touches a database; optional reader accounts
(likes and responses) use Supabase.

## Quick start

Expand All @@ -21,8 +22,9 @@ npm run dev
- Content editor: <http://localhost:3000/keystatic>

Everything works out of the box with no `.env` at all — the newsletter signup
form is the only feature that needs one, and it fails closed (shows an error,
doesn't crash) without it. See [`.env.example`](./.env.example) for what each
form and optional reader accounts (likes/responses, via Supabase — see
[docs/auth-and-engagement.md](./docs/auth-and-engagement.md)) are the only
features that need one, and both fail closed without it. See [`.env.example`](./.env.example) for what each
variable does. If you deploy anywhere other than Vercel, set
`NEXT_PUBLIC_SITE_URL` (e.g. `https://techcoder.tech`), or canonical URLs and
the sitemap will point at `http://localhost:3000`.
Expand Down
33 changes: 33 additions & 0 deletions morya-techcoder/app/(site)/account/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use server";

import { revalidatePath } from "next/cache";
import { createClient, getVerifiedUser } from "@/lib/supabase/server";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { normalizeDisplayName } from "@/lib/engagement/validation";

export type ProfileFormState = { status: "idle" | "saved" | "error"; message?: string };

/** Updates the signed-in reader's display name (the only editable field). */
export async function updateDisplayName(_prev: ProfileFormState, formData: FormData): Promise<ProfileFormState> {
if (!isSupabaseConfigured) return { status: "error", message: "Accounts are not available right now." };

const name = normalizeDisplayName(formData.get("displayName"));
if (!name.ok) return { status: "error", message: name.error };

const supabase = await createClient();
const user = await getVerifiedUser(supabase);
if (!user) return { status: "error", message: "Your session has expired. Please sign in again." };

const { data, error } = await supabase
.from("profiles")
.update({ display_name: name.value })
.eq("id", user.id)
.select("id");
if (error || !data?.length) {
if (error) console.error("[account] update profile:", error.code, error.message);
return { status: "error", message: "Couldn't save your name. Please try again." };
}

revalidatePath("/account");
return { status: "saved", message: "Saved." };
}
58 changes: 58 additions & 0 deletions morya-techcoder/app/(site)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { redirect } from "next/navigation";
import { AuthShell, TextLink } from "@/components/auth/AuthUi";
import { ProfileForm, SignOutButton } from "@/components/auth/ProfileForm";
import UserAvatar from "@/components/ui/UserAvatar";
import { authMetadata } from "@/lib/auth";
import { createClient, getVerifiedUser } from "@/lib/supabase/server";
import { isSupabaseConfigured } from "@/lib/supabase/config";

export const metadata = authMetadata("Your profile");

/** Minimal profile: avatar, display name (editable), email, password, sign out. */
export default async function AccountPage() {
if (!isSupabaseConfigured) redirect("/auth/sign-in?error=unavailable");
const supabase = await createClient();
const user = await getVerifiedUser(supabase);
if (!user) redirect("/auth/sign-in?next=%2Faccount");

const { data: profile } = await supabase
.from("profiles")
.select("display_name, avatar_url")
.eq("id", user.id)
.maybeSingle();
const displayName = profile?.display_name ?? "Reader";
const hasPassword = user.identities?.some((identity) => identity.provider === "email") ?? false;

return (
<AuthShell overline="Account" title="Your profile">
<div className="flex items-center gap-4">
<UserAvatar name={displayName} src={profile?.avatar_url} size={56} />
<div className="min-w-0">
<p className="truncate font-semibold text-tc-text">{displayName}</p>
<p className="truncate text-sm text-tc-text-light">{user.email}</p>
</div>
</div>

<div className="mt-8 border-t border-tc-border pt-6">
<ProfileForm displayName={displayName} />
</div>

<dl className="mt-8 divide-y divide-tc-border border-y border-tc-border text-sm">
<div className="flex items-center justify-between gap-4 py-3.5">
<dt className="text-tc-text-light">Email</dt>
<dd className="truncate font-medium text-tc-text">{user.email}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3.5">
<dt className="text-tc-text-light">Password</dt>
<dd>
<TextLink href="/auth/update-password">{hasPassword ? "Change password" : "Set a password"}</TextLink>
</dd>
</div>
</dl>

<div className="mt-6">
<SignOutButton />
</div>
</AuthShell>
);
}
52 changes: 52 additions & 0 deletions morya-techcoder/app/(site)/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextResponse, type NextRequest } from "next/server";
import type { EmailOtpType } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";
import { isSupabaseConfigured } from "@/lib/supabase/config";
import { safeNextPath } from "@/lib/engagement/validation";

const OTP_TYPES: ReadonlySet<string> = new Set(["email", "signup", "recovery", "email_change", "invite", "magiclink"]);

// Relative Location: the browser resolves it against the URL it actually used,
// so the session cookie and the destination always share a host (proxies and
// tunnels can rewrite the Host header the server sees).
function redirectTo(path: string) {
return new NextResponse(null, { status: 303, headers: { Location: path } });
}

/**
* Lands every auth redirect: Google OAuth and default (PKCE) email links send
* `?code=`; the token-hash email templates (docs/auth-and-engagement.md) send
* `?token_hash=&type=`. Either way the session cookie is set here and the
* reader continues to the sanitized `next` path.
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const next = safeNextPath(searchParams.get("next"));
const code = searchParams.get("code");
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type");

const fail = (reason: string) => {
const params = new URLSearchParams({ error: reason });
if (next !== "/") params.set("next", next);
return redirectTo(`/auth/sign-in?${params}`);
};

if (!isSupabaseConfigured) return fail("unavailable");
// Provider-side errors (e.g. the user cancelled Google consent).
if (searchParams.get("error")) return fail("cancelled");

const supabase = await createClient();

if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return fail("link");
} else if (tokenHash && type && OTP_TYPES.has(type)) {
const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type: type as EmailOtpType });
if (error) return fail("link");
} else {
return fail("link");
}

return redirectTo(next);
}
15 changes: 15 additions & 0 deletions morya-techcoder/app/(site)/auth/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AuthPage } from "@/components/auth/AuthUi";
import { AuthFlow } from "@/components/auth/AuthFlow";
import { authMetadata, readNext, type AuthSearchParams } from "@/lib/auth";

export const metadata = authMetadata("Reset password");

export default async function ForgotPasswordPage({ searchParams }: { searchParams: AuthSearchParams }) {
const next = await readNext(searchParams);

return (
<AuthPage>
<AuthFlow view="forgot-password" next={next} />
</AuthPage>
);
}
25 changes: 25 additions & 0 deletions morya-techcoder/app/(site)/auth/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AuthPage } from "@/components/auth/AuthUi";
import { AuthFlow } from "@/components/auth/AuthFlow";
import { authMetadata, readNext, redirectIfSignedIn, type AuthSearchParams } from "@/lib/auth";

export const metadata = authMetadata("Sign in");

const CALLBACK_ERRORS: Record<string, string> = {
link: "That sign-in link is invalid or has expired. Please try again.",
cancelled: "Sign-in was cancelled.",
unavailable: "Accounts are not available right now.",
};

/** Direct URL and the landing spot for /auth/callback errors; the navbar opens the same flow in a dialog. */
export default async function SignInPage({ searchParams }: { searchParams: AuthSearchParams }) {
const next = await readNext(searchParams);
await redirectIfSignedIn(next);
const errorKey = (await searchParams).error;
const callbackError = typeof errorKey === "string" ? CALLBACK_ERRORS[errorKey] : undefined;

return (
<AuthPage>
<AuthFlow view="sign-in" next={next} notice={callbackError} />
</AuthPage>
);
}
16 changes: 16 additions & 0 deletions morya-techcoder/app/(site)/auth/sign-up/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AuthPage } from "@/components/auth/AuthUi";
import { AuthFlow } from "@/components/auth/AuthFlow";
import { authMetadata, readNext, redirectIfSignedIn, type AuthSearchParams } from "@/lib/auth";

export const metadata = authMetadata("Create account");

export default async function SignUpPage({ searchParams }: { searchParams: AuthSearchParams }) {
const next = await readNext(searchParams);
await redirectIfSignedIn(next);

return (
<AuthPage>
<AuthFlow view="sign-up" next={next} />
</AuthPage>
);
}
22 changes: 22 additions & 0 deletions morya-techcoder/app/(site)/auth/update-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthUi";
import UpdatePasswordForm from "@/components/auth/UpdatePasswordForm";
import { authMetadata } from "@/lib/auth";
import { createClient, getVerifiedUser } from "@/lib/supabase/server";
import { isSupabaseConfigured } from "@/lib/supabase/config";

export const metadata = authMetadata("Choose a new password");

/** Reached from the password-reset email (via /auth/callback) or the profile page. */
export default async function UpdatePasswordPage() {
if (!isSupabaseConfigured) redirect("/auth/sign-in?error=unavailable");
const supabase = await createClient();
const user = await getVerifiedUser(supabase);
if (!user) redirect("/auth/sign-in?error=link");

return (
<AuthShell overline="Account" title="Choose a new password" intro={`For ${user.email}.`}>
<UpdatePasswordForm />
</AuthShell>
);
}
10 changes: 8 additions & 2 deletions morya-techcoder/app/(site)/blog/[slug]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { JetBrains_Mono } from "next/font/google";
import ReadingLayer from "@/components/reading/ReadingLayer";
import { EngagementProvider } from "@/components/engagement/EngagementProvider";
import "./article.css";

const jetbrainsMono = JetBrains_Mono({
Expand All @@ -12,15 +13,20 @@ const jetbrainsMono = JetBrains_Mono({
/**
* Article route layout — scopes JetBrains Mono + immersive reading chrome
* (progress / floating TOC) so Framer Motion never ships on home or list pages.
* Also hosts the claps/comments island, which loads client-side so the
* article HTML stays static.
*/
export default function ArticleLayout({
export default async function ArticleLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ slug: string }>;
}>) {
const { slug } = await params;
return (
<div className={jetbrainsMono.variable}>
{children}
<EngagementProvider slug={slug}>{children}</EngagementProvider>
{/*
Outside the article content tree so fixed chrome isn't trapped in
main's stacking context. Self-gates on reading context.
Expand Down
Loading
Loading