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,383 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Users,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal } from "@/lib/jmap/types";
|
||||
import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store";
|
||||
|
||||
export interface ShareFolderDialogProps {
|
||||
client: IJMAPClient;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRESET_OPTIONS: Record<SharedResourceKind, readonly string[]> = {
|
||||
mailbox: ["read", "readWrite", "manager"],
|
||||
calendar: ["read", "readWrite", "manager"],
|
||||
addressBook: ["read", "readWrite", "manager"],
|
||||
file: ["read", "readWrite", "manager"],
|
||||
};
|
||||
|
||||
export function ShareFolderDialog({
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
onClose,
|
||||
}: ShareFolderDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sharedByMe = useSharingStore((s) => s.sharedByMe);
|
||||
const loadPrincipals = useSharingStore((s) => s.loadPrincipals);
|
||||
const shareFolder = useSharingStore((s) => s.shareFolder);
|
||||
const revokeShare = useSharingStore((s) => s.revokeShare);
|
||||
const changeRole = useSharingStore((s) => s.changeRole);
|
||||
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
loadPrincipals(client)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setAllPrincipals(list);
|
||||
setLoadingPrincipals(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, loadPrincipals]);
|
||||
|
||||
const ownAccountId = client.getAccountId();
|
||||
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of allPrincipals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [allPrincipals]);
|
||||
|
||||
const currentShares = sharedByMe.filter(
|
||||
(f) => f.resourceId === resourceId && f.resourceKind === resourceKind,
|
||||
);
|
||||
|
||||
const principals = useMemo(() => {
|
||||
const existing = new Set(currentShares.map((s) => s.principalId));
|
||||
return allPrincipals.filter(
|
||||
(p) => p.id !== ownAccountId && !existing.has(p.id),
|
||||
);
|
||||
}, [allPrincipals, ownAccountId, currentShares]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await revokeShare(client, resourceId, resourceKind, principalId);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (principalId: string, role: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await changeRole(client, resourceId, resourceKind, principalId, role);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principal.id,
|
||||
"read",
|
||||
message || undefined,
|
||||
);
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrincipals = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return principals;
|
||||
return principals.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [principals, search]);
|
||||
|
||||
const presetOptions = PRESET_OPTIONS[resourceKind];
|
||||
|
||||
const kindLabels: Record<SharedResourceKind, string> = {
|
||||
mailbox: "Mail folder",
|
||||
calendar: "Calendar",
|
||||
addressBook: "Address book",
|
||||
file: "File folder",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title", { name: resourceName })}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("title", { name: resourceName })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kindLabels[resourceKind]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{currentShares.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentShares.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{currentShares.map((share) => {
|
||||
const principal = allPrincipalsById.get(share.principalId);
|
||||
return (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
name={principal?.name}
|
||||
email={principal?.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name ||
|
||||
principal?.email ||
|
||||
share.principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) =>
|
||||
handleChangeRole(share.principalId, e.target.value)
|
||||
}
|
||||
disabled={savingId === share.principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
{presetOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`preset.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
{share.role === "custom" && (
|
||||
<option value="custom">
|
||||
{t("preset.custom")}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(share.principalId)}
|
||||
disabled={savingId === share.principalId}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={t("remove")}
|
||||
title={t("remove")}
|
||||
>
|
||||
{savingId === share.principalId ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 me-2" />
|
||||
{t("add_person")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="space-y-2 border border-border rounded-md p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||
{loadingPrincipals && (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{t("loading_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
{search.trim()
|
||||
? t("no_match")
|
||||
: t("no_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAdd(p)}
|
||||
disabled={savingId === p.id}
|
||||
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||
{p.name}
|
||||
{p.type === "group" && (
|
||||
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||
{t("group")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.email && p.email !== p.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savingId === p.id && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optional message…"
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
/>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user