Skip to main content

Knowledge > Products > ITW Premium > Search & Browse

ITW Search & Browse

Browse Paths

The ITW homepage presents five primary browse paths, each offering a different lens into the illustration library:

PathURLDescriptionExample
By Topic/topics200+ subject topicsFaith, Grace, Love, Forgiveness, Suffering
By Scripture/scripturesBible book/chapter/verse lookupJohn 3:16, Romans 8:28, Psalm 23
By Emotion/emotionsEmotional register categoriesJoy, Hope, Comfort, Challenge, Conviction
By Tradition/traditions17 theological traditionsReformed, Catholic, Pentecostal, Lutheran
By Source Type/sourcesContent origin categoriesMovies, History, Poetry, Devotionals, Quotes

Additional Browse Routes

RoutePurpose
/browse/[type]Paginated list by content type (e.g., /browse/movie-analogy)
/lectionary/[date]Revised Common Lectionary calendar with readings for a specific Sunday
/illustrations/[slug]Individual illustration detail page

Topic Browse

Topics are extracted from the topics array column in unified_rag_content during enrichment. The /topics page displays all topics with illustration counts, sorted by popularity.

Query Pattern

function getIllustrationsByTopic(supabase, topic, page, perPage) {
return supabase
.from('dir_illustrations')
.select('*', { count: 'exact' })
.contains('topics', [topic])
.order('quality_score', { ascending: false })
.range((page - 1) * perPage, page * perPage - 1)
}

Pagination uses Supabase .range(from, to) with count: 'exact' for total page calculation. Default 24 items per page.

Scripture Browse

Scripture search finds illustrations tagged with specific Bible references. The scripture_references array column stores references in standard format (e.g., 'John 3:16', '1 Corinthians 13:4-7').

Query Function

function getIllustrationsByScriptureRef(supabase, reference, excludeId?, limit = 6) {
query = supabase
.from('dir_illustrations')
.select('id, slug, title, teaser, summary, content_type, scripture_references, quality_score')
.contains('scripture_references', [reference])
.order('quality_score', { ascending: false })
.limit(limit)

if (excludeId)
query = query.neq('id', excludeId)

return query
}

This function is also used on illustration detail pages to show "Related by Scripture" sidebar content, passing the current illustration's ID as excludeId to avoid self-reference.

Emotion Browse

Emotions provide an affective dimension for sermon preparation. Pastors preparing a sermon on grief can browse illustrations tagged with "Comfort" or "Sorrow" regardless of topic or scripture.

Query Pattern

function getIllustrationsByEmotion(supabase, emotion) {
return supabase
.from('dir_illustrations')
.select('*', { count: 'exact' })
.contains('emotions', [emotion])
.order('quality_score', { ascending: false })
}

Tradition Browse

Tradition filtering leverages the theological_lens_id FK to sai_theological_lenses. Each of the 17 traditions has a dedicated browse page showing illustrations written from that tradition's perspective.

Query Pattern

function getIllustrationsByTradition(supabase, tradition_id) {
return supabase
.from('dir_illustrations')
.select('*', { count: 'exact' })
.or(`theological_lens_id.eq.${tradition_id},is_universal.eq.true`)
.order('quality_score', { ascending: false })
}

Note the OR clause: tradition pages show both tradition-specific content AND universal content (is_universal = true), giving pastors a comprehensive view.

Content Type Browse

The /browse/[type] route provides paginated browsing by content type (e.g., movie analogies, historical illustrations, devotionals).

Route Logic

function BrowseTypePage({ params: { slug } }) {
// slug is hyphenated: "movie-analogy" -> "movie_analogy"
contentType = slug.replace(/-/g, '_')

// Only show types with meaningful content
count = getCountForType(supabase, contentType)
if (count < 5) return notFound()

illustrations = getIllustrationsByType(supabase, contentType, page, perPage)
return <PaginatedGrid illustrations={illustrations} />
}

Content Type Counts

function getContentTypeCounts(supabase) {
return supabase
.from('dir_illustrations')
.select('content_type')
// Grouped count via RPC or client-side aggregation
}

The /sources page uses these counts to display a card grid of available content types with illustration counts, hiding types with fewer than 5 items.

Lectionary Integration

ITW integrates with the Revised Common Lectionary (RCL) to help pastors find illustrations for upcoming Sunday readings.

How It Works

function getUpcomingSundaysWithReadings(supabase, weeks) {
// 1. Calculate next N Sundays from current date
sundays = calculateUpcomingSundays(weeks)

// 2. For each Sunday, look up RCL readings
// (Old Testament, Psalm, Epistle, Gospel)
for (sunday of sundays) {
readings = getLectionaryReadings(sunday.date)

// 3. For each reading, find matching illustrations
for (reading of readings) {
reading.illustrations = getIllustrationsByScriptureRef(
supabase, reading.reference, null, 3
)
}
}

return sundays
}

Lectionary Display

The "This Sunday's Readings" widget on the homepage shows:

  • Date and liturgical season
  • Four readings (OT, Psalm, Epistle, Gospel)
  • Up to 3 matching illustrations per reading
  • Link to full lectionary page for more

The /lectionary/[date] route provides a full view for any given Sunday with all readings and all matching illustrations.

ITW supports full-text search via a search_vector tsvector column on dir_illustrations. This provides PostgreSQL native full-text search with ranking.

Search Flow

User types query in search bar
|
v
Client sends GET /illustrations?q={query}&page={page}
|
v
Server queries:
SELECT *, ts_rank(search_vector, plainto_tsquery('english', query)) AS rank
FROM dir_illustrations
WHERE search_vector @@ plainto_tsquery('english', query)
ORDER BY rank DESC, quality_score DESC
LIMIT 24 OFFSET (page - 1) * 24
|
v
Results rendered as illustration cards with match highlighting

Search Vector Composition

The search_vector tsvector column combines weighted text from multiple fields:

WeightFieldsPriority
A (highest)titleMost relevant for exact title matches
Bcontent, summaryMain content body
Ctopics, keywords, themesMetadata tags
D (lowest)scripture_references, source_attributionReference data

Pagination

All browse and search pages use consistent pagination:

ParameterValueNotes
Items per page24Fixed across all browse paths
Count methodcount: 'exact'Supabase returns total for page calculation
Range method.range(from, to)Zero-indexed, inclusive
Max pages displayed10UI shows max 10 page buttons with prev/next

Pagination Pseudocode

function paginate(supabase, query, page, perPage = 24) {
from = (page - 1) * perPage
to = from + perPage - 1

{ data, count } = await query
.range(from, to)
.select('*', { count: 'exact' })

totalPages = Math.ceil(count / perPage)
return { data, totalPages, currentPage: page, totalCount: count }
}

Important: Supabase has a 1000-row default limit. All paginated queries explicitly set .range() to stay within bounds. For aggregate queries (e.g., content type counts), pagination is mandatory.

Content Gating

Content gating enforces the three-tier visibility system on every page load. The gating logic runs on both card displays and detail pages.

Card Display (List/Grid Views)

FieldShown to AllNotes
TitleYesAlways visible
TeaserYes~150 characters, always visible
Content typeYesBadge on card
Topics/emotionsYesTag pills
Scripture referencesYesClickable links
Quality scoreNoInternal metric, not displayed
Lock iconConditionalShown when user cannot access full content

Cards show teaser text to everyone. If the user cannot view the full content, a lock icon appears with a CTA.

Detail Page Gating

function canViewFull(illustration, user, subscription) {
tier = illustration.visibility_tier

if (tier === 'public')
return true

if (tier === 'free_signup')
return user !== null // any authenticated user

if (tier === 'premium')
return subscription?.status === 'active' || subscription?.status === 'trialing'

return false
}

Gated Detail Page Behavior

When a user cannot view the full content:

[Title]
[Teaser text - 150 chars]
[Lock icon + blur effect over content area]

┌─────────────────────────────────────────┐
│ 🔒 This illustration requires │
│ [a free account / Premium access] │
│ │
│ [Sign Up Free] or [Go Premium] │
└─────────────────────────────────────────┘

[Related illustrations sidebar - still visible]
[Scripture cross-references - still visible]

The CTA adapts based on what the user needs:

  • No account: "Sign up free to read this illustration" -> /signup
  • Free account, premium content: "Upgrade to Premium for full access" -> /signup?plan=premium
  • Premium subscriber: Full content displayed, no gating

Card vs Detail Field Availability

FieldCard (list)Detail (full access)Detail (gated)
idYesYesYes
slugYesYesYes
titleYesYesYes
teaserYesYesYes
summaryYesYesYes
content_typeYesYesYes
themesYesYesYes
scripture_referencesYesYesYes
emotionsYesYesYes
audience_primaryYesYesYes
quality_scoreYes (internal)Yes (internal)Yes (internal)
view_countYesYesYes
visibility_tierYes (for gating)YesYes
theological_lens_idYesYesYes
is_universalYesYesYes
toneYesYesYes
primary_authorYesYesYes
contentNoYesNo (locked)
word_countNoYesNo
lens_tagsNoYesNo
audience_suitabilityNoYesNo
source_attributionNoYesNo
source_typeNoYesNo
use_countNoYesNo
created_atNoYesNo
application_pointsNoYesNo
sermon_sectionsNoYesNo
spiritual_disciplinesNoYesNo
creative_approachNoYesNo
image_urlNoYesNo

The homepage displays featured illustrations selected by quality:

function getFeaturedIllustrations(supabase, limit = 12) {
return supabase
.from('dir_illustrations')
.select('id, slug, title, teaser, summary, content_type, topics, emotions, quality_score')
.eq('visibility_tier', 'public') // Only public content on homepage
.order('quality_score', { ascending: false })
.limit(limit)
}

Featured content is always from the public tier so that unauthenticated visitors see real content immediately -- no sign-up wall on the homepage.

Query Functions Reference

All query functions live in sermon-illustrations/src/lib/queries/:

illustrations.ts

FunctionArgsReturnsUsed By
getFeaturedIllustrations(supabase, limit)Top-quality public illustrationsHomepage
getIllustrationBySlug(supabase, slug)Single illustration with all fieldsDetail page
getIllustrationsByTopic(supabase, topic, page, perPage)Paginated by topicTopic browse
getIllustrationsByEmotion(supabase, emotion)All for an emotionEmotion browse
getIllustrationsByTradition(supabase, tradition_id)Tradition + universalTradition browse
getIllustrationsByScriptureRef(supabase, ref, excludeId?, limit)By scripture passageScripture browse, detail sidebar
getContentTypeCounts(supabase)Count per content typeSource type browse
searchIllustrations(supabase, query, page, perPage)Full-text search resultsSearch results page

browse.ts

FunctionArgsReturnsUsed By
getIllustrationsByType(supabase, type, page, perPage)Paginated by content_type/browse/[type]
getAllTopics(supabase)Distinct topics with counts/topics
getAllEmotions(supabase)Distinct emotions with counts/emotions

lectionary.ts

FunctionArgsReturnsUsed By
getUpcomingSundaysWithReadings(supabase, weeks)Sunday dates + RCL readings + illustrationsHomepage widget, /lectionary
getLectionaryReadings(date)RCL readings for a specific date/lectionary/[date]

Performance Considerations

ConcernMitigation
Large result setsAll queries use .range() pagination, max 24 per page
Search performancesearch_vector tsvector column with GIN index
Quality sortingquality_score indexed for ORDER BY
Content type countsCached or computed on build
Materialized view stalenesspg_cron refreshes every 15 min, manual refresh available
Supabase row limitAll queries explicitly paginate (never rely on default 1000 limit)

See Also