Setup initial project

This commit is contained in:
Aerilyn Weber 2026-03-27 14:50:34 +09:00
commit db79af06f7
119 changed files with 20761 additions and 0 deletions

View file

@ -0,0 +1,61 @@
import Link from 'next/link';
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<DashboardCard
title="Product Library"
description="Manage your food products and nutrition data"
href="/products"
/>
<DashboardCard
title="Recipes"
description="Create and manage recipes with auto-nutrition"
href="/recipes"
/>
<DashboardCard
title="Pantry"
description="Track what's in your fridge and pantry"
href="/pantry"
/>
<DashboardCard
title="Meal Plans"
description="Plan your weekly meals and hit nutrition targets"
href="/meal-plans"
/>
<DashboardCard
title="Shopping Lists"
description="Create shopping lists and track prices"
href="/shopping"
/>
<DashboardCard
title="Settings"
description="Manage household and account settings"
href="/settings"
/>
</div>
</div>
);
}
function DashboardCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</Link>
);
}

View file

@ -0,0 +1,14 @@
import { Sidebar } from '@/components/layout/Sidebar';
import { TopBar } from '@/components/layout/TopBar';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex flex-1 flex-col">
<TopBar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}

View file

@ -0,0 +1,32 @@
export default function SettingsPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl space-y-6">
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2>
<p className="text-sm text-gray-500">
Household management will be available here. Create a household, invite members, or
switch between households.
</p>
</section>
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Account</h2>
<p className="text-sm text-gray-500">
Account settings are managed through Keycloak. Click the button below to manage your
profile.
</p>
<a
href={`${process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'http://localhost:8080'}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'meshitrack'}/account`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Manage Keycloak Account
</a>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

View file

@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: 'MeshiTrack',
description: 'Nutrition & Pantry Management Platform',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">{children}</body>
</html>
);
}

View file

@ -0,0 +1,20 @@
'use client';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm rounded-xl border bg-white p-8 shadow-sm">
<h1 className="text-2xl font-bold text-center mb-2">MeshiTrack</h1>
<p className="text-gray-500 text-center text-sm mb-6">Sign in to manage your kitchen</p>
<button
onClick={() => signIn('keycloak', { callbackUrl: '/dashboard' })}
className="block w-full rounded-lg bg-primary-600 py-3 text-center text-white font-medium hover:bg-primary-700 transition-colors"
>
Sign in with Keycloak
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import Link from 'next/link';
export default function HomePage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-primary-700 mb-4">MeshiTrack</h1>
<p className="text-gray-600 mb-8 max-w-md">
Your household nutrition &amp; pantry management platform. Track food, plan meals, reduce
waste, save money.
</p>
<Link
href="/login"
className="inline-block rounded-lg bg-primary-600 px-6 py-3 text-white font-medium hover:bg-primary-700 transition-colors"
>
Sign In
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import Link from 'next/link';
const navItems = [
{ label: 'Dashboard', href: '/dashboard' },
{ label: 'Products', href: '/products' },
{ label: 'Recipes', href: '/recipes' },
{ label: 'Pantry', href: '/pantry' },
{ label: 'Meal Plans', href: '/meal-plans' },
{ label: 'Shopping', href: '/shopping' },
{ label: 'Settings', href: '/settings' },
];
export function Sidebar() {
return (
<aside className="flex w-64 flex-col border-r bg-white">
<div className="flex h-16 items-center border-b px-6">
<Link href="/dashboard" className="text-xl font-bold text-primary-700">
MeshiTrack
</Link>
</div>
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
}

View file

@ -0,0 +1,28 @@
import { auth } from '@/lib/auth';
export async function TopBar() {
const session = await auth();
const name = session?.user?.name ?? 'Unknown';
const initial = name.charAt(0).toUpperCase();
const householdId = session?.householdIds?.[0] ?? null;
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="text-sm text-gray-500">
{householdId ? (
<span className="rounded-md border px-3 py-1 font-medium text-gray-700">
{householdId}
</span>
) : (
<span className="rounded-md border px-3 py-1 text-gray-400">No household</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{name}</span>
<div className="h-8 w-8 rounded-full bg-primary-200 flex items-center justify-center text-sm font-medium text-primary-800">
{initial}
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,52 @@
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
const keycloakInternalUrl = process.env.KEYCLOAK_URL!;
const keycloakPublicUrl = process.env.NEXT_PUBLIC_KEYCLOAK_URL!;
const realm = process.env.KEYCLOAK_REALM!;
const internalBase = `${keycloakInternalUrl}/realms/${realm}`;
const publicBase = `${keycloakPublicUrl}/realms/${realm}`;
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
// issuer must match KC_HOSTNAME_URL so iss claim validation passes
issuer: publicBase,
// auth.js calls oauth4webapi.discoveryRequest(issuer) which constructs the URL from
// `issuer` directly — our `wellKnown` override is ignored in that code path.
// Bypass discovery entirely by pre-supplying all endpoints:
// - authorization uses the public URL (browser redirect)
// - token/userinfo/jwks use the internal Docker hostname (server-side fetches)
// These values match what KC_HOSTNAME_URL would return in the discovery document anyway.
authorization: `${publicBase}/protocol/openid-connect/auth`,
token: `${internalBase}/protocol/openid-connect/token`,
userinfo: `${internalBase}/protocol/openid-connect/userinfo`,
jwks_endpoint: `${internalBase}/protocol/openid-connect/certs`,
}),
],
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
// householdIds is injected into the ID token by the Keycloak protocol mapper
token.householdIds = (profile as Record<string, unknown>)?.['householdIds'] as
| string[]
| undefined;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.householdIds = (token.householdIds as string[] | undefined) ?? [];
return session;
},
},
});

View file

@ -0,0 +1,6 @@
export { auth as proxy } from '@/lib/auth';
export const config = {
// Protect all routes except auth callbacks, the login page, and Next.js internals.
matcher: ['/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)'],
};

View file

@ -0,0 +1,73 @@
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
class ApiClient {
private _accessToken: string | null = null;
public set accessToken(token: string) {
this._accessToken = token;
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
return headers;
}
public async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: this.getHeaders(),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
}
public async post<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
method: 'POST',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
}
public async patch<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
}
public async delete<T = void>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,24 @@
@import "tailwindcss";
@theme {
--color-primary-50: #f0fdf4;
--color-primary-100: #dcfce7;
--color-primary-200: #bbf7d0;
--color-primary-300: #86efac;
--color-primary-400: #4ade80;
--color-primary-500: #22c55e;
--color-primary-600: #16a34a;
--color-primary-700: #15803d;
--color-primary-800: #166534;
--color-primary-900: #14532d;
}
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}