Admin reorganization: nav groups, streak/progress display in Study Users; v1.1.1

- Split "Manage" nav group into Audience, Insights, Tools
- Moved Email Templates into Configure group
- Added Testimonials to About jump nav
- Added RSS Feed tab to Podcast Hub
- Updated Study Users expanded card: shows last studied date, streak (current + longest), and per-study lesson completion count
- Streak pill shown in collapsed header when > 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-22 13:27:41 -04:00
parent 1e4fe5f0e3
commit bc025b5dab
2 changed files with 65 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.0", "version": "1.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+64 -5
View File
@@ -141,7 +141,10 @@ interface StudyUserRecord {
displayName: string displayName: string
createdAt: string | null createdAt: string | null
lastLoginAt: string | null lastLoginAt: string | null
enrolledStudies: Array<{ slug: string; title: string }> lastStudiedDate: string | null
currentStreak: number
longestStreak: number
enrolledStudies: Array<{ slug: string; title: string; completedLessons?: number; totalLessons?: number }>
noteCount: number noteCount: number
subscribeNewsletter: boolean subscribeNewsletter: boolean
studyRemindersEnabled: boolean studyRemindersEnabled: boolean
@@ -276,6 +279,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<div className="admin-study-user-header-right"> <div className="admin-study-user-header-right">
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span> <span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
<span className="admin-study-user-pill">{user.noteCount} notes</span> <span className="admin-study-user-pill">{user.noteCount} notes</span>
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span> <span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
</div> </div>
</button> </button>
@@ -285,6 +289,8 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<div className="admin-study-user-meta"> <div className="admin-study-user-meta">
<span>Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}</span> <span>Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}</span>
<span>Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'}</span> <span>Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'}</span>
<span>Last studied: {user.lastStudiedDate ?? '—'}</span>
<span>Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})</span>
<span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span> <span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span>
<span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}</span> <span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}</span>
</div> </div>
@@ -329,7 +335,14 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<ul className="admin-study-user-enrollments"> <ul className="admin-study-user-enrollments">
{user.enrolledStudies.map(e => ( {user.enrolledStudies.map(e => (
<li key={e.slug}> <li key={e.slug}>
<span>{e.title}</span> <span>
{e.title}
{e.totalLessons != null && (
<span style={{ color: '#8a7a3a', fontSize: '0.8em', marginLeft: '0.5rem' }}>
{e.completedLessons ?? 0}/{e.totalLessons} lessons
</span>
)}
</span>
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => handleUnenroll(user, e.slug)}>Remove</button> <button type="button" className="btn-admin-remove" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => handleUnenroll(user, e.slug)}>Remove</button>
</li> </li>
))} ))}
@@ -748,7 +761,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
], ],
}, },
{ {
group: 'Manage', group: 'Audience',
options: [ options: [
{ value: 'questions', label: 'Questions' }, { value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' }, { value: 'study-comments', label: 'Study Comments' },
@@ -756,8 +769,17 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
{ value: 'contacts', label: 'Contacts' }, { value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' }, { value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' }, { value: 'study-users', label: 'Study Users' },
{ value: 'email-templates', label: 'Email Templates' }, ],
},
{
group: 'Insights',
options: [
{ value: 'analytics', label: 'Analytics' }, { value: 'analytics', label: 'Analytics' },
],
},
{
group: 'Tools',
options: [
{ value: 'assets', label: 'Asset Manager' }, { value: 'assets', label: 'Asset Manager' },
{ value: 'qr-codes', label: 'QR Codes' }, { value: 'qr-codes', label: 'QR Codes' },
], ],
@@ -767,6 +789,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
options: [ options: [
{ value: 'global', label: 'Footer & Global' }, { value: 'global', label: 'Footer & Global' },
{ value: 'seo', label: 'SEO & Redirects' }, { value: 'seo', label: 'SEO & Redirects' },
{ value: 'email-templates', label: 'Email Templates' },
{ value: 'legal', label: 'Legal Pages' }, { value: 'legal', label: 'Legal Pages' },
{ value: 'security', label: 'Security' }, { value: 'security', label: 'Security' },
{ value: 'brand', label: 'Brand Kit' }, { value: 'brand', label: 'Brand Kit' },
@@ -792,6 +815,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
{ id: 'about-show-copy', label: 'Show Copy' }, { id: 'about-show-copy', label: 'Show Copy' },
{ id: 'about-nate-bio', label: 'Nate Bio' }, { id: 'about-nate-bio', label: 'Nate Bio' },
{ id: 'about-images', label: 'Images' }, { id: 'about-images', label: 'Images' },
{ id: 'about-testimonials', label: 'Testimonials' },
], ],
contact: [ contact: [
{ id: 'contact-profile', label: 'Profile' }, { id: 'contact-profile', label: 'Profile' },
@@ -820,7 +844,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
], ],
} }
type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'podcast-checklist' | 'episode-scripts' type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'podcast-checklist' | 'episode-scripts' | 'rss-feed'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
@@ -3815,6 +3839,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button> <button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button> <button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button> <button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
</div> </div>
</section> </section>
)} )}
@@ -4816,6 +4841,40 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* RSS FEED */}
{(adminView === 'podcast' && podcastTab === 'rss-feed') && (() => {
const canonicalUrl = (form as { seo?: { canonicalUrl?: string } }).seo?.canonicalUrl?.replace(/\/$/, '') ?? ''
const feedUrl = canonicalUrl ? `${canonicalUrl}/feed.xml` : '/feed.xml'
const anchorFeedUrl = 'https://anchor.fm/s/11068d290/podcast/rss'
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>RSS Feed</h2>
<p>Your podcast feed is available at your own domain and proxies the Anchor source feed.</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ background: '#1a1a16', border: '1px solid #2a2518', borderRadius: '10px', padding: '1.25rem 1.5rem' }}>
<p style={{ margin: '0 0 0.35rem', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: '#7a7060' }}>Your Feed URL</p>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<code style={{ fontSize: '0.95rem', color: '#e0d5b5', wordBreak: 'break-all' }}>{feedUrl}</code>
<button type="button" className="btn-admin-save" onClick={() => navigator.clipboard.writeText(feedUrl)}>Copy</button>
<a href={feedUrl} target="_blank" rel="noopener noreferrer" className="btn-admin-reset">Open </a>
</div>
<p style={{ margin: '0.75rem 0 0', fontSize: '0.85rem', color: '#7a7060' }}>Submit this URL to Apple Podcasts, Spotify, and other directories to keep your feed on your own domain.</p>
</div>
<div style={{ background: '#1a1a16', border: '1px solid #2a2518', borderRadius: '10px', padding: '1.25rem 1.5rem' }}>
<p style={{ margin: '0 0 0.35rem', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: '#7a7060' }}>Anchor Source Feed</p>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<code style={{ fontSize: '0.88rem', color: '#7a7060', wordBreak: 'break-all' }}>{anchorFeedUrl}</code>
<a href={anchorFeedUrl} target="_blank" rel="noopener noreferrer" className="btn-admin-reset">Open </a>
</div>
<p style={{ margin: '0.75rem 0 0', fontSize: '0.85rem', color: '#7a7060' }}>Your feed.xml proxies this source and rewrites the channel link to your domain. Episodes are cached for 30 minutes.</p>
</div>
</div>
</section>
)
})()}
{/* STUDY COMMENTS */} {/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => { {adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => { const filtered = studyComments.filter(c => {