From 2f27cb86c468c383c04550c5e3035b5dde16cebe Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 00:27:51 +0000 Subject: [PATCH] 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 --- .claude/launch.json | 13 +- packages/app/scripts/dev.sh | 19 +- .../app/src/components/content/FilmCard.vue | 5 + .../app/src/components/content/FilmDetail.vue | 23 +- .../src/components/content/MagazineGrid.vue | 414 ++++++++++-------- packages/app/src/composables/useAI.ts | 2 +- .../app/src/composables/useContentPanel.ts | 50 ++- 7 files changed, 313 insertions(+), 213 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 6f3007ab..f7e736a4 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } diff --git a/packages/app/scripts/dev.sh b/packages/app/scripts/dev.sh index ff3eebfe..d2aecc31 100755 --- a/packages/app/scripts/dev.sh +++ b/packages/app/scripts/dev.sh @@ -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 diff --git a/packages/app/src/components/content/FilmCard.vue b/packages/app/src/components/content/FilmCard.vue index fbbc3917..99725a87 100644 --- a/packages/app/src/components/content/FilmCard.vue +++ b/packages/app/src/components/content/FilmCard.vue @@ -29,6 +29,11 @@ :class="isDark ? 'text-white/40' : 'text-gray-500'"> {{ film.year }}

+

+ {{ film.synopsis }} +

-
+
-

- {{ film.synopsis }} -

+
+

+ Why watch +

+

+ {{ film.synopsis }} +

+
-
+

Cast

@@ -60,7 +67,7 @@

-
+

Watch on

@@ -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(null) diff --git a/packages/app/src/components/content/MagazineGrid.vue b/packages/app/src/components/content/MagazineGrid.vue index 9ea11687..82f605b9 100644 --- a/packages/app/src/components/content/MagazineGrid.vue +++ b/packages/app/src/components/content/MagazineGrid.vue @@ -1,13 +1,11 @@ @@ -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(() => { + 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, '$1') - const withParas = safe.replace(/\n\n+/g, '

').replace(/\n/g, '
') - return `

${withParas}

` -} diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 96a36cf2..2cc72c62 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -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]]). diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts index a3304d85..90e6f7aa 100644 --- a/packages/app/src/composables/useContentPanel.ts +++ b/packages/app/src/composables/useContentPanel.ts @@ -115,8 +115,8 @@ function addSection( content: string, seen: Set, ): 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() const blockContents = new Set() // 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() @@ -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,