From dc4c80a08b95aedf1f0733cb4117413a44a0024b Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 6 Mar 2026 16:27:20 +0000 Subject: [PATCH] feat(app): add recipe content panel with grid and detail views - Add RecipeGrid with search, meta badges (time/servings/calories), ingredient preview - Add RecipeDetail with ingredient checklist, scaling slider, numbered steps - Add generateRecipeFallback SVG for recipe cards (text-only style) - Wire recipe extraction into content panel tab system - Add isRecipeQuery/isRecipeLikeResponse classifiers in contentFiltering Co-Authored-By: Claude Opus 4.6 --- .../src/components/content/ContentPanel.vue | 20 +- .../src/components/content/RecipeDetail.vue | 172 ++++++++++++++++++ .../app/src/components/content/RecipeGrid.vue | 125 +++++++++++++ .../app/src/composables/contentFiltering.ts | 17 +- .../app/src/composables/useContentPanel.ts | 28 ++- .../app/src/composables/useImageFallback.ts | 5 + packages/app/src/pages/ChatPage.vue | 1 + 7 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/components/content/RecipeDetail.vue create mode 100644 packages/app/src/components/content/RecipeGrid.vue diff --git a/packages/app/src/components/content/ContentPanel.vue b/packages/app/src/components/content/ContentPanel.vue index 416bf7fe..ce984770 100644 --- a/packages/app/src/components/content/ContentPanel.vue +++ b/packages/app/src/components/content/ContentPanel.vue @@ -92,6 +92,11 @@ :place="selectedPlace" @back="closePlaceDetail" /> + + - !!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedImage.value || selectedPlace.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0) + !!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedImage.value || selectedPlace.value || selectedRecipe.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0) ) const windowWidth = ref(window.innerWidth) @@ -343,6 +360,7 @@ const TAB_LABELS: Record = { tvshow: 'TV', image: 'Images', place: 'Places', + recipe: 'Recipes', song: 'Music', podcast: 'Podcasts', news: 'News', diff --git a/packages/app/src/components/content/RecipeDetail.vue b/packages/app/src/components/content/RecipeDetail.vue new file mode 100644 index 00000000..651277d7 --- /dev/null +++ b/packages/app/src/components/content/RecipeDetail.vue @@ -0,0 +1,172 @@ + + + diff --git a/packages/app/src/components/content/RecipeGrid.vue b/packages/app/src/components/content/RecipeGrid.vue new file mode 100644 index 00000000..836d4685 --- /dev/null +++ b/packages/app/src/components/content/RecipeGrid.vue @@ -0,0 +1,125 @@ + + + diff --git a/packages/app/src/composables/contentFiltering.ts b/packages/app/src/composables/contentFiltering.ts index 66db2ac8..62dae6a1 100644 --- a/packages/app/src/composables/contentFiltering.ts +++ b/packages/app/src/composables/contentFiltering.ts @@ -1,4 +1,4 @@ -export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'app' | 'favorites' | 'discover' | 'prompt' +export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'recipe' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'app' | 'favorites' | 'discover' | 'prompt' export interface MagazineSection { title: string @@ -81,6 +81,18 @@ export function isPlaceLikeResponse(text: string): boolean { (text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2 } +// ─── Recipe classifiers ───────────────────────────────────────── + +export function isRecipeQuery(q: string): boolean { + if (!q) return false + return /\b(recipe|recipes|cook|cooking|bake|baking|meal|meals|dish|dishes|ingredient|ingredients|how to make|how to cook|how to bake)\b/i.test(q) || + /make me a|cook me|bake me|recipe for|what can i (make|cook|bake)|meal prep|meal plan/i.test(q) +} + +export function isRecipeLikeResponse(text: string): boolean { + return /([]) const panelPodcasts = ref([]) const panelImages = ref([]) const panelPlaces = ref([]) +const panelRecipes = ref([]) const panelApps = ref([]) const panelCodeBlocks = ref([]) const selectedFilm = ref(null) @@ -48,6 +51,7 @@ const selectedArticle = ref(null) const selectedImage = ref(null) const selectedPlace = ref(null) const selectedWebsite = ref(null) +const selectedRecipe = ref(null) const selectedApp = ref(null) const selectedMagazineSection = ref(null) const magazineSectionIndex = ref(0) @@ -108,6 +112,8 @@ export function useContentPanel() { const images = extractAllImages(text, userQuery) const places = extractAllPlaces(text, userQuery) + const recipes = extractRecipes(text) + const hasRecipes = recipes.length > 0 || isRecipeLikeResponse(text) // Magazine = bullet-style sections (- **Title**: Content) const magazineSections = extractMagazineSections(text) @@ -147,7 +153,7 @@ export function useContentPanel() { }) } - const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode) + const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode, hasRecipes) // Always append Prompt tab as the rightmost tab const tabsWithPrompt = tabs.length > 0 ? [...tabs, 'prompt' as const] : ['prompt' as const] availableTabs.value = tabsWithPrompt @@ -163,6 +169,7 @@ export function useContentPanel() { const showNews = tabs.includes('news') const showWebsitesTab = tabs.includes('websites') const showMagazine = tabs.includes('magazine') + const showRecipes = tabs.includes('recipe') const showApps = tabs.includes('app') const showCode = tabs.includes('code') @@ -176,6 +183,7 @@ export function useContentPanel() { const visibleNews = showNews ? mergedNews : [] const visibleWebsites = showWebsitesTab ? mergedWebsites : [] const visibleMagazineSections = showMagazine ? magazineSections : [] + const visibleRecipes = showRecipes ? recipes : [] const visibleApps = showApps ? apps : [] const visibleCodeBlocks = showCode ? codeBlocks : [] @@ -188,6 +196,7 @@ export function useContentPanel() { panelPodcasts.value = visiblePodcasts panelWebResults.value = visibleNews panelWebsites.value = visibleWebsites + panelRecipes.value = visibleRecipes panelApps.value = visibleApps panelCodeBlocks.value = visibleCodeBlocks panelMagazineSections.value = visibleMagazineSections @@ -202,6 +211,7 @@ export function useContentPanel() { selectedSong.value = null selectedPodcast.value = null selectedArticle.value = null + selectedRecipe.value = null selectedApp.value = null selectedWebsite.value = null selectedMagazineSection.value = null @@ -244,6 +254,8 @@ export function useContentPanel() { const ctx = extractQueryContext(userQuery) panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief' } + else if (visibleRecipes.length === 1) panelTitle.value = visibleRecipes[0].title + else if (visibleRecipes.length > 1) panelTitle.value = `${visibleRecipes.length} Recipes` else if (visibleCodeBlocks.length > 0) panelTitle.value = `${visibleCodeBlocks.length} Code Blocks` else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps` else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites` @@ -284,6 +296,8 @@ export function useContentPanel() { const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text) const images = extractAllImages(text, userQuery) const places = extractAllPlaces(text, userQuery) + const inlineRecipes = extractRecipes(text) + const hasRecipes = inlineRecipes.length > 0 || isRecipeLikeResponse(text) const hasAnyOtherInline = films.length > 0 || songs.length > 0 || podcasts.length > 0 || books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0 const hasMagazine = magazineSections.length >= 1 && ( @@ -291,7 +305,7 @@ export function useContentPanel() { /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) || (!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2) ) - const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode) + const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode, hasRecipes) return { films: tabs.includes('film') ? films : [], books: tabs.includes('book') ? books : [], @@ -320,6 +334,7 @@ export function useContentPanel() { selectedSong.value = null selectedPodcast.value = null selectedArticle.value = null + selectedRecipe.value = null selectedApp.value = null selectedWebsite.value = null selectedMagazineSection.value = null @@ -383,6 +398,9 @@ export function useContentPanel() { function openPlaceDetail(place: Place) { clearAllSelections(); selectedPlace.value = place } function closePlaceDetail() { selectedPlace.value = null } + function openRecipeDetail(recipe: RecipeData) { clearAllSelections(); selectedRecipe.value = recipe } + function closeRecipeDetail() { selectedRecipe.value = null } + function openAppDetail(app: AppEntry) { clearAllSelections(); selectedApp.value = app } function closeAppDetail() { selectedApp.value = null } @@ -445,6 +463,7 @@ export function useContentPanel() { panelPodcasts, panelWebResults, panelWebsites, + panelRecipes, panelApps, panelCodeBlocks, panelMagazineSections, @@ -457,6 +476,7 @@ export function useContentPanel() { selectedSong, selectedPodcast, selectedArticle, + selectedRecipe, selectedApp, selectedWebsite, selectedMagazineSection, @@ -496,6 +516,8 @@ export function useContentPanel() { closeArticleDetail, openWebsiteDetail, closeWebsiteDetail, + openRecipeDetail, + closeRecipeDetail, openAppDetail, closeAppDetail, openMagazineSectionDetail, diff --git a/packages/app/src/composables/useImageFallback.ts b/packages/app/src/composables/useImageFallback.ts index 2f341763..ce3a0ce7 100644 --- a/packages/app/src/composables/useImageFallback.ts +++ b/packages/app/src/composables/useImageFallback.ts @@ -349,6 +349,11 @@ export function generatePlaceFallback(name: string, cuisine?: string): string { return buildSquareFallback('PLACE', name, cuisine, hue, 20) } +export function generateRecipeFallback(title: string, time?: string): string { + const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360 + return buildSquareFallback('RECIPE', title, time, hue, 30) +} + /** Place image: Wikipedia (try restaurant/place name) */ export async function fetchPlaceImage( name: string, diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index bb365fe8..fbf82802 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -598,6 +598,7 @@ const TAB_LABELS: Record = { tvshow: 'TV', image: 'Images', place: 'Places', + recipe: 'Recipes', song: 'Songs', podcast: 'Podcasts', news: 'News',