wip: magazine tile layout, film descriptions, dev script fixes
- MagazineGrid: New Yorker tile layout with wide/half/dark/banner tiles - Extract AI film descriptions into synopsis field - Fix section extraction to handle ### headers - Strip emojis from magazine titles and content - FilmDetail: conditional synopsis with "Why watch" header - FilmCard: synopsis preview for external films - Consolidate dev server to single script - Fix dev.sh for macOS bash 3.2 compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c82b4ef54f
commit
2f27cb86c4
+3
-10
@@ -2,18 +2,11 @@
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "app",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@aiui/app", "dev:vite"],
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": ["packages/app/scripts/dev.sh"],
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
},
|
||||
{
|
||||
"name": "claude-proxy",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@aiui/app", "dev:proxy"],
|
||||
"port": 3141,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,23 +7,24 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
cleanup() {
|
||||
# Kill all child processes
|
||||
kill 0 2>/dev/null || true
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Use local node_modules binaries
|
||||
BIN="$APP_DIR/node_modules/.bin"
|
||||
|
||||
# Start Claude proxy in background
|
||||
npx tsx "$APP_DIR/server/claude-proxy.ts" &
|
||||
PROXY_PID=$!
|
||||
"$BIN/tsx" server/claude-proxy.ts &
|
||||
|
||||
# Give proxy a moment to bind port
|
||||
sleep 0.3
|
||||
|
||||
# Start Vite dev server in foreground
|
||||
npx vite --host &
|
||||
VITE_PID=$!
|
||||
# Start Vite dev server in background
|
||||
"$BIN/vite" --host &
|
||||
|
||||
# Wait for either to exit — then cleanup kills both
|
||||
wait -n $PROXY_PID $VITE_PID 2>/dev/null || true
|
||||
# Wait for all background jobs (compatible with bash 3.2 on macOS)
|
||||
wait
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-500'">
|
||||
{{ film.year }}<template v-if="film.director"> · {{ film.director }}</template>
|
||||
</p>
|
||||
<p v-if="isExternal && film.synopsis"
|
||||
class="text-[10px] mt-0.5 line-clamp-2"
|
||||
:class="isDark ? 'text-white/35' : 'text-gray-400'">
|
||||
{{ film.synopsis }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5 mt-1.5">
|
||||
<span v-if="film.rating > 0"
|
||||
class="text-[10px] font-semibold px-1.5 py-0.5 rounded"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div v-if="film.genres.length" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="genre in film.genres"
|
||||
:key="genre"
|
||||
@@ -47,12 +47,19 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-600'">
|
||||
{{ film.synopsis }}
|
||||
</p>
|
||||
<div v-if="film.synopsis">
|
||||
<h4 v-if="isExternal"
|
||||
class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-1.5"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
Why watch
|
||||
</h4>
|
||||
<p class="text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-600'">
|
||||
{{ film.synopsis }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="film.cast.length">
|
||||
<h4 class="text-xs font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">Cast</h4>
|
||||
<p class="text-sm" :class="isDark ? 'text-white/70' : 'text-gray-700'">
|
||||
@@ -60,7 +67,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="film.sources.length">
|
||||
<h4 class="text-xs font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">Watch on</h4>
|
||||
<div class="space-y-2">
|
||||
@@ -106,6 +113,8 @@ defineEmits<{ back: [] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const isExternal = computed(() => props.film.id.startsWith('ext-'))
|
||||
|
||||
// Fallback order: backdrop (mock) → TMDB API → poster (mock) → gradient
|
||||
const bannerTry = ref<'backdrop' | 'tmdb' | 'poster' | 'done'>('backdrop')
|
||||
const tmdbBannerUrl = ref<string | null>(null)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<template>
|
||||
<div class="magazine-newyorker h-full flex flex-col"
|
||||
<div class="magazine h-full flex flex-col"
|
||||
:class="isDark ? 'magazine-dark' : 'magazine-light'">
|
||||
<!-- Masthead: editorial, minimal -->
|
||||
<header
|
||||
class="shrink-0 px-4 py-3 flex items-center justify-between border-b"
|
||||
:class="isDark ? 'border-white/10' : 'border-black/8'"
|
||||
>
|
||||
<h1 class="font-serif text-lg font-semibold tracking-tight"
|
||||
:class="isDark ? 'text-white/95' : 'text-gray-900'">
|
||||
<!-- Masthead -->
|
||||
<header class="shrink-0 px-5 py-4 flex items-center justify-between border-b"
|
||||
:class="isDark ? 'border-white/10' : 'border-black/10'">
|
||||
<h1 class="font-serif text-xl font-bold tracking-tight"
|
||||
:class="isDark ? 'text-white' : 'text-black'">
|
||||
AI Brief
|
||||
</h1>
|
||||
<div class="shrink-0">
|
||||
@@ -15,146 +13,108 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar pb-16">
|
||||
<!-- Query context (subtle, editorial) -->
|
||||
<div v-if="headlineText" class="px-4 sm:px-6 pt-4 pb-2">
|
||||
<p class="text-[10px] uppercase tracking-widest"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-500'">
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- Query context -->
|
||||
<div v-if="headlineText" class="px-5 pt-4 pb-1">
|
||||
<p class="text-[9px] uppercase tracking-[0.3em] font-medium"
|
||||
:class="isDark ? 'text-white/30' : 'text-black/40'">
|
||||
In response to
|
||||
</p>
|
||||
<p class="font-serif text-sm mt-0.5" :class="isDark ? 'text-white/70' : 'text-gray-600'">
|
||||
<p class="font-serif text-sm mt-0.5 italic"
|
||||
:class="isDark ? 'text-white/60' : 'text-black/50'">
|
||||
{{ headlineText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Lead story: hero + headline (New Yorker featured style) -->
|
||||
<article v-if="sections[0]" class="border-b" :class="isDark ? 'border-white/10' : 'border-black/8'">
|
||||
<!-- Hero image above lead -->
|
||||
<div class="relative w-full aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
:src="heroImageDisplay"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 sm:p-6">
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-[0.2em] font-medium block mb-1"
|
||||
:class="isDark ? 'text-white/60' : 'text-white/80'"
|
||||
>
|
||||
The Lead
|
||||
</span>
|
||||
<h2 class="font-serif text-xl sm:text-2xl md:text-3xl font-semibold leading-tight text-white drop-shadow-sm">
|
||||
{{ sections[0].title }}
|
||||
</h2>
|
||||
<p v-if="sections[0].author"
|
||||
class="text-xs mt-1.5 text-white/90">
|
||||
By {{ sections[0].author }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 sm:px-6 py-5 sm:py-6">
|
||||
<div class="max-w-2xl">
|
||||
<div
|
||||
class="magazine-prose text-sm sm:text-base leading-relaxed"
|
||||
:class="isDark ? 'text-white/85' : 'text-gray-700'"
|
||||
v-html="formatContent(sections[0].content)"
|
||||
/>
|
||||
<a
|
||||
v-if="sections[0].url"
|
||||
href="#"
|
||||
class="inline-block mt-4 text-xs font-medium underline underline-offset-2 hover:no-underline"
|
||||
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-600 hover:text-gray-900'"
|
||||
@click.prevent="openLink(sections[0].url!)"
|
||||
>
|
||||
Read more →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<!-- Tile grid -->
|
||||
<div class="px-3 py-3">
|
||||
<div class="grid grid-cols-2 gap-[1px]"
|
||||
:class="isDark ? 'bg-white/8' : 'bg-black/8'">
|
||||
<template v-for="(tile, i) in tiles" :key="i">
|
||||
<!-- Banner tile: full width with icon -->
|
||||
<div v-if="tile.type === 'banner'"
|
||||
class="col-span-2 flex flex-col items-center justify-center py-6 px-5"
|
||||
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'">
|
||||
<svg class="w-6 h-6 mb-2" :class="isDark ? 'text-white/20' : 'text-black/15'"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="tile.icon === 'compass'" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v2m0 16v2m10-10h-2M4 12H2m15.07-5.07l-1.41 1.41M8.34 15.66l-1.41 1.41m0-11.14l1.41 1.41m7.32 7.32l1.41 1.41" />
|
||||
<path v-else-if="tile.icon === 'bookmark'" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
<path v-else-if="tile.icon === 'lightning'" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
<p class="text-[9px] uppercase tracking-[0.3em] font-semibold text-center"
|
||||
:class="isDark ? 'text-white/30' : 'text-black/30'">
|
||||
{{ tile.label }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section: Today's Mix / The Lede -->
|
||||
<div v-if="sections.length > 1" class="px-4 sm:px-6 py-6 sm:py-8">
|
||||
<h3 class="text-[10px] uppercase tracking-[0.25em] font-semibold mb-5"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
{{ sections.length > 2 ? "Today's Mix" : 'More' }}
|
||||
</h3>
|
||||
<!-- Wide tile: full width, for lead/summary -->
|
||||
<button v-else-if="tile.type === 'wide'"
|
||||
class="col-span-2 text-left px-5 py-5 transition-colors cursor-pointer"
|
||||
:class="isDark
|
||||
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
|
||||
: 'bg-[#faf9f6] hover:bg-black/[0.02]'"
|
||||
@click="tile.section?.url && openLink(tile.section.url)">
|
||||
<p v-if="tile.label"
|
||||
class="text-[9px] uppercase tracking-[0.3em] font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-black/35'">
|
||||
{{ tile.label }}
|
||||
</p>
|
||||
<h2 class="font-serif text-lg font-bold leading-snug mb-2"
|
||||
:class="isDark ? 'text-white/95' : 'text-black/90'">
|
||||
{{ tile.title }}
|
||||
</h2>
|
||||
<p v-if="tile.author"
|
||||
class="text-[10px] mb-2"
|
||||
:class="isDark ? 'text-white/40' : 'text-black/40'">
|
||||
By {{ tile.author }}
|
||||
</p>
|
||||
<p class="font-serif text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/70' : 'text-black/60'">
|
||||
{{ tile.text }}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div class="space-y-8">
|
||||
<article
|
||||
v-for="(section, i) in sections.slice(1)"
|
||||
:key="i"
|
||||
class="group"
|
||||
>
|
||||
<h4 class="font-serif text-base sm:text-lg font-semibold leading-snug mb-1.5"
|
||||
:class="isDark ? 'text-white/95 group-hover:text-white' : 'text-gray-900 group-hover:text-gray-800'">
|
||||
{{ section.title }}
|
||||
</h4>
|
||||
<p v-if="section.author"
|
||||
class="text-xs mb-2"
|
||||
:class="isDark ? 'text-white/55' : 'text-gray-500'">
|
||||
By {{ section.author }}
|
||||
</p>
|
||||
<div
|
||||
class="magazine-prose text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/75' : 'text-gray-600'"
|
||||
v-html="formatContent(section.content)"
|
||||
/>
|
||||
<a
|
||||
v-if="section.url"
|
||||
href="#"
|
||||
class="inline-block mt-2 text-xs font-medium underline underline-offset-2 hover:no-underline"
|
||||
:class="isDark ? 'text-white/60 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
|
||||
@click.prevent="openLink(section.url!)"
|
||||
>
|
||||
Read more →
|
||||
</a>
|
||||
</article>
|
||||
<!-- Standard tile: half width -->
|
||||
<button v-else
|
||||
class="text-left px-4 py-4 transition-colors flex flex-col cursor-pointer"
|
||||
:class="[
|
||||
isDark
|
||||
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
|
||||
: 'bg-[#faf9f6] hover:bg-black/[0.02]',
|
||||
tile.type === 'dark'
|
||||
? isDark ? 'bg-white/[0.04]' : 'bg-black/[0.04]'
|
||||
: ''
|
||||
]"
|
||||
@click="tile.section?.url && openLink(tile.section.url)">
|
||||
<p v-if="tile.label"
|
||||
class="text-[8px] uppercase tracking-[0.25em] font-semibold mb-1.5"
|
||||
:class="isDark ? 'text-white/25' : 'text-black/30'">
|
||||
{{ tile.label }}
|
||||
</p>
|
||||
<h3 class="font-serif text-sm font-bold leading-snug mb-1"
|
||||
:class="isDark ? 'text-white/90' : 'text-black/85'">
|
||||
{{ tile.title }}
|
||||
</h3>
|
||||
<p class="font-serif text-xs leading-relaxed flex-1"
|
||||
:class="isDark ? 'text-white/55' : 'text-black/50'">
|
||||
{{ tile.text }}
|
||||
</p>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="sections.length === 0" class="flex items-center justify-center py-16 px-4">
|
||||
<p class="text-sm" :class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
No sections to display
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Below the fold: image pair (Daily Cartoon / Shouts & Murmurs style) -->
|
||||
<section class="px-4 sm:px-6 py-8 border-t" :class="isDark ? 'border-white/8' : 'border-black/6'">
|
||||
<h3 class="text-[10px] uppercase tracking-[0.25em] font-semibold mb-4"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
In Case You Missed It
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="overflow-hidden" :class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'">
|
||||
<div class="aspect-[4/3] relative">
|
||||
<img
|
||||
:src="heroImageDisplay"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden relative" :class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'">
|
||||
<div class="aspect-[4/3] relative">
|
||||
<img
|
||||
:src="memeImageUrl"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-3"
|
||||
:class="isDark ? 'bg-black/80' : 'bg-black/70'">
|
||||
<p class="text-[11px] font-medium text-white/95">
|
||||
{{ memeCaption }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -165,6 +125,16 @@ import { useTheme } from '@/composables/useTheme'
|
||||
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
||||
import type { MagazineSection } from '@/composables/useContentPanel'
|
||||
|
||||
interface Tile {
|
||||
type: 'wide' | 'half' | 'dark' | 'banner'
|
||||
title: string
|
||||
text: string
|
||||
label?: string
|
||||
author?: string
|
||||
icon?: string
|
||||
section?: MagazineSection
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sections: MagazineSection[]
|
||||
heroImageUrl?: string | null
|
||||
@@ -179,35 +149,147 @@ const props = withDefaults(defineProps<{
|
||||
const { isDark } = useTheme()
|
||||
const overlayStore = useArticleOverlayStore()
|
||||
|
||||
function isSafeImgUrl(u: string | undefined | null): u is string {
|
||||
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
|
||||
const bannerIcons = ['compass', 'bookmark', 'lightning', 'lines'] as const
|
||||
const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
|
||||
|
||||
function cleanText(text: string): string {
|
||||
return text
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '')
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/[-•]\s*/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const heroImageDisplay = computed(() => {
|
||||
if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl
|
||||
const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief'
|
||||
return `https://picsum.photos/seed/${seed}/800/450`
|
||||
function truncate(text: string, max: number): string {
|
||||
const clean = cleanText(text)
|
||||
if (clean.length <= max) return clean
|
||||
return clean.slice(0, max).replace(/\s+\S*$/, '') + '\u2009...'
|
||||
}
|
||||
|
||||
/** Break a section's content into individual points (split on bullets/newlines) */
|
||||
function splitIntoBullets(content: string): string[] {
|
||||
return content
|
||||
.split(/\n\s*[-•]\s*|\n{2,}/)
|
||||
.map(s => s.replace(/^[-•]\s*/, '').replace(/\*\*/g, '').replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').trim())
|
||||
.filter(s => s.length > 10)
|
||||
}
|
||||
|
||||
const tiles = computed<Tile[]>(() => {
|
||||
const result: Tile[] = []
|
||||
const secs = props.sections
|
||||
if (!secs.length) return result
|
||||
|
||||
// Seeded pseudo-random based on query for consistent layout
|
||||
let seed = 0
|
||||
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
|
||||
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
|
||||
|
||||
secs.forEach((section, i) => {
|
||||
const bullets = splitIntoBullets(section.content)
|
||||
|
||||
if (i === 0) {
|
||||
// Lead section: always wide
|
||||
result.push({
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
text: truncate(section.content, 200),
|
||||
label: 'The Lead',
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Insert a banner between groups to break up content
|
||||
if (i % 3 === 0 && i > 0) {
|
||||
const bIdx = Math.floor(rand() * bannerIcons.length)
|
||||
result.push({
|
||||
type: 'banner',
|
||||
title: '',
|
||||
text: '',
|
||||
icon: bannerIcons[bIdx],
|
||||
label: bannerLabels[bIdx],
|
||||
})
|
||||
}
|
||||
|
||||
// If the section has multiple bullets, split into individual tiles
|
||||
if (bullets.length >= 2) {
|
||||
// Section header as wide tile
|
||||
result.push({
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
text: truncate(bullets[0], 150),
|
||||
label: section.author ? `By ${section.author}` : undefined,
|
||||
section,
|
||||
})
|
||||
// Remaining bullets as half-width tiles in pairs
|
||||
for (let b = 1; b < bullets.length; b++) {
|
||||
const isOdd = rand() > 0.7
|
||||
result.push({
|
||||
type: isOdd ? 'dark' : 'half',
|
||||
title: extractBulletTitle(bullets[b]) || section.title,
|
||||
text: truncate(cleanBulletTitle(bullets[b]), 100),
|
||||
section,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Single-content section: alternate between wide and half
|
||||
const useWide = rand() > 0.5 || section.content.length > 200
|
||||
result.push({
|
||||
type: useWide ? 'wide' : 'half',
|
||||
title: section.title,
|
||||
text: truncate(section.content, useWide ? 200 : 100),
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
// If half, add a companion dark tile for visual pairing
|
||||
if (!useWide) {
|
||||
result.push({
|
||||
type: 'dark',
|
||||
title: section.title,
|
||||
text: truncate(section.content.slice(100), 80),
|
||||
section,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure half tiles are paired (no orphans)
|
||||
const finalResult: Tile[] = []
|
||||
let pendingHalf = false
|
||||
for (const tile of result) {
|
||||
finalResult.push(tile)
|
||||
if (tile.type === 'half' || tile.type === 'dark') {
|
||||
pendingHalf = !pendingHalf
|
||||
} else {
|
||||
if (pendingHalf) {
|
||||
// Insert a spacer dark tile to pair the orphan
|
||||
finalResult.splice(finalResult.length - 1, 0, {
|
||||
type: 'dark', title: '', text: '', label: '',
|
||||
})
|
||||
pendingHalf = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we end with an unpaired half, pad it
|
||||
if (pendingHalf) {
|
||||
finalResult.push({ type: 'dark', title: '', text: '', label: '' })
|
||||
}
|
||||
|
||||
return finalResult
|
||||
})
|
||||
|
||||
const memeImageUrl = computed(() => {
|
||||
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
|
||||
const q = (props.query || '').toLowerCase()
|
||||
const combined = text + ' ' + q
|
||||
if (/\bbearish|bear|fear|dump|crash|extreme fear\b/.test(combined)) return 'https://i.imgflip.com/wxica.jpg'
|
||||
if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg'
|
||||
if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg'
|
||||
if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg'
|
||||
return 'https://i.imgflip.com/1bij.jpg'
|
||||
})
|
||||
/** Extract bold title from a bullet point like "**Title** - rest" */
|
||||
function extractBulletTitle(text: string): string {
|
||||
const m = /^\*\*([^*]+)\*\*/.exec(text)
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
|
||||
const memeCaption = computed(() => {
|
||||
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
|
||||
if (/\bbearish|fear|extreme fear\b/.test(text)) return 'Me checking my portfolio'
|
||||
if (/\bbull|rally|moon\b/.test(text)) return 'Buying the dip'
|
||||
if (/\bmacro|fed|inflation\b/.test(text)) return 'The economy rn'
|
||||
if (/\bbitcoin|btc\b/.test(text)) return 'Bitcoin holders'
|
||||
return 'Markets be like'
|
||||
})
|
||||
function cleanBulletTitle(text: string): string {
|
||||
return text.replace(/^\*\*[^*]+\*\*\s*[-–—:]\s*/, '').trim()
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
overlayStore.open(url, '', undefined, undefined)
|
||||
@@ -216,22 +298,12 @@ function openLink(url: string) {
|
||||
const headlineText = computed(() => {
|
||||
const q = (props.query ?? '').trim()
|
||||
if (!q) return props.title
|
||||
return q.length > 100 ? q.slice(0, 97) + '…' : q
|
||||
return q.length > 100 ? q.slice(0, 97) + '...' : q
|
||||
})
|
||||
|
||||
function formatContent(text: string): string {
|
||||
const safe = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
const withParas = safe.replace(/\n\n+/g, '</p><p class="mt-3">').replace(/\n/g, '<br/>')
|
||||
return `<p>${withParas}</p>`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.magazine-newyorker {
|
||||
.magazine {
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
}
|
||||
.magazine-light {
|
||||
@@ -240,10 +312,4 @@ function formatContent(text: string): string {
|
||||
.magazine-dark {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
.magazine-prose :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.magazine-prose :deep(p + p) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the u
|
||||
|
||||
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
|
||||
|
||||
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
|
||||
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]]. Write a brief reason why the film is worth watching on the same line as the tag.
|
||||
|
||||
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
|
||||
- Library songs: [[song:ID]] where ID is the song's id (e.g. [[song:s1]]).
|
||||
|
||||
@@ -115,8 +115,8 @@ function addSection(
|
||||
content: string,
|
||||
seen: Set<string>,
|
||||
): void {
|
||||
const t = title.trim().replace(/\s+/g, ' ').slice(0, 150)
|
||||
const c = content.trim().replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
|
||||
const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
|
||||
const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
|
||||
if (t.length < 2 || c.length < 15) return
|
||||
const key = `${t.slice(0, 50)}`
|
||||
if (seen.has(key)) return
|
||||
@@ -143,11 +143,11 @@ function extractMagazineSections(text: string): MagazineSection[] {
|
||||
const seen = new Set<string>()
|
||||
const blockContents = new Set<string>() // avoid duplicate bullets already in ## blocks
|
||||
|
||||
// 1. ## Heading blocks: full content until next ## or **Section**
|
||||
const headingRe = /^##\s*[^\n]*?(?:⚡|🔥|📌|✨)?\s*(.+?)\n\n([\s\S]+?)(?=\n##\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\z)/gim
|
||||
// 1. ## or ### Heading blocks: full content until next heading or **Section**
|
||||
const headingRe = /^#{2,3}\s*[^\n]*?(?:⚡|🔥|📌|✨|📺|⭐|🔄|🎬|🎭|🎵|🎧|📖|💡|🔍|🌍|💭|🧠|🔬|📊|🏆|📝)?\s*(.+?)\n\n([\s\S]+?)(?=\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\n\n---|\n\n\*\*TL;DR|\z)/gim
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = headingRe.exec(text)) !== null) {
|
||||
const title = m[1].trim().replace(/^[⚡🔥📌✨]\s*/, '').split(':')[0].trim()
|
||||
const title = m[1].trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
|
||||
const content = m[2].trim()
|
||||
if (content.length > 20) {
|
||||
blockContents.add(content)
|
||||
@@ -156,15 +156,15 @@ function extractMagazineSections(text: string): MagazineSection[] {
|
||||
}
|
||||
|
||||
// 2. **Pro/ Anti camp** blocks with their bullets
|
||||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n##\s|\n\nThis is|\n\nFor deeper|\z)/gim
|
||||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
|
||||
while ((m = campRe.exec(text)) !== null) {
|
||||
const title = m[1].trim()
|
||||
const content = m[2].trim()
|
||||
if (content.length > 15) addSection(sections, title, content, seen)
|
||||
}
|
||||
|
||||
// 3. Bullets: - **Title**: Content (skip if already in a ## block)
|
||||
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n##\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
|
||||
// 3. Bullets: - **Title**: Content (skip if already in a ## or ### block)
|
||||
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
|
||||
while ((m = bulletRe.exec(text)) !== null) {
|
||||
const raw = m[2].trim().replace(/\n+/g, ' ')
|
||||
if (raw.length < 15) continue
|
||||
@@ -173,14 +173,14 @@ function extractMagazineSections(text: string): MagazineSection[] {
|
||||
}
|
||||
|
||||
// 4. - **Name** (Role) description — attributed quotes
|
||||
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n##\s|\z)/gms
|
||||
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
|
||||
while ((m = attrRe.exec(text)) !== null) {
|
||||
const content = m[3].trim().replace(/\n+/g, ' ')
|
||||
if (content.length > 20) addSection(sections, `${m[1].trim()} (${m[2].trim()})`, content, seen)
|
||||
}
|
||||
|
||||
// 5. Intro paragraph before first ## or ---
|
||||
const introMatch = /^([\s\S]+?)(?=\n##\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
|
||||
// 5. Intro paragraph before first ## / ### or ---
|
||||
const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
|
||||
if (introMatch) {
|
||||
const intro = introMatch[1].trim().replace(/^[#*_\s-]+/gm, '').trim()
|
||||
if (intro.length > 60 && !seen.has('Summary')) {
|
||||
@@ -415,6 +415,32 @@ export function useContentPanel() {
|
||||
.filter((f): f is Film => !!f)
|
||||
}
|
||||
|
||||
function extractDescriptionForTag(text: string, matchIndex: number, matchLength: number): string {
|
||||
const prevNewline = text.lastIndexOf('\n', matchIndex - 1)
|
||||
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1
|
||||
const nextNewline = text.indexOf('\n', matchIndex + matchLength)
|
||||
const lineEnd = nextNewline === -1 ? text.length : nextNewline
|
||||
|
||||
let line = text.slice(lineStart, lineEnd)
|
||||
// Remove the tag itself
|
||||
line = line.replace(text.slice(matchIndex, matchIndex + matchLength), '')
|
||||
// Remove other content tags on the same line
|
||||
line = line.replace(/\[\[(?:film|song|podcast)(?:_ext)?:[^\]]*\]\]/g, '')
|
||||
// Remove leading bullet/list markers
|
||||
line = line.replace(/^\s*[-*•]\s*/, '').replace(/^\s*\d+\.\s*/, '')
|
||||
// Remove **Bold Title** followed by separator
|
||||
line = line.replace(/\*\*[^*]+\*\*\s*[-–—:]\s*/, '').replace(/\*\*[^*]+\*\*\s*/, '')
|
||||
// Remove stray markdown bold/italic
|
||||
line = line.replace(/\*\*/g, '').replace(/\*/g, '')
|
||||
// Remove parenthetical (Year) duplicating tag data
|
||||
line = line.replace(/\(\d{4}\)\s*/g, '')
|
||||
// Clean separators at edges
|
||||
line = line.replace(/^[\s\-–—:,]+/, '').replace(/[\s\-–—:,]+$/, '')
|
||||
|
||||
const result = line.trim().slice(0, 300)
|
||||
return result.length >= 10 ? result : ''
|
||||
}
|
||||
|
||||
function extractExternalFilms(text: string): Film[] {
|
||||
const films: Film[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -432,7 +458,7 @@ export function useContentPanel() {
|
||||
title,
|
||||
year,
|
||||
posterUrl: generatePosterFallback(title, year),
|
||||
synopsis: '',
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
genres: [],
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
|
||||
Reference in New Issue
Block a user