feat(renderer): recipe & event card renderers (M10.4, M10.5)

- RecipeCard: ingredients checklist, numbered steps, scale slider
- EventCard: date chip, countdown timer, ICS download, Google Calendar
- Extract <recipe_ext> and <event_ext> tags from AI responses
- Both render inline in chat messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:55:02 +00:00
co-authored by Claude Opus 4.6
parent aec87a347c
commit c070e76515
4 changed files with 362 additions and 0 deletions
@@ -184,6 +184,22 @@
/>
</div>
<div v-if="inlineRecipes.length > 0" class="mt-3 space-y-2" @click.stop>
<RecipeCard
v-for="(recipe, i) in inlineRecipes"
:key="`recipe-${i}`"
:recipe="recipe"
/>
</div>
<div v-if="inlineEvents.length > 0" class="mt-3 space-y-2" @click.stop>
<EventCard
v-for="(event, i) in inlineEvents"
:key="`event-${i}`"
:event="event"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
@@ -325,6 +341,9 @@ import NewsCard from '@/components/content/NewsCard.vue'
import NostrEmbed from '@/components/chat/NostrEmbed.vue'
import CashuToken from '@/components/chat/CashuToken.vue'
import { extractCashuTokens } from '@/utils/cashu'
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
const props = withDefaults(
defineProps<{
@@ -417,6 +436,16 @@ const cashuTokens = computed(() => {
return extractCashuTokens(props.message.content)
})
const inlineRecipes = computed(() => {
if (isUser.value) return []
return extractRecipes(props.message.content)
})
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
@@ -0,0 +1,142 @@
<template>
<div class="event-card rounded-xl bg-white/5 border border-white/10 px-4 py-3">
<div class="flex items-start gap-3">
<!-- Date chip -->
<div class="shrink-0 w-14 text-center rounded-lg bg-accent/10 border border-accent/20 py-1.5">
<p class="text-[10px] text-accent/70 uppercase">{{ monthLabel }}</p>
<p class="text-lg font-bold text-accent leading-tight">{{ dayLabel }}</p>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-white/90 truncate">{{ event.title }}</h3>
<p v-if="event.location" class="text-xs text-white/50 mt-0.5 truncate">{{ event.location }}</p>
<p v-if="event.description" class="text-xs text-white/40 mt-1 line-clamp-2">{{ event.description }}</p>
<!-- Countdown -->
<p v-if="countdownText" class="text-[10px] mt-1.5" :class="isPast ? 'text-white/30' : 'text-accent/70'">
{{ countdownText }}
</p>
<!-- Actions -->
<div class="flex gap-2 mt-2">
<button
class="text-[10px] px-2 py-1 rounded bg-white/5 border border-white/10 text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
title="Download ICS file"
@click.stop="downloadIcs"
>
Add to Calendar
</button>
<a
:href="googleCalendarUrl"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] px-2 py-1 rounded bg-white/5 border border-white/10 text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click.stop
>
Google Calendar
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import type { EventData } from '@/composables/contentExtraction'
const props = defineProps<{ event: EventData }>()
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const eventDate = computed(() => {
if (!props.event.date) return null
const d = new Date(props.event.date)
return isNaN(d.getTime()) ? null : d
})
const monthLabel = computed(() => {
if (!eventDate.value) return '?'
return eventDate.value.toLocaleString('en', { month: 'short' }).toUpperCase()
})
const dayLabel = computed(() => {
if (!eventDate.value) return '?'
return eventDate.value.getDate()
})
const isPast = computed(() => {
if (!eventDate.value) return false
return eventDate.value.getTime() < now.value
})
const countdownText = computed(() => {
if (!eventDate.value) return ''
const diff = eventDate.value.getTime() - now.value
if (diff < 0) return 'Event has passed'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (days > 0) return `${days}d ${hours}h remaining`
if (hours > 0) return `${hours}h ${minutes}m remaining`
return `${minutes}m remaining`
})
function formatIcsDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
}
function downloadIcs() {
const d = eventDate.value
if (!d) return
const end = new Date(d.getTime() + 60 * 60 * 1000) // 1 hour default
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`DTSTART:${formatIcsDate(d)}`,
`DTEND:${formatIcsDate(end)}`,
`SUMMARY:${props.event.title}`,
props.event.location ? `LOCATION:${props.event.location}` : '',
props.event.description ? `DESCRIPTION:${props.event.description}` : '',
props.event.url ? `URL:${props.event.url}` : '',
'END:VEVENT',
'END:VCALENDAR',
].filter(Boolean).join('\r\n')
const blob = new Blob([ics], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`
a.click()
URL.revokeObjectURL(url)
}
const googleCalendarUrl = computed(() => {
const d = eventDate.value
if (!d) return '#'
const start = formatIcsDate(d).replace('Z', '')
const end = formatIcsDate(new Date(d.getTime() + 60 * 60 * 1000)).replace('Z', '')
const params = new URLSearchParams({
action: 'TEMPLATE',
text: props.event.title,
dates: `${start}/${end}`,
})
if (props.event.location) params.set('location', props.event.location)
if (props.event.description) params.set('details', props.event.description)
return `https://calendar.google.com/calendar/render?${params.toString()}`
})
</script>
@@ -0,0 +1,111 @@
<template>
<div class="recipe-card rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-white/5">
<h3 class="text-sm font-semibold text-white/90">{{ recipe.title }}</h3>
<div class="flex gap-3 mt-1.5">
<span v-if="recipe.time" class="text-[10px] text-white/40 flex items-center gap-1">
<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="text-[10px] text-white/40 flex items-center gap-1">
<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>
{{ scaledServings }} servings
</span>
<span v-if="recipe.calories" class="text-[10px] text-white/40">{{ recipe.calories }} cal</span>
</div>
</div>
<!-- Scale slider -->
<div class="px-4 py-2 border-b border-white/5 flex items-center gap-3">
<label class="text-[10px] text-white/30">Scale</label>
<input
v-model.number="scaleFactor"
type="range"
min="0.5"
max="4"
step="0.5"
class="flex-1 h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
/>
<span class="text-[10px] text-white/50 tabular-nums w-8 text-right">{{ scaleFactor }}×</span>
</div>
<!-- Ingredients -->
<div class="px-4 py-3 border-b border-white/5">
<h4 class="text-[11px] text-white/40 uppercase tracking-wider mb-2">Ingredients</h4>
<ul class="space-y-1">
<li
v-for="(ing, i) in scaledIngredients"
:key="i"
class="flex items-start gap-2 text-xs cursor-pointer select-none"
:class="checkedIngredients.has(i) ? 'line-through text-white/30' : 'text-white/70'"
@click="toggleIngredient(i)"
>
<span class="shrink-0 mt-0.5 w-4 h-4 rounded border flex items-center justify-center transition-colors"
:class="checkedIngredients.has(i) ? 'border-accent/50 bg-accent/20' : 'border-white/20'">
<svg v-if="checkedIngredients.has(i)" class="w-2.5 h-2.5 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-3">
<h4 class="text-[11px] text-white/40 uppercase tracking-wider mb-2">Steps</h4>
<ol class="space-y-2">
<li
v-for="(step, i) in recipe.steps"
:key="i"
class="flex gap-2 text-xs text-white/70"
>
<span class="shrink-0 w-5 h-5 rounded-full bg-accent/15 text-accent text-[10px] flex items-center justify-center font-medium">{{ i + 1 }}</span>
<span class="leading-relaxed">{{ step }}</span>
</li>
</ol>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
export interface Recipe {
title: string
servings?: string
time?: string
calories?: string
ingredients: string[]
steps: string[]
}
const props = defineProps<{ recipe: Recipe }>()
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)
})
// Scale numeric quantities in ingredients
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>
@@ -1063,3 +1063,83 @@ export function stripMarkdownLinks(text: string): string {
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ─── Recipe extraction (M10.4) ─────────────────────────────────
export interface RecipeData {
title: string
servings?: string
time?: string
calories?: string
ingredients: string[]
steps: string[]
}
const RECIPE_EXT_RE = /<recipe_ext\s+([^>]+)>([\s\S]*?)<\/recipe_ext>/gi
export function extractRecipes(text: string): RecipeData[] {
const results: RecipeData[] = []
let m: RegExpExecArray | null
while ((m = RECIPE_EXT_RE.exec(text)) !== null) {
const attrs = m[1]
const body = m[2]
const title = attrs.match(/title="([^"]*)"/)?.[1] || 'Recipe'
const servings = attrs.match(/servings="([^"]*)"/)?.[1]
const time = attrs.match(/time="([^"]*)"/)?.[1]
const calories = attrs.match(/calories="([^"]*)"/)?.[1]
// Parse body: lines starting with - are ingredients, numbered lines are steps
const lines = body.split('\n').map(l => l.trim()).filter(Boolean)
const ingredients: string[] = []
const steps: string[] = []
for (const line of lines) {
if (/^[-*]\s+/.test(line)) {
ingredients.push(line.replace(/^[-*]\s+/, ''))
} else if (/^\d+[.)]\s+/.test(line)) {
steps.push(line.replace(/^\d+[.)]\s+/, ''))
}
}
results.push({ title, servings, time, calories, ingredients, steps })
}
return results
}
export function stripRecipeTags(text: string): string {
return text.replace(RECIPE_EXT_RE, '').replace(/\n{3,}/g, '\n\n').trim()
}
// ─── Event extraction (M10.5) ──────────────────────────────────
export interface EventData {
title: string
date: string
location?: string
url?: string
description?: string
}
const EVENT_EXT_RE = /<event_ext\s+([^>]*?)(?:\/>|>([\s\S]*?)<\/event_ext>)/gi
export function extractEvents(text: string): EventData[] {
const results: EventData[] = []
let m: RegExpExecArray | null
while ((m = EVENT_EXT_RE.exec(text)) !== null) {
const attrs = m[1]
const body = m[2]?.trim()
const title = attrs.match(/title="([^"]*)"/)?.[1] || 'Event'
const date = attrs.match(/date="([^"]*)"/)?.[1] || ''
const location = attrs.match(/location="([^"]*)"/)?.[1]
const url = attrs.match(/url="([^"]*)"/)?.[1]
results.push({ title, date, location, url, description: body })
}
return results
}
export function stripEventTags(text: string): string {
return text.replace(EVENT_EXT_RE, '').replace(/\n{3,}/g, '\n\n').trim()
}