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:
co-authored by
Claude Opus 4.6
parent
2304e5904e
commit
dc4c80a08b
@@ -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,
|
||||
|
||||
@@ -598,6 +598,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
tvshow: 'TV',
|
||||
image: 'Images',
|
||||
place: 'Places',
|
||||
recipe: 'Recipes',
|
||||
song: 'Songs',
|
||||
podcast: 'Podcasts',
|
||||
news: 'News',
|
||||
|
||||
Reference in New Issue
Block a user