2026-02-19 23:20:19 +09:00
commit 0e21562088
139 changed files with 35467 additions and 0 deletions

129
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* API helpers for server components (forwards cookies) and client components (credentials: include).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
const STATE_CHANGING_METHODS = ["POST", "PATCH", "PUT", "DELETE"];
// ─── Read csrfToken cookie in the browser ─────────────────────────────────────
function getClientCsrfToken(): string {
if (typeof document === "undefined") return "";
const match = document.cookie.match(/(?:^|;\s*)csrfToken=([^;]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
// ─── Server-side fetch (use in Server Components / Route Handlers) ─────────────
export async function apiFetch<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
// Dynamic import so this module can also be imported in client components
// The `cookies` call only runs on the server side
let cookieHeader = "";
let csrfToken = "";
try {
const { cookies } = await import("next/headers");
const store = await cookies();
cookieHeader = store
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
// Forward the CSRF token from the cookie store for mutating server-side calls
csrfToken = store.get("csrfToken")?.value ?? "";
} catch {
// We're on the client — skip cookie forwarding
}
const method = (init?.method ?? "GET").toUpperCase();
const needsCsrf = STATE_CHANGING_METHODS.includes(method);
const res = await fetch(`${API_URL}${path}`, {
...init,
cache: "no-store",
headers: {
"Content-Type": "application/json",
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}),
...(init?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) {
let msg = `API error ${res.status}`;
try {
const body = await res.json();
msg = body?.error?.message || body?.message || msg;
} catch {
/* ignore */
}
throw new ApiError(msg, res.status);
}
// Some endpoints return 204 No Content
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
// ─── Client-side fetch (use in Client Components) ─────────────────────────────
async function doClientFetch(path: string, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? "GET").toUpperCase();
const needsCsrf = STATE_CHANGING_METHODS.includes(method);
const csrfToken = needsCsrf ? getClientCsrfToken() : "";
return fetch(`${API_URL}${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}),
...(init?.headers as Record<string, string> | undefined),
},
});
}
export async function clientFetch<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
let res = await doClientFetch(path, init);
// Auto-refresh expired access token on 401, then retry once.
// Skip for /auth/ paths to avoid refresh loops.
if (res.status === 401 && !path.startsWith("/auth/")) {
const refreshRes = await doClientFetch("/auth/refresh", { method: "POST" });
if (refreshRes.ok) {
res = await doClientFetch(path, init);
}
}
if (!res.ok) {
let msg = `API error ${res.status}`;
try {
const body = await res.json();
msg = body?.error?.message || body?.message || msg;
} catch {
/* ignore */
}
throw new ApiError(msg, res.status);
}
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
export class ApiError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
this.name = "ApiError";
}
}
export { API_URL };

128
frontend/lib/auth.tsx Normal file
View File

@@ -0,0 +1,128 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { clientFetch, ApiError } from "./api";
import { CurrentUser, UserRole } from "./types";
interface AuthContextValue {
currentUser: CurrentUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
/** Calls GET /auth/me with the httpOnly cookie to restore the current user.
* Returns the JWT payload if valid, clears auth state if 401. */
const refresh = useCallback(async () => {
try {
const data = await clientFetch<{ success: boolean; user: CurrentUser }>(
"/auth/me"
);
if (data.user) {
const user: CurrentUser = {
sub: data.user.sub,
email: data.user.email,
role: (data.user.role as string).toUpperCase() as UserRole,
name: data.user.name,
};
setCurrentUser(user);
sessionStorage.setItem("currentUser", JSON.stringify(user));
}
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setCurrentUser(null);
sessionStorage.removeItem("currentUser");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const login = useCallback(async (email: string, password: string) => {
const data = await clientFetch<{
success: boolean;
user?: CurrentUser;
accessToken?: string;
}>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
// The NestJS login returns { success, accessToken, user } for JSON clients
if (data.user) {
// Normalize role to uppercase enum
const user: CurrentUser = {
sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
email: data.user.email,
role: (data.user.role as string).toUpperCase() as UserRole,
name: data.user.name,
};
setCurrentUser(user);
sessionStorage.setItem("currentUser", JSON.stringify(user));
}
}, []);
const register = useCallback(
async (name: string, email: string, password: string) => {
const data = await clientFetch<{
success: boolean;
user?: CurrentUser;
}>("/auth/register", {
method: "POST",
body: JSON.stringify({ name, email, password }),
});
if (data.user) {
const user: CurrentUser = {
sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
email: data.user.email,
role: (data.user.role as string).toUpperCase() as UserRole,
name: data.user.name,
};
setCurrentUser(user);
sessionStorage.setItem("currentUser", JSON.stringify(user));
}
},
[]
);
const logout = useCallback(async () => {
try {
await clientFetch("/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
setCurrentUser(null);
sessionStorage.removeItem("currentUser");
}, []);
return (
<AuthContext.Provider
value={{ currentUser, loading, login, register, logout, refresh }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

86
frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,86 @@
export enum PostStatus {
DRAFT = "draft",
PUBLISHED = "published",
ARCHIVED = "archived",
}
export enum ContentFormat {
MARKDOWN = "markdown",
HTML = "html",
}
export enum UserRole {
ADMIN = "ADMIN",
MANAGER = "MANAGER",
MEMBER = "MEMBER",
}
export interface User {
id: string;
email: string;
name?: string;
role: UserRole;
isActive: boolean;
createdAt: string;
}
export interface FeaturedImage {
url: string;
alt: string;
}
export interface BlogPost {
id: string;
title: string;
slug: string;
status: PostStatus;
excerpt: string;
content: string;
contentFormat: ContentFormat;
authorId: string;
author?: Pick<User, "id" | "email" | "name" | "role">;
featuredImageUrl?: string;
featuredImageAlt?: string;
isFeatured: boolean;
views: number;
tags: string[];
categories: string[];
createdAt: string;
updatedAt: string;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface TagCloudItem {
name: string;
count: number;
}
export interface CreatePostDto {
title: string;
slug?: string;
excerpt?: string;
content: string;
contentFormat?: ContentFormat;
status?: PostStatus;
featuredImageUrl?: string;
featuredImageAlt?: string;
isFeatured?: boolean;
tags?: string; // comma-separated string
categories?: string; // comma-separated string
}
export interface CurrentUser {
sub: string;
email: string;
role: UserRole;
name?: string;
iat?: number;
exp?: number;
}

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}