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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 16:27:20 +00:00
co-authored by Claude Opus 4.6
parent 2304e5904e
commit dc4c80a08b
7 changed files with 363 additions and 5 deletions
@@ -92,6 +92,11 @@
:place="selectedPlace"
@back="closePlaceDetail"
/>
<RecipeDetail
v-else-if="selectedRecipe"
:recipe="selectedRecipe"
@back="closeRecipeDetail"
/>
<AppDetail
v-else-if="selectedApp"
:app="selectedApp"
@@ -166,6 +171,12 @@
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
@@ -227,6 +238,8 @@ import ImageGrid from './ImageGrid.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceGrid from './PlaceGrid.vue'
import PlaceDetail from './PlaceDetail.vue'
import RecipeGrid from './RecipeGrid.vue'
import RecipeDetail from './RecipeDetail.vue'
import PodcastGrid from './PodcastGrid.vue'
import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
@@ -271,6 +284,7 @@ const {
panelWebsites,
panelImages,
panelPlaces,
panelRecipes,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
@@ -286,6 +300,7 @@ const {
selectedImage,
selectedPlace,
selectedArticle,
selectedRecipe,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
@@ -303,6 +318,8 @@ const {
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
openRecipeDetail,
closeRecipeDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
@@ -316,7 +333,7 @@ const {
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(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<ContentTab, string> = {
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Music',
podcast: 'Podcasts',
news: 'News',
@@ -0,0 +1,172 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="touch-target rounded-lg transition-colors shrink-0"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5'"
aria-label="Back to recipes"
@click="$emit('back')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 class="text-sm font-bold truncate" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ recipe.title }}
</h3>
</div>
<!-- Cover banner -->
<div class="shrink-0 h-28 relative overflow-hidden">
<img
:src="generateRecipeFallback(recipe.title, recipe.time)"
:alt="recipe.title"
class="w-full h-full object-cover"
/>
</div>
<!-- Recipe content (reuses RecipeCard logic inline) -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Meta -->
<div class="px-4 py-3 flex gap-3 flex-wrap"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<span v-if="recipe.time" class="text-xs flex items-center gap-1"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ recipe.time }}
</span>
<span v-if="recipe.servings" class="text-xs flex items-center gap-1"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ scaledServings }} servings
</span>
<span v-if="recipe.calories" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ recipe.calories }} cal
</span>
</div>
<!-- Scale slider -->
<div class="px-4 py-3 flex items-center gap-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<label class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">Scale</label>
<input
v-model.number="scaleFactor"
type="range"
min="0.5"
max="4"
step="0.5"
class="flex-1 h-1 accent-accent rounded-full appearance-none cursor-pointer"
:class="isDark ? 'bg-white/10' : 'bg-black/10'"
/>
<span class="text-xs tabular-nums w-8 text-right"
:class="isDark ? 'text-white/50' : 'text-gray-500'">{{ scaleFactor }}x</span>
</div>
<!-- Ingredients -->
<div class="px-4 py-4"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<h4 class="text-xs uppercase tracking-wider mb-3 font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Ingredients
</h4>
<ul class="space-y-2">
<li
v-for="(ing, i) in scaledIngredients"
:key="i"
class="flex items-start gap-2.5 text-sm cursor-pointer select-none transition-colors"
:class="checkedIngredients.has(i)
? isDark ? 'line-through text-white/25' : 'line-through text-gray-300'
: isDark ? 'text-white/70' : 'text-gray-700'"
@click="toggleIngredient(i)"
>
<span class="shrink-0 mt-0.5 w-5 h-5 rounded border flex items-center justify-center transition-colors"
:class="checkedIngredients.has(i)
? 'border-accent/50 bg-accent/20'
: isDark ? 'border-white/20' : 'border-gray-300'">
<svg v-if="checkedIngredients.has(i)" class="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
{{ ing }}
</li>
</ul>
</div>
<!-- Steps -->
<div class="px-4 py-4 pb-16">
<h4 class="text-xs uppercase tracking-wider mb-3 font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Steps
</h4>
<ol class="space-y-3">
<li
v-for="(step, i) in recipe.steps"
:key="i"
class="flex gap-3 text-sm"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
<span class="shrink-0 w-6 h-6 rounded-full bg-accent/15 text-accent text-xs flex items-center justify-center font-semibold">
{{ i + 1 }}
</span>
<span class="leading-relaxed pt-0.5">{{ step }}</span>
</li>
</ol>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import type { RecipeData } from '@/composables/contentExtraction'
import { useTheme } from '@/composables/useTheme'
import { generateRecipeFallback } from '@/composables/useImageFallback'
const props = defineProps<{ recipe: RecipeData }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const scaleFactor = ref(1)
const checkedIngredients = reactive(new Set<number>())
const scaledServings = computed(() => {
const base = parseInt(props.recipe.servings || '0', 10)
if (!base) return props.recipe.servings
return Math.round(base * scaleFactor.value)
})
const scaledIngredients = computed(() => {
return props.recipe.ingredients.map((ing) =>
ing.replace(/(\d+\.?\d*)/g, (match) => {
const num = parseFloat(match)
const scaled = num * scaleFactor.value
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(1)
})
)
})
function toggleIngredient(idx: number) {
if (checkedIngredients.has(idx)) {
checkedIngredients.delete(idx)
} else {
checkedIngredients.add(idx)
}
}
</script>
@@ -0,0 +1,125 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredRecipes.length }} recipes
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-if="recipes.length > 3"
v-model="search"
type="text"
placeholder="Search recipes..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="(recipe, i) in filteredRecipes"
:key="i"
class="group flex flex-col items-stretch text-left w-full rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:class="isDark
? 'bg-white/[0.04] border border-white/8 hover:bg-white/[0.07]'
: 'bg-black/[0.02] border border-black/5 hover:bg-black/[0.05]'"
:aria-label="recipe.title"
@click="$emit('selectRecipe', recipe)"
>
<!-- Cover image area -->
<div class="aspect-[3/1] relative w-full overflow-hidden">
<img
:src="generateRecipeFallback(recipe.title, recipe.time)"
:alt="recipe.title"
class="w-full h-full object-cover"
/>
</div>
<!-- Card body -->
<div class="p-3">
<p class="text-sm font-semibold leading-tight line-clamp-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ recipe.title }}
</p>
<!-- Meta row -->
<div class="flex items-center gap-3 mt-2 flex-wrap">
<span v-if="recipe.time" class="flex items-center gap-1 text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ recipe.time }}
</span>
<span v-if="recipe.servings" class="flex items-center gap-1 text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ recipe.servings }}
</span>
<span v-if="recipe.calories" class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ recipe.calories }} cal
</span>
</div>
<!-- Ingredient preview -->
<p v-if="recipe.ingredients.length > 0" class="text-xs mt-2 line-clamp-1"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ recipe.ingredients.slice(0, 4).join(' \u00B7 ') }}
</p>
</div>
</button>
</div>
<div v-if="filteredRecipes.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No recipes match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RecipeData } from '@/composables/contentExtraction'
import { useTheme } from '@/composables/useTheme'
import { generateRecipeFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
recipes: RecipeData[]
title?: string
}>(), {
title: 'Recipes',
})
defineEmits<{ selectRecipe: [recipe: RecipeData] }>()
const { isDark } = useTheme()
const search = ref('')
const filteredRecipes = computed(() => {
if (!search.value.trim()) return props.recipes
const q = search.value.toLowerCase()
return props.recipes.filter(r =>
r.title.toLowerCase().includes(q) ||
r.ingredients.some(ing => ing.toLowerCase().includes(q))
)
})
</script>
@@ -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 /<recipe_ext\s/i.test(text)
}
// ─── Code classifiers ───────────────────────────────────────────
export function isCodeQuery(q: string): boolean {
@@ -162,6 +174,7 @@ export function preferredFirstTab(userQuery: string): ContentTab | null {
if (/\b(tv\b|tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
if (isRecipeQuery(q)) return 'recipe'
if (isCodeQuery(q)) return 'code'
if (isAppQuery(q)) return 'app'
if (isNostrQuery(q)) return 'nostr'
@@ -185,6 +198,7 @@ export function filterTabsByContext(
hasNostr: boolean,
hasApps: boolean,
hasCode = false,
hasRecipes = false,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
@@ -196,6 +210,7 @@ export function filterTabsByContext(
if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
if (hasPlaces) all.push('place')
if (hasRecipes) all.push('recipe')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasCode) all.push('code')
@@ -1,5 +1,6 @@
import { ref } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { RecipeData } from './contentExtraction'
import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -11,7 +12,7 @@ import {
extractMagazineSections, extractMagazineHeroImage,
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
extractFilmIds, extractSongIds, extractPodcastIds,
extractApps, extractCodeBlocks,
extractApps, extractCodeBlocks, extractRecipes,
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
} from './contentExtraction'
import type { AppEntry, CodeBlock } from './contentExtraction'
@@ -19,6 +20,7 @@ import {
isNewsQuery, isNewsLikeResponse, isTVQuery,
isNostrQuery, isNostrLikeResponse,
isCodeQuery, isCodeLikeResponse,
isRecipeLikeResponse,
filterTabsByContext, extractQueryContext,
} from './contentFiltering'
export type { ContentTab, MagazineSection } from './contentFiltering'
@@ -37,6 +39,7 @@ const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
const panelRecipes = ref<RecipeData[]>([])
const panelApps = ref<AppEntry[]>([])
const panelCodeBlocks = ref<CodeBlock[]>([])
const selectedFilm = ref<Film | null>(null)
@@ -48,6 +51,7 @@ const selectedArticle = ref<WebSearchResult | null>(null)
const selectedImage = ref<ImageItem | null>(null)
const selectedPlace = ref<Place | null>(null)
const selectedWebsite = ref<WebSearchResult | null>(null)
const selectedRecipe = ref<RecipeData | null>(null)
const selectedApp = ref<AppEntry | null>(null)
const selectedMagazineSection = ref<MagazineSection | null>(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,
@@ -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,
+1
View File
@@ -598,6 +598,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Songs',
podcast: 'Podcasts',
news: 'News',