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:
Rotimi Best
2026-08-02 21:34:06 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 06c3a7feba
commit 0482790126
29 changed files with 706 additions and 156 deletions
@@ -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'];
+4
View File
@@ -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`).
### 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/`)
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;
};
+2
View File
@@ -91,3 +91,5 @@ export * as CommentTree from './custom/comment-tree';
export * as Certificate from './custom/certificate-preview';
export * as EmailPreview from './custom/email-preview';
export * from './custom/animation';
export { AttachmentList } from './custom/attachment-list';
export type { AttachmentListFile, AttachmentListLabels, AttachmentListMode } from './custom/attachment-list';