first pass at layout, created basic frontpage components, added station and time to schema

This commit is contained in:
taogaetz 2025-08-17 21:19:50 -04:00
parent b0905697da
commit 161b83371e
19 changed files with 1474 additions and 168 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules
.vercel
.netlify
.wrangler
database.db
/.svelte-kit
/build

21
package-lock.json generated
View File

@ -8,18 +8,18 @@
"name": "chefbible",
"version": "0.0.1",
"dependencies": {
"@faker-js/faker": "^9.9.0",
"@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.9.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.2.1",
"daisyui": "^5.0.50",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@ -669,6 +669,7 @@
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
"integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -1635,6 +1636,8 @@
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@ -2195,6 +2198,16 @@
"node": ">=4"
}
},
"node_modules/daisyui": {
"version": "5.0.50",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.50.tgz",
"integrity": "sha512-c1PweK5RI1C76q58FKvbS4jzgyNJSP6CGTQ+KkZYzADdJoERnOxFoeLfDHmQgxLpjEzlYhFMXCeodQNLCC9bow==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -4440,7 +4453,9 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/uri-js": {
"version": "4.4.1",

View File

@ -14,15 +14,17 @@
"format": "prettier --write ."
},
"prisma": {
"seed": "tsx ./prisma/seed.ts"
},
"seed": "tsx ./prisma/seed.ts"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.9.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"daisyui": "^5.0.50",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",

View File

@ -1,97 +0,0 @@
-- CreateTable
CREATE TABLE "public"."Recipe" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"instructions" TEXT,
"photoUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Recipe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Ingredient" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Ingredient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."RecipeIngredient" (
"id" TEXT NOT NULL,
"recipeId" TEXT NOT NULL,
"ingredientId" TEXT NOT NULL,
"quantity" DOUBLE PRECISION,
"unit" TEXT,
"prep" TEXT,
CONSTRAINT "RecipeIngredient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Allergen" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "Allergen_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Menu" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"season" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Menu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."MenuRecipe" (
"id" TEXT NOT NULL,
"menuId" TEXT NOT NULL,
"recipeId" TEXT NOT NULL,
"position" INTEGER,
CONSTRAINT "MenuRecipe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."_IngredientAllergens" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_IngredientAllergens_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Ingredient_name_key" ON "public"."Ingredient"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Allergen_name_key" ON "public"."Allergen"("name");
-- CreateIndex
CREATE INDEX "_IngredientAllergens_B_index" ON "public"."_IngredientAllergens"("B");
-- AddForeignKey
ALTER TABLE "public"."RecipeIngredient" ADD CONSTRAINT "RecipeIngredient_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "public"."Recipe"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RecipeIngredient" ADD CONSTRAINT "RecipeIngredient_ingredientId_fkey" FOREIGN KEY ("ingredientId") REFERENCES "public"."Ingredient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MenuRecipe" ADD CONSTRAINT "MenuRecipe_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "public"."Menu"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MenuRecipe" ADD CONSTRAINT "MenuRecipe_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "public"."Recipe"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_IngredientAllergens" ADD CONSTRAINT "_IngredientAllergens_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Allergen"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_IngredientAllergens" ADD CONSTRAINT "_IngredientAllergens_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Ingredient"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,74 @@
-- CreateTable
CREATE TABLE "Recipe" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"instructions" TEXT,
"photoUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Ingredient" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "RecipeIngredient" (
"id" TEXT NOT NULL PRIMARY KEY,
"recipeId" TEXT NOT NULL,
"ingredientId" TEXT NOT NULL,
"quantity" REAL,
"unit" TEXT,
"prep" TEXT,
CONSTRAINT "RecipeIngredient_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RecipeIngredient_ingredientId_fkey" FOREIGN KEY ("ingredientId") REFERENCES "Ingredient" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Allergen" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT
);
-- CreateTable
CREATE TABLE "Menu" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"season" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "MenuRecipe" (
"id" TEXT NOT NULL PRIMARY KEY,
"menuId" TEXT NOT NULL,
"recipeId" TEXT NOT NULL,
"position" INTEGER,
CONSTRAINT "MenuRecipe_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "Menu" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "MenuRecipe_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "_IngredientAllergens" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_IngredientAllergens_A_fkey" FOREIGN KEY ("A") REFERENCES "Allergen" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_IngredientAllergens_B_fkey" FOREIGN KEY ("B") REFERENCES "Ingredient" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Ingredient_name_key" ON "Ingredient"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Allergen_name_key" ON "Allergen"("name");
-- CreateIndex
CREATE UNIQUE INDEX "_IngredientAllergens_AB_unique" ON "_IngredientAllergens"("A", "B");
-- CreateIndex
CREATE INDEX "_IngredientAllergens_B_index" ON "_IngredientAllergens"("B");

View File

@ -0,0 +1,19 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Recipe" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"instructions" TEXT,
"photoUrl" TEXT,
"time" TEXT NOT NULL DEFAULT 'Medium',
"station" TEXT NOT NULL DEFAULT 'Pans',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Recipe" ("createdAt", "description", "id", "instructions", "name", "photoUrl", "updatedAt") SELECT "createdAt", "description", "id", "instructions", "name", "photoUrl", "updatedAt" FROM "Recipe";
DROP TABLE "Recipe";
ALTER TABLE "new_Recipe" RENAME TO "Recipe";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "sqlite"

View File

@ -13,18 +13,20 @@ generator client {
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "sqlite"
url = "file:./database.db"
}
model Recipe {
id String @id @default(cuid())
name String
description String?
id String @id @default(cuid())
name String
description String?
instructions String?
photoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
photoUrl String?
time String @default("Medium") // Quick, Medium, Long
station String @default("Pans") // Garde Manger, Pans, Grill
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
ingredients RecipeIngredient[]
@ -32,16 +34,16 @@ model Recipe {
}
model Ingredient {
id String @id @default(cuid())
name String @unique
allergens Allergen[] @relation("IngredientAllergens")
createdAt DateTime @default(now())
id String @id @default(cuid())
name String @unique
allergens Allergen[] @relation("IngredientAllergens")
createdAt DateTime @default(now())
recipes RecipeIngredient[]
recipes RecipeIngredient[]
}
model RecipeIngredient {
id String @id @default(cuid())
id String @id @default(cuid())
recipeId String
ingredientId String
quantity Float?
@ -49,33 +51,33 @@ model RecipeIngredient {
prep String?
// Relations
recipe Recipe @relation(fields: [recipeId], references: [id])
ingredient Ingredient @relation(fields: [ingredientId], references: [id])
recipe Recipe @relation(fields: [recipeId], references: [id])
ingredient Ingredient @relation(fields: [ingredientId], references: [id])
}
model Allergen {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
description String?
ingredients Ingredient[] @relation("IngredientAllergens")
ingredients Ingredient[] @relation("IngredientAllergens")
}
model Menu {
id String @id @default(cuid())
id String @id @default(cuid())
name String
season String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
recipes MenuRecipe[]
recipes MenuRecipe[]
}
model MenuRecipe {
id String @id @default(cuid())
menuId String
recipeId String
position Int?
id String @id @default(cuid())
menuId String
recipeId String
position Int?
// Relations
menu Menu @relation(fields: [menuId], references: [id])
recipe Recipe @relation(fields: [recipeId], references: [id])
menu Menu @relation(fields: [menuId], references: [id])
recipe Recipe @relation(fields: [recipeId], references: [id])
}

View File

@ -25,35 +25,107 @@ async function main() {
);
// Ingredients (mix of spices, vegetables, meats, fruits)
const ingredients = await Promise.all(
Array.from({ length: 20 }).map(() => {
const typePick = faker.helpers.arrayElement([
() => faker.food.ingredient(),
() => faker.food.spice(),
() => faker.food.vegetable(),
() => faker.food.meat(),
() => faker.food.fruit(),
]);
const ingredientNames = new Set<string>();
const ingredients: any[] = [];
while (ingredientNames.size < 20) {
const typePick = faker.helpers.arrayElement([
() => faker.food.ingredient(),
() => faker.food.spice(),
() => faker.food.vegetable(),
() => faker.food.meat(),
() => faker.food.fruit(),
]);
const name = typePick();
if (!ingredientNames.has(name)) {
ingredientNames.add(name);
const randomAllergens = faker.helpers.arrayElements(allergens, faker.number.int({ min: 0, max: 2 }));
return prisma.ingredient.create({
const ingredient = await prisma.ingredient.create({
data: {
name: typePick(),
name,
allergens: { connect: randomAllergens.map(a => ({ id: a.id })) },
},
});
})
);
ingredients.push(ingredient);
}
}
// Recipes with realistic kitchen data
const recipeData = [
{
name: 'Caesar Salad',
description: 'Classic Caesar salad with homemade dressing and croutons',
time: 'Quick',
station: 'Garde Manger'
},
{
name: 'Grilled Salmon',
description: 'Fresh salmon fillet grilled to perfection with herbs',
time: 'Medium',
station: 'Grill'
},
{
name: 'Beef Stir Fry',
description: 'Tender beef with vegetables in a savory sauce',
time: 'Quick',
station: 'Pans'
},
{
name: 'Braised Short Ribs',
description: 'Slow-cooked short ribs in red wine reduction',
time: 'Long',
station: 'Pans'
},
{
name: 'Charcuterie Board',
description: 'Artisanal meats and cheeses with accompaniments',
time: 'Quick',
station: 'Garde Manger'
},
{
name: 'Grilled Chicken Breast',
description: 'Herb-marinated chicken breast with seasonal vegetables',
time: 'Medium',
station: 'Grill'
},
{
name: 'Pasta Carbonara',
description: 'Classic Italian pasta with eggs, cheese, and pancetta',
time: 'Medium',
station: 'Pans'
},
{
name: 'Beef Tenderloin',
description: 'Premium cut grilled to your preference',
time: 'Medium',
station: 'Grill'
},
{
name: 'Garden Salad',
description: 'Fresh mixed greens with house vinaigrette',
time: 'Quick',
station: 'Garde Manger'
},
{
name: 'Duck Confit',
description: 'Traditional French duck confit with crispy skin',
time: 'Long',
station: 'Pans'
}
];
// Recipes
const recipes = await Promise.all(
Array.from({ length: 10 }).map(async () => {
recipeData.map(async (recipeInfo) => {
const recipeIngredients = faker.helpers.arrayElements(ingredients, faker.number.int({ min: 3, max: 6 }));
return prisma.recipe.create({
data: {
name: faker.food.dish(),
description: faker.food.description(),
name: recipeInfo.name,
description: recipeInfo.description,
time: recipeInfo.time,
station: recipeInfo.station,
instructions: faker.lorem.paragraph(),
photoUrl: faker.image.urlLoremFlickr({ category: 'food' }),
photoUrl: null,
ingredients: {
create: recipeIngredients.map(ing => ({
ingredientId: ing.id,

View File

@ -1 +1,49 @@
@import 'tailwindcss';
@plugin "daisyui";
/* Global styles for Chef Bible */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Better focus styles for accessibility */
button:focus-visible,
a:focus-visible {
outline: 2px solid #667eea;
outline-offset: 2px;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@ -0,0 +1,362 @@
<script lang="ts">
import type { Recipe } from '@prisma-app/client';
import { goto } from '$app/navigation';
export let recipe: Recipe;
function handleClick() {
goto(`/recipe/${recipe.id}`);
}
</script>
<div class="recipe-card" on:click={handleClick}>
<div class="image-container">
{#if recipe.photoUrl}
<img
src={recipe.photoUrl}
alt={recipe.name}
loading="lazy"
on:error={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
{/if}
<div class="placeholder-image hidden">
<div class="placeholder-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<div class="placeholder-text">
<span class="recipe-name">{recipe.name}</span>
<span class="recipe-station">{recipe.station}</span>
</div>
</div>
<div class="image-overlay">
<button class="view-button">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
View Recipe
</button>
</div>
</div>
<div class="content">
<h3 class="title">{recipe.name}</h3>
{#if recipe.description}
<p class="description">{recipe.description}</p>
{/if}
<div class="meta">
<span class="time">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</svg>
{recipe.time || 'Medium'}
</span>
<span class="station">{recipe.station || 'Pans'}</span>
</div>
</div>
</div>
<style>
.recipe-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 0, 0, 0.05);
cursor: pointer;
}
.recipe-card:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.image-container {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.recipe-card:hover .image-container img {
transform: scale(1.05);
}
.placeholder-image {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
color: #64748b;
gap: 16px;
position: relative;
overflow: hidden;
}
.placeholder-image::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.1) 50%,
transparent 70%
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.placeholder-image.hidden {
display: none;
}
.placeholder-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
position: relative;
z-index: 1;
}
.placeholder-icon svg {
color: white;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.placeholder-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
z-index: 1;
}
.recipe-name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
text-align: center;
line-height: 1.3;
max-width: 90%;
}
.recipe-station {
font-size: 12px;
font-weight: 600;
color: #64748b;
background: rgba(255, 255, 255, 0.8);
padding: 4px 12px;
border-radius: 12px;
backdrop-filter: blur(4px);
}
.placeholder-image span {
font-size: 14px;
font-weight: 500;
text-align: center;
max-width: 80%;
line-height: 1.3;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.1) 100%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.recipe-card:hover .image-overlay {
opacity: 1;
}
.view-button {
background: rgba(255, 255, 255, 0.95);
color: #1f2937;
border: none;
padding: 12px 20px;
border-radius: 25px;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
}
.view-button:hover {
background: white;
transform: scale(1.05);
}
.content {
padding: 20px;
flex: 1;
display: flex;
flex-direction: column;
}
.title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px 0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.description {
color: #6b7280;
font-size: 14px;
line-height: 1.5;
margin: 0 0 16px 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.time,
.station {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
}
.time {
color: #6b7280;
}
.station {
color: #059669;
background: #d1fae5;
padding: 4px 8px;
border-radius: 12px;
}
/* Mobile optimizations */
@media (max-width: 640px) {
.content {
padding: 16px;
}
.title {
font-size: 16px;
}
.description {
font-size: 13px;
}
.image-container {
height: 180px;
}
.placeholder-icon {
width: 60px;
height: 60px;
}
.placeholder-icon svg {
width: 32px;
height: 32px;
}
.recipe-name {
font-size: 14px;
}
.recipe-station {
font-size: 11px;
padding: 3px 10px;
}
}
</style>

View File

@ -0,0 +1,314 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
const dispatch = createEventDispatcher();
export let recipes: any[] = [];
export let placeholder: string = 'Search recipes...';
let searchTerm = '';
let filteredRecipes: any[] = [];
let showResults = false;
function handleSearch() {
if (!searchTerm.trim()) {
filteredRecipes = [];
showResults = false;
return;
}
const term = searchTerm.toLowerCase();
filteredRecipes = recipes.filter(
(recipe) =>
recipe.name.toLowerCase().includes(term) ||
recipe.description?.toLowerCase().includes(term) ||
recipe.station.toLowerCase().includes(term) ||
recipe.time.toLowerCase().includes(term)
);
showResults = true;
}
function handleRecipeClick(recipe: any) {
goto(`/recipe/${recipe.id}`);
searchTerm = '';
showResults = false;
}
function handleInputFocus() {
if (searchTerm.trim()) {
showResults = true;
}
}
let searchContainer: HTMLElement;
// Handle clicks outside the search container
function handleClickOutside(event: MouseEvent) {
if (searchContainer && !searchContainer.contains(event.target as Node)) {
showResults = false;
}
}
// Add event listener when component mounts
import { onMount } from 'svelte';
onMount(() => {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
});
</script>
<div class="search-container" bind:this={searchContainer}>
<div class="search-input-wrapper">
<svg
class="search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchTerm}
on:input={handleSearch}
on:focus={handleInputFocus}
{placeholder}
class="search-input"
/>
{#if searchTerm}
<button
class="clear-button"
on:click={() => {
searchTerm = '';
showResults = false;
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{#if showResults && filteredRecipes.length > 0}
<div class="search-results">
{#each filteredRecipes as recipe}
<button class="result-item" on:click={() => handleRecipeClick(recipe)}>
<div class="result-content">
<h4 class="result-title">{recipe.name}</h4>
{#if recipe.description}
<p class="result-description">{recipe.description}</p>
{/if}
<div class="result-meta">
<span class="result-station">{recipe.station}</span>
<span class="result-time">{recipe.time}</span>
</div>
</div>
<svg
class="result-arrow"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
{/each}
</div>
{:else if showResults && searchTerm.trim()}
<div class="search-results">
<div class="no-results">
<p>No recipes found for "{searchTerm}"</p>
</div>
</div>
{/if}
</div>
<style>
.search-container {
position: relative;
width: 100%;
max-width: 500px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: white;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper:focus-within {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.search-icon {
position: absolute;
left: 16px;
color: #9ca3af;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 14px 16px 14px 48px;
border: none;
outline: none;
font-size: 16px;
background: transparent;
color: #1f2937;
}
.search-input::placeholder {
color: #9ca3af;
}
.clear-button {
position: absolute;
right: 12px;
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.clear-button:hover {
background: #f3f4f6;
color: #6b7280;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e7eb;
margin-top: 8px;
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
.result-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: none;
background: none;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f3f4f6;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover {
background: #f9fafb;
}
.result-content {
flex: 1;
text-align: left;
}
.result-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 4px 0;
}
.result-description {
font-size: 14px;
color: #6b7280;
margin: 0 0 8px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-meta {
display: flex;
gap: 12px;
}
.result-station,
.result-time {
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 6px;
}
.result-station {
background: #d1fae5;
color: #059669;
}
.result-time {
background: #fef3c7;
color: #d97706;
}
.result-arrow {
color: #9ca3af;
flex-shrink: 0;
}
.no-results {
padding: 24px 16px;
text-align: center;
color: #6b7280;
}
/* Mobile responsive */
@media (max-width: 640px) {
.search-container {
max-width: 100%;
}
.search-input {
font-size: 16px; /* Prevents zoom on iOS */
}
.search-results {
max-height: 300px;
}
}
</style>

View File

@ -1,10 +1,6 @@
// place files you want to import through the `$lib` alias in this folder.
import { PrismaClient } from '@prisma-app/client';
import { DATABASE_URL } from '$env/static/private';
import { withAccelerate } from '@prisma/extension-accelerate';
const prisma = new PrismaClient({
datasourceUrl: DATABASE_URL
}).$extends(withAccelerate());
const prisma = new PrismaClient()
export default prisma

View File

@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
import prisma from '$lib/server/prisma';
export const load: LayoutServerLoad = async () => {
const recipes = await prisma.recipe.findMany();
return { recipes };
};

View File

@ -1,12 +1,83 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import SearchBar from '$lib/components/SearchBar.svelte';
import type { LayoutData } from './$types';
let { children } = $props();
let { children, data } = $props<{ children: any; data: LayoutData }>();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
<div class="layout">
<header class="global-header">
<div class="header-content">
<div class="logo">
<a href="/">Chef Bible</a>
</div>
<SearchBar recipes={data.recipes} />
</div>
</header>
<main class="main-content">
{@render children?.()}
</main>
</div>
<style>
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.global-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 16px 0;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.logo a {
font-size: 1.5rem;
font-weight: 800;
color: #1f2937;
text-decoration: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.main-content {
flex: 1;
}
/* Mobile responsive */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
padding: 0 16px;
}
.logo a {
font-size: 1.25rem;
}
}
</style>

View File

@ -1,9 +0,0 @@
// TODO: Fix this frickin bug!!!!
import prisma from '$lib/server/prisma';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const recipes = await prisma.recipe.findMany();
return { recipes };
};

View File

@ -1,7 +1,72 @@
<script lang="ts">
import type { PageProps } from "./$types";
let { data }: PageProps = $props();
console.log(data)
import type { PageProps } from './$types';
import RecipeCard from '$lib/components/RecipeCard.svelte';
let { data }: PageProps = $props();
// Debug: Log the first recipe to see what fields are available
console.log('First recipe data:', data.recipes[0]);
</script>
<svelte:head>
<title>Chef Bible - Recipes</title>
<meta name="description" content="Birbante" />
</svelte:head>
<main class="container">
<section class="recipes-section">
<div class="recipes-grid">
{#each data.recipes as recipe}
<RecipeCard {recipe} />
{/each}
</div>
</section>
</main>
<style>
.container {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 0;
margin: 0;
}
.recipes-section {
padding: 40px 20px;
max-width: 1400px;
margin: 0 auto;
}
.recipes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
align-items: start;
}
/* Mobile-first responsive design */
@media (max-width: 640px) {
.recipes-section {
padding: 24px 16px;
}
.recipes-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
@media (min-width: 641px) and (max-width: 1024px) {
.recipes-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
}
@media (min-width: 1025px) {
.recipes-grid {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 28px;
}
}
</style>

View File

@ -0,0 +1,22 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import prisma from '$lib/server/prisma';
export const load: PageServerLoad = async ({ params }) => {
const recipe = await prisma.recipe.findUnique({
where: { id: params.id },
include: {
ingredients: {
include: {
ingredient: true
}
}
}
});
if (!recipe) {
throw error(404, 'Recipe not found');
}
return { recipe };
};

View File

@ -0,0 +1,341 @@
<script lang="ts">
import type { PageProps } from './$types';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let { data }: PageProps = $props();
const { recipe } = data;
function goBack() {
goto('/');
}
</script>
<svelte:head>
<title>{recipe.name} - Chef Bible</title>
<meta name="description" content={recipe.description || `Recipe for ${recipe.name}`} />
</svelte:head>
<main class="recipe-page">
<header class="recipe-header">
<button class="back-button" on:click={goBack}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back to Recipes
</button>
</header>
<div class="recipe-content">
<div class="recipe-hero">
<div class="recipe-image">
{#if recipe.photoUrl}
<img src={recipe.photoUrl} alt={recipe.name} />
{:else}
<div class="placeholder-image">
<svg
width="80"
height="80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{recipe.name}</span>
</div>
{/if}
</div>
<div class="recipe-info">
<h1 class="recipe-title">{recipe.name}</h1>
{#if recipe.description}
<p class="recipe-description">{recipe.description}</p>
{/if}
<div class="recipe-meta">
<div class="meta-item">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</svg>
<span>{recipe.time}</span>
</div>
<div class="meta-item">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 3h18v18H3z" />
<path d="M9 9h6v6H9z" />
</svg>
<span>{recipe.station}</span>
</div>
</div>
</div>
</div>
<div class="recipe-details">
{#if recipe.ingredients && recipe.ingredients.length > 0}
<section class="ingredients-section">
<h2>Ingredients</h2>
<ul class="ingredients-list">
{#each recipe.ingredients as recipeIngredient}
<li class="ingredient-item">
<span class="ingredient-name">{recipeIngredient.ingredient.name}</span>
{#if recipeIngredient.quantity || recipeIngredient.unit}
<span class="ingredient-amount">
{recipeIngredient.quantity}
{recipeIngredient.unit}
</span>
{/if}
{#if recipeIngredient.prep}
<span class="ingredient-prep">({recipeIngredient.prep})</span>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if recipe.instructions}
<section class="instructions-section">
<h2>Instructions</h2>
<div class="instructions-content">
{recipe.instructions}
</div>
</section>
{/if}
</div>
</div>
</main>
<style>
.recipe-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 0;
margin: 0;
}
.recipe-header {
padding: 20px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.back-button {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: #6b7280;
font-size: 16px;
font-weight: 500;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s ease;
}
.back-button:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.recipe-content {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.recipe-hero {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 40px;
margin-bottom: 40px;
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.recipe-image {
width: 100%;
height: 300px;
border-radius: 12px;
overflow: hidden;
}
.recipe-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-image {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
color: #6b7280;
gap: 16px;
}
.placeholder-image svg {
color: #9ca3af;
}
.recipe-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.recipe-title {
font-size: 2.5rem;
font-weight: 800;
color: #1f2937;
margin: 0 0 16px 0;
line-height: 1.2;
}
.recipe-description {
font-size: 1.125rem;
color: #6b7280;
line-height: 1.6;
margin: 0 0 24px 0;
}
.recipe-meta {
display: flex;
gap: 24px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f3f4f6;
border-radius: 12px;
font-weight: 600;
color: #374151;
}
.recipe-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.ingredients-section,
.instructions-section {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.ingredients-section h2,
.instructions-section h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 24px 0;
}
.ingredients-list {
list-style: none;
padding: 0;
margin: 0;
}
.ingredient-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.ingredient-item:last-child {
border-bottom: none;
}
.ingredient-name {
font-weight: 600;
color: #1f2937;
flex: 1;
}
.ingredient-amount {
color: #6b7280;
font-weight: 500;
}
.ingredient-prep {
color: #9ca3af;
font-style: italic;
font-size: 0.875rem;
}
.instructions-content {
line-height: 1.7;
color: #374151;
font-size: 1rem;
}
/* Mobile responsive */
@media (max-width: 768px) {
.recipe-hero {
grid-template-columns: 1fr;
gap: 24px;
padding: 24px;
}
.recipe-title {
font-size: 2rem;
}
.recipe-details {
grid-template-columns: 1fr;
gap: 24px;
}
.recipe-content {
padding: 20px;
}
.recipe-meta {
flex-direction: column;
gap: 12px;
}
}
</style>