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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * fix(ui): guard AttachmentList against undefined files prop Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
06c3a7feba
commit
0482790126
+3
-1
@@ -67,4 +67,6 @@ apps/db-agent
|
|||||||
|
|
||||||
# Generated htpasswd files (hashes are safe to store in secrets/CI, plaintext never committed)
|
# Generated htpasswd files (hashes are safe to store in secrets/CI, plaintext never committed)
|
||||||
.htpasswd
|
.htpasswd
|
||||||
*.htpasswd
|
*.htpasswd
|
||||||
|
|
||||||
|
output.css
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
z-index: 150;
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility z-modal {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
@utility z-menu-elevated {
|
@utility z-menu-elevated {
|
||||||
z-index: 250;
|
z-index: 250;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-11
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { lessonDocUpload, resetDocumentUploadStore } from '../store';
|
import { lessonDocUpload } from '../store';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
let selectedFile: File | null = $state(null);
|
let selectedFile: File | null = $state(null);
|
||||||
let errorTimeout: NodeJS.Timeout | 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 isDisabled = $derived($lessonDocUpload.isUploading || $isFreePlan);
|
||||||
|
|
||||||
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
|
let uploadedDocument: NonNullable<Lesson['documents']>[number] | null = $state(null);
|
||||||
@@ -69,6 +70,45 @@
|
|||||||
return null;
|
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) {
|
function selectFile(file: File) {
|
||||||
const error = validateFile(file);
|
const error = validateFile(file);
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -174,10 +214,16 @@
|
|||||||
|
|
||||||
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
|
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
|
||||||
|
|
||||||
// Reset after a short delay
|
if (successResetTimeout) clearTimeout(successResetTimeout);
|
||||||
setTimeout(() => {
|
|
||||||
resetDocumentUploadStore();
|
successResetTimeout = setTimeout(() => {
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
|
uploadedDocument = null;
|
||||||
|
lessonDocUpload.update((state) => ({
|
||||||
|
...state,
|
||||||
|
uploadProgress: 0
|
||||||
|
}));
|
||||||
|
successResetTimeout = null;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
@@ -224,18 +270,14 @@
|
|||||||
autoClearError($lessonDocUpload.error);
|
autoClearError($lessonDocUpload.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear error timeout when component is destroyed
|
// Clear timeouts when component is destroyed
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (errorTimeout) clearTimeout(errorTimeout);
|
if (errorTimeout) clearTimeout(errorTimeout);
|
||||||
|
if (successResetTimeout) clearTimeout(successResetTimeout);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root open={$lessonDocUpload.isModalOpen} onOpenChange={handleOpenChange}>
|
||||||
bind:open={$lessonDocUpload.isModalOpen}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Content class="w-[90%] max-w-4/5">
|
<Dialog.Content class="w-[90%] max-w-4/5">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.document.upload_title')}</Dialog.Title>
|
<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">
|
<script lang="ts">
|
||||||
|
import { AttachmentList } from '@cio/ui';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import MODES from '$lib/utils/constants/mode';
|
import MODES from '$lib/utils/constants/mode';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
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 type { LessonDocument } from '$features/course/utils/types';
|
||||||
|
import { getDocumentIndexByAttachmentId, mapAttachmentsToDocuments, toAttachmentFiles } from './document-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: (typeof MODES)[keyof typeof MODES];
|
mode?: (typeof MODES)[keyof typeof MODES];
|
||||||
displayDocuments?: LessonDocument[];
|
displayDocuments?: LessonDocument[];
|
||||||
downloadingDocuments: Set<string>;
|
|
||||||
formatFileSize: (bytes: number) => string;
|
formatFileSize: (bytes: number) => string;
|
||||||
openDocumentUploadModal: () => void;
|
openDocumentUploadModal: () => void;
|
||||||
requestRemoveDocument: (index: number) => void;
|
requestRemoveDocument: (index: number) => void;
|
||||||
onViewPDF: (doc: LessonDocument) => void;
|
onViewDocument: (doc: LessonDocument) => void;
|
||||||
downloadDocument: (doc: LessonDocument) => Promise<void>;
|
downloadDocument: (doc: LessonDocument) => Promise<void>;
|
||||||
|
reorderDocuments: (documents: LessonDocument[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
mode = MODES.view,
|
mode = MODES.view,
|
||||||
displayDocuments = [],
|
displayDocuments = [],
|
||||||
downloadingDocuments,
|
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
openDocumentUploadModal,
|
openDocumentUploadModal,
|
||||||
requestRemoveDocument,
|
requestRemoveDocument,
|
||||||
onViewPDF,
|
onViewDocument,
|
||||||
downloadDocument
|
downloadDocument,
|
||||||
|
reorderDocuments
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
{#if mode === MODES.edit}
|
{#if mode === MODES.edit}
|
||||||
@@ -41,21 +86,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isEmpty(displayDocuments)}
|
{#if !isEmpty(displayDocuments)}
|
||||||
{@const isEditMode = mode === MODES.edit}
|
<AttachmentList
|
||||||
<Item.Group class="flex w-full gap-4">
|
mode={attachmentMode}
|
||||||
{#each displayDocuments as document, index}
|
files={attachmentFiles}
|
||||||
<DocumentCard
|
labels={attachmentLabels}
|
||||||
{document}
|
formatSize={formatFileSize}
|
||||||
{index}
|
onView={handleView}
|
||||||
{isEditMode}
|
onDownload={attachmentMode === 'view' ? handleDownload : undefined}
|
||||||
isDownloading={downloadingDocuments.has(document.name)}
|
onDelete={attachmentMode === 'edit' ? handleDelete : undefined}
|
||||||
{formatFileSize}
|
onReorder={attachmentMode === 'edit' ? handleReorder : undefined}
|
||||||
onRemove={() => (isEditMode ? requestRemoveDocument(index) : undefined)}
|
/>
|
||||||
{onViewPDF}
|
|
||||||
onDownload={downloadDocument}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</Item.Group>
|
|
||||||
{:else if mode === MODES.edit}
|
{:else if mode === MODES.edit}
|
||||||
<Empty
|
<Empty
|
||||||
title={$t('course.navItem.lessons.materials.tabs.document.empty_title')}
|
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 { mode = MODES.view }: Props = $props();
|
||||||
|
|
||||||
let downloadingDocuments = $state(new Set<string>());
|
|
||||||
let openDeleteDocumentModal = $state(false);
|
let openDeleteDocumentModal = $state(false);
|
||||||
let documentIndexToDelete = $state<number | null>(null);
|
let documentIndexToDelete = $state<number | null>(null);
|
||||||
let viewingPDF: any = $state(null);
|
let viewingPDF: any = $state(null);
|
||||||
@@ -83,9 +82,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadDocument(doc: LessonDocument) {
|
async function downloadDocument(doc: LessonDocument) {
|
||||||
downloadingDocuments.add(doc.name);
|
|
||||||
downloadingDocuments = downloadingDocuments;
|
|
||||||
|
|
||||||
if (!doc.key) {
|
if (!doc.key) {
|
||||||
snackbar.error('Document not loaded correctly');
|
snackbar.error('Document not loaded correctly');
|
||||||
return;
|
return;
|
||||||
@@ -105,12 +101,10 @@
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
snackbar.success($t('course.navItem.lessons.materials.tabs.document.download_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading document:', error);
|
console.error('Error downloading document:', error);
|
||||||
window.open(doc.link, '_blank');
|
snackbar.error($t('course.navItem.lessons.materials.tabs.document.download_error'));
|
||||||
} finally {
|
|
||||||
downloadingDocuments.delete(doc.name);
|
|
||||||
downloadingDocuments = downloadingDocuments;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +249,19 @@
|
|||||||
viewPDF(doc);
|
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() {
|
function closePDFViewer() {
|
||||||
// Cancel any ongoing render task
|
// Cancel any ongoing render task
|
||||||
if (currentRenderTask) {
|
if (currentRenderTask) {
|
||||||
@@ -320,19 +327,19 @@
|
|||||||
<DocumentList
|
<DocumentList
|
||||||
{mode}
|
{mode}
|
||||||
{displayDocuments}
|
{displayDocuments}
|
||||||
{downloadingDocuments}
|
|
||||||
{formatFileSize}
|
{formatFileSize}
|
||||||
{openDocumentUploadModal}
|
{openDocumentUploadModal}
|
||||||
{requestRemoveDocument}
|
{requestRemoveDocument}
|
||||||
onViewPDF={handleViewPDF}
|
onViewDocument={handleViewDocument}
|
||||||
{downloadDocument}
|
{downloadDocument}
|
||||||
|
{reorderDocuments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
|
<DeleteModal bind:open={openDeleteDocumentModal} onDelete={confirmRemoveDocument} />
|
||||||
|
|
||||||
<!-- PDF Viewer Modal -->
|
<!-- PDF Viewer Modal -->
|
||||||
{#if pdfViewerOpen}
|
{#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 -->
|
<!-- 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 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">
|
<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_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_MAX_WIDTH = 420;
|
||||||
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Annuller upload",
|
"cancel_upload": "Annuller upload",
|
||||||
"upload_cancelled": "Upload annulleret af brugeren",
|
"upload_cancelled": "Upload annulleret af brugeren",
|
||||||
"downloading": "Downloader...",
|
"downloading": "Downloader...",
|
||||||
|
"download_success": "Dokument downloadet",
|
||||||
|
"download_error": "Kunne ikke downloade dokumentet",
|
||||||
"view_pdf": "Se PDF",
|
"view_pdf": "Se PDF",
|
||||||
"remove_document": "Fjern dokumentet",
|
"remove_document": "Fjern dokumentet",
|
||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Kunne ikke gengive PDF-siden",
|
"failed_to_render_pdf": "Kunne ikke gengive PDF-siden",
|
||||||
"use_arrow_keys": "Brug piletasterne til at navigere, +/- for at zoome, eller klik på kontrollerne ovenfor. Højreklik er deaktiveret.",
|
"use_arrow_keys": "Brug piletasterne til at navigere, +/- for at zoome, eller klik på kontrollerne ovenfor. Højreklik er deaktiveret.",
|
||||||
"empty_title": "Ingen dokumenter endnu",
|
"empty_title": "Ingen dokumenter endnu",
|
||||||
"empty_description": "Upload dokumenter for at dele med dine elever"
|
"empty_description": "Upload dokumenter for at dele med dine elever",
|
||||||
|
"attachments_heading": "Vedhæftede filer",
|
||||||
|
"file_count": "{count, plural, one {# fil} other {# filer}}",
|
||||||
|
"view_file": "Vis fil",
|
||||||
|
"reorder_file": "Omarranger fil"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Video",
|
"title": "Video",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Hochladen abbrechen",
|
"cancel_upload": "Hochladen abbrechen",
|
||||||
"upload_cancelled": "Der Upload wurde vom Benutzer abgebrochen",
|
"upload_cancelled": "Der Upload wurde vom Benutzer abgebrochen",
|
||||||
"downloading": "Herunterladen...",
|
"downloading": "Herunterladen...",
|
||||||
|
"download_success": "Dokument erfolgreich heruntergeladen",
|
||||||
|
"download_error": "Dokument konnte nicht heruntergeladen werden",
|
||||||
"view_pdf": "PDF ansehen",
|
"view_pdf": "PDF ansehen",
|
||||||
"remove_document": "Dokument entfernen",
|
"remove_document": "Dokument entfernen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "PDF-Seite konnte nicht gerendert werden",
|
"failed_to_render_pdf": "PDF-Seite konnte nicht gerendert werden",
|
||||||
"use_arrow_keys": "Verwenden Sie die Pfeiltasten zum Navigieren, +/- zum Zoomen oder klicken Sie auf die Steuerelemente oben. Der Rechtsklick ist deaktiviert.",
|
"use_arrow_keys": "Verwenden Sie die Pfeiltasten zum Navigieren, +/- zum Zoomen oder klicken Sie auf die Steuerelemente oben. Der Rechtsklick ist deaktiviert.",
|
||||||
"empty_title": "Noch keine Dokumente",
|
"empty_title": "Noch keine Dokumente",
|
||||||
"empty_description": "Laden Sie Dokumente hoch, um sie mit Ihren Schülern zu teilen"
|
"empty_description": "Laden Sie Dokumente hoch, um sie mit Ihren Schülern zu teilen",
|
||||||
|
"attachments_heading": "Anhänge",
|
||||||
|
"file_count": "{count, plural, one {# Datei} other {# Dateien}}",
|
||||||
|
"view_file": "Datei anzeigen",
|
||||||
|
"reorder_file": "Datei neu anordnen"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Video",
|
"title": "Video",
|
||||||
|
|||||||
@@ -2448,6 +2448,8 @@
|
|||||||
"cancel_upload": "Cancel Upload",
|
"cancel_upload": "Cancel Upload",
|
||||||
"upload_cancelled": "Upload cancelled by user",
|
"upload_cancelled": "Upload cancelled by user",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
|
"download_success": "Document downloaded successfully",
|
||||||
|
"download_error": "Failed to download document",
|
||||||
"view_pdf": "View PDF",
|
"view_pdf": "View PDF",
|
||||||
"remove_document": "Remove document",
|
"remove_document": "Remove document",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -2457,7 +2459,11 @@
|
|||||||
"failed_to_render_pdf": "Failed to render PDF page",
|
"failed_to_render_pdf": "Failed to render PDF page",
|
||||||
"use_arrow_keys": "Use arrow keys to navigate, +/- to zoom, or click the controls above. Right-click is disabled.",
|
"use_arrow_keys": "Use arrow keys to navigate, +/- to zoom, or click the controls above. Right-click is disabled.",
|
||||||
"empty_title": "No documents yet",
|
"empty_title": "No documents yet",
|
||||||
"empty_description": "Upload documents to share with your students"
|
"empty_description": "Upload documents to share with your students",
|
||||||
|
"attachments_heading": "Attachments",
|
||||||
|
"file_count": "{count, plural, one {# file} other {# files}}",
|
||||||
|
"view_file": "View file",
|
||||||
|
"reorder_file": "Reorder file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button_done": "Done"
|
"button_done": "Done"
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Cancelar carga",
|
"cancel_upload": "Cancelar carga",
|
||||||
"upload_cancelled": "Subida cancelada por el usuario",
|
"upload_cancelled": "Subida cancelada por el usuario",
|
||||||
"downloading": "Descargando...",
|
"downloading": "Descargando...",
|
||||||
|
"download_success": "Documento descargado correctamente",
|
||||||
|
"download_error": "No se pudo descargar el documento",
|
||||||
"view_pdf": "Ver PDF",
|
"view_pdf": "Ver PDF",
|
||||||
"remove_document": "Quitar documento",
|
"remove_document": "Quitar documento",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "No se pudo renderizar la página PDF",
|
"failed_to_render_pdf": "No se pudo renderizar la página PDF",
|
||||||
"use_arrow_keys": "Utilice las teclas de flecha para navegar, +/- para hacer zoom o haga clic en los controles de arriba. El clic derecho está deshabilitado.",
|
"use_arrow_keys": "Utilice las teclas de flecha para navegar, +/- para hacer zoom o haga clic en los controles de arriba. El clic derecho está deshabilitado.",
|
||||||
"empty_title": "Aún no hay documentos",
|
"empty_title": "Aún no hay documentos",
|
||||||
"empty_description": "Cargue documentos para compartir con sus estudiantes"
|
"empty_description": "Cargue documentos para compartir con sus estudiantes",
|
||||||
|
"attachments_heading": "Adjuntos",
|
||||||
|
"file_count": "{count, plural, one {# archivo} other {# archivos}}",
|
||||||
|
"view_file": "Ver archivo",
|
||||||
|
"reorder_file": "Reordenar archivo"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Video",
|
"title": "Video",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Annuler le téléchargement",
|
"cancel_upload": "Annuler le téléchargement",
|
||||||
"upload_cancelled": "Téléchargement annulé par l'utilisateur",
|
"upload_cancelled": "Téléchargement annulé par l'utilisateur",
|
||||||
"downloading": "Téléchargement...",
|
"downloading": "Téléchargement...",
|
||||||
|
"download_success": "Document téléchargé avec succès",
|
||||||
|
"download_error": "Échec du téléchargement du document",
|
||||||
"view_pdf": "Afficher le PDF",
|
"view_pdf": "Afficher le PDF",
|
||||||
"remove_document": "Supprimer le document",
|
"remove_document": "Supprimer le document",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Échec du rendu de la page PDF",
|
"failed_to_render_pdf": "Échec du rendu de la page PDF",
|
||||||
"use_arrow_keys": "Utilisez les touches fléchées pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
|
"use_arrow_keys": "Utilisez les touches fléchées pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
|
||||||
"empty_title": "Aucun document pour l'instant",
|
"empty_title": "Aucun document pour l'instant",
|
||||||
"empty_description": "Téléchargez des documents à partager avec vos étudiants"
|
"empty_description": "Téléchargez des documents à partager avec vos étudiants",
|
||||||
|
"attachments_heading": "Pièces jointes",
|
||||||
|
"file_count": "{count, plural, one {# fichier} other {# fichiers}}",
|
||||||
|
"view_file": "Voir le fichier",
|
||||||
|
"reorder_file": "Réordonner le fichier"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Vidéo",
|
"title": "Vidéo",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "अपलोड रद्द करें",
|
"cancel_upload": "अपलोड रद्द करें",
|
||||||
"upload_cancelled": "उपयोगकर्ता द्वारा अपलोड रद्द कर दिया गया",
|
"upload_cancelled": "उपयोगकर्ता द्वारा अपलोड रद्द कर दिया गया",
|
||||||
"downloading": "डाउनलोड हो रहा है...",
|
"downloading": "डाउनलोड हो रहा है...",
|
||||||
|
"download_success": "दस्तावेज़ सफलतापूर्वक डाउनलोड हुआ",
|
||||||
|
"download_error": "दस्तावेज़ डाउनलोड करने में विफल",
|
||||||
"view_pdf": "पीडीएफ देखें",
|
"view_pdf": "पीडीएफ देखें",
|
||||||
"remove_document": "दस्तावेज़ हटाएँ",
|
"remove_document": "दस्तावेज़ हटाएँ",
|
||||||
"delete": "हटाएँ",
|
"delete": "हटाएँ",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "पीडीएफ पेज प्रस्तुत करने में विफल",
|
"failed_to_render_pdf": "पीडीएफ पेज प्रस्तुत करने में विफल",
|
||||||
"use_arrow_keys": "नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, ज़ूम करने के लिए +/- का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है.",
|
"use_arrow_keys": "नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, ज़ूम करने के लिए +/- का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है.",
|
||||||
"empty_title": "अभी तक कोई दस्तावेज़ नहीं",
|
"empty_title": "अभी तक कोई दस्तावेज़ नहीं",
|
||||||
"empty_description": "अपने छात्रों के साथ साझा करने के लिए दस्तावेज़ अपलोड करें"
|
"empty_description": "अपने छात्रों के साथ साझा करने के लिए दस्तावेज़ अपलोड करें",
|
||||||
|
"attachments_heading": "अटैचमेंट",
|
||||||
|
"file_count": "{count, plural, one {# फ़ाइल} other {# फ़ाइलें}}",
|
||||||
|
"view_file": "फ़ाइल देखें",
|
||||||
|
"reorder_file": "फ़ाइल को पुनः क्रमित करें"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "वीडियो",
|
"title": "वीडियो",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Anuluj przesyłanie",
|
"cancel_upload": "Anuluj przesyłanie",
|
||||||
"upload_cancelled": "Przesyłanie anulowane przez użytkownika",
|
"upload_cancelled": "Przesyłanie anulowane przez użytkownika",
|
||||||
"downloading": "Pobieram...",
|
"downloading": "Pobieram...",
|
||||||
|
"download_success": "Dokument pobrano pomyślnie",
|
||||||
|
"download_error": "Nie udało się pobrać dokumentu",
|
||||||
"view_pdf": "Zobacz PDF",
|
"view_pdf": "Zobacz PDF",
|
||||||
"remove_document": "Usuń dokument",
|
"remove_document": "Usuń dokument",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Nie udało się wyrenderować strony PDF",
|
"failed_to_render_pdf": "Nie udało się wyrenderować strony PDF",
|
||||||
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć, lub kliknij elementy sterujące powyżej. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
|
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć, lub kliknij elementy sterujące powyżej. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
|
||||||
"empty_title": "Nie ma jeszcze dokumentów",
|
"empty_title": "Nie ma jeszcze dokumentów",
|
||||||
"empty_description": "Prześlij dokumenty, aby udostępnić je swoim uczniom"
|
"empty_description": "Prześlij dokumenty, aby udostępnić je swoim uczniom",
|
||||||
|
"attachments_heading": "Załączniki",
|
||||||
|
"file_count": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# plika}}",
|
||||||
|
"view_file": "Wyświetl plik",
|
||||||
|
"reorder_file": "Zmień kolejność pliku"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Wideo",
|
"title": "Wideo",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Cancelar upload",
|
"cancel_upload": "Cancelar upload",
|
||||||
"upload_cancelled": "Upload cancelado pelo usuário",
|
"upload_cancelled": "Upload cancelado pelo usuário",
|
||||||
"downloading": "Baixando...",
|
"downloading": "Baixando...",
|
||||||
|
"download_success": "Documento baixado com sucesso",
|
||||||
|
"download_error": "Falha ao baixar o documento",
|
||||||
"view_pdf": "Ver PDF",
|
"view_pdf": "Ver PDF",
|
||||||
"remove_document": "Remover documento",
|
"remove_document": "Remover documento",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Falha ao renderizar a página PDF",
|
"failed_to_render_pdf": "Falha ao renderizar a página PDF",
|
||||||
"use_arrow_keys": "Use as teclas de seta para navegar, +/- para ampliar ou clique nos controles acima. O clique com o botão direito está desativado.",
|
"use_arrow_keys": "Use as teclas de seta para navegar, +/- para ampliar ou clique nos controles acima. O clique com o botão direito está desativado.",
|
||||||
"empty_title": "Ainda não há documentos",
|
"empty_title": "Ainda não há documentos",
|
||||||
"empty_description": "Faça upload de documentos para compartilhar com seus alunos"
|
"empty_description": "Faça upload de documentos para compartilhar com seus alunos",
|
||||||
|
"attachments_heading": "Anexos",
|
||||||
|
"file_count": "{count, plural, one {# arquivo} other {# arquivos}}",
|
||||||
|
"view_file": "Ver arquivo",
|
||||||
|
"reorder_file": "Reordenar arquivo"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Vídeo",
|
"title": "Vídeo",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Отменить загрузку",
|
"cancel_upload": "Отменить загрузку",
|
||||||
"upload_cancelled": "Загрузка отменена пользователем",
|
"upload_cancelled": "Загрузка отменена пользователем",
|
||||||
"downloading": "Загрузка...",
|
"downloading": "Загрузка...",
|
||||||
|
"download_success": "Документ успешно загружен",
|
||||||
|
"download_error": "Не удалось загрузить документ",
|
||||||
"view_pdf": "Посмотреть PDF",
|
"view_pdf": "Посмотреть PDF",
|
||||||
"remove_document": "Удалить документ",
|
"remove_document": "Удалить документ",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Не удалось отобразить страницу PDF.",
|
"failed_to_render_pdf": "Не удалось отобразить страницу PDF.",
|
||||||
"use_arrow_keys": "Используйте клавиши со стрелками для навигации, +/- для масштабирования или нажимайте элементы управления выше. Щелчок правой кнопкой мыши отключен.",
|
"use_arrow_keys": "Используйте клавиши со стрелками для навигации, +/- для масштабирования или нажимайте элементы управления выше. Щелчок правой кнопкой мыши отключен.",
|
||||||
"empty_title": "Документов пока нет",
|
"empty_title": "Документов пока нет",
|
||||||
"empty_description": "Загрузите документы, чтобы поделиться ими со своими учениками"
|
"empty_description": "Загрузите документы, чтобы поделиться ими со своими учениками",
|
||||||
|
"attachments_heading": "Вложения",
|
||||||
|
"file_count": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
|
||||||
|
"view_file": "Просмотреть файл",
|
||||||
|
"reorder_file": "Изменить порядок файла"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Видео",
|
"title": "Видео",
|
||||||
|
|||||||
@@ -2007,6 +2007,8 @@
|
|||||||
"cancel_upload": "Hủy tải lên",
|
"cancel_upload": "Hủy tải lên",
|
||||||
"upload_cancelled": "Tải lên bị người dùng hủy",
|
"upload_cancelled": "Tải lên bị người dùng hủy",
|
||||||
"downloading": "Đang tải xuống...",
|
"downloading": "Đang tải xuống...",
|
||||||
|
"download_success": "Tài liệu đã được tải xuống thành công",
|
||||||
|
"download_error": "Không thể tải xuống tài liệu",
|
||||||
"view_pdf": "Xem PDF",
|
"view_pdf": "Xem PDF",
|
||||||
"remove_document": "Xóa tài liệu",
|
"remove_document": "Xóa tài liệu",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
@@ -2016,7 +2018,11 @@
|
|||||||
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
|
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
|
||||||
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- để thu phóng hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
|
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- để thu phóng hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
|
||||||
"empty_title": "Chưa có tài liệu nào",
|
"empty_title": "Chưa có tài liệu nào",
|
||||||
"empty_description": "Tải lên tài liệu để chia sẻ với sinh viên của bạn"
|
"empty_description": "Tải lên tài liệu để chia sẻ với sinh viên của bạn",
|
||||||
|
"attachments_heading": "Tệp đính kèm",
|
||||||
|
"file_count": "{count, plural, one {# tệp} other {# tệp}}",
|
||||||
|
"view_file": "Xem tệp",
|
||||||
|
"reorder_file": "Sắp xếp lại tệp"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Video",
|
"title": "Video",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import { AttachmentList } from '@cio/ui';
|
||||||
|
import { FIELDS } from './fields';
|
||||||
|
|
||||||
|
const sampleFiles = [
|
||||||
|
{
|
||||||
|
id: 'pm-interview-guide',
|
||||||
|
name: 'PM Interview Guide.pdf',
|
||||||
|
size: 7844,
|
||||||
|
type: 'pdf'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product-roadmap-template',
|
||||||
|
name: 'Product Roadmap Template.docx',
|
||||||
|
size: 24576,
|
||||||
|
type: 'docx'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const viewLabels = {
|
||||||
|
title: 'Attachments',
|
||||||
|
fileCount: '2 files',
|
||||||
|
view: 'View file',
|
||||||
|
download: 'Download file',
|
||||||
|
delete: 'Delete file',
|
||||||
|
reorder: 'Reorder file'
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Molecules/AttachmentList',
|
||||||
|
component: AttachmentList,
|
||||||
|
args: {
|
||||||
|
mode: 'view',
|
||||||
|
files: sampleFiles,
|
||||||
|
labels: viewLabels
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
controls: {
|
||||||
|
include: FIELDS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
onView: { control: false },
|
||||||
|
onDownload: { control: false },
|
||||||
|
onDelete: { control: false },
|
||||||
|
onReorder: { control: false },
|
||||||
|
formatSize: { control: false }
|
||||||
|
},
|
||||||
|
tags: ['autodocs']
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="ViewMode" parameters={{ layout: 'padded' }}>
|
||||||
|
{#snippet template()}
|
||||||
|
<div class="ui:w-full ui:max-w-xl">
|
||||||
|
<AttachmentList
|
||||||
|
mode="view"
|
||||||
|
files={sampleFiles}
|
||||||
|
labels={viewLabels}
|
||||||
|
onView={(file) => console.log('view', file.name)}
|
||||||
|
onDownload={(file) => console.log('download', file.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="EditMode" parameters={{ layout: 'padded' }}>
|
||||||
|
{#snippet template()}
|
||||||
|
<div class="ui:w-full ui:max-w-xl">
|
||||||
|
<AttachmentList
|
||||||
|
mode="edit"
|
||||||
|
files={sampleFiles}
|
||||||
|
labels={viewLabels}
|
||||||
|
onView={(file) => console.log('view', file.name)}
|
||||||
|
onDelete={(file) => console.log('delete', file.name)}
|
||||||
|
onReorder={(files) =>
|
||||||
|
console.log(
|
||||||
|
'reorder',
|
||||||
|
files.map((file) => file.name)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="SingleFile" parameters={{ layout: 'padded' }}>
|
||||||
|
{#snippet template()}
|
||||||
|
<div class="ui:w-full ui:max-w-xl">
|
||||||
|
<AttachmentList
|
||||||
|
mode="view"
|
||||||
|
files={[sampleFiles[0]]}
|
||||||
|
labels={{
|
||||||
|
...viewLabels,
|
||||||
|
fileCount: '1 file'
|
||||||
|
}}
|
||||||
|
onView={(file) => console.log('view', file.name)}
|
||||||
|
onDownload={(file) => console.log('download', file.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const FIELDS = ['mode', 'files', 'labels', 'class'];
|
||||||
@@ -96,6 +96,10 @@ Learner and author UIs for exercise questions (take, preview, review, submission
|
|||||||
|
|
||||||
Marketing / demo widget: left-hand list of question types and a live **take**-mode preview using `ExerciseQuestion.QuestionRenderer`. Copy is English-only (no dashboard i18n). Also consumed by the **`@cio/embeds`** app as a CDN bundle (`apps/embeds`).
|
Marketing / demo widget: left-hand list of question types and a live **take**-mode preview using `ExerciseQuestion.QuestionRenderer`. Copy is English-only (no dashboard i18n). Also consumed by the **`@cio/embeds`** app as a CDN bundle (`apps/embeds`).
|
||||||
|
|
||||||
|
### Attachment list (`src/custom/attachment-list/`)
|
||||||
|
|
||||||
|
Presentational list for lesson (or similar) file attachments with **view** and **edit** modes. View mode shows a header (paperclip + title + file count) and rows with view/download icon buttons. Edit mode shows sortable rows (when `onReorder` is provided) with a drag handle, view, and delete actions. Copy is passed via the `labels: AttachmentListLabels` prop (including `reorder` for the drag handle) so dashboard wrappers can supply translated strings. `AttachmentListFile.type` accepts a file extension or MIME type for icon styling. See `Molecules/AttachmentList` in Storybook.
|
||||||
|
|
||||||
### Live session card (`src/custom/live-session-card/`)
|
### Live session card (`src/custom/live-session-card/`)
|
||||||
|
|
||||||
Presentational card for a live-class lesson with three states (`live`, `upcoming`, `ended`) derived from `lessonAt` + `durationMinutes`, or forced via the `status` prop (used by Storybook). Shows a join/copy action set when live, an "Add to calendar" combo button (Google, Outlook.com, Office 365, Yahoo, plus an `.ics` download for Apple) and a countdown when upcoming. All copy is passed in via the `labels: LiveSessionLabels` prop, so the dashboard wrapper supplies translated strings; `onCopyLink` fires after the link is copied (e.g. for a snackbar). See `Molecules/LiveSessionCard` in Storybook.
|
Presentational card for a live-class lesson with three states (`live`, `upcoming`, `ended`) derived from `lessonAt` + `durationMinutes`, or forced via the `status` prop (used by Storybook). Shows a join/copy action set when live, an "Add to calendar" combo button (Google, Outlook.com, Office 365, Yahoo, plus an `.ics` download for Apple) and a countdown when upcoming. All copy is passed in via the `labels: LiveSessionLabels` prop, so the dashboard wrapper supplies translated strings; `onCopyLink` fires after the link is copied (e.g. for a snackbar). See `Molecules/LiveSessionCard` in Storybook.
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||||
|
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||||
|
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
|
||||||
|
import { dragHandle } from 'svelte-dnd-action';
|
||||||
|
import { Button } from '../../base/button';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
import { formatFileSize } from './format-file-size';
|
||||||
|
import { getFileTypeStyle } from './file-type-style';
|
||||||
|
import type { AttachmentListFile, AttachmentListLabels, AttachmentListMode } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: AttachmentListFile;
|
||||||
|
mode: AttachmentListMode;
|
||||||
|
labels: AttachmentListLabels;
|
||||||
|
showDragHandle?: boolean;
|
||||||
|
formatSize?: (bytes: number) => string;
|
||||||
|
onView?: (file: AttachmentListFile) => void;
|
||||||
|
onDownload?: (file: AttachmentListFile) => void | Promise<void>;
|
||||||
|
onDelete?: (file: AttachmentListFile) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
mode,
|
||||||
|
labels,
|
||||||
|
showDragHandle = false,
|
||||||
|
formatSize = formatFileSize,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const fileTypeStyle = $derived(getFileTypeStyle(file.type ?? file.name));
|
||||||
|
const sizeLabel = $derived(file.size != null ? formatSize(file.size) : null);
|
||||||
|
let isDownloading = $state(false);
|
||||||
|
|
||||||
|
async function handleDownloadClick() {
|
||||||
|
if (!onDownload) return;
|
||||||
|
|
||||||
|
isDownloading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onDownload(file);
|
||||||
|
} finally {
|
||||||
|
isDownloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'ui:flex ui:items-center ui:gap-3 ui:border-b ui:border-border ui:px-4 ui:py-3 ui:last:border-b-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#if showDragHandle}
|
||||||
|
<div
|
||||||
|
use:dragHandle
|
||||||
|
class="ui:text-muted-foreground ui:flex ui:shrink-0 ui:cursor-grab ui:items-center ui:active:cursor-grabbing"
|
||||||
|
aria-label={`${labels.reorder}: ${file.name}`}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon class="ui:size-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'ui:flex ui:size-9 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-md',
|
||||||
|
fileTypeStyle.backgroundClass
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<FileTextIcon class={cn('ui:size-4', fileTypeStyle.iconClass)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui:min-w-0 ui:flex-1">
|
||||||
|
<p class="ui:truncate ui:text-sm ui:font-semibold">{file.name}</p>
|
||||||
|
{#if sizeLabel}
|
||||||
|
<p class="ui:text-muted-foreground ui:mt-0.5 ui:text-xs">{sizeLabel}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-1.5">
|
||||||
|
{#if onView}
|
||||||
|
<Button type="button" variant="outline" size="icon-sm" aria-label={labels.view} onclick={() => onView(file)}>
|
||||||
|
<EyeIcon class="ui:size-4" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'view' && onDownload}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={labels.download}
|
||||||
|
loading={isDownloading}
|
||||||
|
onclick={handleDownloadClick}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="ui:size-4" />
|
||||||
|
</Button>
|
||||||
|
{:else if mode === 'edit' && onDelete}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
class="ui:text-destructive ui:hover:text-destructive"
|
||||||
|
aria-label={labels.delete}
|
||||||
|
onclick={() => onDelete(file)}
|
||||||
|
>
|
||||||
|
<Trash2Icon class="ui:size-4" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
||||||
|
import { dragHandleZone } from 'svelte-dnd-action';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
import AttachmentListRow from './attachment-list-row.svelte';
|
||||||
|
import type { AttachmentListFile, AttachmentListLabels, AttachmentListMode } from './types';
|
||||||
|
|
||||||
|
const defaultLabels: AttachmentListLabels = {
|
||||||
|
title: 'Attachments',
|
||||||
|
fileCount: '',
|
||||||
|
view: 'View file',
|
||||||
|
download: 'Download file',
|
||||||
|
delete: 'Delete file',
|
||||||
|
reorder: 'Reorder file'
|
||||||
|
};
|
||||||
|
|
||||||
|
const flipDurationMs = 150;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode?: AttachmentListMode;
|
||||||
|
files?: AttachmentListFile[];
|
||||||
|
labels?: AttachmentListLabels;
|
||||||
|
formatSize?: (bytes: number) => string;
|
||||||
|
onView?: (file: AttachmentListFile) => void;
|
||||||
|
onDownload?: (file: AttachmentListFile) => void | Promise<void>;
|
||||||
|
onDelete?: (file: AttachmentListFile) => void;
|
||||||
|
onReorder?: (files: AttachmentListFile[]) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode = 'view',
|
||||||
|
files = [],
|
||||||
|
labels = defaultLabels,
|
||||||
|
formatSize,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
onReorder,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function getFilesSignature(nextFiles: AttachmentListFile[] = []): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
nextFiles.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderedFiles = $state<AttachmentListFile[]>([]);
|
||||||
|
let lastSyncedSignature = $state('');
|
||||||
|
|
||||||
|
function syncOrderedFiles(nextFiles: AttachmentListFile[] = []) {
|
||||||
|
const signature = getFilesSignature(nextFiles);
|
||||||
|
|
||||||
|
if (signature === lastSyncedSignature && orderedFiles.length > 0) return;
|
||||||
|
|
||||||
|
orderedFiles = nextFiles;
|
||||||
|
lastSyncedSignature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDndConsider(event: CustomEvent<{ items: AttachmentListFile[] }>) {
|
||||||
|
orderedFiles = event.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDndFinalize(event: CustomEvent<{ items: AttachmentListFile[] }>) {
|
||||||
|
orderedFiles = event.detail.items;
|
||||||
|
lastSyncedSignature = getFilesSignature(orderedFiles);
|
||||||
|
onReorder?.(orderedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayFiles = $derived(mode === 'edit' ? (orderedFiles.length > 0 ? orderedFiles : files) : files);
|
||||||
|
const isReorderable = $derived(mode === 'edit' && Boolean(onReorder));
|
||||||
|
const reorderItems = $derived(orderedFiles.length > 0 ? orderedFiles : files);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
syncOrderedFiles(files);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-slot="attachment-list"
|
||||||
|
class={cn('ui:flex ui:w-full ui:flex-col ui:overflow-hidden ui:rounded-md ui:border ui:border-border', className)}
|
||||||
|
>
|
||||||
|
{#if mode === 'view'}
|
||||||
|
<div class="ui:flex ui:items-center ui:justify-between ui:border-b ui:border-border ui:p-2">
|
||||||
|
<div class="ui:flex ui:items-center ui:gap-2">
|
||||||
|
<PaperclipIcon class="ui:text-muted-foreground ui:size-3.5" aria-hidden="true" />
|
||||||
|
<h3 class="ui:text-xs">{labels.title}</h3>
|
||||||
|
</div>
|
||||||
|
<span class="ui:text-muted-foreground ui:text-xs">{labels.fileCount}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isReorderable}
|
||||||
|
<section
|
||||||
|
class="ui:flex ui:flex-col"
|
||||||
|
use:dragHandleZone={{
|
||||||
|
items: reorderItems,
|
||||||
|
flipDurationMs,
|
||||||
|
dropFromOthersDisabled: true,
|
||||||
|
dropTargetStyle: {
|
||||||
|
border: '2px var(--ring) solid',
|
||||||
|
'border-style': 'dashed',
|
||||||
|
'border-radius': '0.375rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onconsider={handleDndConsider}
|
||||||
|
onfinalize={handleDndFinalize}
|
||||||
|
>
|
||||||
|
{#each displayFiles as file (file.id)}
|
||||||
|
<div animate:flip={{ duration: flipDurationMs }}>
|
||||||
|
<AttachmentListRow
|
||||||
|
{file}
|
||||||
|
{mode}
|
||||||
|
{labels}
|
||||||
|
showDragHandle={true}
|
||||||
|
{formatSize}
|
||||||
|
{onView}
|
||||||
|
{onDownload}
|
||||||
|
{onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<div role="list" class="ui:flex ui:flex-col">
|
||||||
|
{#each displayFiles as file (file.id)}
|
||||||
|
<div role="listitem">
|
||||||
|
<AttachmentListRow
|
||||||
|
{file}
|
||||||
|
{mode}
|
||||||
|
{labels}
|
||||||
|
showDragHandle={false}
|
||||||
|
{formatSize}
|
||||||
|
{onView}
|
||||||
|
{onDownload}
|
||||||
|
{onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type FileTypeStyle = {
|
||||||
|
backgroundClass: string;
|
||||||
|
iconClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_TO_EXTENSION: Record<string, string> = {
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'application/msword': 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx'
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveExtension(typeOrName: string): string {
|
||||||
|
const normalized = typeOrName.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalized) return '';
|
||||||
|
|
||||||
|
const mimeExtension = MIME_TO_EXTENSION[normalized];
|
||||||
|
|
||||||
|
if (mimeExtension) return mimeExtension;
|
||||||
|
|
||||||
|
if (normalized.includes('/')) return '';
|
||||||
|
|
||||||
|
if (normalized.includes('.')) {
|
||||||
|
return normalized.split('.').pop() ?? normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileTypeStyle(typeOrName: string): FileTypeStyle {
|
||||||
|
const extension = resolveExtension(typeOrName);
|
||||||
|
|
||||||
|
switch (extension) {
|
||||||
|
case 'pdf':
|
||||||
|
return {
|
||||||
|
backgroundClass: 'ui:bg-destructive/15',
|
||||||
|
iconClass: 'ui:text-destructive'
|
||||||
|
};
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return {
|
||||||
|
backgroundClass: 'ui:bg-primary/15',
|
||||||
|
iconClass: 'ui:text-primary'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
backgroundClass: 'ui:bg-muted',
|
||||||
|
iconClass: 'ui:text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const unit = 1024;
|
||||||
|
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(unit)), units.length - 1);
|
||||||
|
const value = bytes / Math.pow(unit, unitIndex);
|
||||||
|
|
||||||
|
return `${parseFloat(value.toFixed(2))} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as AttachmentList } from './attachment-list.svelte';
|
||||||
|
export { formatFileSize } from './format-file-size';
|
||||||
|
export { getFileTypeStyle } from './file-type-style';
|
||||||
|
export type { AttachmentListFile, AttachmentListLabels, AttachmentListMode } from './types';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type AttachmentListFile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
/** File extension (e.g. `pdf`, `docx`) or MIME type (e.g. `application/pdf`). Used for icon styling. */
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentListMode = 'view' | 'edit';
|
||||||
|
|
||||||
|
export type AttachmentListLabels = {
|
||||||
|
title: string;
|
||||||
|
fileCount: string;
|
||||||
|
view: string;
|
||||||
|
download: string;
|
||||||
|
delete: string;
|
||||||
|
reorder: string;
|
||||||
|
};
|
||||||
@@ -91,3 +91,5 @@ export * as CommentTree from './custom/comment-tree';
|
|||||||
export * as Certificate from './custom/certificate-preview';
|
export * as Certificate from './custom/certificate-preview';
|
||||||
export * as EmailPreview from './custom/email-preview';
|
export * as EmailPreview from './custom/email-preview';
|
||||||
export * from './custom/animation';
|
export * from './custom/animation';
|
||||||
|
export { AttachmentList } from './custom/attachment-list';
|
||||||
|
export type { AttachmentListFile, AttachmentListLabels, AttachmentListMode } from './custom/attachment-list';
|
||||||
|
|||||||
Reference in New Issue
Block a user