Skip to content

Commit 8b14d88

Browse files
feat: cross-space copy, in-app zip explorer, readable session list
- Copy files/folders between your personal Drive and shared spaces, both directions, via a two-step destination picker. Reuses the normal upload endpoints so quota + membership stay enforced server-side; read-only members can pull copies into their own Drive, ZK vaults are excluded. - Browse a .zip's contents (tree, sizes, per-entry preview/download) without extracting it into the Drive — fully client-side via fflate. - Parse the raw user-agent in the active-sessions list into a readable 'Chrome 149 · Windows' label with a device icon (full UA on hover).
1 parent a2a4083 commit 8b14d88

7 files changed

Lines changed: 716 additions & 21 deletions

File tree

‎apps/web/src/app/(app)/account/page.tsx‎

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { useAuth } from '@/lib/auth';
1212
import { useT } from '@/lib/i18n';
1313
import { prompt, toast } from '@/components/ui/overlays';
1414
import { Select } from '@/components/ui/Select';
15+
import { Monitor, Smartphone } from 'lucide-react';
16+
import { parseUserAgent } from '@/lib/userAgent';
1517

1618
interface TwoFaStatus {
1719
enabled: boolean;
@@ -247,24 +249,35 @@ export default function AccountPage() {
247249
</button>
248250
</div>
249251
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
250-
{sessions.map((s) => (
251-
<div key={s.id} className="flex items-center justify-between gap-2 py-2 text-sm">
252-
<div className="min-w-0">
253-
<p className="truncate">
254-
{s.ip ?? t('account.unknownIp')}
255-
{s.current && <span className="ml-2 text-xs text-accent">{t('account.thisDevice')}</span>}
256-
</p>
257-
<p className="truncate text-xs text-neutral-400">
258-
{s.userAgent ?? t('account.unknownDevice')} · {t('account.lastSeen', { date: new Date(s.lastSeenAt).toLocaleString() })}
259-
</p>
252+
{sessions.map((s) => {
253+
const ua = parseUserAgent(s.userAgent);
254+
const DeviceIcon = ua.mobile ? Smartphone : Monitor;
255+
return (
256+
<div key={s.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
257+
<div className="flex min-w-0 items-center gap-3">
258+
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-white/[0.04] text-zinc-400">
259+
<DeviceIcon size={17} />
260+
</span>
261+
<div className="min-w-0">
262+
<p className="flex items-center gap-2 truncate font-medium">
263+
<span className="truncate" title={s.userAgent ?? undefined}>
264+
{s.userAgent ? ua.label : t('account.unknownDevice')}
265+
</span>
266+
{s.current && <span className="shrink-0 rounded bg-accent-soft px-1.5 py-0.5 text-[11px] text-violet-300">{t('account.thisDevice')}</span>}
267+
</p>
268+
<p className="truncate text-xs text-neutral-400">
269+
{s.ip ?? t('account.unknownIp')} · {t('account.lastSeen', { date: new Date(s.lastSeenAt).toLocaleString() })}
270+
</p>
271+
</div>
272+
</div>
273+
{!s.current && (
274+
<button className="btn-danger shrink-0 px-2 py-1" onClick={() => revoke(s.id)}>
275+
{t('account.revoke')}
276+
</button>
277+
)}
260278
</div>
261-
{!s.current && (
262-
<button className="btn-danger px-2 py-1" onClick={() => revoke(s.id)}>
263-
{t('account.revoke')}
264-
</button>
265-
)}
266-
</div>
267-
))}
279+
);
280+
})}
268281
</div>
269282
</section>
270283

‎apps/web/src/app/(app)/drive/page.tsx‎

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
PackageOpen,
5252
Check,
5353
X,
54+
Users,
5455
} from 'lucide-react';
5556
import { zip as fzip, unzip as funzip } from 'fflate';
5657
import type { PublicFile, PublicFolder } from '@opencoperlock/shared/client';
@@ -79,6 +80,8 @@ import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp';
7980
import { VersionHistory } from '@/components/drive/VersionHistory';
8081
import { ContextMenu } from '@/components/drive/ContextMenu';
8182
import { FolderPicker } from '@/components/drive/FolderPicker';
83+
import { ZipExplorer } from '@/components/drive/ZipExplorer';
84+
import { CrossCopyPicker, type CopyDest } from '@/components/drive/CrossCopyPicker';
8285
import { Menu, type MenuItem } from '@/components/ui/Menu';
8386

8487
interface ZkFile {
@@ -138,6 +141,8 @@ export default function EspacesPage() {
138141
const [viewing, setViewing] = useState<ViewerSource | null>(null);
139142
const [shareLink, setShareLink] = useState<string | null>(null);
140143
const [versionsFor, setVersionsFor] = useState<PublicFile | null>(null);
144+
const [zipView, setZipView] = useState<{ name: string; entries: Record<string, Uint8Array> } | null>(null);
145+
const [copyPicker, setCopyPicker] = useState<{ items: { kind: 'file' | 'folder'; id: string }[] } | null>(null);
141146
const fileInput = useRef<HTMLInputElement>(null);
142147

143148
// selection / clipboard / overlays
@@ -1294,11 +1299,83 @@ export default function EspacesPage() {
12941299
}
12951300
}
12961301

1302+
// Open the .zip in a read-only in-memory browser (no extraction into the Drive).
1303+
async function exploreZip(e: Entry) {
1304+
setBusy(t('drive.opening'));
1305+
try {
1306+
const archive = await entryBytes(e);
1307+
if (!archive) return;
1308+
const entries = await unzipAsync(archive.data);
1309+
setZipView({ name: archive.name, entries });
1310+
} catch (err) {
1311+
setError(err instanceof ApiError ? err.message : t('drive.extractFailed'));
1312+
} finally {
1313+
setBusy(null);
1314+
}
1315+
}
1316+
1317+
// ── Cross-realm copy: personal → shared space ──────────────────────────────
1318+
// Only offered for non-ZK spaces (a blind vault can't hand plaintext to another realm). Reuses the
1319+
// normal endpoints so the server keeps enforcing membership + quota on the destination.
1320+
async function copyFileToShared(spaceId: string, folderId: string | null, srcId: string, name: string) {
1321+
const res = await fetch(api.url(`/files/${srcId}/download`), { credentials: 'include' });
1322+
if (!res.ok) throw new ApiError(res.status, t('drive.copyFailed'));
1323+
const data = new Uint8Array(await res.arrayBuffer());
1324+
const form = new FormData();
1325+
form.append('file', new File([asPart(data)], name));
1326+
await api.upload(`/spaces/${spaceId}/files${folderId ? `?folderId=${encodeURIComponent(folderId)}` : ''}`, form);
1327+
}
1328+
async function copyFolderToShared(spaceId: string, destParentId: string | null, folderId: string, name: string) {
1329+
const created = await api.post<{ folder: { id: string } }>(`/spaces/${spaceId}/folders`, { name, parentId: destParentId });
1330+
const newId = created.folder.id;
1331+
const { files } = await api.get<{ files: PublicFile[] }>(`/files?folderId=${encodeURIComponent(folderId)}`);
1332+
for (const f of files) await copyFileToShared(spaceId, newId, f.id, f.name);
1333+
for (const sub of allFolders.filter((x) => x.parentId === folderId)) {
1334+
await copyFolderToShared(spaceId, newId, sub.id, sub.name);
1335+
}
1336+
}
1337+
async function performCopyToShared(dest: CopyDest) {
1338+
if (dest.realm !== 'shared' || !copyPicker) return;
1339+
const items = copyPicker.items;
1340+
setCopyPicker(null);
1341+
setBusy(t('drive.copying'));
1342+
try {
1343+
let n = 0;
1344+
for (const it of items) {
1345+
if (it.kind === 'file') {
1346+
const f = files.find((x) => x.id === it.id);
1347+
if (f) await copyFileToShared(dest.spaceId, dest.folderId, f.id, f.name);
1348+
} else {
1349+
const fld = allFolders.find((x) => x.id === it.id);
1350+
if (fld) await copyFolderToShared(dest.spaceId, dest.folderId, fld.id, fld.name);
1351+
}
1352+
n += 1;
1353+
}
1354+
toast(t('drive.copiedToShared', { n, name: dest.label }), 'success');
1355+
} catch (err) {
1356+
setError(err instanceof ApiError ? err.message : t('drive.copyFailed'));
1357+
} finally {
1358+
setBusy(null);
1359+
}
1360+
}
1361+
function copyToShared(kind: 'file' | 'folder', id: string) {
1362+
setCopyPicker({ items: [{ kind, id }] });
1363+
}
1364+
function bulkCopyToShared() {
1365+
const items: { kind: 'file' | 'folder'; id: string }[] = [];
1366+
for (const e of entriesByKeys([...selected])) {
1367+
if (e.kind === 'folder') items.push({ kind: 'folder', id: e.folder.id });
1368+
else if (e.kind === 'file') items.push({ kind: 'file', id: e.file.id });
1369+
}
1370+
if (items.length) setCopyPicker({ items });
1371+
}
1372+
12971373
// ── Menu items (shared by the "⋮" overflow menu and the right-click context menu) ──
12981374
function folderMenuItems(folder: PublicFolder): MenuItem[] {
12991375
return [
13001376
{ label: t('drive.actionRename'), icon: Pencil, onClick: () => void renameFolder(folder) },
13011377
...(!isZk ? [{ label: t('drive.actionMove'), icon: FolderInput, onClick: () => void move('folder', folder.id) }] : []),
1378+
...(!isZk ? [{ label: t('drive.actionCopyToShared'), icon: Users, onClick: () => copyToShared('folder', folder.id) }] : []),
13021379
...(!isZk ? [{ label: t('drive.actionShare'), icon: Share2, onClick: () => void share('folder', folder.id) }] : []),
13031380
{ label: t('drive.actionDelete'), icon: Trash2, danger: true, onClick: () => void deleteFolder(folder.id) },
13041381
];
@@ -1318,9 +1395,13 @@ export default function EspacesPage() {
13181395
return [
13191396
{ label: t('drive.actionRename'), icon: Pencil, onClick: () => void renameFile(file) },
13201397
{ label: t('drive.actionMove'), icon: FolderInput, onClick: () => void move('file', file.id) },
1398+
{ label: t('drive.actionCopyToShared'), icon: Users, onClick: () => copyToShared('file', file.id) },
13211399
{ label: t('drive.actionDuplicate'), icon: Files, onClick: () => void duplicateFile(file.id, file.name, file.mimeType, currentFolderId!, true).then(reloadCurrent) },
13221400
...(isZip(file.name, file.mimeType)
1323-
? [{ label: t('drive.actionExtract'), icon: PackageOpen, onClick: () => void extractZip({ key: `file:${file.id}`, kind: 'file', name: file.name, size: file.sizeBytes, date: 0, file }) }]
1401+
? [
1402+
{ label: t('drive.actionExploreZip'), icon: Archive, onClick: () => void exploreZip({ key: `file:${file.id}`, kind: 'file', name: file.name, size: file.sizeBytes, date: 0, file }) },
1403+
{ label: t('drive.actionExtract'), icon: PackageOpen, onClick: () => void extractZip({ key: `file:${file.id}`, kind: 'file', name: file.name, size: file.sizeBytes, date: 0, file }) },
1404+
]
13241405
: []),
13251406
{ label: t('drive.actionShare'), icon: Share2, onClick: () => void share('file', file.id) },
13261407
{ label: t('drive.actionVersions'), icon: History, onClick: () => setVersionsFor(file) },
@@ -1330,7 +1411,10 @@ export default function EspacesPage() {
13301411
function zkMenuItems(zk: ZkFile): MenuItem[] {
13311412
return [
13321413
...(isZip(zkName(zk))
1333-
? [{ label: t('drive.actionExtract'), icon: PackageOpen, onClick: () => void extractZip({ key: `zk:${zk.id}`, kind: 'zk', name: zkName(zk), size: zk.sizeBytes, date: 0, zk }) }]
1414+
? [
1415+
{ label: t('drive.actionExploreZip'), icon: Archive, onClick: () => void exploreZip({ key: `zk:${zk.id}`, kind: 'zk', name: zkName(zk), size: zk.sizeBytes, date: 0, zk }) },
1416+
{ label: t('drive.actionExtract'), icon: PackageOpen, onClick: () => void extractZip({ key: `zk:${zk.id}`, kind: 'zk', name: zkName(zk), size: zk.sizeBytes, date: 0, zk }) },
1417+
]
13341418
: []),
13351419
{ label: t('drive.actionDelete'), icon: Trash2, danger: true, onClick: () => void deleteFile(zk.id) },
13361420
];
@@ -1347,6 +1431,7 @@ export default function EspacesPage() {
13471431
items.push({ label: t('drive.actionCopy'), icon: Copy, onClick: () => buildClip('copy') });
13481432
items.push({ label: t('drive.actionCut'), icon: Scissors, onClick: () => buildClip('cut') });
13491433
items.push({ label: t('drive.bulkMove'), icon: FolderInput, onClick: () => void bulkMove() });
1434+
items.push({ label: t('drive.actionCopyToShared'), icon: Users, onClick: () => bulkCopyToShared() });
13501435
}
13511436
items.push({ label: t('drive.bulkDelete'), icon: Trash2, danger: true, onClick: () => void bulkDelete() });
13521437
return items;
@@ -1385,7 +1470,7 @@ export default function EspacesPage() {
13851470
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
13861471
// A ref keeps the latest handler so we register the window listener only once.
13871472
const keyHandler = (e: KeyboardEvent) => {
1388-
if (paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor || ctxMenu) return;
1473+
if (paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor || ctxMenu || zipView || copyPicker) return;
13891474
if (document.querySelector('[class*="z-[100]"]')) return; // an in-app dialog is open
13901475
const mod = e.metaKey || e.ctrlKey;
13911476
if (mod && e.key.toLowerCase() === 'k') {
@@ -1859,6 +1944,10 @@ export default function EspacesPage() {
18591944
onRestored={reloadCurrent}
18601945
/>
18611946
)}
1947+
{zipView && <ZipExplorer name={zipView.name} entries={zipView.entries} onClose={() => setZipView(null)} />}
1948+
{copyPicker && (
1949+
<CrossCopyPicker target="shared" count={copyPicker.items.length} onPick={(d) => void performCopyToShared(d)} onClose={() => setCopyPicker(null)} />
1950+
)}
18621951
{paletteOpen && <CommandPalette items={paletteItems} onSearch={searchFiles} onClose={() => setPaletteOpen(false)} />}
18631952
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
18641953
{viewing && <FileViewer source={viewing} onClose={() => setViewing(null)} />}

‎apps/web/src/app/(app)/spaces/[id]/page.tsx‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
LogOut,
2929
Eye,
3030
File as FileIcon,
31+
FolderLock,
3132
} from 'lucide-react';
3233
import type {
3334
PublicFile,
@@ -43,6 +44,7 @@ import { useT } from '@/lib/i18n';
4344
import { Select } from '@/components/ui/Select';
4445
import { FileViewer, type ViewerSource } from '@/components/FileViewer';
4546
import { ContextMenu } from '@/components/drive/ContextMenu';
47+
import { CrossCopyPicker, type CopyDest } from '@/components/drive/CrossCopyPicker';
4648
import type { MenuItem } from '@/components/ui/Menu';
4749
import { confirm, choose, prompt, toast } from '@/components/ui/overlays';
4850

@@ -63,6 +65,8 @@ export default function SpacePage() {
6365
const [showManage, setShowManage] = useState(false);
6466
const [viewing, setViewing] = useState<ViewerSource | null>(null);
6567
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
68+
const [copyItem, setCopyItem] = useState<{ kind: 'file' | 'folder'; id: string } | null>(null);
69+
const [copyBusy, setCopyBusy] = useState(false);
6670
const fileInput = useRef<HTMLInputElement>(null);
6771

6872
const canWrite = space ? space.myRole === 'OWNER' || space.myRole === 'EDITOR' : false;
@@ -214,11 +218,53 @@ export default function SpacePage() {
214218
}
215219
}
216220

221+
// ── Cross-realm copy: shared space → personal Drive ────────────────────────
222+
// Any member (even a VIEWER) can pull a copy into their own Drive — it's billed to THEIR quota via
223+
// the normal personal upload endpoint, and reads are already allowed by membership.
224+
async function copyFileToPersonal(destFolderId: string, srcId: string, name: string) {
225+
const res = await fetch(api.url(`/spaces/${spaceId}/files/${srcId}/download`), { credentials: 'include' });
226+
if (!res.ok) throw new ApiError(res.status, t('space.copyFailed'));
227+
const blob = await res.blob();
228+
const form = new FormData();
229+
form.append('file', new File([blob], name));
230+
await api.upload(`/files?folderId=${encodeURIComponent(destFolderId)}`, form);
231+
}
232+
async function copyFolderToPersonal(destParentId: string, folderId: string, name: string) {
233+
const created = await api.post<{ folder: { id: string } }>('/folders', { name, parentId: destParentId });
234+
const newId = created.folder.id;
235+
const { files: kids } = await api.get<{ files: PublicFile[] }>(`/spaces/${spaceId}/files?folderId=${encodeURIComponent(folderId)}`);
236+
for (const f of kids) await copyFileToPersonal(newId, f.id, f.name);
237+
for (const sub of folders.filter((x) => x.parentId === folderId)) {
238+
await copyFolderToPersonal(newId, sub.id, sub.name);
239+
}
240+
}
241+
async function performCopyToPersonal(dest: CopyDest) {
242+
if (dest.realm !== 'personal' || !copyItem) return;
243+
const item = copyItem;
244+
setCopyItem(null);
245+
setCopyBusy(true);
246+
try {
247+
if (item.kind === 'file') {
248+
const f = files.find((x) => x.id === item.id);
249+
if (f) await copyFileToPersonal(dest.folderId, f.id, f.name);
250+
} else {
251+
const fld = folders.find((x) => x.id === item.id);
252+
if (fld) await copyFolderToPersonal(dest.folderId, fld.id, fld.name);
253+
}
254+
toast(t('space.copiedToPersonal', { name: dest.label }), 'success');
255+
} catch (err) {
256+
toast(err instanceof ApiError ? err.message : t('space.copyFailed'), 'error');
257+
} finally {
258+
setCopyBusy(false);
259+
}
260+
}
261+
217262
// ── Right-click menus ──────────────────────────────────────────────────────
218263
function fileMenu(f: PublicFile): MenuItem[] {
219264
const items: MenuItem[] = [
220265
{ label: t('space.openFile'), icon: Eye, onClick: () => openFile(f) },
221266
{ label: t('space.download'), icon: Download, onClick: () => download(f) },
267+
{ label: t('space.copyToPersonal'), icon: FolderLock, onClick: () => setCopyItem({ kind: 'file', id: f.id }) },
222268
];
223269
if (canWrite) {
224270
items.push({ label: t('space.rename'), icon: Pencil, onClick: () => void renameFile(f) });
@@ -227,7 +273,10 @@ export default function SpacePage() {
227273
return items;
228274
}
229275
function folderMenu(f: PublicFolder): MenuItem[] {
230-
const items: MenuItem[] = [{ label: t('space.openFile'), icon: FolderIcon, onClick: () => setCwd(f.id) }];
276+
const items: MenuItem[] = [
277+
{ label: t('space.openFile'), icon: FolderIcon, onClick: () => setCwd(f.id) },
278+
{ label: t('space.copyToPersonal'), icon: FolderLock, onClick: () => setCopyItem({ kind: 'folder', id: f.id }) },
279+
];
231280
if (canWrite) {
232281
items.push({ label: t('space.rename'), icon: Pencil, onClick: () => void renameFolder(f) });
233282
items.push({ label: t('space.delete'), icon: Trash2, danger: true, onClick: () => void deleteFolder(f) });
@@ -513,6 +562,16 @@ export default function SpacePage() {
513562

514563
{viewing && <FileViewer source={viewing} onClose={() => setViewing(null)} />}
515564
{ctxMenu && <ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />}
565+
{copyItem && (
566+
<CrossCopyPicker target="personal" count={1} onPick={(d) => void performCopyToPersonal(d)} onClose={() => setCopyItem(null)} />
567+
)}
568+
{copyBusy && (
569+
<div className="fixed inset-0 z-[150] grid place-items-center bg-black/50 backdrop-blur-sm">
570+
<div className="rounded-xl border border-white/10 bg-[#15151d] px-5 py-4 text-sm text-zinc-200 shadow-2xl">
571+
{t('space.copying')}
572+
</div>
573+
</div>
574+
)}
516575
</div>
517576
);
518577
}

0 commit comments

Comments
 (0)