feat(ui): add AttachmentList component for lesson file attachments (#822)
* feat(ui): add AttachmentList component for lesson file attachments Introduce a reusable AttachmentList molecule with view and edit modes. View mode shows a header with file count and view/download actions; edit mode supports drag-to-reorder, view, and delete. Includes Storybook stories and package exports. Co-authored-by: Rotimi Best <[email protected]> * fix(ui): address AttachmentList PR review feedback - Sync edit-mode file state using full metadata signature, not IDs only - Clamp formatFileSize unit index and guard non-finite inputs - Recognize common MIME types for file-type icon styling - Scope role=list to row container; use dragHandleZone for reordering - Add reorder label for drag handle accessibility Co-authored-by: Rotimi Best <[email protected]> * fix(ui): guard AttachmentList against undefined files prop Co-authored-by: Rotimi Best <[email protected]> * fix(ui): render AttachmentList stories and guard missing props Use Storybook template snippets and default args so stories mount correctly. Default mode and labels in the component for autodocs, and align dragHandleZone items with rendered rows. Co-authored-by: Rotimi Best <[email protected]> * feat(dashboard): use AttachmentList on lesson documents tab Replace the horizontal DocumentCard grid with AttachmentList in view and edit modes, including drag-to-reorder in edit. Tighten the list header to p-2 with xs text, add document mapping helpers, and translate new labels. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): reset document upload modal state on close Clear selected file, success message, and upload progress when the add document modal closes so reopening starts fresh. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): raise PDF viewer above sidebar and AI chat Use z-modal (200) instead of z-100 so the full-screen PDF overlay stacks above z-app-bar-elevated (150) course sidebar and z-app-bar (100) AI panel. Add the missing z-modal utility to dashboard app.css to mirror the UI scale. Co-authored-by: Rotimi Best <[email protected]> * chore: minor changes * feat(dashboard): show spinner and snackbar on document download AttachmentList download button now shows the Button loading spinner immediately while the async download runs. On success, a snackbar confirms the download; on failure, an error snackbar is shown instead of opening a new tab. Co-authored-by: Rotimi Best <[email protected]> * fix(ui): address PR review feedback on AttachmentList - Reject negative byte counts in formatFileSize instead of using Math.abs - Make AttachmentList mode/files/labels props optional; disable cross-list drops - Include file name in drag-handle aria-label for screen readers - Prevent upload modal from closing while an upload is in progress - Use ICU plural rules for attachment file_count across all locales Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
06c3a7feba
commit
0482790126
+53
-11
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { lessonDocUpload, resetDocumentUploadStore } from '../store';
|
||||
import { lessonDocUpload } from '../store';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
let selectedFile: File | null = $state(null);
|
||||
let errorTimeout: NodeJS.Timeout | null = $state(null);
|
||||
let successResetTimeout: ReturnType<typeof setTimeout> | null = $state(null);
|
||||
let isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
|
||||
|
||||
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
|
||||
@@ -69,6 +70,45 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function resetModalState() {
|
||||
selectedFile = null;
|
||||
uploadedDocument = null;
|
||||
|
||||
if (successResetTimeout) {
|
||||
clearTimeout(successResetTimeout);
|
||||
successResetTimeout = null;
|
||||
}
|
||||
|
||||
if (errorTimeout) {
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = null;
|
||||
}
|
||||
|
||||
lessonDocUpload.update((state) => ({
|
||||
...state,
|
||||
error: null,
|
||||
isUploading: false,
|
||||
uploadProgress: 0,
|
||||
isCancelled: false
|
||||
}));
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
$lessonDocUpload.isModalOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lessonDocUpload.isUploading) {
|
||||
$lessonDocUpload.isModalOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$lessonDocUpload.isModalOpen = false;
|
||||
resetModalState();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
@@ -174,10 +214,16 @@
|
||||
|
||||
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
|
||||
|
||||
// Reset after a short delay
|
||||
setTimeout(() => {
|
||||
resetDocumentUploadStore();
|
||||
if (successResetTimeout) clearTimeout(successResetTimeout);
|
||||
|
||||
successResetTimeout = setTimeout(() => {
|
||||
selectedFile = null;
|
||||
uploadedDocument = null;
|
||||
lessonDocUpload.update((state) => ({
|
||||
...state,
|
||||
uploadProgress: 0
|
||||
}));
|
||||
successResetTimeout = null;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
@@ -224,18 +270,14 @@
|
||||
autoClearError($lessonDocUpload.error);
|
||||
});
|
||||
|
||||
// Clear error timeout when component is destroyed
|
||||
// Clear timeouts when component is destroyed
|
||||
onDestroy(() => {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
if (successResetTimeout) clearTimeout(successResetTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$lessonDocUpload.isModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Root open={$lessonDocUpload.isModalOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="w-[90%] max-w-4/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.remove_document')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { DocumentCard } from '@cio/ui';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import DocumentCardDropdown from './document-card-dropdown.svelte';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
document: LessonDocument;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
isDownloading: boolean;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
onRemove: () => void;
|
||||
onViewPDF: (doc: LessonDocument) => void;
|
||||
onDownload: (doc: LessonDocument) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
document: doc,
|
||||
index: _index,
|
||||
isEditMode,
|
||||
isDownloading,
|
||||
formatFileSize,
|
||||
onRemove,
|
||||
onViewPDF,
|
||||
onDownload
|
||||
}: Props = $props();
|
||||
|
||||
const subtitle = $derived(
|
||||
[doc.type.toUpperCase(), doc.size != null ? formatFileSize(doc.size) : null].filter(Boolean).join(' · ') || '–'
|
||||
);
|
||||
</script>
|
||||
|
||||
<DocumentCard title={doc.name} {subtitle} class="group relative">
|
||||
{#snippet header()}
|
||||
{#if isEditMode}
|
||||
<DocumentCardDropdown {onRemove} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if doc.type === 'pdf'}
|
||||
<Button variant="outline" size="sm" onclick={() => onViewPDF(doc)} class="gap-1.5">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_pdf')}
|
||||
</Button>
|
||||
{:else}
|
||||
<a
|
||||
href={doc.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:border-input ui:bg-background ui:text-foreground hover:ui:bg-accent hover:ui:text-accent-foreground ui:shrink inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.view_document')}
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => onDownload(doc)}
|
||||
disabled={isDownloading}
|
||||
class="ui:shrink! gap-1.5"
|
||||
>
|
||||
<DownloadIcon size={14} />
|
||||
{isDownloading
|
||||
? $t('course.navItem.lessons.materials.tabs.document.downloading')
|
||||
: $t('course.navItem.lessons.materials.tabs.document.download')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DocumentCard>
|
||||
+62
-22
@@ -1,35 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { AttachmentList } from '@cio/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import DocumentCard from './document-card.svelte';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
import { getDocumentIndexByAttachmentId, mapAttachmentsToDocuments, toAttachmentFiles } from './document-utils';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
displayDocuments?: LessonDocument[];
|
||||
downloadingDocuments: Set<string>;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
openDocumentUploadModal: () => void;
|
||||
requestRemoveDocument: (index: number) => void;
|
||||
onViewPDF: (doc: LessonDocument) => void;
|
||||
onViewDocument: (doc: LessonDocument) => void;
|
||||
downloadDocument: (doc: LessonDocument) => Promise<void>;
|
||||
reorderDocuments: (documents: LessonDocument[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = MODES.view,
|
||||
displayDocuments = [],
|
||||
downloadingDocuments,
|
||||
formatFileSize,
|
||||
openDocumentUploadModal,
|
||||
requestRemoveDocument,
|
||||
onViewPDF,
|
||||
downloadDocument
|
||||
onViewDocument,
|
||||
downloadDocument,
|
||||
reorderDocuments
|
||||
}: Props = $props();
|
||||
|
||||
const attachmentMode = $derived(mode === MODES.edit ? 'edit' : 'view');
|
||||
const attachmentFiles = $derived(toAttachmentFiles(displayDocuments));
|
||||
|
||||
const attachmentLabels = $derived({
|
||||
title: t.get('course.navItem.lessons.materials.tabs.document.attachments_heading'),
|
||||
fileCount: t.get('course.navItem.lessons.materials.tabs.document.file_count', {
|
||||
count: displayDocuments.length
|
||||
}),
|
||||
view: t.get('course.navItem.lessons.materials.tabs.document.view_file'),
|
||||
download: t.get('course.navItem.lessons.materials.tabs.document.download'),
|
||||
delete: t.get('course.navItem.lessons.materials.tabs.document.delete'),
|
||||
reorder: t.get('course.navItem.lessons.materials.tabs.document.reorder_file')
|
||||
});
|
||||
|
||||
function handleView(file: (typeof attachmentFiles)[number]) {
|
||||
const index = getDocumentIndexByAttachmentId(displayDocuments, file.id);
|
||||
const document = displayDocuments[index];
|
||||
|
||||
if (!document) return;
|
||||
|
||||
onViewDocument(document);
|
||||
}
|
||||
|
||||
function handleDownload(file: (typeof attachmentFiles)[number]) {
|
||||
const index = getDocumentIndexByAttachmentId(displayDocuments, file.id);
|
||||
const document = displayDocuments[index];
|
||||
|
||||
if (!document) return;
|
||||
|
||||
void downloadDocument(document);
|
||||
}
|
||||
|
||||
function handleDelete(file: (typeof attachmentFiles)[number]) {
|
||||
const index = getDocumentIndexByAttachmentId(displayDocuments, file.id);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
requestRemoveDocument(index);
|
||||
}
|
||||
|
||||
function handleReorder(files: (typeof attachmentFiles)[number][]) {
|
||||
const reorderedDocuments = mapAttachmentsToDocuments(displayDocuments, files);
|
||||
reorderDocuments(reorderedDocuments);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
@@ -41,21 +86,16 @@
|
||||
{/if}
|
||||
|
||||
{#if !isEmpty(displayDocuments)}
|
||||
{@const isEditMode = mode === MODES.edit}
|
||||
<Item.Group class="flex w-full gap-4">
|
||||
{#each displayDocuments as document, index}
|
||||
<DocumentCard
|
||||
{document}
|
||||
{index}
|
||||
{isEditMode}
|
||||
isDownloading={downloadingDocuments.has(document.name)}
|
||||
{formatFileSize}
|
||||
onRemove={() => (isEditMode ? requestRemoveDocument(index) : undefined)}
|
||||
{onViewPDF}
|
||||
onDownload={downloadDocument}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
<AttachmentList
|
||||
mode={attachmentMode}
|
||||
files={attachmentFiles}
|
||||
labels={attachmentLabels}
|
||||
formatSize={formatFileSize}
|
||||
onView={handleView}
|
||||
onDownload={attachmentMode === 'view' ? handleDownload : undefined}
|
||||
onDelete={attachmentMode === 'edit' ? handleDelete : undefined}
|
||||
onReorder={attachmentMode === 'edit' ? handleReorder : undefined}
|
||||
/>
|
||||
{:else if mode === MODES.edit}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.materials.tabs.document.empty_title')}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AttachmentListFile } from '@cio/ui';
|
||||
import type { LessonDocument } from '$features/course/utils/types';
|
||||
|
||||
export function getDocumentAttachmentId(document: LessonDocument, index: number): string {
|
||||
return document.key || `document-${index}`;
|
||||
}
|
||||
|
||||
export function toAttachmentFiles(documents: LessonDocument[]): AttachmentListFile[] {
|
||||
return documents.map((document, index) => ({
|
||||
id: getDocumentAttachmentId(document, index),
|
||||
name: document.name,
|
||||
size: document.size,
|
||||
type: document.type
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDocumentIndexByAttachmentId(documents: LessonDocument[], attachmentId: string): number {
|
||||
return documents.findIndex((document, index) => getDocumentAttachmentId(document, index) === attachmentId);
|
||||
}
|
||||
|
||||
export function mapAttachmentsToDocuments(
|
||||
documents: LessonDocument[],
|
||||
attachments: AttachmentListFile[]
|
||||
): LessonDocument[] {
|
||||
return attachments
|
||||
.map((attachment) => {
|
||||
const index = getDocumentIndexByAttachmentId(documents, attachment.id);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
return documents[index];
|
||||
})
|
||||
.filter((document): document is LessonDocument => document != null);
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
let { mode = MODES.view }: Props = $props();
|
||||
|
||||
let downloadingDocuments = $state(new Set<string>());
|
||||
let openDeleteDocumentModal = $state(false);
|
||||
let documentIndexToDelete = $state<number | null>(null);
|
||||
let viewingPDF: any = $state(null);
|
||||
@@ -83,9 +82,6 @@
|
||||
}
|
||||
|
||||
async function downloadDocument(doc: LessonDocument) {
|
||||
downloadingDocuments.add(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
|
||||
if (!doc.key) {
|
||||
snackbar.error('Document not loaded correctly');
|
||||
return;
|
||||
@@ -105,12 +101,10 @@
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
snackbar.success($t('course.navItem.lessons.materials.tabs.document.download_success'));
|
||||
} catch (error) {
|
||||
console.error('Error downloading document:', error);
|
||||
window.open(doc.link, '_blank');
|
||||
} finally {
|
||||
downloadingDocuments.delete(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
snackbar.error($t('course.navItem.lessons.materials.tabs.document.download_error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +249,19 @@
|
||||
viewPDF(doc);
|
||||
}
|
||||
|
||||
function handleViewDocument(doc: LessonDocument) {
|
||||
if (doc.type === 'pdf') {
|
||||
handleViewPDF(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(doc.link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function reorderDocuments(documents: LessonDocument[]) {
|
||||
lessonApi.updateLessonState('documents', documents);
|
||||
}
|
||||
|
||||
function closePDFViewer() {
|
||||
// Cancel any ongoing render task
|
||||
if (currentRenderTask) {
|
||||
@@ -320,19 +327,19 @@
|
||||
<DocumentList
|
||||
{mode}
|
||||
{displayDocuments}
|
||||
{downloadingDocuments}
|
||||
{formatFileSize}
|
||||
{openDocumentUploadModal}
|
||||
{requestRemoveDocument}
|
||||
onViewPDF={handleViewPDF}
|
||||
onViewDocument={handleViewDocument}
|
||||
{downloadDocument}
|
||||
{reorderDocuments}
|
||||
/>
|
||||
|
||||
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if pdfViewerOpen}
|
||||
<div class="fixed inset-0 z-100 flex flex-col bg-white dark:bg-neutral-800">
|
||||
<div class="z-modal fixed inset-0 flex flex-col bg-white dark:bg-neutral-800">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
|
||||
@@ -15,6 +15,6 @@ export const NAV_IDS = {
|
||||
};
|
||||
|
||||
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 320;
|
||||
export const COURSE_SIDEBAR_MIN_WIDTH = 220;
|
||||
export const COURSE_SIDEBAR_MIN_WIDTH = 280;
|
||||
export const COURSE_SIDEBAR_MAX_WIDTH = 420;
|
||||
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
||||
|
||||
Reference in New Issue
Block a user