Add Books + editable Recommended Resources sections; improve content block links; v1.1.38

- CustomBlock: add linkUrl/linkLabel fields for a styled button below the body
- Body text auto-linkifies raw URLs into gold anchor tags
- New 'books' placement: Reading List grid on Downloads page (cover, desc, Amazon btn)
- New 'recommended' placement: editable Recommended Resources section on Downloads
  page; falls back to Truth For Life widget when empty
- Admin: Books and Recommended Resources panels added to Content nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-26 08:16:36 -04:00
parent a35e04320a
commit eb1d3ddd9c
5 changed files with 348 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.37",
"version": "1.1.38",
"type": "module",
"scripts": {
"dev": "vite",
+121 -1
View File
@@ -699,7 +699,7 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -731,6 +731,8 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
{ value: 'downloads', label: 'Downloads' },
{ value: 'custom-links', label: 'Custom Links' },
{ value: 'content-blocks', label: 'Content Blocks' },
{ value: 'books', label: 'Books' },
{ value: 'recommended-resources', label: 'Recommended Resources' },
],
},
{
@@ -1996,11 +1998,15 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
if (placement === 'footer') return 'Footer navigation links'
if (placement === 'otherSites') return 'Other site links in footer dropdown'
if (placement === 'externalSites') return 'Homepage external site links'
if (placement === 'books') return 'Downloads page (Reading List)'
if (placement === 'recommended') return 'Downloads page (Recommended Resources)'
return 'Downloads page (More Resources)'
}
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
if (placement === 'resources') return '/resources'
if (placement === 'books') return '/resources'
if (placement === 'recommended') return '/resources'
return '/'
}
@@ -2162,6 +2168,26 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
}))
}
function addBook() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', amazonUrl: '', amazonLabel: 'View on Amazon', placement: 'books' as const },
],
}))
}
function addRecommended() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'recommended' as const },
],
}))
}
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
setForm(f => ({
...f,
@@ -3828,6 +3854,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
<textarea id={`block-body-${block.id}`} value={block.body} rows={3} placeholder="Write your content here…" onChange={e => updateBlock(block.id, 'body', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`block-link-url-${block.id}`}>Button Link URL <span className="admin-field-optional">(optional)</span></label>
<input id={`block-link-url-${block.id}`} type="url" value={block.linkUrl ?? ''} placeholder="https://…" onChange={e => updateBlock(block.id, 'linkUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`block-link-label-${block.id}`}>Button Label <span className="admin-field-optional">(optional)</span></label>
<input id={`block-link-label-${block.id}`} type="text" value={block.linkLabel ?? ''} placeholder="e.g. Get the book on Amazon" onChange={e => updateBlock(block.id, 'linkLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
</div>
@@ -3837,6 +3871,92 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section>
)}
{/* BOOKS */}
{adminView === 'books' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Books</h2>
<p>Amazon books shown in the Reading List section on the Downloads page. Add a cover image, description, and Amazon link for each book.</p>
</div>
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
<div key={book.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`book-title-${book.id}`}>Title</label>
<input id={`book-title-${book.id}`} type="text" value={book.label} placeholder="Book title" onChange={e => updateLink(book.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-description-${book.id}`}>Description</label>
<textarea id={`book-description-${book.id}`} rows={3} value={book.description ?? ''} placeholder="Short description of the book" onChange={e => updateLink(book.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-image-${book.id}`}>Cover Image URL</label>
<input id={`book-image-${book.id}`} type="text" value={book.imageUrl ?? ''} placeholder="/uploads/cover.jpg" onChange={e => updateLink(book.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(book.imageUrl, value => updateLink(book.id, 'imageUrl', value), `book-image-${book.id}-asset`)}
{renderImagePreview(book.imageUrl, `${book.label || 'Book'} cover`)}
</div>
<div className="admin-field">
<label htmlFor={`book-amazon-url-${book.id}`}>Amazon URL</label>
<input id={`book-amazon-url-${book.id}`} type="url" value={book.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(book.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-amazon-label-${book.id}`}>Amazon Button Label</label>
<input id={`book-amazon-label-${book.id}`} type="text" value={book.amazonLabel ?? ''} placeholder="View on Amazon" onChange={e => updateLink(book.id, 'amazonLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-url-${book.id}`}>Link URL <span className="admin-field-optional">(optional fallback)</span></label>
<input id={`book-url-${book.id}`} type="url" value={book.url ?? ''} placeholder="https://..." onChange={e => updateLink(book.id, 'url', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(book.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addBook}>+ Add Book</button>
{renderSaveStatus()}
</section>
)}
{/* RECOMMENDED RESOURCES */}
{adminView === 'recommended-resources' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Recommended Resources</h2>
<p>Curated resources shown at the bottom of the Downloads page. When at least one is added, it replaces the default Truth For Life widget. Add a title, image, link, and description for each resource.</p>
</div>
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').length === 0 && (
<p className="admin-stats-note">No custom recommended resources yet the Truth For Life widget shows by default.</p>
)}
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').map(item => (
<div key={item.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`rec-title-${item.id}`}>Title</label>
<input id={`rec-title-${item.id}`} type="text" value={item.label} placeholder="Resource title" onChange={e => updateLink(item.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-url-${item.id}`}>Link URL</label>
<input id={`rec-url-${item.id}`} type="url" value={item.url} placeholder="https://..." onChange={e => updateLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-description-${item.id}`}>Description</label>
<textarea id={`rec-description-${item.id}`} rows={3} value={item.description ?? ''} placeholder="What is this resource?" onChange={e => updateLink(item.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-image-${item.id}`}>Image URL</label>
<input id={`rec-image-${item.id}`} type="text" value={item.imageUrl ?? ''} placeholder="/uploads/example.jpg" onChange={e => updateLink(item.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(item.imageUrl, value => updateLink(item.id, 'imageUrl', value), `rec-image-${item.id}-asset`)}
{renderImagePreview(item.imageUrl, `${item.label || 'Resource'} image`)}
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(item.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addRecommended}>+ Add Recommended Resource</button>
{renderSaveStatus()}
</section>
)}
{/* STUDIES */}
{adminView === 'colossians-study' && (
<section className="admin-panel-section">
+142
View File
@@ -4407,6 +4407,140 @@
padding: 0;
}
.custom-block-inline-link {
color: var(--brand-gold);
text-decoration: underline;
word-break: break-all;
}
.custom-block-link-btn {
display: inline-block;
margin-top: 1.5rem;
}
/* ── Books section ── */
.section-books {
background: #0d0d0d;
border-top: 1px solid rgba(201, 168, 76, 0.12);
padding: 4rem 0;
}
.books-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.book-card {
display: flex;
gap: 1.25rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
}
.book-card-cover {
width: 80px;
height: 110px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.book-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.book-card-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
line-height: 1.3;
}
.book-card-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.book-card-btn {
align-self: flex-start;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
}
/* ── Recommended Resources section ── */
.recommended-resources-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.recommended-resource-card {
display: flex;
gap: 1.25rem;
align-items: flex-start;
text-decoration: none;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.recommended-resource-card:hover {
border-color: rgba(201, 168, 76, 0.35);
}
.recommended-resource-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.recommended-resource-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recommended-resource-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
}
.recommended-resource-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.recommended-resource-action {
font-size: 0.85rem;
color: var(--brand-gold);
font-weight: 600;
}
/* ── Study Guide section ── */
.section-guide {
background: #0d0d0d;
@@ -5414,6 +5548,14 @@
opacity: 0.85;
}
.admin-field-optional {
font-weight: 400;
letter-spacing: 0;
text-transform: none;
opacity: 0.6;
font-size: 0.75rem;
}
.admin-field input,
.admin-field textarea {
background: #111;
+81 -2
View File
@@ -885,6 +885,16 @@ function ShareShowButton() {
)
}
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const parts = text.split(urlRegex)
return parts.map((part, i) =>
urlRegex.test(part)
? <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="custom-block-inline-link">{part}</a>
: part
)
}
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
const blocks = (content.customBlocks ?? []).filter(block => {
const blockPage = block.page ?? 'downloads'
@@ -897,7 +907,12 @@ function CustomBlocksSection({ content, page }: { content: SiteContent; page: st
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
<div className="section-inner custom-block-inner">
<h2>{block.heading}</h2>
<p>{block.body}</p>
<p>{linkifyText(block.body)}</p>
{block.linkUrl && (
<a href={block.linkUrl} target="_blank" rel="noopener noreferrer" className="btn-primary custom-block-link-btn">
{block.linkLabel || 'Learn More'}
</a>
)}
</div>
</section>
))}
@@ -1723,6 +1738,69 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
}
function RecommendedResourcesSection({ content }: { content: SiteContent }) {
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
if (recommended.length === 0) return <TruthForLifeWidget type="resourcead" />
return (
<section className="section-tfl" aria-label="Recommended resources">
<div className="section-inner">
<div className="tfl-label-row">
<h2 className="section-heading" style={{ marginBottom: '1rem' }}>
<span className="ornament"></span> Recommended Resources <span className="ornament"></span>
</h2>
</div>
<div className="recommended-resources-list">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
)}
<div className="recommended-resource-meta">
<span className="recommended-resource-title">{item.label}</span>
{item.description && <p className="recommended-resource-description">{item.description}</p>}
<span className="recommended-resource-action">Learn more </span>
</div>
</a>
))}
</div>
</div>
</section>
)
}
function BooksSectionSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
if (books.length === 0) return null
return (
<section className="section-resources section-books" aria-label="Recommended books">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Reading List</p>
<h2 className="section-heading">Books worth your time.</h2>
</div>
<div className="books-list">
{books.map(book => (
<div key={book.id} className="book-card">
{book.imageUrl && (
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
)}
<div className="book-card-meta">
<span className="book-card-title">{book.label}</span>
{book.description && <p className="book-card-description">{book.description}</p>}
{book.amazonUrl && (
<a href={book.amazonUrl} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
{book.amazonLabel || 'View on Amazon'}
</a>
)}
</div>
</div>
))}
</div>
</div>
</section>
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
@@ -1735,8 +1813,9 @@ function ResourcesPage({ content }: { content: SiteContent }) {
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<BooksSectionSection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<TruthForLifeWidget type="resourcead" />
<RecommendedResourcesSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
+3 -1
View File
@@ -26,7 +26,7 @@ export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites'
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites' | 'books' | 'recommended'
imageUrl?: string
description?: string
amazonUrl?: string
@@ -39,6 +39,8 @@ export interface CustomBlock {
heading: string
body: string
page?: 'homepage' | 'start-here' | 'episodes' | 'downloads' | 'about' | 'contact' | 'questions'
linkUrl?: string
linkLabel?: string
}
export interface WhereToNextCard {