Add chunk tagging, persisted Split View prefs, sticky tab bar, and Strong's hover badges
- Tag chunks with topics; aggregate into project index and add a Home page tag filter - Persist studyLayout/activeStudyTab in localStorage - Make the Split View tab bar sticky while scrolling - Show a hoverable Strong's number badge with gloss on each word study card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+129
-3
@@ -135,6 +135,7 @@ export function migrateChunk(chunk) {
|
|||||||
episodeNumber: chunk.episodeNumber ?? '',
|
episodeNumber: chunk.episodeNumber ?? '',
|
||||||
episodeTitle: chunk.episodeTitle ?? '',
|
episodeTitle: chunk.episodeTitle ?? '',
|
||||||
finalScript: chunk.finalScript ?? '',
|
finalScript: chunk.finalScript ?? '',
|
||||||
|
tags: chunk.tags ?? [],
|
||||||
}; // already new format
|
}; // already new format
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -143,6 +144,7 @@ export function migrateChunk(chunk) {
|
|||||||
interpretation: '',
|
interpretation: '',
|
||||||
application: '',
|
application: '',
|
||||||
crossReferences: [],
|
crossReferences: [],
|
||||||
|
tags: [],
|
||||||
spilloverEndVerse: null,
|
spilloverEndVerse: null,
|
||||||
generalNotes: '',
|
generalNotes: '',
|
||||||
episodeNumber: '',
|
episodeNumber: '',
|
||||||
@@ -261,11 +263,19 @@ function saveProjectToStorage(project) {
|
|||||||
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
||||||
const index = loadProjectIndex();
|
const index = loadProjectIndex();
|
||||||
const existing = index.findIndex((e) => e.id === updated.id);
|
const existing = index.findIndex((e) => e.id === updated.id);
|
||||||
|
const tags = Array.from(
|
||||||
|
new Set(
|
||||||
|
(updated.chapters ?? [])
|
||||||
|
.flatMap((ch) => ch.chunks ?? [])
|
||||||
|
.flatMap((chunk) => chunk.tags ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
const summary = {
|
const summary = {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
lastEdited: updated.lastEdited,
|
lastEdited: updated.lastEdited,
|
||||||
chapterSummary: buildChapterSummary(updated),
|
chapterSummary: buildChapterSummary(updated),
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
index[existing] = summary;
|
index[existing] = summary;
|
||||||
@@ -855,10 +865,23 @@ const App = () => {
|
|||||||
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
||||||
|
const [homeTagFilter, setHomeTagFilter] = useState('');
|
||||||
const [renamingId, setRenamingId] = useState(null);
|
const [renamingId, setRenamingId] = useState(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const [studyLayout, setStudyLayout] = useState('stacked'); // 'stacked' | 'split'
|
const [studyLayout, setStudyLayout] = useState(
|
||||||
const [activeStudyTab, setActiveStudyTab] = useState('notes');
|
() => localStorage.getItem('studyLayout') || 'stacked',
|
||||||
|
); // 'stacked' | 'split'
|
||||||
|
const [activeStudyTab, setActiveStudyTab] = useState(
|
||||||
|
() => localStorage.getItem('activeStudyTab') || 'notes',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('studyLayout', studyLayout);
|
||||||
|
}, [studyLayout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('activeStudyTab', activeStudyTab);
|
||||||
|
}, [activeStudyTab]);
|
||||||
const [verseSearch, setVerseSearch] = useState('');
|
const [verseSearch, setVerseSearch] = useState('');
|
||||||
const [collapsedSections, setCollapsedSections] = useState({});
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||||
@@ -943,6 +966,7 @@ const App = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCrossRefSuggestions([]);
|
setCrossRefSuggestions([]);
|
||||||
|
setTagInput('');
|
||||||
}, [selectedChunk?.id]);
|
}, [selectedChunk?.id]);
|
||||||
const selectedChunkChapter = selectedChunkChapterIndex >= 0
|
const selectedChunkChapter = selectedChunkChapterIndex >= 0
|
||||||
? project.chapters[selectedChunkChapterIndex]
|
? project.chapters[selectedChunkChapterIndex]
|
||||||
@@ -1159,6 +1183,7 @@ const App = () => {
|
|||||||
episodeNumber: '',
|
episodeNumber: '',
|
||||||
episodeTitle: '',
|
episodeTitle: '',
|
||||||
finalScript: '',
|
finalScript: '',
|
||||||
|
tags: [],
|
||||||
};
|
};
|
||||||
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
||||||
});
|
});
|
||||||
@@ -1218,6 +1243,7 @@ const App = () => {
|
|||||||
application: '',
|
application: '',
|
||||||
crossReferences: [],
|
crossReferences: [],
|
||||||
greekWords: [],
|
greekWords: [],
|
||||||
|
tags: [],
|
||||||
};
|
};
|
||||||
existing.add(key);
|
existing.add(key);
|
||||||
newChunks.push(newChunk);
|
newChunks.push(newChunk);
|
||||||
@@ -1499,6 +1525,26 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
|
||||||
|
const addTag = (chunkId) => {
|
||||||
|
const tag = tagInput.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
const existing = selectedChunk?.tags ?? [];
|
||||||
|
if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) {
|
||||||
|
setTagInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateChunk(chunkId, { tags: [...existing, tag] });
|
||||||
|
setTagInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (chunkId, tag) => {
|
||||||
|
updateChunk(chunkId, {
|
||||||
|
tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
|
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
|
||||||
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
||||||
const _crossRefDatasetCacheRef = useRef({});
|
const _crossRefDatasetCacheRef = useRef({});
|
||||||
@@ -2490,11 +2536,33 @@ const restoreRemoteProject = async (id) => {
|
|||||||
<option value="passage">Passage</option>
|
<option value="passage">Passage</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{(() => {
|
||||||
|
const allTags = Array.from(
|
||||||
|
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
|
||||||
|
).sort((a, b) => a.localeCompare(b));
|
||||||
|
if (allTags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<label className="text-sm text-slate-600">
|
||||||
|
Tag{' '}
|
||||||
|
<select
|
||||||
|
value={homeTagFilter}
|
||||||
|
onChange={(e) => setHomeTagFilter(e.target.value)}
|
||||||
|
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
>
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{projectIndex
|
{projectIndex
|
||||||
.slice()
|
.slice()
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
|
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
||||||
const q = homeSearch.trim().toLowerCase();
|
const q = homeSearch.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return entry.title?.toLowerCase().includes(q)
|
return entry.title?.toLowerCase().includes(q)
|
||||||
@@ -2565,6 +2633,15 @@ const restoreRemoteProject = async (id) => {
|
|||||||
{entry.chapterSummary && (
|
{entry.chapterSummary && (
|
||||||
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
|
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
|
||||||
)}
|
)}
|
||||||
|
{entry.tags?.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{entry.tags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
|
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -3069,7 +3146,7 @@ const restoreRemoteProject = async (id) => {
|
|||||||
{/* Right column (study panels) */}
|
{/* Right column (study panels) */}
|
||||||
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex-1 lg:basis-0 lg:min-w-0' : ''}`}>
|
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex-1 lg:basis-0 lg:min-w-0' : ''}`}>
|
||||||
{studyLayout === 'split' && (
|
{studyLayout === 'split' && (
|
||||||
<div className="flex flex-wrap gap-2 rounded-3xl border border-slate-200 bg-white p-2">
|
<div className="sticky top-0 z-10 flex flex-wrap gap-2 rounded-3xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||||
{STUDY_TABS.map((tab) => (
|
{STUDY_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -3166,6 +3243,40 @@ const restoreRemoteProject = async (id) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Tags</h3>
|
||||||
|
<p className="mb-3 text-xs text-slate-500">Label this chunk by topic so you can search across studies later.</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(selectedChunk.tags ?? []).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(selectedChunk.id, tag)}
|
||||||
|
className="text-indigo-400 transition hover:text-indigo-700"
|
||||||
|
aria-label={`Remove tag ${tag}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); addTag(selectedChunk.id); }
|
||||||
|
}}
|
||||||
|
placeholder="Add a tag and press Enter"
|
||||||
|
className="min-w-[10rem] flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cross-references */}
|
{/* Cross-references */}
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
||||||
<button
|
<button
|
||||||
@@ -3322,6 +3433,21 @@ const restoreRemoteProject = async (id) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{word.strongNumber ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="group relative inline-flex cursor-default items-center rounded-full bg-sky-100 px-2.5 py-0.5 text-xs font-semibold text-sky-700"
|
||||||
|
title={word.shortDefinition || word.englishGloss || ''}
|
||||||
|
>
|
||||||
|
{word.strongNumber}
|
||||||
|
{(word.shortDefinition || word.englishGloss) && (
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-1 hidden w-max max-w-xs rounded-xl bg-slate-900 px-3 py-2 text-xs font-normal leading-5 text-white shadow-lg group-hover:block">
|
||||||
|
{word.shortDefinition || word.englishGloss}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||||
<label className="text-sm text-slate-600">
|
<label className="text-sm text-slate-600">
|
||||||
Greek word
|
Greek word
|
||||||
|
|||||||
Reference in New Issue
Block a user