feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy
- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Folder,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSharingStore, type SharedResourceKind, type SharedFolder } from "@/stores/sharing-store";
|
||||
|
||||
const ICON_CLASS = "w-4 h-4 shrink-0";
|
||||
|
||||
function KindIcon({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return <Folder className={cn(ICON_CLASS, "text-blue-600/80")} />;
|
||||
case "calendar":
|
||||
return <Calendar className={cn(ICON_CLASS, "text-emerald-600/80")} />;
|
||||
case "addressBook":
|
||||
return <BookUser className={cn(ICON_CLASS, "text-violet-600/80")} />;
|
||||
case "file":
|
||||
return <HardDrive className={cn(ICON_CLASS, "text-amber-600/80")} />;
|
||||
}
|
||||
}
|
||||
|
||||
function KindLabel({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return "Mail";
|
||||
case "calendar":
|
||||
return "Calendar";
|
||||
case "addressBook":
|
||||
return "Contacts";
|
||||
case "file":
|
||||
return "Files";
|
||||
}
|
||||
}
|
||||
|
||||
export function SharingSettings() {
|
||||
const t = useTranslations("settings");
|
||||
const tSharing = useTranslations("sharing");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const {
|
||||
sharedByMe,
|
||||
sharedWithMe,
|
||||
loading,
|
||||
fetchShares,
|
||||
revokeShare,
|
||||
changeRole,
|
||||
acceptShare,
|
||||
declineShare,
|
||||
} = useSharingStore();
|
||||
const [activeTab, setActiveTab] = useState<"byMe" | "withMe">("byMe");
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (client) fetchShares(client);
|
||||
}, [client, fetchShares]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) handleRefresh();
|
||||
}, [client, handleRefresh]);
|
||||
|
||||
const handleRevoke = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await revokeShare(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeRole = async (share: SharedFolder, role: string) => {
|
||||
if (!client) return;
|
||||
await changeRole(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
role,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAccept = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await acceptShare(client, share);
|
||||
};
|
||||
|
||||
const handleDecline = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await declineShare(client, share);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 border-b border-border mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("byMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "byMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_by_me")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("withMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "withMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_with_me")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-md hover:bg-muted text-muted-foreground disabled:opacity-50 transition-colors"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-4 h-4", loading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "byMe" && (
|
||||
<>
|
||||
{sharedByMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_by_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedByMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<Avatar
|
||||
name={share.principalName}
|
||||
email={share.principalEmail ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0 me-1"
|
||||
/>
|
||||
<span className="truncate">{share.principalName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) => handleChangeRole(share, e.target.value)}
|
||||
className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="read">
|
||||
{tSharing("preset.read")}
|
||||
</option>
|
||||
<option value="readWrite">
|
||||
{tSharing("preset.readWrite")}
|
||||
</option>
|
||||
<option value="manager">
|
||||
{tSharing("preset.manager")}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={tSharing("remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "withMe" && (
|
||||
<>
|
||||
{sharedWithMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_with_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedWithMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="truncate">
|
||||
{tSharing("shared_by")}: {share.principalName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-muted rounded px-2 py-0.5 text-muted-foreground">
|
||||
{tSharing(`preset.${share.role}`)}
|
||||
</span>
|
||||
{share.pending ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleAccept(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Check className="w-3 h-3 me-1" />
|
||||
{tSharing("accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<X className="w-3 h-3 me-1" />
|
||||
{tSharing("decline")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{tSharing("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user