Skip to content
Merged
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 app/components/ImageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const Layout: React.FC = () => {
...generatedRecipe,
authorId: user.uid,
averageRating: 0,
saves: 0,
likes: 0,
tags: generatedRecipe.tags || []
};
const savedRecipeId = await publishRecipe(user.uid, recipeToSave);
Expand Down
2 changes: 1 addition & 1 deletion app/components/Recipe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { Recipe } from "../firebase/data";
import { deleteRecipe, addReview, likeRecipe, unlikeRecipe } from "../firebase/data";
import { getUser } from "../firebase/auth";

const InfoBox = ({ icon: Icon, label, value }: { icon: any, label: string, value: string | number }) => (
const InfoBox = ({ icon: Icon, label, value }: { icon: React.ElementType, label: string, value: string | number }) => (
<div className="flex flex-col items-center justify-center p-3 py-4 bg-muted/40 rounded-2xl gap-2 flex-1 min-w-[30%]">
<div className="p-2 bg-background rounded-full shadow-sm text-emerald-600">
<Icon className="w-5 h-5" />
Expand Down
1 change: 1 addition & 0 deletions app/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
Expand Down
1 change: 1 addition & 0 deletions app/components/ui/navigation-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
Expand Down
134 changes: 79 additions & 55 deletions app/firebase/data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { initializeFirestore, addDoc, collection, getDoc, doc, deleteDoc, updateDoc, persistentLocalCache, setDoc, getDocs, query, where, runTransaction, increment } from "firebase/firestore";
import { execute, field, countAll } from "firebase/firestore/pipelines";
import { initializeFirestore, addDoc, collection, doc, deleteDoc, setDoc } from "firebase/firestore";
import { execute, field, countAll, subcollection, average, variable, score, documentMatches, documentId } from "firebase/firestore/pipelines";
import { firebaseApp } from "./firebase";

export interface Review {
Expand Down Expand Up @@ -36,7 +36,7 @@ export interface Recipe {
authorId: string;
tags: string[];
averageRating: number;
saves: number;
likes: number;
prepTime: string;
cookTime: string;
servings: string;
Expand Down Expand Up @@ -70,17 +70,32 @@ export async function publishRecipe(userId: string, recipe: Omit<Recipe, "id">):
}

export async function getRecipe(recipeId: string): Promise<Recipe | null> {
const recipeRef = doc(db, `recipes/${recipeId}`);
const recipeSnapshot = await getDoc(recipeRef);
const pipeline = db.pipeline()
.documents([`recipes/${recipeId}`])
.define(documentId(field("__name__")).as("parentRecipeId"))
.addFields(
subcollection("reviews")
.aggregate(average("rating").as("avg"))
.toScalarExpression()
.as("averageRating"),
db.pipeline()
.collection("likes")
.where(field("recipeId").equal(variable("parentRecipeId")))
.aggregate(countAll().as("count"))
.toScalarExpression()
.as("likes")
);

const { results } = await execute(pipeline);
const result = results[0];

if (!recipeSnapshot.exists()) {
if (!result) {
return null;
}

const recipeData = recipeSnapshot.data() as Recipe;
return {
...recipeData,
id: recipeSnapshot.id,
...result.data(),
id: result.id,
} as Recipe;
}

Expand All @@ -96,52 +111,24 @@ export async function addReview(recipeId: string, userId: string, rating: number
rating,
text: text || ""
});

// get the new average of all reviews
const pipeline = db.pipeline()
.collection(`recipes/${recipeId}/reviews`)
.aggregate(field("rating").average().as("averageRating"));
const { results } = await execute(pipeline);
const data = results[0]?.data();

let average = data && 'averageRating' in data ? data.averageRating as number : null;
if (!average) {
// there isn't an average yet, so set it to our new review score
average = rating;
}

// set the new average rating
await runTransaction(db, async transaction => {
const recipeRef = doc(db, `recipes/${recipeId}`);
transaction.update(recipeRef, { averageRating: average });
});
}

export async function likeRecipe(userId: string, recipeId: string) {
const likeId = `${recipeId}_${userId}`;
await setDoc(doc(db, "saves", likeId), {
await setDoc(doc(db, "likes", likeId), {
userId,
recipeId
});


// increment the total likes on the recipe itself
const recipeRef = doc(db, `recipes/${recipeId}`);
await updateDoc(recipeRef, { saves: increment(1) });
}

export async function unlikeRecipe(userId: string, recipeId: string) {
const likeId = `${recipeId}_${userId}`;
await deleteDoc(doc(db, "saves", likeId));

// decrement the total likes on the recipe itself
const recipeRef = doc(db, `recipes/${recipeId}`);
await updateDoc(recipeRef, { saves: increment(-1) });
await deleteDoc(doc(db, "likes", likeId));
}

export async function isRecipeLikedByUser(userId: string, recipeId: string): Promise<boolean> {
const pipeline = db.pipeline()
.collection("saves")
.collection("likes")
.where(field("userId").equal(userId))
.where(field("recipeId").equal(recipeId))
.limit(1);
Expand All @@ -155,17 +142,37 @@ export async function queryRecipes(filters: {
minRating?: number;
tags?: string[];
authorId?: string;
savedOnly?: true;
likedOnly?: boolean;
sort?: string;
userId?: string;
}): Promise<Recipe[]> {
let pipeline = db.pipeline().collection("recipes");

if (filters.authorId) {
pipeline = pipeline.where(field("authorId").equal(filters.authorId));
if (filters.searchTerm) {
pipeline = pipeline.search({
query: documentMatches(filters.searchTerm),
addFields: [
score().as("searchScore")
]
});
}

if (filters.searchTerm) {
pipeline = pipeline.where(field("title").like(`%${filters.searchTerm}%`));
pipeline = pipeline.define(documentId(field("__name__")).as("parentRecipeId"))
.addFields(
subcollection("reviews")
.aggregate(average("rating").as("avg"))
.toScalarExpression()
.as("averageRating"),
db.pipeline()
.collection("likes")
.where(field("recipeId").equal(variable("parentRecipeId")))
.aggregate(countAll().as("count"))
.toScalarExpression()
.as("likes")
);

if (filters.authorId) {
pipeline = pipeline.where(field("authorId").equal(filters.authorId));
}

if (filters.minRating && filters.minRating > 0) {
Expand All @@ -176,16 +183,33 @@ export async function queryRecipes(filters: {
pipeline = pipeline.where(field("tags").arrayContainsAny(filters.tags));
}

switch (filters.sort) {
case 'title':
pipeline = pipeline.sort(field('title').ascending());
break;
case 'rating':
pipeline = pipeline.sort(field('averageRating').descending());
break;
case 'saves':
pipeline = pipeline.sort(field('saves').descending());
break;
if (filters.likedOnly && filters.userId) {
Comment thread
morganchen12 marked this conversation as resolved.
pipeline = pipeline.addFields(
db.pipeline()
.collection("likes")
.where(field("userId").equal(filters.userId))
.where(field("recipeId").equal(variable("parentRecipeId")))
.limit(1)
.select("userId")
.toScalarExpression()
.as("isLikedByMe")
).where(field("isLikedByMe").equal(null).not());
}
Comment thread
morganchen12 marked this conversation as resolved.

if (filters.sort) {
switch (filters.sort) {
case 'title':
pipeline = pipeline.sort(field('title').ascending());
break;
case 'rating':
pipeline = pipeline.sort(field('averageRating').descending());
break;
case 'likes':
pipeline = pipeline.sort(field('likes').descending());
break;
}
} else if (filters.searchTerm) {
pipeline = pipeline.sort(field('searchScore').descending());
}

const { results } = await execute(pipeline);
Expand Down
10 changes: 5 additions & 5 deletions app/firebase/firebaseAILogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ export async function generateStructuredJsonRecipe(
properties: {
title: Schema.string(),
ingredients: Schema.array({ items: Schema.string() }),
instructions: Schema.string({ description: 'markdown-formatted recipe instructions.' }),
instructions: Schema.string({ description: 'Markdown-formatted recipe instructions. Avoid escaping whitespace characters.' }),
tags: Schema.array({ items: Schema.string() }),
prepTime: Schema.number(),
cookTime: Schema.number(),
servings: Schema.number(),
prepTime: Schema.string(),
cookTime: Schema.string(),
servings: Schema.string(),
},
});

Expand All @@ -104,7 +104,7 @@ export async function generateStructuredJsonRecipe(
async function fileToGenerativePart(file: File) {
const base64EncodedDataPromise = new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result ? (reader.result as String).split(',')[1] : '');
reader.onloadend = () => resolve(reader.result ? (reader.result as string).split(',')[1] : '');
reader.readAsDataURL(file);
});
return {
Expand Down
4 changes: 2 additions & 2 deletions app/routes/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Route } from "./+types/chat";
/* eslint-disable react-refresh/only-export-components */
Comment thread
morganchen12 marked this conversation as resolved.
import React, { useCallback, useMemo, useState } from "react";
import { ai } from "../firebase/firebase";
import { getGenerativeModel } from "firebase/ai";
Expand All @@ -9,7 +9,7 @@ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/componen
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";

export function meta({ }: Route.MetaArgs) {
export function meta() {
Comment thread
morganchen12 marked this conversation as resolved.
return [
{ title: "Chat - Friendly Meals" },
{ name: "description", content: "Chat about cooking with AI" },
Expand Down
6 changes: 3 additions & 3 deletions app/routes/generate.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Route } from "./+types/generate";
/* eslint-disable react-refresh/only-export-components */
Comment thread
morganchen12 marked this conversation as resolved.

import { useState } from "react";
import { IngredientInput } from "@/components/IngredientInput";
Expand All @@ -11,7 +11,7 @@ import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
import { getUser } from "@/firebase/auth";
import { useNavigate } from "react-router";

export function meta({ }: Route.MetaArgs) {
Comment thread
morganchen12 marked this conversation as resolved.
export function meta() {
return [
{ title: "Generate Recipe - Friendly Meals" },
{ name: "description", content: "Generate a new recipe with AI" },
Expand Down Expand Up @@ -53,7 +53,7 @@ export default function GeneratePage() {
...generatedRecipe,
authorId: user.uid,
averageRating: 0,
saves: 0,
likes: 0,
tags: generatedRecipe.tags || []
};
const savedRecipeId = await publishRecipe(user.uid, recipeToSave);
Expand Down
6 changes: 3 additions & 3 deletions app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Route } from "./+types/home";
/* eslint-disable react-refresh/only-export-components */
Comment thread
morganchen12 marked this conversation as resolved.
import { Button } from "@/components/ui/button"
import { Sparkles, Camera, MessageCircle, BookOpen, Flame, Database } from "lucide-react"
import { Sparkles, Camera, MessageCircle, BookOpen, Flame } from "lucide-react"

export function meta({ }: Route.MetaArgs) {
export function meta() {
Comment thread
morganchen12 marked this conversation as resolved.
return [
{ title: "Friendly Meals - AI-Powered Recipe Generator" },
{ name: "description", content: "Generate recipes with Firebase AI Logic and Firestore Pipelines" },
Expand Down
4 changes: 2 additions & 2 deletions app/routes/image.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Route } from "./+types/image";
/* eslint-disable react-refresh/only-export-components */
import ImageLayout from "../components/ImageLayout";

export function meta({ }: Route.MetaArgs) {
export function meta() {
return [
{ title: "Scan Recipe - Friendly Meals" },
{ name: "description", content: "Scan an image to extract a recipe" },
Expand Down
1 change: 1 addition & 0 deletions app/routes/recipe.$recipeId.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import type { Route } from "./+types/recipe.$recipeId";
import Recipe from "../components/Recipe";
import { isRecipeLikedByUser, getRecipe } from "../firebase/data";
Expand Down
Loading