initial commit

This commit is contained in:
2025-08-11 22:24:24 -05:00
commit e10c181a89
23 changed files with 5300 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
# Meal AI Starter (Vite + React)
## Quick start
1. Install dependencies
```bash
npm install
```
2. Start the mock server (this simulates your AI endpoint)
```bash
npm run start:server
```
3. In another terminal, run the frontend dev server
```bash
npm run dev
```
4. Open http://localhost:5173
## Notes
- Replace `server/index.js` with your real AI provider call (OpenAI, Anthropic, or your own) to produce `{"mealPlan":[],"groceryList":[]}` JSON output.
- Grocery lists are saved locally to IndexedDB and visible in the Grocery pane even when offline (PWA cache required).
- For packaging into desktop/mobile, wrap the web build with Tauri/Capacitor/Expo and ship the real server or point to your hosted AI.
That's everything in the starter. Run `npm install`, `npm run start:server`, then `npm run dev` to try it locally.

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4441
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "ai-meal-planning",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start:server": "node server/index.ts"
},
"dependencies": {
"dexie": "^3.2.7",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/body-parser": "^1.19.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"express": "^5.1.0",
"fast-xml-parser": "^5.2.5",
"globals": "^16.3.0",
"ollama": "^0.5.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0"
}
}

8
public/service-worker.js Normal file
View File

@@ -0,0 +1,8 @@
const CACHE = 'meal-ai-cache-v1'
self.addEventListener('install', evt => {
self.skipWaiting()
evt.waitUntil(caches.open(CACHE).then(cache => cache.addAll(['/', '/index.html', '/src/main.jsx'])))
})
self.addEventListener('fetch', evt => {
evt.respondWith(caches.match(evt.request).then(res => res || fetch(evt.request)))
})

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

122
server/index.ts Normal file
View File

@@ -0,0 +1,122 @@
// Very small mock server for local testing. In production replace with your AI provider call.
import express from "express";
import bodyParser from "body-parser";
import cors from "cors";
import { Ollama } from "ollama";
import { XMLParser } from "fast-xml-parser";
const app = express();
app.use(cors());
app.use(bodyParser.json());
const ollama = new Ollama({
host: "http://192.168.1.85:11434",
});
type ChatMessage = {
content: string;
role: "user" | "assistant";
};
const xmlParser = new XMLParser();
const systemPrompt: string = `You are an educated chef that likes to make nutritional meals.
You have an oven, stove top, pots, pans, microwave, grill, nice knives and some other kitchen tools at your disposal.
You do all of your grocery shopping at Trader Joe's, but on rare occasions can go to a different store for a few ingredients.
You like to be able to cook from start to finish in 45 minutes, but up to 60 is acceptable.
You largely prefer to use chicken as your protein but beef and turkey are acceptable on occasion.
You are a strong believer that the microwave should not be used to cook vegetables.
When returning meals, you must put each meal into an XML structure such as
<meal><name></name><ingredients><name></name><quantity></quantity></ingredients><steps></steps></meal>
The steps should be detailed enough for a beginner chef to follow along as if they have never cooked this meal before.
Each meal needs to be able to feed all people involved rather than each person getting a meal. If you are asked for 3 meals you should only return 3 meals.
When combining all ingredients into a shopping list, you must again use an XML
Your entire response needs to be XML <xml><meals></meals><shoppingList></shoppingList></xml>, nothing more, nothing less. If you consider returning something other than XML, you need to stop and start over.
`;
app.post("/api/mealplan", async (req, res) => {
const prompt: string = req.body.prompt || "";
const existingMessages: ChatMessage[] = req.body.existingMessages || [];
let fullPrompt = prompt;
if (!prompt) {
return res.sendStatus(400);
}
if (existingMessages.length === 0) {
fullPrompt = systemPrompt + fullPrompt;
}
const callOllama = () => {
return ollama.chat({
model: "llama3.2:3b",
think: false, //"low",
stream: false,
messages: [
{
content: fullPrompt,
role: "user",
},
],
});
};
const ollamaResponse = await callOllama();
let mealsAndList: any = null;
try {
mealsAndList = xmlParser.parse(ollamaResponse.message.content ?? "");
} catch (e) {
console.log("Failed on first call", e);
}
if (!mealsAndList) {
const ollamaResponse2 = await callOllama();
try {
mealsAndList = xmlParser.parse(ollamaResponse2.message.content ?? "");
} catch (e) {
console.log("Failed on second call", e);
return res.sendStatus(500);
}
}
if (mealsAndList.xml) {
mealsAndList = mealsAndList.xml;
}
let mealPlan: any[] = [];
if (
mealsAndList.meals &&
Array.isArray(mealsAndList.meals) &&
mealsAndList.meals.length > 0
) {
mealPlan = mealsAndList.meals;
} else if (
mealsAndList.meals &&
mealsAndList.meals.meal &&
Array.isArray(mealsAndList.meals.meal) &&
mealsAndList.meals.meal.length > 0
) {
console.log(mealsAndList.meals.meal);
mealPlan = mealsAndList.meals.meal;
} else if (mealsAndList.meals) {
mealPlan = mealsAndList.meals;
}
let shoppingList: any[] = [];
if (mealsAndList.shoppingList) {
shoppingList = mealsAndList.shoppingList;
}
// simple deterministic mock response: you should replace this with a real AI call.
const mock = {
mealPlan,
groceryList: shoppingList,
};
// simulate some delay
await new Promise((r) => setTimeout(r, 600));
res.json(mock);
});
const port = 3000;
app.listen(port, () =>
console.log(`Server listening on http://localhost:${port}`),
);

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 80vw;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

25
src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Chat from "./components/Chat";
import GroceryList from "./components/GroceryList";
export default function App() {
return (
<div
style={{
maxWidth: "80vw",
margin: "24px auto",
fontFamily: "system-ui, sans-serif",
}}
>
<h1>Meal AI Starter</h1>
<p>
Chat with AI to generate meal plans. Grocery lists are stored locally.
</p>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 600px", gap: 16 }}
>
<Chat />
<GroceryList />
</div>
</div>
);
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

188
src/components/Chat.tsx Normal file
View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import useAI from "../hooks/useAI";
import { db } from "../lib/db";
interface Message {
role: "user" | "assistant";
content: string;
ts: number;
}
export default function Chat() {
const [input, setInput] = useState(
"Create a 7-day meal plan (breakfast, lunch, dinner) and a consolidated grocery list for one person. Return JSON: { mealPlan: [...], groceryList: [...] }",
);
const [messages, setMessages] = useState<Message[]>([]);
const [chatId, _setChatId] = useState<number>(1);
const { getMealPlan, loading } = useAI();
const getTimestamp = () => new Date().getTime().toString();
async function send() {
if (!input.trim()) return;
await db.chats.add({
createdAt: getTimestamp(),
message: input,
role: "user",
chatId,
});
const userMsg: Message = { role: "user", content: input, ts: Date.now() };
setMessages((m) => [...m, userMsg]);
const resp = await getMealPlan(input);
console.log("Got my response");
await db.mealPlans.add({
createdAt: getTimestamp(),
data: resp.mealPlan || resp,
});
console.log("Written mealplan to db");
if (false && resp.groceryList) {
await db.groceries.add({
createdAt: getTimestamp(),
items: resp.groceryList,
});
console.log("Written groceries to db");
}
await db.chats.add({
createdAt: getTimestamp(),
message: JSON.stringify(resp, null, 2),
role: "assistant",
chatId,
});
console.log("Written chat to db");
const assistantMsg: Message = {
role: "assistant",
content: JSON.stringify(resp, null, 2),
ts: Date.now(),
};
console.log("Updating state");
setMessages((m) => [...m, assistantMsg]);
setInput("");
}
return (
<div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "#f5f5f5",
borderRight: "1px solid #ddd",
padding: "16px",
overflowY: "auto",
}}
>
{messages.length === 0 && (
<div
style={{
marginBottom: "16px",
fontSize: "14px",
color: "#666",
textAlign: "center",
}}
>
No messages yet ask the AI for a meal plan.
</div>
)}
{messages.map((m, i) => (
<div
key={i}
style={{
marginBottom: "16px",
padding: "12px 16px",
borderRadius: "8px",
backgroundColor: m.role === "user" ? "#e0e0e0" : "#ffffff",
border: m.role === "user" ? "1px solid #ccc" : "none",
maxWidth: "80%",
alignSelf: "flex-start",
}}
>
<div
style={{
fontSize: "12px",
color: "#999",
marginBottom: "4px",
}}
>
{m.role} {new Date(m.ts).toLocaleTimeString()}
</div>
<pre
style={{
whiteSpace: "pre-wrap",
margin: 0,
backgroundColor: m.role === "user" ? "#f0f0f0" : "#ffffff",
padding: "8px 12px",
borderRadius: "4px",
}}
>
{m.content}
</pre>
</div>
))}
</div>
<div
style={{
flex: "0 0 300px",
backgroundColor: "#ffffff",
border: "1px solid #ddd",
padding: "16px",
overflowY: "auto",
}}
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={4}
style={{
width: "100%",
padding: "12px",
fontSize: "14px",
borderRadius: "8px",
border: "1px solid #ccc",
resize: "vertical",
}}
/>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
justifyContent: "flex-end",
}}
>
<button
onClick={send}
disabled={loading}
style={{
padding: "8px 16px",
borderRadius: "4px",
backgroundColor: "#007bff",
color: "#fff",
border: "none",
cursor: loading ? "not-allowed" : "pointer",
transition: "background-color 0.2s",
}}
>
{loading ? "Sending..." : "Send to AI"}
</button>
<button
onClick={() => setInput("")}
style={{
padding: "8px 16px",
borderRadius: "4px",
backgroundColor: "#f0f0f0",
color: "#333",
border: "1px solid #ccc",
transition: "background-color 0.2s",
}}
>
Clear
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { db, type GroceryListData } from "../lib/db";
export default function GroceryList() {
const [lists, setLists] = useState<GroceryListData[]>([]);
useEffect(() => {
let mounted = true;
async function load() {
const all = await db.groceries.orderBy("createdAt").reverse().toArray();
if (mounted) setLists(all);
}
load();
db.groceries.hook("creating", () => load());
db.groceries.hook("updating", () => load());
return () => {
mounted = false;
};
}, []);
return (
<aside
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "#f0f4f8",
border: "1px solid #e0e0e0",
padding: "24px",
overflowY: "auto",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
}}
>
<h3
style={{
marginBottom: "24px",
fontSize: "20px",
color: "#2c3e50",
borderBottom: "2px solid #3498db",
paddingBottom: "8px",
}}
>
Grocery Lists (Local-first)
</h3>
{lists.length === 0 && (
<div
style={{
marginTop: "32px",
textAlign: "center",
color: "#7f8c8d",
fontSize: "16px",
fontStyle: "italic",
}}
>
No grocery lists yet.
</div>
)}
{lists.map((list) => (
<div
style={{
marginBottom: "24px",
padding: "16px",
borderRadius: "12px",
backgroundColor: "#ecf0f1",
border: "1px solid #bdc3c7",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
transition: "transform 0.2s",
}}
>
<div
style={{
fontSize: "14px",
color: "#34495e",
marginBottom: "8px",
}}
>
{new Date(list.createdAt).toLocaleString()}
</div>
<ul
style={{
paddingLeft: "20px",
listStyle: "none",
margin: 0,
color: "#2c3e50",
}}
>
{Array.isArray(list.items) ? (
list.items.map((it, idx) => (
<li
key={idx}
style={{
marginBottom: "6px",
fontSize: "16px",
}}
>
{it.item || it.name || JSON.stringify(it)}
</li>
))
) : (
<li style={{ fontSize: "16px" }}>{JSON.stringify(list.items)}</li>
)}
</ul>
</div>
))}
</aside>
);
}

31
src/hooks/useAI.ts Normal file
View File

@@ -0,0 +1,31 @@
import { useState } from "react";
export interface AIResponse {
mealPlan?: any;
groceryList?: any;
[key: string]: any;
}
const BASE_URL = "http://localhost:3000";
export default function useAI() {
const [loading, setLoading] = useState(false);
async function getMealPlan(prompt: string): Promise<AIResponse> {
setLoading(true);
try {
const res = await fetch(`${BASE_URL}/api/mealplan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!res.ok) throw new Error("AI server error");
const data = await res.json();
return data;
} finally {
setLoading(false);
}
}
return { getMealPlan, loading };
}

68
src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

48
src/lib/db.ts Normal file
View File

@@ -0,0 +1,48 @@
import Dexie, { type Table } from "dexie";
export interface GroceryItem {
item?: string;
qty?: string;
}
export interface ChatMessage {
id?: number;
chatId?: number;
createdAt: string;
message: string;
role: "user" | "assistant";
}
export interface GroceryListData {
id?: number;
createdAt: string;
items: GroceryItem[];
}
export interface MealPlanEntry {
day: string;
meals: { name: string; recipe: string }[];
}
export interface MealPlan {
id?: number;
createdAt: string;
data: MealPlanEntry[];
}
export class MealPlannerDB extends Dexie {
chats!: Table<ChatMessage>;
groceries!: Table<GroceryListData>;
mealPlans!: Table<MealPlan>;
constructor() {
super("MealPlannerDB");
this.version(3).stores({
chats: "++id,createdAt",
groceries: "++id,createdAt",
mealPlans: "++id,createdAt",
});
}
}
export const db = new MealPlannerDB();

15
src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
});
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})