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
9 changes: 9 additions & 0 deletions ai-studio-demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"

# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
8 changes: 8 additions & 0 deletions ai-studio-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
20 changes: 20 additions & 0 deletions ai-studio-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>

# Run and deploy your AI Studio app

This contains everything you need to run your app locally.

View your app in AI Studio: https://ai.studio/apps/bundled/heirloom_recipes

## Run Locally

**Prerequisites:** Node.js


1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
10 changes: 10 additions & 0 deletions ai-studio-demo/firebase-applet-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"projectId": "makersuite-showcase",
"appId": "1:853813963450:web:7096691fd837dc4edc3083",
"apiKey": "AIzaSyDcqYZsSsbU73XCs5fCoJ_RT-cC3V9ClFQ",
"authDomain": "makersuite-showcase.firebaseapp.com",
"firestoreDatabaseId": "ai-studio-afdb1f17-1b5e-4330-a1eb-94f41aea4049",
"storageBucket": "makersuite-showcase.firebasestorage.app",
"messagingSenderId": "853813963450",
"measurementId": ""
}
50 changes: 50 additions & 0 deletions ai-studio-demo/firebase-blueprint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"entities": {
"Recipe": {
"title": "Recipe",
"description": "A culinary recipe with ingredients and instructions.",
"type": "object",
"properties": {
"title": { "type": "string", "description": "Name of the dish" },
"ingredients": { "type": "array", "items": { "type": "string" }, "description": "List of ingredients" },
"instructions": { "type": "array", "items": { "type": "string" }, "description": "Step-by-step instructions" },
"category": { "type": "string", "enum": ["Breakfast", "Lunch", "Dinner", "Dessert", "Snack", "Drink", "Other"], "description": "Recipe category" },
"rating": { "type": "number", "description": "User rating from 1 to 5" },
"authorId": { "type": "string", "description": "UID of the user who created the recipe" },
"householdId": { "type": "string", "description": "ID of the household this recipe belongs to" },
"sourceUrl": { "type": "string", "description": "Original URL if imported" },
"imageUrl": { "type": "string", "description": "URL of the recipe image" },
"createdAt": { "type": "string", "format": "date-time", "description": "When the recipe was added" }
},
"required": ["title", "ingredients", "instructions", "authorId", "householdId", "createdAt"]
},
"Household": {
"title": "Household",
"description": "A group of users sharing recipes.",
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name of the household" },
"ownerId": { "type": "string", "description": "UID of the household owner" },
"members": {
"type": "object",
"description": "Map of user UIDs to their roles (e.g., 'admin', 'member', 'viewer')"
}
},
"required": ["name", "ownerId", "members"]
},
"UserProfile": {
"title": "User Profile",
"description": "Publicly visible user information.",
"type": "object",
"properties": {
"displayName": { "type": "string" },
"photoURL": { "type": "string" }
}
}
},
"firestore": {
"/recipes/{recipeId}": { "schema": "Recipe", "description": "Collection of all recipes" },
"/households/{householdId}": { "schema": "Household", "description": "Collection of households" },
"/users/{userId}": { "schema": "UserProfile", "description": "Public user profiles" }
}
}
143 changes: 143 additions & 0 deletions ai-studio-demo/firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ===============================================================
// Assumed Data Model
// ===============================================================
//
// Collection: users
// Document ID: {userId}
// Fields:
// - displayName: string (required)
// - photoURL: string (optional)
//
// Collection: households
// Document ID: {householdId}
// Fields:
// - name: string (required)
// - ownerId: string (required)
// - members: map (required) - { [userId]: role }
//
// Collection: recipes
// Document ID: {recipeId}
// Fields:
// - title: string (required)
// - ingredients: list (required)
// - instructions: list (required)
// - category: string (required)
// - rating: number (optional)
// - authorId: string (required)
// - householdId: string (required)
// - sourceUrl: string (optional)
// - imageUrl: string (optional)
// - createdAt: timestamp (required)
//
// ===============================================================

// ===============================================================
// Helper Functions
// ===============================================================
function isAuthenticated() {
return request.auth != null;
}

function isAdmin() {
return isAuthenticated() &&
(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin' ||
(request.auth.token.email == "sodoom@google.com" && request.auth.token.email_verified == true));
}

function isOwner(userId) {
return isAuthenticated() && request.auth.uid == userId;
}

// Check if user is a member of a household using the household document data
function isMemberOf(householdData) {
return isAuthenticated() &&
householdData.members.get(request.auth.uid, 'none') in ['admin', 'member', 'viewer'];
}

// Check if user is a member of a household by fetching the document
function isMemberOfId(householdId) {
return isAuthenticated() &&
isMemberOf(get(/databases/$(database)/documents/households/$(householdId)).data);
}

function getHouseholdRole(householdId) {
return get(/databases/$(database)/documents/households/$(householdId)).data.members.get(request.auth.uid, 'none');
}

function isHouseholdAdmin(householdId) {
let household = get(/databases/$(database)/documents/households/$(householdId)).data;
return isAuthenticated() &&
(household.members.get(request.auth.uid, 'none') == 'admin' || household.ownerId == request.auth.uid);
}

function hasRequiredFields(fields) {
return request.resource.data.keys().hasAll(fields);
}

function hasOnlyAllowedFields(fields) {
return request.resource.data.keys().hasOnly(fields);
}

// ===============================================================
// Domain Validators
// ===============================================================
function isValidRecipe(data) {
return hasRequiredFields(['title', 'ingredients', 'instructions', 'authorId', 'householdId', 'createdAt']) &&
data.title is string && data.title.size() > 0 && data.title.size() < 200 &&
data.ingredients is list && data.ingredients.size() > 0 &&
data.instructions is list && data.instructions.size() > 0 &&
data.createdAt is timestamp;
}

function isValidHousehold(data) {
return hasRequiredFields(['name', 'ownerId', 'members']) &&
data.name is string && data.name.size() > 0 && data.name.size() < 100 &&
data.members is map &&
(!('createdAt' in data) || data.createdAt is timestamp);
}

// ===============================================================
// Rules
// ===============================================================

match /users/{userId} {
allow read: if isAuthenticated();
allow write: if isOwner(userId) || isAdmin();
}

match /households/{householdId} {
allow read: if isMemberOf(resource.data) || isAdmin();
allow create: if isAuthenticated() &&
(request.resource.data.ownerId == request.auth.uid || isAdmin()) &&
request.resource.data.members[request.auth.uid] == 'admin' &&
isValidHousehold(request.resource.data);
allow update: if (isHouseholdAdmin(householdId) || isAdmin()) &&
request.resource.data.ownerId == resource.data.ownerId &&
isValidHousehold(request.resource.data);
allow delete: if (isAuthenticated() && resource.data.ownerId == request.auth.uid) || isAdmin();
}

match /recipes/{recipeId} {
allow read: if isAdmin() || (isAuthenticated() &&
(resource.data.authorId == request.auth.uid ||
get(/databases/$(database)/documents/households/$(resource.data.householdId)).data.members.get(request.auth.uid, 'none') in ['admin', 'member', 'viewer']));
allow create: if isAuthenticated() &&
(isMemberOfId(request.resource.data.householdId) || isAdmin()) &&
(getHouseholdRole(request.resource.data.householdId) in ['admin', 'member'] || isAdmin()) &&
(request.resource.data.authorId == request.auth.uid || isAdmin()) &&
isValidRecipe(request.resource.data);
allow update: if isAuthenticated() &&
(isMemberOfId(resource.data.householdId) || isAdmin()) &&
(getHouseholdRole(resource.data.householdId) in ['admin', 'member'] || resource.data.authorId == request.auth.uid || isAdmin()) &&
request.resource.data.authorId == resource.data.authorId &&
request.resource.data.householdId == resource.data.householdId &&
request.resource.data.createdAt == resource.data.createdAt &&
isValidRecipe(request.resource.data);
allow delete: if isAdmin() || (isAuthenticated() &&
(isHouseholdAdmin(resource.data.householdId) || resource.data.authorId == request.auth.uid));
}
}
}
Empty file added ai-studio-demo/index.css
Empty file.
13 changes: 13 additions & 0 deletions ai-studio-demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6 changes: 6 additions & 0 deletions ai-studio-demo/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Heirloom Recipes",
"description": "Organize and import recipes with ease. Collaborate with your household and keep your culinary traditions alive.",
"requestFramePermissions": [],
"majorCapabilities": []
}
Loading
Loading