mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-13 04:50:23 +08:00
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:
+22
-14
@@ -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
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
"folderCourseCountUnit": "دورة",
|
||||
"deleteFolderTitle": "حذف المجلد «{{name}}»",
|
||||
"deleteFolderEmptyDesc": "هذا المجلد فارغ. لا يمكن التراجع عن الحذف.",
|
||||
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
|
||||
"deleteFolderUngroupDesc": "تبقى الدورات في «دوراتي»، تصبح غير مصنّفة.",
|
||||
"moveToFolder": "نقل إلى مجلد",
|
||||
"moveToFolderLabel": "نقل إلى مجلد",
|
||||
"ungrouped": "غير مصنّف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
"folderCourseCountUnit": "件のコース",
|
||||
"deleteFolderTitle": "フォルダ「{{name}}」を削除",
|
||||
"deleteFolderEmptyDesc": "このフォルダは空です。削除後は復元できません。",
|
||||
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
|
||||
"deleteFolderUngroupDesc": "コースは「マイコース」に残り、未分類になります。",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"moveToFolderLabel": "フォルダに移動",
|
||||
"ungrouped": "未分類",
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
"folderCourseCountUnit": "개 강의",
|
||||
"deleteFolderTitle": "폴더 \"{{name}}\" 삭제",
|
||||
"deleteFolderEmptyDesc": "이 폴더는 비어 있습니다. 삭제 후 복구할 수 없습니다.",
|
||||
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
|
||||
"deleteFolderUngroupDesc": "강의는 «내 강의»에 유지되며, 미분류로 변경됩니다.",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"moveToFolderLabel": "폴더로 이동",
|
||||
"ungrouped": "미분류",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
"folderCourseCountUnit": "курсов",
|
||||
"deleteFolderTitle": "Удалить папку «{{name}}»",
|
||||
"deleteFolderEmptyDesc": "Эта папка пуста. Удаление необратимо.",
|
||||
"deleteFolderUngroupDesc": "Courses stay in \"My courses\", just unfiled.",
|
||||
"deleteFolderUngroupDesc": "Курсы остаются в «Мои курсы», просто без папки.",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"moveToFolderLabel": "Переместить в папку",
|
||||
"ungrouped": "Без папки",
|
||||
|
||||
@@ -825,7 +825,7 @@
|
||||
"folderCourseCountUnit": "門課程",
|
||||
"deleteFolderTitle": "刪除資料夾「{{name}}」",
|
||||
"deleteFolderEmptyDesc": "這個資料夾是空的。刪除後無法復原。",
|
||||
"deleteFolderUngroupDesc": "课程保留在「我的课程」,只是不再归类。",
|
||||
"deleteFolderUngroupDesc": "課程保留在「我的課程」,只是不再歸類。",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"moveToFolderLabel": "移動到資料夾",
|
||||
"ungrouped": "未分類",
|
||||
|
||||
@@ -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)'}`);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user