Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# ShadowBrain

[![Version](https://img.shields.io/badge/version-0.13.0-yellow)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.15.0-yellow)](CHANGELOG.md)

</div>

Expand Down
2 changes: 1 addition & 1 deletion docs/design-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ accent on detail view. See the design system spec for the full rule.

| Family | Role | Weights | Source |
| -------------- | -------------------- | ------------- | ----------------------------------- |
| Inter | Sans, primary UI | 400, 500, 700 | Google Fonts via `next/font/google` |
| Geist | Sans, primary UI | 400, 500, 700 | Google Fonts via `next/font/google` |
| Newsreader | Serif, brand moments | 400, 600 | Google Fonts via `next/font/google` |
| JetBrains Mono | Mono, code/data | 400, 500 | Google Fonts via `next/font/google` |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shadowbrain",
"version": "0.13.0",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion src/app/__tests__/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ vi.mock("next/headers", () => ({
}));

vi.mock("next/font/google", () => ({
Inter: () => ({ variable: "--font-sans", className: null }),
Geist: () => ({ variable: "--font-sans", className: null }),
Newsreader: () => ({ variable: "--font-serif", className: null }),
JetBrains_Mono: () => ({ variable: "--font-mono", className: null }),
}));
Expand Down
2 changes: 1 addition & 1 deletion src/app/globals.css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("design system — globals.css tokens", () => {
}
});

it("exposes font CSS variables for Inter, Newsreader, and JetBrains Mono", () => {
it("exposes font CSS variables for Geist, Newsreader, and JetBrains Mono", () => {
expect(css).toMatch(/--font-sans:\s*var\(--font-sans\)/);
expect(css).toMatch(/--font-serif:\s*var\(--font-serif\)/);
expect(css).toMatch(/--font-mono:\s*var\(--font-mono\)/);
Expand Down
12 changes: 8 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Geist, JetBrains_Mono, Newsreader } from "next/font/google";
import { cookies } from "next/headers";

import { SkipToContent } from "@/components/layout/skip-to-content";
Expand All @@ -14,14 +14,18 @@ import "./globals.css";

/**
* Three typefaces per the design system spec:
* - Inter — sans, primary UI (400, 500, 700)
* - Geist — sans, primary UI (400, 500, 700)
* - Newsreader — serif, brand moments (400, 600)
* - JetBrains Mono — mono, code/data (400, 500)
*
* All three are loaded via `next/font/google` for zero-runtime cost
* and exposed as CSS variables consumed by Tailwind's font utilities.
* Geist is the technical grotesque that bridges the literary serif and
* the JetBrains mono — it shares its design language with Geist Mono,
* so the UI sans sits naturally beside the mono accents on buttons,
* dialogs, and data markers.
*/
const inter = Inter({
const geist = Geist({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-sans",
Expand Down Expand Up @@ -74,7 +78,7 @@ export default async function RootLayout({
return (
<html
lang="en"
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable} h-full antialiased`}
className={`${geist.variable} ${newsreader.variable} ${jetbrainsMono.variable} h-full antialiased`}
>
<body className="bg-background text-foreground flex min-h-full flex-col">
{/*
Expand Down
2 changes: 1 addition & 1 deletion src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function LoginForm({ from }: LoginFormProps) {
<button
type="submit"
disabled={submitting}
className="bg-primary text-primary-foreground hover:bg-primary/80 rounded-sm px-3 py-2 font-sans text-sm font-medium transition-colors disabled:opacity-50"
className="bg-primary text-primary-foreground hover:bg-primary/80 hover:border-border-strong cursor-pointer rounded-sm border border-transparent bg-clip-padding px-3 py-2 font-mono text-xs font-normal tracking-[0.12em] capitalize transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
Expand Down
158 changes: 158 additions & 0 deletions src/app/tags/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// @vitest-environment jsdom

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
TagsApiError,
createTag,
deleteTag,
fetchTags,
renameTag,
} from "./api";

/**
* Tests for the Tags API client.
*
* Each test stubs `global.fetch` and asserts both the request shape
* (method, URL, body) and the response handling — including the 409
* → `CONFLICT` code mapping the dialogs rely on for inline duplicate
* messages.
*/

function jsonResponse(body: unknown, init: { status?: number } = {}): Response {
const status = init.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}

const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("fetchTags", () => {
it("GETs /api/tags and returns the tags array", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
tags: [
{ id: "1", name: "alpha", color: null, created_at: "x", count: 3 },
],
total: 1,
})
);

const result = await fetchTags();

expect(fetchMock).toHaveBeenCalledWith(
"/api/tags",
expect.objectContaining({ method: "GET", credentials: "same-origin" })
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("alpha");
expect(result[0].count).toBe(3);
});

it("returns an empty array when the body has no tags", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}));
expect(await fetchTags()).toEqual([]);
});

it("throws a TagsApiError on a non-ok response", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { status: 500 }));
await expect(fetchTags()).rejects.toBeInstanceOf(TagsApiError);
});

it("forwards the abort signal", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ tags: [] }));
const controller = new AbortController();
await fetchTags(controller.signal);
expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal);
});
});

describe("createTag", () => {
it("POSTs the name and returns the created tag", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{ id: "9", name: "new", color: null, created_at: "x" },
{ status: 201 }
)
);

const tag = await createTag("new");

expect(fetchMock).toHaveBeenCalledWith(
"/api/tags",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ name: "new" }),
})
);
expect(tag.id).toBe("9");
});

it("maps a 409 to a TagsApiError with code CONFLICT", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{ error: { code: "CONFLICT", message: "exists" } },
{ status: 409 }
)
);

await expect(createTag("dup")).rejects.toMatchObject({
name: "TagsApiError",
status: 409,
code: "CONFLICT",
});
});
});

describe("renameTag", () => {
it("PATCHes /api/tags/[id] with the new name", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ id: "5", name: "renamed", color: null, created_at: "x" })
);

const tag = await renameTag("5", "renamed");

expect(fetchMock).toHaveBeenCalledWith(
"/api/tags/5",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ name: "renamed" }),
})
);
expect(tag.name).toBe("renamed");
});

it("encodes the id in the URL", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}));
await renameTag("a/b", "x");
expect(fetchMock.mock.calls[0][0]).toBe("/api/tags/a%2Fb");
});
});

describe("deleteTag", () => {
it("DELETEs /api/tags/[id]", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ id: "7" }));
await deleteTag("7");
expect(fetchMock).toHaveBeenCalledWith(
"/api/tags/7",
expect.objectContaining({ method: "DELETE" })
);
});

it("throws a TagsApiError on failure", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { status: 404 }));
await expect(deleteTag("missing")).rejects.toBeInstanceOf(TagsApiError);
});
});
120 changes: 120 additions & 0 deletions src/app/tags/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Browser-side API client for the Tags page.
*
* Wraps `fetch` for the four `/api/tags` operations the page needs:
* - `fetchTags()` — `GET /api/tags` (list with counts)
* - `createTag(name)` — `POST /api/tags` (create)
* - `renameTag(id, …)` — `PATCH /api/tags/[id]` (rename)
* - `deleteTag(id)` — `DELETE /api/tags/[id]` (delete)
*
* Every call uses `credentials: "same-origin"` so the HttpOnly
* session cookie rides along, matching the rest of the app.
*
* Failures throw a `TagsApiError` carrying the HTTP status and the
* server's `code` (e.g. `CONFLICT`) when present, so the dialogs can
* map a 409 to an inline "name already exists" message instead of the
* generic error banner.
*/

import type { TagWithCount } from "./types";

export class TagsApiError extends Error {
readonly status: number;
/** The server's machine-readable error code, when present
* (e.g. `CONFLICT`, `VALIDATION_ERROR`). */
readonly code: string | null;
constructor(status: number, message: string, code: string | null = null) {
super(message);
this.name = "TagsApiError";
this.status = status;
this.code = code;
}
}

/** Read the `error.code` field from a JSON error body, tolerating a
* non-JSON or unexpectedly-shaped response. */
async function readErrorCode(response: Response): Promise<string | null> {
try {
const body = (await response.json()) as unknown;
if (
body &&
typeof body === "object" &&
"error" in body &&
body.error &&
typeof body.error === "object" &&
"code" in body.error &&
typeof (body.error as { code: unknown }).code === "string"
) {
return (body.error as { code: string }).code;
}
} catch {
// Non-JSON body — fall through to a null code.
}
return null;
}

async function throwForResponse(response: Response): Promise<never> {
const code = await readErrorCode(response);
throw new TagsApiError(
response.status,
`Request failed with status ${response.status}`,
code
);
}

/** Fetch all tags with their usage counts. */
export async function fetchTags(signal?: AbortSignal): Promise<TagWithCount[]> {
const response = await fetch("/api/tags", {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) await throwForResponse(response);

const body = (await response.json()) as { tags?: unknown };
return Array.isArray(body.tags) ? (body.tags as TagWithCount[]) : [];
}

/** Create a new tag. Resolves with the created tag row. */
export async function createTag(name: string): Promise<TagWithCount> {
const response = await fetch("/api/tags", {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ name }),
});
if (!response.ok) await throwForResponse(response);
return (await response.json()) as TagWithCount;
}

/** Rename an existing tag. Resolves with the updated tag row. */
export async function renameTag(
id: string,
name: string
): Promise<TagWithCount> {
const response = await fetch(`/api/tags/${encodeURIComponent(id)}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ name }),
});
if (!response.ok) await throwForResponse(response);
return (await response.json()) as TagWithCount;
}

/** Delete a tag. */
export async function deleteTag(id: string): Promise<void> {
const response = await fetch(`/api/tags/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!response.ok) await throwForResponse(response);
}
Loading
Loading