- 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
280 lines
9.3 KiB
TypeScript
280 lines
9.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|