feat: P2.2 Create Appointment from Email + P2.4 Calendar Dashlet + P2.12 Action Wheel + P2.14 Share Files
- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal with subject, body, participants, date. calendar-store newEventPrefill state. - P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots, day click navigates to calendar. Collapsible, respect firstDayOfWeek. - P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG menu with keyboard nav, animations. Wired into email-list, contact-list, file-browser, calendar-month-view right-click handlers. - P2.14: 'Send as Attachment' button in file browser — opens compose tab with selected files pre-attached via Pro tab store.
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2,
|
||||
Menu, Users, Share2, MailPlus, Paperclip,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
@@ -106,6 +107,8 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** Send selected files as email attachments - opens the composer with files pre-attached. */
|
||||
onSendAsAttachment?: (names: string[]) => void;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -384,6 +387,7 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onSendAsAttachment,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -401,6 +405,60 @@ export function FileBrowser({
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuResourceName) return [];
|
||||
const name = radialMenuResourceName;
|
||||
const resource = resources.find((r) => r.name === name);
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "rename",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("rename"),
|
||||
onClick: () => { setRenameTarget(name); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDelete(name); },
|
||||
destructive: true,
|
||||
});
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "download",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("download"),
|
||||
onClick: () => { onDownload(name); },
|
||||
});
|
||||
}
|
||||
if (canShare(resource)) {
|
||||
items.push({
|
||||
id: "share",
|
||||
icon: <Share2 className="w-5 h-5" />,
|
||||
label: t("share"),
|
||||
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
|
||||
});
|
||||
}
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "send-as-attachment",
|
||||
icon: <Paperclip className="w-5 h-5" />,
|
||||
label: t("send_as_attachment"),
|
||||
onClick: () => {},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -765,6 +823,9 @@ export function FileBrowser({
|
||||
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuResourceName(name);
|
||||
setRadialMenuOpen(true);
|
||||
};
|
||||
|
||||
// Adjust context menu position to stay within viewport
|
||||
@@ -974,28 +1035,49 @@ export function FileBrowser({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selectedResources.size > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedResources.size > 0 && (() => {
|
||||
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
||||
const hasFiles = fileNames.length > 0;
|
||||
const showBatch = selectedResources.size > 1;
|
||||
if (!showBatch && !hasFiles) return null;
|
||||
return (
|
||||
<>
|
||||
{showBatch && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload(fileNames)}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({fileNames.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasFiles && onSendAsAttachment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onSendAsAttachment(fileNames)}
|
||||
>
|
||||
<MailPlus className="w-4 h-4 me-1" />
|
||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{clipboard && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1669,6 +1751,14 @@ export function FileBrowser({
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user