mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-09 04:32:41 +08:00
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:
+30
-33
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ميغابايت",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 МБ",
|
||||
|
||||
@@ -896,7 +896,8 @@
|
||||
"folderDeleteFailed": "删除文件夹失败",
|
||||
"moveFailed": "移动课程失败",
|
||||
"emptyFolderHint": "这个文件夹还是空的",
|
||||
"backToAllCourses": "返回全部课程"
|
||||
"backToAllCourses": "返回全部课程",
|
||||
"emptyLibraryHint": "还没有课程——在上方创建,或导入一个课程。"
|
||||
},
|
||||
"upload": {
|
||||
"pdfSizeLimit": "支持最大50MB的PDF文件",
|
||||
|
||||
+14
-13
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user