fix(courses): address 3rd review — ungate folders, fix empty-library hero, stabilize the bar

Per review (wyuc, CHANGES_REQUESTED on 19729038):

P1 — ship folders unconditionally: remove NEXT_PUBLIC_ENABLE_COURSE_FOLDERS,
the isCourseFoldersEnabled helper, the .env.example entry, and every
flag-on/flag-off UI branch. Folder metadata is always loaded; create,
move, drag, and folder navigation are always available. The IndexedDB
v16 schema stays intact.

P1 — full-screen landing hero only when the library is truly empty: the
hero uses min-h-[calc(100dvh-8rem)] only when there are zero courses AND
zero folders. A `hydrated` flag waits for both async loads before
selecting the layout, so folders arriving from storage do not flip the
hero from full-screen to compact.

P2 — geometrically stable centered bar: the Recent bar gets a fixed
height (h-9) so entering/leaving a folder (which toggles the New-folder
action and the folder path) does not shift the search/import controls.
This commit is contained in:
Percy
2026-08-06 15:29:21 +08:00
parent 1972903860
commit 860a11702c
3 changed files with 62 additions and 93 deletions
-3
View File
@@ -297,6 +297,3 @@ DEFAULT_MODEL=
# Use @openmaic/renderer for the classroom playback canvas. Default: false (legacy renderer).
# NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED=false
# Group courses into user-created folders on the home page. Default: false.
# NEXT_PUBLIC_ENABLE_COURSE_FOLDERS=false
+62 -78
View File
@@ -78,11 +78,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { useImportClassroom } from '@/lib/import/use-import-classroom';
import {
isCourseFoldersEnabled,
isPptxImportEnabled,
shouldShowVocationalTestUi,
} from '@/lib/config/feature-flags';
import { isPptxImportEnabled, shouldShowVocationalTestUi } from '@/lib/config/feature-flags';
import { useImportPptx } from '@/lib/import/use-import-pptx';
import { InteractiveModeButton } from '@/components/generation/interactive-mode-button';
@@ -97,11 +93,6 @@ const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';
// flag until it's wired end-to-end, so the UI doesn't expose a no-op button.
const PPTX_IMPORT_ENABLED = isPptxImportEnabled();
// Course folders — hide every folder affordance (new-folder button, folder
// tiles, breadcrumb, move menu) until the feature is enabled. The underlying
// storage layer is always present; the flag only gates the UI surface.
const COURSE_FOLDERS_ENABLED = isCourseFoldersEnabled();
interface FormState {
courseMaterials: SelectedCourseMaterial[];
requirement: string;
@@ -195,6 +186,10 @@ function HomePage() {
// that folder's course list. Searching flattens every course regardless of
// folder and annotates each with its folder name.
const [folders, setFolders] = useState<FolderRecord[]>([]);
// True once the initial classroom + folder loads resolve. Guards layout
// selection so the hero does not flip between full-screen and compact as the
// two async reads land (avoids a visible layout shift on first paint).
const [hydrated, setHydrated] = useState(false);
const [currentFolderId, setCurrentFolderId] = useState<string | undefined>(undefined);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FolderRecord | null>(null);
@@ -271,8 +266,9 @@ function HomePage() {
useMediaGenerationStore.getState().revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
loadClassrooms();
if (COURSE_FOLDERS_ENABLED) loadFolders();
// Both reads resolve before flipping `hydrated`, so the hero layout does
// not thrash between full-screen and compact as each lands independently.
void Promise.all([loadClassrooms(), loadFolders()]).finally(() => setHydrated(true));
return () => {
revokeThumbnailSlideMediaUrls(thumbnailsRef.current);
@@ -405,27 +401,20 @@ function HomePage() {
// that folder's members.
const folderNameById = useMemo(() => new Map(folders.map((f) => [f.id, f.name])), [folders]);
// When the feature flag is off, treat the folder world as empty so the home
// page renders exactly the legacy unfiled-only grid.
const effectiveFolders = COURSE_FOLDERS_ENABLED ? folders : [];
const effectiveCurrentFolderId = COURSE_FOLDERS_ENABLED ? currentFolderId : undefined;
const isSearching = deferredSearchQuery.trim().length > 0;
// The course tiles rendered in the active view: search flattens everything;
// a folder shows only its members; the root shows unfiled courses (folder
// tiles are rendered separately above them).
const visibleClassrooms = useMemo(() => {
if (isSearching) return filteredClassrooms;
if (effectiveCurrentFolderId)
return filteredClassrooms.filter((c) => c.folderId === effectiveCurrentFolderId);
if (currentFolderId) return filteredClassrooms.filter((c) => c.folderId === currentFolderId);
return filteredClassrooms.filter(
(c) => c.folderId === undefined || !folderNameById.has(c.folderId),
);
}, [filteredClassrooms, isSearching, effectiveCurrentFolderId, folderNameById]);
}, [filteredClassrooms, isSearching, currentFolderId, folderNameById]);
const currentFolderClassrooms = useMemo(
() =>
effectiveCurrentFolderId
? classrooms.filter((c) => c.folderId === effectiveCurrentFolderId)
: [],
[classrooms, effectiveCurrentFolderId],
() => (currentFolderId ? classrooms.filter((c) => c.folderId === currentFolderId) : []),
[classrooms, currentFolderId],
);
const courseCountByFolder = useMemo(() => {
const counts = new Map<string, number>();
@@ -449,7 +438,7 @@ function HomePage() {
}
return byFolder;
}, [classrooms, thumbnails]);
const currentFolder = effectiveFolders.find((f) => f.id === effectiveCurrentFolderId);
const currentFolder = folders.find((f) => f.id === currentFolderId);
const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
@@ -735,7 +724,10 @@ function HomePage() {
transition={{ duration: 0.6, ease: 'easeOut' }}
className={cn(
'relative z-20 w-full max-w-[800px] flex flex-col items-center',
classrooms.length === 0 && !effectiveCurrentFolderId
// Full-screen landing hero only when the library is truly empty
// (zero courses AND zero folders) and storage has hydrated, so the
// hero does not flip from full-screen to compact once folders load.
hydrated && classrooms.length === 0 && folders.length === 0
? 'justify-center min-h-[calc(100dvh-8rem)]'
: 'mt-[10vh]',
)}
@@ -944,20 +936,22 @@ function HomePage() {
{/* Keep the section alive as long as there is anything to show: courses,
folders, or — when the flag is on — the "New folder" affordance. This
avoids trapping persisted folders when the last course is deleted. */}
{(classrooms.length > 0 || effectiveFolders.length > 0 || COURSE_FOLDERS_ENABLED) && (
{(classrooms.length > 0 || folders.length > 0) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center"
>
{/* Trigger — divider-line with centered text */}
<div className="group w-full flex items-center gap-4 py-2">
{/* Trigger — divider-line with centered text. Fixed height keeps the
bar geometrically stable when the New-folder action or the folder
path appears/disappears (entering vs leaving a folder). */}
<div className="group w-full flex items-center gap-4 h-9">
<div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
<div className="shrink-0 flex items-center gap-3 text-[13px] text-muted-foreground/60 select-none">
<button
onClick={() => {
if (effectiveCurrentFolderId) setCurrentFolderId(undefined);
if (currentFolderId) setCurrentFolderId(undefined);
else persistRecentOpen(!recentOpen);
}}
className="flex items-center gap-2 hover:text-foreground/70 transition-colors cursor-pointer"
@@ -1086,7 +1080,7 @@ function HomePage() {
</button>
)}
{/* New folder — round icon button, matches the import/upload affordances. */}
{COURSE_FOLDERS_ENABLED && !currentFolderId && !isSearching && (
{!currentFolderId && !isSearching && (
<button
type="button"
onClick={() => {
@@ -1114,13 +1108,11 @@ function HomePage() {
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
className="w-full overflow-hidden"
>
{effectiveFolders.length === 0 && classrooms.length === 0 ? (
{folders.length === 0 && classrooms.length === 0 ? (
<div className="pt-8 pb-2 text-center text-[13px] text-muted-foreground/60">
{t('classroom.emptyLibraryHint')}
</div>
) : !isSearching &&
effectiveCurrentFolderId &&
currentFolderClassrooms.length === 0 ? (
) : !isSearching && currentFolderId && currentFolderClassrooms.length === 0 ? (
// Empty folder: hint directly below the centered path bar.
<div className="pt-8 text-center">
<p className="text-[14px] text-muted-foreground">
@@ -1163,8 +1155,8 @@ function HomePage() {
key={
isSearching
? 'search'
: effectiveCurrentFolderId
? `folder-${effectiveCurrentFolderId}`
: currentFolderId
? `folder-${currentFolderId}`
: 'root'
}
initial={{ opacity: 0, y: 8 }}
@@ -1175,8 +1167,8 @@ function HomePage() {
>
{/* Root + non-search: render folder tiles first. */}
{!isSearching &&
effectiveCurrentFolderId === undefined &&
effectiveFolders.map((folder, i) => (
currentFolderId === undefined &&
folders.map((folder, i) => (
<motion.div
key={folder.id}
initial={{ opacity: 0, y: 16 }}
@@ -1214,25 +1206,21 @@ function HomePage() {
onCancelDelete={() => setPendingDeleteId(null)}
onClick={() => router.push(`/classroom/${classroom.id}`)}
overlay={
COURSE_FOLDERS_ENABLED ? (
<>
<MoveToFolderMenu
folders={effectiveFolders}
currentFolderId={classroom.folderId}
onMove={(folderId) =>
handleMoveCourse(classroom.id, folderId)
}
onCreateAndMove={handleCreateAndMove(classroom.id)}
/>
{/* Search view: show the owning folder as a badge. */}
{isSearching && classroom.folderId && (
<span className="absolute bottom-2 left-2 z-10 inline-flex items-center gap-1 rounded-md bg-violet-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm pointer-events-none">
<Folder className="size-2.5" />
{folderNameById.get(classroom.folderId) ?? ''}
</span>
)}
</>
) : undefined
<>
<MoveToFolderMenu
folders={folders}
currentFolderId={classroom.folderId}
onMove={(folderId) => handleMoveCourse(classroom.id, folderId)}
onCreateAndMove={handleCreateAndMove(classroom.id)}
/>
{/* Search view: show the owning folder as a badge. */}
{isSearching && classroom.folderId && (
<span className="absolute bottom-2 left-2 z-10 inline-flex items-center gap-1 rounded-md bg-violet-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm pointer-events-none">
<Folder className="size-2.5" />
{folderNameById.get(classroom.folderId) ?? ''}
</span>
)}
</>
}
/>
</motion.div>
@@ -1249,26 +1237,22 @@ function HomePage() {
{/* Folder dialogs — mounted at the top level so they are reachable even
while the Recent section is collapsed or the course list is empty. */}
{COURSE_FOLDERS_ENABLED && (
<>
<NewFolderDialog
open={newFolderOpen}
onOpenChange={(open) => {
setNewFolderOpen(open);
if (!open) setCreateAndMoveTarget(null);
}}
folders={effectiveFolders}
onCreate={handleCreateFolder}
/>
<DeleteFolderDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
folder={deleteTarget}
courseCount={deleteTargetCourseCount}
onConfirm={confirmDeleteFolder}
/>
</>
)}
<NewFolderDialog
open={newFolderOpen}
onOpenChange={(open) => {
setNewFolderOpen(open);
if (!open) setCreateAndMoveTarget(null);
}}
folders={folders}
onCreate={handleCreateFolder}
/>
<DeleteFolderDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
folder={deleteTarget}
courseCount={deleteTargetCourseCount}
onConfirm={confirmDeleteFolder}
/>
{/* Footer — flows with content, at the very end */}
<div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40">
@@ -1633,7 +1617,7 @@ function ClassroomCard({
<div
className="group cursor-pointer"
onClick={confirmingDelete ? undefined : onClick}
draggable={COURSE_FOLDERS_ENABLED && !confirmingDelete}
draggable={!confirmingDelete}
onDragStart={(e) => {
e.dataTransfer.setData('text/stage-id', classroom.id);
e.dataTransfer.effectAllowed = 'move';
-12
View File
@@ -82,15 +82,3 @@ export function isVideoExportEnabled(): boolean {
export function isPptxImportEnabled(): boolean {
return readBoolean(process.env.NEXT_PUBLIC_ENABLE_PPTX_IMPORT);
}
/**
* Course folders — grouping courses into user-created folders on the home page.
* Default OFF. Gates the "New folder" entry point, the folder navigation
* (breadcrumb / folder tiles), and the per-course "Move to folder" menu. The
* underlying Dexie tables (`folders` / `stageFolders`) and storage functions
* are always available; this flag hides the affordances until the feature is
* stable.
*/
export function isCourseFoldersEnabled(): boolean {
return readBoolean(process.env.NEXT_PUBLIC_ENABLE_COURSE_FOLDERS);
}