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:
Bernd Rodler
2026-08-07 13:15:04 +02:00
parent 67f61f18d0
commit 83e29b3ef1
14 changed files with 933 additions and 42 deletions
+113 -23
View File
@@ -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