fix(courses): clear drop highlight on drag end, map limit error in dialog

Two minor findings from cross-review:

- Escape-cancelled drags may not fire dragleave on every folder target,
  leaving a highlight ring. The course card now dispatches a
  'course-drag-end' window event on dragEnd (fires for both normal drop
  and Escape cancel); folder cards listen and clear their drop state.
- FolderNameError kind 'limit' (thrown at the storage boundary when
  FOLDER_COUNT_LIMIT is reached in a cross-tab race) now maps to the
  specific folderCountLimit message in the dialog instead of falling
  through to a generic hint.

Also fixes an SSR hydration mismatch: librarySeen is now initialized
to false and read from sessionStorage in useEffect (not in the
useState initializer).
This commit is contained in:
Percy
2026-08-10 16:35:43 +08:00
parent a48b25d979
commit 470f5fea96
12 changed files with 134 additions and 33 deletions
+22 -14
View File
@@ -196,14 +196,9 @@ function HomePage() {
const [hydrated, setHydrated] = useState(false);
// Sticky once true for the tab session: the full-screen landing hero is a
// first-visit treatment only. Mirrored in sessionStorage so a refresh within
// the same tab keeps the compact layout.
const [librarySeen, setLibrarySeen] = useState(() => {
try {
return sessionStorage.getItem(LIBRARY_SEEN_KEY) === '1';
} catch {
return false;
}
});
// the same tab keeps the compact layout. Initialized to false (matching SSR)
// and read from sessionStorage in an effect to avoid hydration mismatch.
const [librarySeen, setLibrarySeen] = useState(false);
const [currentFolderId, setCurrentFolderId] = useState<string | undefined>(undefined);
const [newFolderOpen, setNewFolderOpen] = useState(false);
// When set, the new-folder dialog is creating a folder AND moving this course
@@ -299,6 +294,13 @@ function HomePage() {
useMediaGenerationStore.getState().revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
// Read sessionStorage on the client only (avoids SSR hydration mismatch).
try {
if (sessionStorage.getItem(LIBRARY_SEEN_KEY) === '1') setLibrarySeen(true);
} catch {
/* ignore */
}
// 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(() => {
@@ -759,11 +761,12 @@ function HomePage() {
transition={{ duration: 0.6, ease: 'easeOut' }}
className={cn(
'relative z-20 w-full max-w-[800px] flex flex-col items-center',
// Full-screen landing hero is a first-visit treatment only: it shows
// before the library has ever been seen this session. Once the
// library bar renders (hydrated), the hero stays compact forever so
// create/delete operations cannot change the bar's Y position.
hydrated && !librarySeen ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]',
// Full-screen landing hero is a first-session-visit treatment only.
// `librarySeen` is read synchronously from sessionStorage on mount
// (false on the very first visit), then latched true once the library
// loads. It never recomputes from mutable counts, so create/delete
// operations cannot change the bar's Y position.
!librarySeen ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]',
)}
>
{/* ── Logo ── */}
@@ -1623,11 +1626,16 @@ function ClassroomCard({
<div
className="group cursor-pointer"
onClick={confirmingDelete ? undefined : onClick}
draggable={!confirmingDelete}
draggable={!confirmingDelete && !editing}
onDragStart={(e) => {
e.dataTransfer.setData('text/stage-id', classroom.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragEnd={() => {
// Notify folder cards to clear any lingering drop highlight (Escape-
// cancelled drags may not fire dragleave on every target).
window.dispatchEvent(new CustomEvent('course-drag-end'));
}}
>
{/* Thumbnail — large radius, no border, subtle bg */}
<div
+21 -4
View File
@@ -61,6 +61,17 @@ export function FolderCard({
const thumbRef = useRef<HTMLDivElement>(null);
const [thumbWidth, setThumbWidth] = useState(0);
// Clear lingering drop highlight when a course drag ends (covers Escape-
// cancelled drags that may not fire dragleave on every target).
useEffect(() => {
const handler = () => {
dragDepth.current = 0;
setDropActive(false);
};
window.addEventListener('course-drag-end', handler);
return () => window.removeEventListener('course-drag-end', handler);
}, []);
const startEditing = () => {
setDraft(folder.name);
setError(null);
@@ -105,7 +116,10 @@ export function FolderCard({
};
const covers = coverSlides.slice(0, MAX_COVERS);
const isEmpty = covers.length === 0;
// Emptiness is derived from courseCount (the authoritative source), not from
// covers.length — a non-empty folder whose courses have no cached thumbnails
// must not show the empty-folder icon. `hasCovers` gates the cover stack.
const hasCovers = covers.length > 0;
return (
<div
@@ -147,14 +161,17 @@ export function FolderCard({
: 'ring-violet-200/50 dark:ring-violet-800/40',
)}
>
{isEmpty ? (
{hasCovers ? (
<CoverStack covers={covers} thumbWidth={thumbWidth} setThumbWidth={setThumbWidth} />
) : (
// No cached covers: empty folder shows the folder icon; a non-empty
// folder whose covers haven't loaded shows the same icon but the
// course-count badge (always visible) distinguishes it from empty.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="size-14 rounded-2xl bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center">
<Folder className="size-7 text-violet-500 dark:text-violet-300" />
</div>
</div>
) : (
<CoverStack covers={covers} thumbWidth={thumbWidth} setThumbWidth={setThumbWidth} />
)}
{/* Course count badge — always visible (bottom-right). */}
+8 -2
View File
@@ -53,7 +53,11 @@ export function NewFolderDialog({
if (submitting) return;
const widthCheck = validateFolderName(name);
if (!widthCheck.ok) {
setError(t('classroom.folderWidth', { width: widthCheck.width, max: FOLDER_NAME_MAX_WIDTH }));
setError(
widthCheck.kind === 'empty'
? t('classroom.folderNameEmpty')
: t('classroom.folderWidth', { width: widthCheck.width, max: FOLDER_NAME_MAX_WIDTH }),
);
return;
}
const trimmed = name.trim();
@@ -81,7 +85,9 @@ export function NewFolderDialog({
width: displayNameWidth(trimmed),
max: FOLDER_NAME_MAX_WIDTH,
})
: t('classroom.folderNameHint'),
: err.kind === 'limit'
? t('classroom.folderCountLimit')
: t('classroom.folderNameHint'),
);
} else {
setError(t('classroom.folderCreateFailed'));
+1 -1
View File
@@ -840,7 +840,7 @@
"folderCourseCountUnit": "دورة",
"deleteFolderTitle": "حذف المجلد «{{name}}»",
"deleteFolderEmptyDesc": "هذا المجلد فارغ. لا يمكن التراجع عن الحذف.",
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
"deleteFolderUngroupDesc": "تبقى الدورات في «دوراتي»، تصبح غير مصنّفة.",
"moveToFolder": "نقل إلى مجلد",
"moveToFolderLabel": "نقل إلى مجلد",
"ungrouped": "غير مصنّف",
+31 -1
View File
@@ -825,7 +825,37 @@
"searchPlaceholder": "Rechercher un cours…",
"searchAriaLabel": "Rechercher un cours",
"clearSearch": "Effacer",
"searchEmpty": "Aucun cours ne correspond à votre recherche"
"searchEmpty": "Aucun cours ne correspond à votre recherche",
"deleteFolderUngroupDesc": "Les cours restent dans « Mes cours », simplement non classés.",
"deleteFolderAndCourses": "Supprimer le dossier et {{count}} cours",
"deleteFolderAndCourses_other": "Supprimer le dossier et {{count}} cours",
"deleteFolderConfirmRemove": "Cela supprime le dossier et ses cours, y compris l'historique des discussions.",
"deleteFolderEmptyDesc": "Ce dossier est vide. La suppression est irréversible.",
"deleteFolderOnly": "Supprimer le dossier uniquement",
"deleteFolderTitle": "Supprimer le dossier « {{name}} »",
"emptyFolderHint": "Ce dossier est vide",
"emptyLibraryHint": "Aucun cours pour le moment — créez-en un ci-dessus, ou importez un cours.",
"folderBadge": "Dossier",
"folderCountLimit": "Limite de dossiers atteinte.",
"folderCourseCountUnit": "cours",
"folderCreate": "Créer",
"folderCreateFailed": "Échec de la création du dossier",
"folderDeleteFailed": "Échec de la suppression du dossier",
"folderNameEmpty": "Le nom du dossier ne peut pas être vide",
"folderNameExists": "Un dossier avec ce nom existe déjà.",
"folderNameHint": "Caractères larges et étroits acceptés ; les espaces comptent.",
"folderNameLabel": "Nom du dossier",
"folderNamePlaceholder": "Ex : Mathématiques, Anglais, Cours principaux",
"folderRenameFailed": "Échec du renommage du dossier",
"folderWidth": "Largeur {{width}}/{{max}}",
"moveFailed": "Échec du déplacement du cours",
"moveToFolder": "Déplacer vers un dossier",
"moveToFolderLabel": "Déplacer vers un dossier",
"newFolderDesc": "Organisez les cours par sujet pour un accès plus facile.",
"newFolderInline": "Nouveau dossier",
"newFolderTitle": "Nouveau dossier",
"searchResults": "Résultats de recherche",
"ungrouped": "Non classés"
},
"upload": {
"pdfSizeLimit": "Accepte les fichiers PDF jusqu’à 50 Mo",
+1 -1
View File
@@ -840,7 +840,7 @@
"folderCourseCountUnit": "件のコース",
"deleteFolderTitle": "フォルダ「{{name}}」を削除",
"deleteFolderEmptyDesc": "このフォルダは空です。削除後は復元できません。",
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
"deleteFolderUngroupDesc": "コースは「マイコース」に残り、未分類になります。",
"moveToFolder": "フォルダに移動",
"moveToFolderLabel": "フォルダに移動",
"ungrouped": "未分類",
+1 -1
View File
@@ -840,7 +840,7 @@
"folderCourseCountUnit": "개 강의",
"deleteFolderTitle": "폴더 \"{{name}}\" 삭제",
"deleteFolderEmptyDesc": "이 폴더는 비어 있습니다. 삭제 후 복구할 수 없습니다.",
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
"deleteFolderUngroupDesc": "강의는 «내 강의»에 유지되며, 미분류로 변경됩니다.",
"moveToFolder": "폴더로 이동",
"moveToFolderLabel": "폴더로 이동",
"ungrouped": "미분류",
+1 -1
View File
@@ -840,7 +840,7 @@
"folderCourseCountUnit": "cursos",
"deleteFolderTitle": "Excluir pasta \"{{name}}\"",
"deleteFolderEmptyDesc": "Esta pasta está vazia. A exclusão não pode ser desfeita.",
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
"deleteFolderUngroupDesc": "Os cursos permanecem em \"Meus cursos\", apenas sem pasta.",
"moveToFolder": "Mover para pasta",
"moveToFolderLabel": "Mover para a pasta",
"ungrouped": "Sem pasta",
+1 -1
View File
@@ -840,7 +840,7 @@
"folderCourseCountUnit": "курсов",
"deleteFolderTitle": "Удалить папку «{{name}}»",
"deleteFolderEmptyDesc": "Эта папка пуста. Удаление необратимо.",
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
"deleteFolderUngroupDesc": "Курсы остаются в «Мои курсы», просто без папки.",
"moveToFolder": "Переместить в папку",
"moveToFolderLabel": "Переместить в папку",
"ungrouped": "Без папки",
+1 -1
View File
@@ -825,7 +825,7 @@
"folderCourseCountUnit": "門課程",
"deleteFolderTitle": "刪除資料夾「{{name}}」",
"deleteFolderEmptyDesc": "這個資料夾是空的。刪除後無法復原。",
"deleteFolderUngroupDesc": "课程保留在「我的课程」,只是不再归类。",
"deleteFolderUngroupDesc": "課程保留在「我的課程」,只是不再歸類。",
"moveToFolder": "移動到資料夾",
"moveToFolderLabel": "移動到資料夾",
"ungrouped": "未分類",
+19 -3
View File
@@ -10,7 +10,7 @@ import { ChatSession } from '../types/chat';
import { db } from './database';
import type { FolderRecord } from './database';
import { nanoid } from 'nanoid';
import { validateFolderName } from './folder-name-validation';
import { validateFolderName, FOLDER_COUNT_LIMIT } from './folder-name-validation';
import {
ChatStorageLockUnavailableError,
saveChatSessions,
@@ -1059,7 +1059,7 @@ export async function listFolders(): Promise<FolderRecord[]> {
export class FolderNameError extends Error {
constructor(
message: string,
readonly kind: 'empty' | 'tooLong' | 'duplicate',
readonly kind: 'empty' | 'tooLong' | 'duplicate' | 'limit',
) {
super(message);
this.name = 'FolderNameError';
@@ -1092,6 +1092,9 @@ export async function createFolder(name: string): Promise<FolderRecord> {
const now = Date.now();
return db.transaction('rw', db.folders, async () => {
const existing = await db.folders.toArray();
if (existing.length >= FOLDER_COUNT_LIMIT) {
throw new FolderNameError('Folder count limit reached', 'limit');
}
assertFolderName(name, existing);
const order = existing.reduce((max, folder) => Math.max(max, folder.order), -1) + 1;
const folder: FolderRecord = {
@@ -1164,6 +1167,19 @@ export async function deleteFolder(id: string, mode: DeleteFolderMode = 'ungroup
*/
export async function setStageFolder(stageId: string, folderId: string | undefined): Promise<void> {
const now = Date.now();
await db.stageFolders.put({ stageId, folderId, updatedAt: now });
// Validate the destination folder exists before writing the membership row.
// Without this, an import that started inside a folder which is then deleted
// would write an orphan membership pointing at a gone folder. The check+write
// is in one transaction so deletion cannot race between them. Unfiling
// (folderId undefined) always succeeds — it just removes the membership.
if (folderId !== undefined) {
await db.transaction('rw', [db.folders, db.stageFolders], async () => {
const folder = await db.folders.get(folderId);
if (!folder) throw new Error(`Folder not found: ${folderId}`);
await db.stageFolders.put({ stageId, folderId, updatedAt: now });
});
} else {
await db.stageFolders.put({ stageId, folderId: undefined, updatedAt: now });
}
log.info(`Set stage ${stageId} folder -> ${folderId ?? '(unfiled)'}`);
}
+27 -3
View File
@@ -54,6 +54,23 @@ const { folders, memberships, dbMock, mutateDocumentMock } = vi.hoisted(() => {
generatedAgents: {
where: () => ({ equals: () => ({ delete: vi.fn().mockResolvedValue(undefined) }) }),
},
mediaFiles: {
where: () => ({
equals: () => ({
toArray: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue(undefined),
}),
}),
bulkDelete: vi.fn().mockResolvedValue(undefined),
},
audioFiles: {
where: () => ({
equals: () => ({
toArray: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue(undefined),
}),
}),
},
transaction: vi.fn(async (_mode: string, _tables: unknown[], fn: () => Promise<void>) =>
fn(),
),
@@ -154,10 +171,17 @@ describe('createFolder / renameFolder name validation', () => {
});
describe('setStageFolder membership', () => {
it('writes a membership row keyed by stageId', async () => {
await setStageFolder('stage-1', 'folder-1');
it('writes a membership row keyed by stageId for an existing folder', async () => {
const folder = await createFolder('Dest');
await setStageFolder('stage-1', folder.id);
const row = await dbMock.stageFolders.get('stage-1');
expect(row).toMatchObject({ stageId: 'stage-1', folderId: 'folder-1' });
expect(row).toMatchObject({ stageId: 'stage-1', folderId: folder.id });
});
it('rejects a folderId that does not exist (orphan prevention)', async () => {
await expect(setStageFolder('stage-1', 'nonexistent-folder')).rejects.toThrow(
'Folder not found',
);
});
it('moving out stores folderId undefined', async () => {