fix(courses): address 2nd review — folder-view layout, drop highlight, validation parity, a11y, i18n

Per review (wyuc, CHANGES_REQUESTED on 9f77686f):

P1 — folder view layout: opening a folder compacts the hero and surfaces
the library content; the centered header shows a single path
"Recent > Folder name"; the empty-folder state sits directly below it;
clicking Recent returns to the root grid. The duplicate content
breadcrumb is dropped (kept only for search).

P2 — correctness & a11y:
- Clear the folder drop highlight on every drag exit (dragenter counter)
  and gate it on the text/stage-id payload.
- Duplicate-name check in the dialog is now case-insensitive, matching
  the storage boundary; FolderNameError is mapped to specific messages.
- Renames report the actual submitted width, not a hardcoded 0.
- createFolder/renameFolder run read-check-write in one read-write
  transaction so the uniqueness invariant cannot race across tabs.
- The move-menu trigger is focus-visible and pointer-coarse reachable
  (visible on touch / keyboard), not hover-only.

Extra:
- Empty library (no courses, no folders) shows a dedicated hint instead
  of the search-empty string.
- Translate all newly added folder strings across ja/ko/pt/ru/ar, fix
  Simplified-Chinese text in zh-TW, and add the emptyLibraryHint key.
This commit is contained in:
Percy
2026-08-06 12:25:21 +08:00
parent 9f77686f91
commit 1972903860
14 changed files with 184 additions and 145 deletions
+30 -33
View File
@@ -66,6 +66,7 @@ import {
type DeleteFolderMode,
} from '@/lib/utils/stage-storage';
import type { FolderRecord } from '@/lib/utils/database';
import { displayNameWidth, FOLDER_NAME_MAX_WIDTH } from '@/lib/utils/folder-name-validation';
import { FolderCard } from '@/components/discovery/folder-card';
import { NewFolderDialog, DeleteFolderDialog } from '@/components/discovery/folder-dialogs';
import { MoveToFolderMenu } from '@/components/discovery/move-to-folder-menu';
@@ -330,7 +331,11 @@ function HomePage() {
} catch (err) {
if (err instanceof FolderNameError) {
if (err.kind === 'duplicate') return t('classroom.folderNameExists');
if (err.kind === 'tooLong') return t('classroom.folderWidth', { width: 0, max: 40 });
if (err.kind === 'tooLong')
return t('classroom.folderWidth', {
width: displayNameWidth(trimmed),
max: FOLDER_NAME_MAX_WIDTH,
});
return t('classroom.folderNameHint');
}
log.error('Failed to rename folder:', err);
@@ -730,7 +735,9 @@ 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 ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]',
classrooms.length === 0 && !effectiveCurrentFolderId
? 'justify-center min-h-[calc(100dvh-8rem)]'
: 'mt-[10vh]',
)}
>
{/* ── Logo ── */}
@@ -957,6 +964,14 @@ function HomePage() {
>
<Clock className="size-3.5" />
{t('classroom.recentClassrooms')}
{currentFolder && (
<>
<ChevronRight className="size-3 opacity-40" />
<span className="text-foreground/80 truncate max-w-[160px]">
{currentFolder.name}
</span>
</>
)}
<span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span>
<motion.div
animate={{ rotate: recentOpen ? 180 : 0 }}
@@ -1101,22 +1116,16 @@ function HomePage() {
>
{effectiveFolders.length === 0 && classrooms.length === 0 ? (
<div className="pt-8 pb-2 text-center text-[13px] text-muted-foreground/60">
{t('classroom.searchEmpty')}
{t('classroom.emptyLibraryHint')}
</div>
) : !isSearching &&
effectiveCurrentFolderId &&
currentFolderClassrooms.length === 0 ? (
// Empty folder: hint + back link.
// Empty folder: hint directly below the centered path bar.
<div className="pt-8 text-center">
<p className="text-[14px] text-muted-foreground">
{t('classroom.emptyFolderHint')}
</p>
<button
onClick={() => setCurrentFolderId(undefined)}
className="mt-3 text-[13px] text-violet-500 hover:text-violet-600"
>
{t('classroom.backToAllCourses')}
</button>
</div>
) : isSearching && filteredClassrooms.length === 0 ? (
<div className="pt-8 pb-2 text-center text-[13px] text-muted-foreground/60">
@@ -1124,40 +1133,28 @@ function HomePage() {
</div>
) : (
<div className="pt-8">
{/* Breadcrumb — shown inside a folder or while searching. */}
{(effectiveCurrentFolderId || isSearching) && (
{/* Breadcrumb — shown only while searching (the folder path
already lives in the centered header above). */}
{isSearching && (
<div className="mb-4 flex items-center gap-1.5 text-[13px] text-muted-foreground">
<button
type="button"
onClick={() => {
setCurrentFolderId(undefined);
if (isSearching) {
setSearchQuery('');
setSearchOpen(false);
}
setSearchQuery('');
setSearchOpen(false);
}}
className="hover:text-foreground transition-colors"
>
{t('classroom.recentClassrooms')}
</button>
<ChevronRight className="size-3.5" />
{isSearching ? (
<>
<span className="text-foreground font-medium">
{t('classroom.searchResults')}
</span>
<span className="ml-1.5 text-[12px] text-muted-foreground tabular-nums">
({filteredClassrooms.length})
</span>
</>
) : (
<>
<Folder className="size-3.5 text-violet-500" />
<span className="text-foreground font-medium truncate max-w-[60vw]">
{currentFolder?.name}
</span>
</>
)}
<span className="text-foreground font-medium">
{t('classroom.searchResults')}
</span>
<span className="ml-1.5 text-[12px] text-muted-foreground tabular-nums">
({filteredClassrooms.length})
</span>
</div>
)}
+17 -6
View File
@@ -46,6 +46,7 @@ export function FolderCard({
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [dropActive, setDropActive] = useState(false);
const dragDepth = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [thumbWidth, setThumbWidth] = useState(0);
@@ -90,18 +91,28 @@ export function FolderCard({
<div
className="group cursor-pointer"
onClick={editing ? undefined : onOpen}
onDragOver={(e) => {
onDragEnter={(e) => {
if (editing) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Only react to course-card drags (carrying a stage-id payload).
if (!Array.from(e.dataTransfer.types).includes('text/stage-id')) return;
dragDepth.current += 1;
setDropActive(true);
}}
onDragLeave={(e) => {
// Only clear when leaving the card entirely, not when crossing children.
if (e.currentTarget === e.target) setDropActive(false);
onDragOver={(e) => {
if (editing) return;
if (!Array.from(e.dataTransfer.types).includes('text/stage-id')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDragLeave={() => {
// dragenter/dragleave fire per child element; balance them with a
// counter so the highlight clears only when the pointer fully exits.
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDropActive(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDropActive(false);
const stageId = e.dataTransfer.getData('text/stage-id');
if (stageId) onDropCourse(stageId);
+19 -3
View File
@@ -18,6 +18,7 @@ import {
validateFolderName,
} from '@/lib/utils/folder-name-validation';
import type { FolderRecord } from '@/lib/utils/database';
import { FolderNameError } from '@/lib/utils/stage-storage';
// ============ F1: New folder dialog ============
@@ -56,7 +57,8 @@ export function NewFolderDialog({
return;
}
const trimmed = name.trim();
if (folders.some((f) => f.name === trimmed)) {
// Case-insensitive duplicate check, matching the storage-boundary rule.
if (folders.some((f) => f.name.toLowerCase() === trimmed.toLowerCase())) {
setError(t('classroom.folderNameExists'));
return;
}
@@ -68,8 +70,22 @@ export function NewFolderDialog({
try {
await onCreate(trimmed);
onOpenChange(false);
} catch {
setError(t('classroom.folderCreateFailed'));
} catch (err) {
// Map the storage-boundary error to a specific message when possible.
if (err instanceof FolderNameError) {
setError(
err.kind === 'duplicate'
? t('classroom.folderNameExists')
: err.kind === 'tooLong'
? t('classroom.folderWidth', {
width: displayNameWidth(trimmed),
max: FOLDER_NAME_MAX_WIDTH,
})
: t('classroom.folderNameHint'),
);
} else {
setError(t('classroom.folderCreateFailed'));
}
} finally {
setSubmitting(false);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function MoveToFolderMenu({
title={t('classroom.moveToFolder')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="absolute top-2 right-20 size-7 inline-flex items-center justify-center rounded-full bg-black/30 hover:bg-black/50 text-white hover:text-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity z-10"
className="absolute top-2 right-20 size-7 inline-flex items-center justify-center rounded-full bg-black/30 hover:bg-black/50 text-white hover:text-white backdrop-blur-sm opacity-0 transition-opacity z-10 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 pointer-coarse:opacity-100 group-hover:opacity-100"
>
<FolderInput className="size-3.5" />
</button>
+14 -13
View File
@@ -869,7 +869,7 @@
"searchEmpty": "لا توجد دروس مطابقة",
"newFolderTitle": "New folder",
"newFolderDesc": "Group courses by topic or use for easier access.",
"newFolderInline": "New folder…",
"newFolderInline": "مجلد جديد",
"folderNameLabel": "Folder name",
"folderNamePlaceholder": "e.g. Math, English, Major courses",
"folderNameHint": "CJK and Latin both OK; spaces count.",
@@ -877,8 +877,8 @@
"folderCountLimit": "Folder count limit reached",
"folderWidth": "Width {{width}}/{{max}}",
"folderCreate": "Create",
"folderBadge": "Folder",
"folderCourseCountUnit": "courses",
"folderBadge": "مجلد",
"folderCourseCountUnit": "دورة",
"deleteFolderTitle": "Delete folder \"{{name}}\"",
"deleteFolderEmptyDesc": "This folder is empty. Deletion cannot be undone.",
"deleteFolderDesc": "This folder has {{count}} course(s). Choose how to handle them.",
@@ -887,16 +887,17 @@
"deleteFolderRemoveTitle": "Remove courses from \"My courses\" too",
"deleteFolderRemoveDesc": "Courses leave My courses and chat history is wiped. Irreversible.",
"deleteFolderRemoveAction": "Remove and delete",
"moveToFolder": "Move to folder",
"moveToFolderLabel": "Move to…",
"ungrouped": "Ungrouped",
"searchResults": "Search results",
"folderCreateFailed": "Failed to create folder",
"folderRenameFailed": "Failed to rename folder",
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"moveToFolder": "نقل إلى مجلد",
"moveToFolderLabel": "نقل إلى مجلد",
"ungrouped": "غير مصنّف",
"searchResults": "نتائج البحث",
"folderCreateFailed": "فشل إنشاء المجلد",
"folderRenameFailed": "فشلت إعادة تسمية المجلد",
"folderDeleteFailed": "فشل حذف المجلد",
"moveFailed": "فشل نقل الدورة",
"emptyFolderHint": "هذا المجلد فارغ",
"backToAllCourses": "العودة إلى كل الدورات",
"emptyLibraryHint": "لا توجد دورات بعد — أنشئ واحدة بالأعلى أو استورد دورة."
},
"upload": {
"pdfSizeLimit": "يدعم ملفات PDF حتى 50 ميغابايت",
+2 -1
View File
@@ -896,7 +896,8 @@
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"backToAllCourses": "Back to all courses",
"emptyLibraryHint": "No courses yet — create one above, or import a course."
},
"upload": {
"pdfSizeLimit": "Supports PDF files up to 50MB",
+2 -1
View File
@@ -896,7 +896,8 @@
"folderDeleteFailed": "Error al eliminar la carpeta",
"moveFailed": "Error al mover el curso",
"emptyFolderHint": "Esta carpeta está vacía",
"backToAllCourses": "Volver a todos los cursos"
"backToAllCourses": "Volver a todos los cursos",
"emptyLibraryHint": "Aún no hay cursos — crea uno arriba o importa un curso."
},
"upload": {
"pdfSizeLimit": "Admite archivos PDF de hasta 50 MB",
+14 -13
View File
@@ -869,7 +869,7 @@
"searchEmpty": "該当するコースが見つかりません",
"newFolderTitle": "New folder",
"newFolderDesc": "Group courses by topic or use for easier access.",
"newFolderInline": "New folder…",
"newFolderInline": "新規フォルダ",
"folderNameLabel": "Folder name",
"folderNamePlaceholder": "e.g. Math, English, Major courses",
"folderNameHint": "CJK and Latin both OK; spaces count.",
@@ -877,8 +877,8 @@
"folderCountLimit": "Folder count limit reached",
"folderWidth": "Width {{width}}/{{max}}",
"folderCreate": "Create",
"folderBadge": "Folder",
"folderCourseCountUnit": "courses",
"folderBadge": "フォルダ",
"folderCourseCountUnit": "件のコース",
"deleteFolderTitle": "Delete folder \"{{name}}\"",
"deleteFolderEmptyDesc": "This folder is empty. Deletion cannot be undone.",
"deleteFolderDesc": "This folder has {{count}} course(s). Choose how to handle them.",
@@ -887,16 +887,17 @@
"deleteFolderRemoveTitle": "Remove courses from \"My courses\" too",
"deleteFolderRemoveDesc": "Courses leave My courses and chat history is wiped. Irreversible.",
"deleteFolderRemoveAction": "Remove and delete",
"moveToFolder": "Move to folder",
"moveToFolderLabel": "Move to…",
"ungrouped": "Ungrouped",
"searchResults": "Search results",
"folderCreateFailed": "Failed to create folder",
"folderRenameFailed": "Failed to rename folder",
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"moveToFolder": "フォルダに移動",
"moveToFolderLabel": "フォルダに移動",
"ungrouped": "未分類",
"searchResults": "検索結果",
"folderCreateFailed": "フォルダの作成に失敗しました",
"folderRenameFailed": "フォルダの名前変更に失敗しました",
"folderDeleteFailed": "フォルダの削除に失敗しました",
"moveFailed": "コースの移動に失敗しました",
"emptyFolderHint": "このフォルダは空です",
"backToAllCourses": "すべてのコースに戻る",
"emptyLibraryHint": "まだコースがありません。上で作成するか、コースをインポートしてください。"
},
"upload": {
"pdfSizeLimit": "50MBまでのPDFファイルに対応しています",
+14 -13
View File
@@ -869,7 +869,7 @@
"searchEmpty": "검색 결과에 해당하는 강좌가 없습니다",
"newFolderTitle": "New folder",
"newFolderDesc": "Group courses by topic or use for easier access.",
"newFolderInline": "New folder…",
"newFolderInline": "새 폴더",
"folderNameLabel": "Folder name",
"folderNamePlaceholder": "e.g. Math, English, Major courses",
"folderNameHint": "CJK and Latin both OK; spaces count.",
@@ -877,8 +877,8 @@
"folderCountLimit": "Folder count limit reached",
"folderWidth": "Width {{width}}/{{max}}",
"folderCreate": "Create",
"folderBadge": "Folder",
"folderCourseCountUnit": "courses",
"folderBadge": "폴더",
"folderCourseCountUnit": "개 강의",
"deleteFolderTitle": "Delete folder \"{{name}}\"",
"deleteFolderEmptyDesc": "This folder is empty. Deletion cannot be undone.",
"deleteFolderDesc": "This folder has {{count}} course(s). Choose how to handle them.",
@@ -887,16 +887,17 @@
"deleteFolderRemoveTitle": "Remove courses from \"My courses\" too",
"deleteFolderRemoveDesc": "Courses leave My courses and chat history is wiped. Irreversible.",
"deleteFolderRemoveAction": "Remove and delete",
"moveToFolder": "Move to folder",
"moveToFolderLabel": "Move to…",
"ungrouped": "Ungrouped",
"searchResults": "Search results",
"folderCreateFailed": "Failed to create folder",
"folderRenameFailed": "Failed to rename folder",
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"moveToFolder": "폴더로 이동",
"moveToFolderLabel": "폴더로 이동",
"ungrouped": "미분류",
"searchResults": "검색 결과",
"folderCreateFailed": "폴더 만들기에 실패했습니다",
"folderRenameFailed": "폴더 이름 변경에 실패했습니다",
"folderDeleteFailed": "폴더 삭제에 실패했습니다",
"moveFailed": "강의 이동에 실패했습니다",
"emptyFolderHint": "이 폴더가 비어 있습니다",
"backToAllCourses": "전체 강의로 돌아가기",
"emptyLibraryHint": "아직 강의가 없습니다. 위에서 만들거나 강의를 가져오세요."
},
"upload": {
"pdfSizeLimit": "최대 50MB PDF 파일을 지원합니다",
+14 -13
View File
@@ -869,7 +869,7 @@
"searchEmpty": "Nenhum curso corresponde à sua busca",
"newFolderTitle": "New folder",
"newFolderDesc": "Group courses by topic or use for easier access.",
"newFolderInline": "New folder…",
"newFolderInline": "Nova pasta",
"folderNameLabel": "Folder name",
"folderNamePlaceholder": "e.g. Math, English, Major courses",
"folderNameHint": "CJK and Latin both OK; spaces count.",
@@ -877,8 +877,8 @@
"folderCountLimit": "Folder count limit reached",
"folderWidth": "Width {{width}}/{{max}}",
"folderCreate": "Create",
"folderBadge": "Folder",
"folderCourseCountUnit": "courses",
"folderBadge": "Pasta",
"folderCourseCountUnit": "cursos",
"deleteFolderTitle": "Delete folder \"{{name}}\"",
"deleteFolderEmptyDesc": "This folder is empty. Deletion cannot be undone.",
"deleteFolderDesc": "This folder has {{count}} course(s). Choose how to handle them.",
@@ -887,16 +887,17 @@
"deleteFolderRemoveTitle": "Remove courses from \"My courses\" too",
"deleteFolderRemoveDesc": "Courses leave My courses and chat history is wiped. Irreversible.",
"deleteFolderRemoveAction": "Remove and delete",
"moveToFolder": "Move to folder",
"moveToFolderLabel": "Move to…",
"ungrouped": "Ungrouped",
"searchResults": "Search results",
"folderCreateFailed": "Failed to create folder",
"folderRenameFailed": "Failed to rename folder",
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"moveToFolder": "Mover para pasta",
"moveToFolderLabel": "Mover para a pasta",
"ungrouped": "Sem pasta",
"searchResults": "Resultados da busca",
"folderCreateFailed": "Falha ao criar a pasta",
"folderRenameFailed": "Falha ao renomear a pasta",
"folderDeleteFailed": "Falha ao excluir a pasta",
"moveFailed": "Falha ao mover o curso",
"emptyFolderHint": "Esta pasta está vazia",
"backToAllCourses": "Voltar para todos os cursos",
"emptyLibraryHint": "Nenhum curso ainda — crie um acima ou importe um curso."
},
"upload": {
"pdfSizeLimit": "Suporta arquivos PDF de até 50 MB",
+14 -13
View File
@@ -869,7 +869,7 @@
"searchEmpty": "Курсы не найдены",
"newFolderTitle": "New folder",
"newFolderDesc": "Group courses by topic or use for easier access.",
"newFolderInline": "New folder…",
"newFolderInline": "Новая папка",
"folderNameLabel": "Folder name",
"folderNamePlaceholder": "e.g. Math, English, Major courses",
"folderNameHint": "CJK and Latin both OK; spaces count.",
@@ -877,8 +877,8 @@
"folderCountLimit": "Folder count limit reached",
"folderWidth": "Width {{width}}/{{max}}",
"folderCreate": "Create",
"folderBadge": "Folder",
"folderCourseCountUnit": "courses",
"folderBadge": "Папка",
"folderCourseCountUnit": "курсов",
"deleteFolderTitle": "Delete folder \"{{name}}\"",
"deleteFolderEmptyDesc": "This folder is empty. Deletion cannot be undone.",
"deleteFolderDesc": "This folder has {{count}} course(s). Choose how to handle them.",
@@ -887,16 +887,17 @@
"deleteFolderRemoveTitle": "Remove courses from \"My courses\" too",
"deleteFolderRemoveDesc": "Courses leave My courses and chat history is wiped. Irreversible.",
"deleteFolderRemoveAction": "Remove and delete",
"moveToFolder": "Move to folder",
"moveToFolderLabel": "Move to…",
"ungrouped": "Ungrouped",
"searchResults": "Search results",
"folderCreateFailed": "Failed to create folder",
"folderRenameFailed": "Failed to rename folder",
"folderDeleteFailed": "Failed to delete folder",
"moveFailed": "Failed to move course",
"emptyFolderHint": "This folder is empty",
"backToAllCourses": "Back to all courses"
"moveToFolder": "Переместить в папку",
"moveToFolderLabel": "Переместить в папку",
"ungrouped": "Без папки",
"searchResults": "Результаты поиска",
"folderCreateFailed": "Не удалось создать папку",
"folderRenameFailed": "Не удалось переименовать папку",
"folderDeleteFailed": "Не удалось удалить папку",
"moveFailed": "Не удалось переместить курс",
"emptyFolderHint": "Эта папка пуста",
"backToAllCourses": "Назад ко всем курсам",
"emptyLibraryHint": "Курсов пока нет — создайте выше или импортируйте курс."
},
"upload": {
"pdfSizeLimit": "Поддержка PDF до 50 МБ",
+2 -1
View File
@@ -896,7 +896,8 @@
"folderDeleteFailed": "删除文件夹失败",
"moveFailed": "移动课程失败",
"emptyFolderHint": "这个文件夹还是空的",
"backToAllCourses": "返回全部课程"
"backToAllCourses": "返回全部课程",
"emptyLibraryHint": "还没有课程——在上方创建,或导入一个课程。"
},
"upload": {
"pdfSizeLimit": "支持最大50MB的PDF文件",
+14 -13
View File
@@ -854,7 +854,7 @@
"searchPlaceholder": "搜尋課程...",
"newFolderTitle": "新建文件夹",
"newFolderDesc": "把课程按主题或用途归类,方便查找。",
"newFolderInline": "新建文件夹…",
"newFolderInline": "新增資料夾",
"folderNameLabel": "文件夹名称",
"folderNamePlaceholder": "例如:数学、英语、专业课",
"folderNameHint": "中英文均可,空格计入。",
@@ -862,8 +862,8 @@
"folderCountLimit": "文件夹数量已达上限",
"folderWidth": "宽度 {{width}}/{{max}}",
"folderCreate": "创建",
"folderBadge": "文件夹",
"folderCourseCountUnit": "门课程",
"folderBadge": "資料夾",
"folderCourseCountUnit": "門課程",
"deleteFolderTitle": "删除文件夹「{{name}}」",
"deleteFolderEmptyDesc": "这是一个空文件夹,删除后不可恢复。",
"deleteFolderDesc": "这个文件夹里有 {{count}} 门课程,请选择课程的处理方式。",
@@ -872,16 +872,17 @@
"deleteFolderRemoveTitle": "连课程一起移出「我的课程」",
"deleteFolderRemoveDesc": "课程不再出现在我的课程,对话记录一并清除,不可恢复。",
"deleteFolderRemoveAction": "移出并删除",
"moveToFolder": "移动到文件夹",
"moveToFolderLabel": "移动到…",
"ungrouped": "未分组",
"searchResults": "搜索结果",
"folderCreateFailed": "创建文件夹失败",
"folderRenameFailed": "重命名文件夹失败",
"folderDeleteFailed": "删除文件夹失败",
"moveFailed": "移动课程失败",
"emptyFolderHint": "这个文件夹还是空的",
"backToAllCourses": "返回全部课程"
"moveToFolder": "移動到資料夾",
"moveToFolderLabel": "移動到資料夾",
"ungrouped": "未分類",
"searchResults": "搜尋結果",
"folderCreateFailed": "建立資料夾失敗",
"folderRenameFailed": "重新命名資料夾失敗",
"folderDeleteFailed": "刪除資料夾失敗",
"moveFailed": "移動課程失敗",
"emptyFolderHint": "這個資料夾是空的",
"backToAllCourses": "返回全部課程",
"emptyLibraryHint": "還沒有課程——在上方建立,或匯入一個課程。"
},
"upload": {
"pdfSizeLimit": "支援最大50MB的PDF檔案",
+27 -21
View File
@@ -928,37 +928,43 @@ function assertFolderName(name: string, existing: FolderRecord[], currentId?: st
/**
* Create a folder. `order` is placed after the current maximum so new folders
* land at the end of the list. Validates the name (width + uniqueness) at the
* storage boundary.
* storage boundary, with the read-check-write inside a read-write transaction
* so two tabs cannot both pass the duplicate check before either write commits.
*/
export async function createFolder(name: string): Promise<FolderRecord> {
const now = Date.now();
const existing = await db.folders.toArray();
assertFolderName(name, existing);
const order = existing.reduce((max, folder) => Math.max(max, folder.order), -1) + 1;
const folder: FolderRecord = {
id: nanoid(),
name: name.trim(),
order,
createdAt: now,
updatedAt: now,
};
await db.folders.put(folder);
log.info(`Created folder "${name}" (${folder.id})`);
return folder;
return db.transaction('rw', db.folders, async () => {
const existing = await db.folders.toArray();
assertFolderName(name, existing);
const order = existing.reduce((max, folder) => Math.max(max, folder.order), -1) + 1;
const folder: FolderRecord = {
id: nanoid(),
name: name.trim(),
order,
createdAt: now,
updatedAt: now,
};
await db.folders.put(folder);
log.info(`Created folder "${name}" (${folder.id})`);
return folder;
});
}
/**
* Rename a folder. Validates the name (width + uniqueness excluding itself) at
* the storage boundary, so the UI invariant cannot be bypassed.
* the storage boundary, with the read-check-write inside a read-write
* transaction so the UI invariant cannot be bypassed or raced across tabs.
*/
export async function renameFolder(id: string, name: string): Promise<void> {
const now = Date.now();
const existing = await db.folders.toArray();
assertFolderName(name, existing, id);
const folder = existing.find((f) => f.id === id);
if (!folder) throw new Error(`Folder not found: ${id}`);
await db.folders.put({ ...folder, name: name.trim(), updatedAt: now });
log.info(`Renamed folder ${id} to "${name}"`);
await db.transaction('rw', db.folders, async () => {
const existing = await db.folders.toArray();
assertFolderName(name, existing, id);
const folder = existing.find((f) => f.id === id);
if (!folder) throw new Error(`Folder not found: ${id}`);
await db.folders.put({ ...folder, name: name.trim(), updatedAt: now });
log.info(`Renamed folder ${id} to "${name}"`);
});
}
export type DeleteFolderMode = 'ungroup' | 'remove';