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:
co-authored by
Claude Opus 4.6
parent
aec87a347c
commit
c070e76515
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user