From 6dcf854ccc83d78bc0f6426412703ea0964f6996 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Tue, 12 May 2026 11:46:46 +0800 Subject: [PATCH 01/69] fix(files): single close button in preview; floating bulk action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file preview dialog rendered two overlapping close buttons (one from the dialog wrapper, one from FileDetailPanel's legacy sidebar header). Remove the inner one since FileDetailPanel now only lives inside the dialog. The BulkActionBar pushed the file list downward when selecting, causing misclicks. Promote it to a floating overlay anchored bottom-center of the page body with a slide-up transition — the list no longer shifts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../organisms/files/FileDetailPanel.vue | 9 ----- .../organisms/files/FilePreviewDialog.vue | 2 +- web/src/pages/files/MyFiles.vue | 38 ++++++++++++++++--- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/web/src/components/organisms/files/FileDetailPanel.vue b/web/src/components/organisms/files/FileDetailPanel.vue index 51828b9..3167841 100644 --- a/web/src/components/organisms/files/FileDetailPanel.vue +++ b/web/src/components/organisms/files/FileDetailPanel.vue @@ -14,7 +14,6 @@ import type { FileItem } from '../../../types/file'; GlobalWorkerOptions.workerSrc = pdfWorkerUrl; const props = defineProps<{ file: FileItem | null }>(); -const emit = defineEmits<{ (e: 'close'): void }>(); const isLoading = ref(false); const isPdfRendering = ref(false); @@ -373,7 +372,6 @@ onUnmounted(() => {

{{ file.name }}

{{ selectedMime || 'unknown type' }} | {{ formatBytes(file.size) }}

-
@@ -471,7 +469,6 @@ onUnmounted(() => { font-size: var(--text-small); } -.detail__close, .detail__action, .detail__pdf-btn { height: var(--row-h); @@ -485,12 +482,6 @@ onUnmounted(() => { transition: background-color var(--mo-duration-fast) var(--mo-easing), color var(--mo-duration-fast) var(--mo-easing); } -.detail__close { - width: var(--row-h); - padding: 0; -} - -.detail__close:hover, .detail__action:hover, .detail__pdf-btn:hover:not(:disabled) { background: var(--surface-inset); diff --git a/web/src/components/organisms/files/FilePreviewDialog.vue b/web/src/components/organisms/files/FilePreviewDialog.vue index f7299b0..b0ce844 100644 --- a/web/src/components/organisms/files/FilePreviewDialog.vue +++ b/web/src/components/organisms/files/FilePreviewDialog.vue @@ -54,7 +54,7 @@ const onOverlayClick = (ev: MouseEvent) => { ×
- +
diff --git a/web/src/pages/files/MyFiles.vue b/web/src/pages/files/MyFiles.vue index e16cfda..551af70 100644 --- a/web/src/pages/files/MyFiles.vue +++ b/web/src/pages/files/MyFiles.vue @@ -113,10 +113,6 @@ onUnmounted(() => { eventBus.off('move-items', drag.onSidebarMove); eventBus.off - -
@@ -145,11 +141,19 @@ onUnmounted(() => { eventBus.off('move-items', drag.onSidebarMove); eventBus.off @close="a.isShareDialogVisible.value = false" /> + + +
+ +
+
From 571b74d11166a8829f37ca5b423237be018dd79a Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Tue, 12 May 2026 11:48:00 +0800 Subject: [PATCH 02/69] fix(shell): wrap /agent in MainLayout; silent refresh removes table flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /agent routes lived under templates/AgentLayout in isolation, with no app shell or sidebar nav — users had no way back to /files. Nest the route under MainLayout so the LeftSidebar (which already has /agent and /files entries) is always present; strip templates/AgentLayout's viewport sizing and duplicate padding since MainLayout now owns the content frame. The file list flickered on every auto-refresh and post-mutation refetch because fetchFolderContents flipped isLoading=true, unmounting FileTable and mounting an EmptyState placeholder before remounting the table. Add a silent option to fetchFolderContents — silent calls leave isLoading and items intact and swap items in place when the response arrives. The auto-refresh timer and all post-mutation callers (create folder, delete, batch move, batch delete, upload) opt in. MyFiles template also keeps FileTable mounted whenever items > 0, so loading placeholder is only visible on a true first load. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/components/templates/AgentLayout.vue | 15 ++++----- web/src/composables/useBatchActions.ts | 2 +- web/src/composables/useFileActions.ts | 6 ++-- web/src/composables/useUpload.ts | 2 +- web/src/pages/files/MyFiles.vue | 10 +++--- web/src/router/routes.ts | 32 ++++++++++---------- web/src/store/file.ts | 22 +++++++++----- 7 files changed, 47 insertions(+), 42 deletions(-) diff --git a/web/src/components/templates/AgentLayout.vue b/web/src/components/templates/AgentLayout.vue index 6fade97..49b7dad 100644 --- a/web/src/components/templates/AgentLayout.vue +++ b/web/src/components/templates/AgentLayout.vue @@ -1,18 +1,15 @@ diff --git a/web/src/components/organisms/share/ShareAccessPanel.spec.ts b/web/src/components/organisms/share/ShareAccessPanel.spec.ts new file mode 100644 index 0000000..5840306 --- /dev/null +++ b/web/src/components/organisms/share/ShareAccessPanel.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareAccessPanel from './ShareAccessPanel.vue'; + +describe('ShareAccessPanel', () => { + it('shows password form when protected', () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: true, password: '', isAccessing: false }, + }); + expect(w.find('input[type="password"]').exists()).toBe(true); + expect(w.text()).toContain('Unlock'); + }); + + it('shows open-access form when not protected', () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: false, password: '', isAccessing: false }, + }); + expect(w.find('input[type="password"]').exists()).toBe(false); + expect(w.text()).toContain('Get Access'); + }); + + it('emits request-access on button click', async () => { + const w = mount(ShareAccessPanel, { + props: { passwordProtected: false, password: '', isAccessing: false }, + }); + await w.find('button').trigger('click'); + expect(w.emitted('request-access')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/share/ShareAccessPanel.vue b/web/src/components/organisms/share/ShareAccessPanel.vue new file mode 100644 index 0000000..e6ee69a --- /dev/null +++ b/web/src/components/organisms/share/ShareAccessPanel.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/web/src/components/organisms/share/ShareActionsPanel.spec.ts b/web/src/components/organisms/share/ShareActionsPanel.spec.ts new file mode 100644 index 0000000..f167915 --- /dev/null +++ b/web/src/components/organisms/share/ShareActionsPanel.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareActionsPanel from './ShareActionsPanel.vue'; + +const baseProps = { + isFile: true, isFolder: false, + canPreview: true, canDownload: true, + isPreviewing: false, isDownloading: false, isSaving: false, +}; + +describe('ShareActionsPanel', () => { + it('renders preview + download for file mode', () => { + const w = mount(ShareActionsPanel, { props: baseProps }); + expect(w.text()).toContain('Preview'); + expect(w.text()).toContain('Download'); + expect(w.text()).toContain('Save to My Space'); + }); + + it('hides preview + download for folder mode', () => { + const w = mount(ShareActionsPanel, { props: { ...baseProps, isFile: false, isFolder: true } }); + expect(w.text()).not.toContain('Preview'); + expect(w.text()).not.toContain('Download'); + expect(w.text()).toContain('Save Folder'); + }); + + it('emits preview/download/save', async () => { + const w = mount(ShareActionsPanel, { props: baseProps }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Preview'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Download'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Save'))[0].trigger('click'); + expect(w.emitted('preview')).toBeTruthy(); + expect(w.emitted('download')).toBeTruthy(); + expect(w.emitted('save')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/share/ShareActionsPanel.vue b/web/src/components/organisms/share/ShareActionsPanel.vue new file mode 100644 index 0000000..3f51e29 --- /dev/null +++ b/web/src/components/organisms/share/ShareActionsPanel.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/web/src/components/organisms/share/ShareInfoCard.spec.ts b/web/src/components/organisms/share/ShareInfoCard.spec.ts new file mode 100644 index 0000000..4222b3b --- /dev/null +++ b/web/src/components/organisms/share/ShareInfoCard.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import ShareInfoCard from './ShareInfoCard.vue'; +import type { Share } from '../../../types/share'; + +const share: Share = { + shareId: 's1', shareLink: 'abc', itemType: 'file', + itemInfo: { id: 'f', name: 'doc.pdf', size: 2048, mimeType: 'application/pdf' }, + settings: { passwordProtected: true, expireAt: '2026-12-01', allowDownload: true, allowPreview: true }, + createdAt: '2026-05-01T00:00:00Z', +}; + +describe('ShareInfoCard', () => { + it('renders all metadata rows', () => { + const w = mount(ShareInfoCard, { props: { share } }); + expect(w.text()).toContain('file'); + expect(w.text()).toContain('doc.pdf'); + expect(w.text()).toContain('Required'); + expect(w.text()).toContain('2026-12-01'); + }); + + it('shows Never when no expiry', () => { + const noExpiry = { ...share, settings: { ...share.settings, expireAt: null, passwordProtected: false } }; + const w = mount(ShareInfoCard, { props: { share: noExpiry } }); + expect(w.text()).toContain('Never'); + expect(w.text()).toContain('Not required'); + }); +}); diff --git a/web/src/components/organisms/share/ShareInfoCard.vue b/web/src/components/organisms/share/ShareInfoCard.vue new file mode 100644 index 0000000..4bb6ce6 --- /dev/null +++ b/web/src/components/organisms/share/ShareInfoCard.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/web/src/components/organisms/share/index.ts b/web/src/components/organisms/share/index.ts new file mode 100644 index 0000000..b8cb282 --- /dev/null +++ b/web/src/components/organisms/share/index.ts @@ -0,0 +1,3 @@ +export { default as ShareInfoCard } from './ShareInfoCard.vue'; +export { default as ShareAccessPanel } from './ShareAccessPanel.vue'; +export { default as ShareActionsPanel } from './ShareActionsPanel.vue'; diff --git a/web/src/components/organisms/sharing/SharedBatchBar.spec.ts b/web/src/components/organisms/sharing/SharedBatchBar.spec.ts new file mode 100644 index 0000000..26f0af0 --- /dev/null +++ b/web/src/components/organisms/sharing/SharedBatchBar.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedBatchBar from './SharedBatchBar.vue'; + +describe('SharedBatchBar', () => { + it('hidden when count = 0', () => { + const w = mount(SharedBatchBar, { props: { count: 0 } }); + expect(w.find('.shared-batch').exists()).toBe(false); + }); + + it('emits accept + clear', async () => { + const w = mount(SharedBatchBar, { props: { count: 2 } }); + expect(w.text()).toContain('2'); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Accept'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Clear'))[0].trigger('click'); + expect(w.emitted('accept')).toBeTruthy(); + expect(w.emitted('clear')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedBatchBar.vue b/web/src/components/organisms/sharing/SharedBatchBar.vue new file mode 100644 index 0000000..bc5998f --- /dev/null +++ b/web/src/components/organisms/sharing/SharedBatchBar.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/web/src/components/organisms/sharing/SharedLinksTable.spec.ts b/web/src/components/organisms/sharing/SharedLinksTable.spec.ts new file mode 100644 index 0000000..99c5a70 --- /dev/null +++ b/web/src/components/organisms/sharing/SharedLinksTable.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedLinksTable from './SharedLinksTable.vue'; +import type { Share } from '../../../types/share'; + +const items: Share[] = [ + { + shareId: 's1', shareLink: 'abc123', itemType: 'file', + itemInfo: { id: 'f1', name: 'report.pdf', size: 1024, mimeType: 'application/pdf' }, + settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, + createdAt: '2026-05-01T00:00:00Z', visitCount: 3, downloadCount: 1, + }, +]; + +describe('SharedLinksTable', () => { + it('renders rows', () => { + const w = mount(SharedLinksTable, { props: { items } }); + expect(w.text()).toContain('report.pdf'); + expect(w.text()).toContain('abc123'); + expect(w.text()).toContain('3 / 1'); + }); + + it('emits copy + delete', async () => { + const w = mount(SharedLinksTable, { props: { items } }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Copy'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Delete'))[0].trigger('click'); + expect(w.emitted('copy')).toBeTruthy(); + expect(w.emitted('delete')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedLinksTable.vue b/web/src/components/organisms/sharing/SharedLinksTable.vue new file mode 100644 index 0000000..735052e --- /dev/null +++ b/web/src/components/organisms/sharing/SharedLinksTable.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts b/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts new file mode 100644 index 0000000..a42303e --- /dev/null +++ b/web/src/components/organisms/sharing/SharedReceivedTable.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import SharedReceivedTable from './SharedReceivedTable.vue'; +import type { SharedItem } from '../../../types/share'; + +const items: SharedItem[] = [ + { itemType: 'file', id: 'a', name: 'a.txt', size: 100, sharedBy: 'alice', permission: 'read', sharedAt: '2026-05-01T00:00:00Z' }, + { itemType: 'folder', id: 'b', name: 'docs', size: 0, sharedBy: 'bob', permission: 'write', sharedAt: '2026-05-02T00:00:00Z' }, +]; + +describe('SharedReceivedTable', () => { + it('renders header + rows', () => { + const w = mount(SharedReceivedTable, { props: { items, selection: new Set() } }); + expect(w.findAll('.shared-table__row')).toHaveLength(2); + expect(w.text()).toContain('a.txt'); + expect(w.text()).toContain('alice'); + }); + + it('emits accept when accept button clicked', async () => { + const w = mount(SharedReceivedTable, { props: { items, selection: new Set() } }); + await w.findAll('button').filter((b) => b.text().includes('Accept'))[0].trigger('click'); + expect(w.emitted('accept')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/sharing/SharedReceivedTable.vue b/web/src/components/organisms/sharing/SharedReceivedTable.vue new file mode 100644 index 0000000..aac849a --- /dev/null +++ b/web/src/components/organisms/sharing/SharedReceivedTable.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/web/src/components/organisms/sharing/index.ts b/web/src/components/organisms/sharing/index.ts new file mode 100644 index 0000000..2820a38 --- /dev/null +++ b/web/src/components/organisms/sharing/index.ts @@ -0,0 +1,3 @@ +export { default as SharedReceivedTable } from './SharedReceivedTable.vue'; +export { default as SharedLinksTable } from './SharedLinksTable.vue'; +export { default as SharedBatchBar } from './SharedBatchBar.vue'; diff --git a/web/src/components/organisms/trash/TrashTable.spec.ts b/web/src/components/organisms/trash/TrashTable.spec.ts new file mode 100644 index 0000000..a160746 --- /dev/null +++ b/web/src/components/organisms/trash/TrashTable.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '../../../test/mount'; +import TrashTable from './TrashTable.vue'; +import type { RecycleBinItem } from '../../../types/file'; + +const items: RecycleBinItem[] = [ + { itemType: 'file', id: 'a', name: 'a.txt', originalPath: '/foo/bar', size: 100, deletedAt: '2026-05-01T00:00:00Z', autoDeleteAt: '2026-06-01T00:00:00Z', daysUntilPermanentDelete: 14 }, + { itemType: 'file', id: 'b', name: 'b.txt', originalPath: '/foo', size: 200, deletedAt: '2026-05-05T00:00:00Z', autoDeleteAt: '2026-05-15T00:00:00Z', daysUntilPermanentDelete: 3 }, +]; + +describe('TrashTable', () => { + it('renders rows', () => { + const w = mount(TrashTable, { props: { items } }); + expect(w.text()).toContain('a.txt'); + expect(w.text()).toContain('/foo/bar'); + expect(w.text()).toContain('14 days'); + }); + + it('flags near-expiry items', () => { + const w = mount(TrashTable, { props: { items } }); + expect(w.findAll('.trash-table__cell--warning').length).toBeGreaterThan(0); + }); + + it('emits restore + permanent-delete', async () => { + const w = mount(TrashTable, { props: { items } }); + const buttons = w.findAll('button'); + await buttons.filter((b) => b.text().includes('Restore'))[0].trigger('click'); + await buttons.filter((b) => b.text().includes('Delete'))[0].trigger('click'); + expect(w.emitted('restore')).toBeTruthy(); + expect(w.emitted('permanent-delete')).toBeTruthy(); + }); +}); diff --git a/web/src/components/organisms/trash/TrashTable.vue b/web/src/components/organisms/trash/TrashTable.vue new file mode 100644 index 0000000..7bf6acf --- /dev/null +++ b/web/src/components/organisms/trash/TrashTable.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web/src/components/organisms/trash/index.ts b/web/src/components/organisms/trash/index.ts new file mode 100644 index 0000000..d00d5d8 --- /dev/null +++ b/web/src/components/organisms/trash/index.ts @@ -0,0 +1 @@ +export { default as TrashTable } from './TrashTable.vue'; diff --git a/web/src/composables/useShareAccess.ts b/web/src/composables/useShareAccess.ts new file mode 100644 index 0000000..bcddc2b --- /dev/null +++ b/web/src/composables/useShareAccess.ts @@ -0,0 +1,85 @@ +import { computed, ref, type Ref } from 'vue'; +import { accessShare, downloadSharedFile, getShareDetails, previewSharedFile, saveShare } from '../api/share'; +import type { AccessShareResponseData, Share } from '../types/share'; + +export function useShareAccess(shareLink: Ref) { + const share = ref(null); + const accessData = ref(null); + const password = ref(''); + const error = ref(''); + const statusMessage = ref(''); + + const isLoading = ref(false); + const isAccessing = ref(false); + const isSaving = ref(false); + const isDownloading = ref(false); + const isPreviewing = ref(false); + + const isFile = computed(() => share.value?.itemType === 'file'); + const isFolder = computed(() => share.value?.itemType === 'folder'); + const passwordProtected = computed(() => Boolean(share.value?.settings.passwordProtected)); + const canDownload = computed(() => Boolean(accessData.value?.accessUrls.download)); + const canPreview = computed(() => Boolean(accessData.value?.accessUrls.preview)); + + const loadShare = async () => { + error.value = ''; statusMessage.value = ''; isLoading.value = true; + try { + share.value = await getShareDetails(shareLink.value); + if (!share.value.settings.passwordProtected) await requestAccess(); + } catch (e) { console.error('Failed to load share', e); error.value = 'Unable to load share. The link may be invalid or expired.'; } + finally { isLoading.value = false; } + }; + + const requestAccess = async () => { + if (!share.value) return; + isAccessing.value = true; error.value = ''; statusMessage.value = ''; + try { + accessData.value = await accessShare(shareLink.value, password.value.trim() ? { password: password.value.trim() } : {}); + statusMessage.value = 'Access granted.'; + } catch (e) { console.error('Failed to access share', e); error.value = passwordProtected.value ? 'Invalid password or share expired.' : 'Share expired or unavailable.'; } + finally { isAccessing.value = false; } + }; + + const handleDownload = async () => { + if (!accessData.value || !isFile.value || !canDownload.value) return; + isDownloading.value = true; error.value = ''; statusMessage.value = ''; + try { + const blob = await downloadSharedFile(shareLink.value, accessData.value.accessToken); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = share.value?.itemInfo.name || 'download'; + document.body.appendChild(a); a.click(); a.remove(); + window.URL.revokeObjectURL(url); + } catch (e) { console.error('Failed to download shared file', e); error.value = 'Download failed.'; } + finally { isDownloading.value = false; } + }; + + const handlePreview = async () => { + if (!accessData.value || !isFile.value || !canPreview.value) return; + isPreviewing.value = true; error.value = ''; statusMessage.value = ''; + try { + const blob = await previewSharedFile(shareLink.value, accessData.value.accessToken); + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener,noreferrer'); + setTimeout(() => window.URL.revokeObjectURL(url), 30_000); + } catch (e) { console.error('Failed to preview shared file', e); error.value = 'Preview failed.'; } + finally { isPreviewing.value = false; } + }; + + const saveToFolder = async (targetFolderId: string) => { + if (!accessData.value) return; + isSaving.value = true; error.value = ''; statusMessage.value = ''; + try { + const resp = await saveShare(shareLink.value, { targetFolderId, shareAccessToken: accessData.value.accessToken }); + statusMessage.value = `Saved successfully (${resp.itemType}).`; + } catch (e) { console.error('Failed to save share', e); error.value = 'Save failed. Please make sure you are logged in and verified.'; } + finally { isSaving.value = false; } + }; + + return { + share, accessData, password, error, statusMessage, + isLoading, isAccessing, isSaving, isDownloading, isPreviewing, + isFile, isFolder, passwordProtected, canDownload, canPreview, + loadShare, requestAccess, handleDownload, handlePreview, saveToFolder, + }; +} diff --git a/web/src/composables/useSharingCenter.ts b/web/src/composables/useSharingCenter.ts new file mode 100644 index 0000000..dc1d3ed --- /dev/null +++ b/web/src/composables/useSharingCenter.ts @@ -0,0 +1,68 @@ +import { computed, ref } from 'vue'; +import { acceptSharedItem, deleteShare, getSharedItems, getShares } from '../api/share'; +import { useFileSelection } from './useFileSelection'; +import type { Share, SharedItem } from '../types/share'; +import { ui } from '../utils/ui'; + +export type SharedTab = 'received' | 'links'; + +export function useSharingCenter() { + const activeTab = ref('received'); + const isLoading = ref(false); + const sharedItems = ref([]); + const myShares = ref([]); + + const selection = useFileSelection(); + const showBatch = computed(() => selection.selectedCount.value > 0 && activeTab.value === 'received'); + + const loadReceived = async () => { sharedItems.value = (await getSharedItems({ page: 1, perPage: 50, sort: 'sharedAt', order: 'desc' })).items; }; + const loadLinks = async () => { myShares.value = (await getShares({ page: 1, perPage: 50 })).items; }; + + const loadData = async () => { + isLoading.value = true; + try { activeTab.value === 'received' ? await loadReceived() : await loadLinks(); } + finally { isLoading.value = false; } + }; + + const switchTab = async (tab: SharedTab) => { + activeTab.value = tab; selection.clear(); await loadData(); + }; + + const toggleAll = (next: boolean) => { + if (next) sharedItems.value.forEach((i) => selection.selectedItems.value.add(i.id)); + else selection.clear(); + }; + + const acceptOne = async (item: SharedItem) => { + try { await acceptSharedItem(item.id); await loadReceived(); } + catch (e) { console.error('Failed to accept shared item', e); } + }; + + const acceptSelected = async () => { + if (!selection.selectedItems.value.size) return; + await Promise.allSettled(Array.from(selection.selectedItems.value).map((id) => acceptSharedItem(id))); + selection.clear(); + await loadReceived(); + }; + + const removeShare = async (share: Share) => { + const ok = await ui.confirm({ title: 'Delete Share Link', message: `Delete share link ${share.shareLink}?`, confirmText: 'Delete', danger: true }); + if (!ok) return; + try { + await deleteShare(share.shareLink); + myShares.value = myShares.value.filter((e) => e.shareLink !== share.shareLink); + ui.toast({ type: 'success', message: 'Share link deleted.' }); + } catch (e) { console.error('Failed to delete share link', e); ui.toast({ type: 'error', message: 'Failed to delete share link.' }); } + }; + + const copyShare = async (share: Share) => { + const link = `${window.location.origin}/share/${share.shareLink}`; + try { await navigator.clipboard.writeText(link); ui.toast({ type: 'success', message: 'Share link copied.' }); } + catch { await ui.copyText({ title: 'Copy Share Link', message: 'Clipboard is unavailable. Copy this link manually:', text: link }); } + }; + + return { + activeTab, isLoading, sharedItems, myShares, selection, showBatch, + loadData, switchTab, toggleAll, acceptOne, acceptSelected, removeShare, copyShare, + }; +} diff --git a/web/src/pages/__dev/Library.vue b/web/src/pages/__dev/Library.vue index 8cb2bee..ef1de52 100644 --- a/web/src/pages/__dev/Library.vue +++ b/web/src/pages/__dev/Library.vue @@ -3,13 +3,16 @@ import { ref } from 'vue'; import * as A from '../../components/atoms'; import * as M from '../../components/molecules'; import * as F from '../../components/organisms/files'; +import * as Sh from '../../components/organisms/sharing'; +import * as Tr from '../../components/organisms/trash'; +import * as Sa from '../../components/organisms/share'; import { useFilePreview } from '../../composables/useFilePreview'; import type { FileItem } from '../../types/file'; const sections = [ 'Tokens', 'Atoms · Text', 'Atoms · Numbers', 'Atoms · Visual', 'Atoms · Form', 'Molecules · Action', 'Molecules · Input', 'Molecules · Display', 'Molecules · Nav', - 'Organisms · Files', + 'Organisms · Files', 'Organisms · Sharing', 'Organisms · Trash', 'Organisms · Share', ] as const; type Section = typeof sections[number]; @@ -46,6 +49,22 @@ const filesSelection = ref(new Set(['demo-a'])); const filesRenamingId = ref(null); const filesRenameValue = ref(''); +// Sharing / Trash / Share demo state +const sharedSelection = ref(new Set()); +const sharedDemoItems = [ + { itemType: 'file' as const, id: 's1', name: 'plan.docx', size: 4096, sharedBy: 'alice', permission: 'read' as const, sharedAt: '2026-05-09T10:00:00Z' }, + { itemType: 'folder' as const, id: 's2', name: 'designs', size: 0, sharedBy: 'bob', permission: 'write' as const, sharedAt: '2026-05-08T12:30:00Z' }, +]; +const linksDemoItems = [ + { shareId: 'l1', shareLink: 'abc123', itemType: 'file' as const, itemInfo: { id: 'f1', name: 'report.pdf', size: 1024, mimeType: 'application/pdf' }, settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, createdAt: '2026-05-01T00:00:00Z', visitCount: 12, downloadCount: 4 }, +]; +const trashDemoItems = [ + { itemType: 'file' as const, id: 't1', name: 'draft.md', originalPath: '/notes', size: 200, deletedAt: '2026-05-09T18:00:00Z', autoDeleteAt: '2026-06-08T18:00:00Z', daysUntilPermanentDelete: 27 }, + { itemType: 'file' as const, id: 't2', name: 'expired.zip', originalPath: '/archives', size: 1024, deletedAt: '2026-05-05T08:00:00Z', autoDeleteAt: '2026-05-15T08:00:00Z', daysUntilPermanentDelete: 3 }, +]; +const shareDemo = { shareId: 's', shareLink: 'xyz789', itemType: 'file' as const, itemInfo: { id: 'f', name: 'big-report.pdf', size: 1_245_184, mimeType: 'application/pdf' }, settings: { passwordProtected: true, expireAt: '2026-12-01', allowDownload: true, allowPreview: true }, createdAt: '2026-05-01T00:00:00Z' }; +const sharePassword = ref(''); + const demoItems = [ { id: 'demo-a', name: 'README.md', itemType: 'file' as const, @@ -342,6 +361,65 @@ const swatches = [ Last interaction: {{ filesLastInteraction || '—' }} + +
+ Organisms · Sharing + + SharedReceivedTable + + + SharedReceivedTable · empty + + + SharedLinksTable + + + SharedBatchBar · count = 2 + +
+ +
+ Organisms · Trash + + TrashTable + + + TrashTable · empty + +
+ +
+ Organisms · Share + + ShareInfoCard + + + ShareAccessPanel · password mode + + + ShareAccessPanel · open mode + + + ShareActionsPanel · file + + + ShareActionsPanel · folder + +
diff --git a/web/src/pages/share/ShareAccess.vue b/web/src/pages/share/ShareAccess.vue index 505f161..fa5dea5 100644 --- a/web/src/pages/share/ShareAccess.vue +++ b/web/src/pages/share/ShareAccess.vue @@ -1,226 +1,62 @@