✨(front) add CalendarList subcomponents
Add modular subcomponents for CalendarList including item menu, list item, create/edit modal, delete confirmation and subscription URL display modals. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* CalendarItemMenu component.
|
||||
* Context menu for calendar item actions (edit, delete).
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CalendarItemMenuProps } from "./types";
|
||||
|
||||
export const CalendarItemMenu = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSubscription,
|
||||
onClose,
|
||||
}: CalendarItemMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubscription = () => {
|
||||
if (onSubscription) {
|
||||
onSubscription();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="calendar-list__menu">
|
||||
<button className="calendar-list__menu-item" onClick={handleEdit}>
|
||||
<span className="material-icons">edit</span>
|
||||
{t('calendar.list.edit')}
|
||||
</button>
|
||||
{onSubscription && (
|
||||
<button className="calendar-list__menu-item" onClick={handleSubscription}>
|
||||
<span className="material-icons">link</span>
|
||||
{t('calendar.list.subscription')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="calendar-list__menu-item calendar-list__menu-item--danger"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<span className="material-icons">delete</span>
|
||||
{t('calendar.list.delete')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* CalendarListItem components.
|
||||
* Display individual calendar items in the list.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import { CalendarItemMenu } from "./CalendarItemMenu";
|
||||
import type { CalendarListItemProps, SharedCalendarListItemProps } from "./types";
|
||||
|
||||
/**
|
||||
* CalendarListItem - Displays a user-owned calendar.
|
||||
*/
|
||||
export const CalendarListItem = ({
|
||||
calendar,
|
||||
isVisible,
|
||||
isMenuOpen,
|
||||
onToggleVisibility,
|
||||
onMenuToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSubscription,
|
||||
onCloseMenu,
|
||||
}: CalendarListItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="calendar-list__item">
|
||||
<div className="calendar-list__item-checkbox">
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => onToggleVisibility(calendar.url)}
|
||||
label=""
|
||||
aria-label={`${t('calendar.list.showCalendar')} ${calendar.displayName || ''}`}
|
||||
/>
|
||||
<span
|
||||
className="calendar-list__color"
|
||||
style={{ backgroundColor: calendar.color }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="calendar-list__name"
|
||||
title={calendar.displayName || undefined}
|
||||
>
|
||||
{calendar.displayName || 'Sans nom'}
|
||||
</span>
|
||||
<div className="calendar-list__item-actions">
|
||||
<button
|
||||
className="calendar-list__options-btn"
|
||||
onClick={(e) => onMenuToggle(calendar.url, e)}
|
||||
aria-label="Options"
|
||||
>
|
||||
<span className="material-icons">more_horiz</span>
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<CalendarItemMenu
|
||||
onEdit={() => onEdit(calendar)}
|
||||
onDelete={() => onDelete(calendar)}
|
||||
onSubscription={onSubscription ? () => onSubscription(calendar) : undefined}
|
||||
onClose={onCloseMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SharedCalendarListItem - Displays a shared calendar.
|
||||
*/
|
||||
export const SharedCalendarListItem = ({
|
||||
calendar,
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
}: SharedCalendarListItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="calendar-list__item">
|
||||
<div className="calendar-list__item-checkbox">
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => onToggleVisibility(String(calendar.id))}
|
||||
label=""
|
||||
aria-label={`${t('calendar.list.showCalendar')} ${calendar.name}`}
|
||||
/>
|
||||
<span
|
||||
className="calendar-list__color"
|
||||
style={{ backgroundColor: calendar.color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="calendar-list__name" title={calendar.name}>
|
||||
{calendar.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* CalendarModal component.
|
||||
* Handles creation and editing of calendars, including sharing.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalSize,
|
||||
TextArea,
|
||||
} from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import { DEFAULT_COLORS } from "./constants";
|
||||
import type { CalendarModalProps } from "./types";
|
||||
|
||||
export const CalendarModal = ({
|
||||
isOpen,
|
||||
mode,
|
||||
calendar,
|
||||
onClose,
|
||||
onSave,
|
||||
onShare,
|
||||
}: CalendarModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(DEFAULT_COLORS[0]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Share state
|
||||
const [shareEmail, setShareEmail] = useState("");
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [shareSuccess, setShareSuccess] = useState<string | null>(null);
|
||||
const [shareError, setShareError] = useState<string | null>(null);
|
||||
|
||||
// Reset form when modal opens or calendar changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === "edit" && calendar) {
|
||||
setName(calendar.displayName || "");
|
||||
setColor(calendar.color || DEFAULT_COLORS[0]);
|
||||
setDescription(calendar.description || "");
|
||||
} else {
|
||||
setName("");
|
||||
setColor(DEFAULT_COLORS[0]);
|
||||
setDescription("");
|
||||
}
|
||||
setError(null);
|
||||
setShareEmail("");
|
||||
setShareSuccess(null);
|
||||
setShareError(null);
|
||||
}
|
||||
}, [isOpen, mode, calendar]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setError(t('calendar.createCalendar.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(name.trim(), color, description.trim() || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('api.error.unexpected'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!shareEmail.trim() || !onShare) return;
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(shareEmail.trim())) {
|
||||
setShareError(t('calendar.shareCalendar.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSharing(true);
|
||||
setShareError(null);
|
||||
setShareSuccess(null);
|
||||
|
||||
try {
|
||||
const result = await onShare(shareEmail.trim());
|
||||
if (result.success) {
|
||||
setShareSuccess(
|
||||
t('calendar.shareCalendar.success', { email: shareEmail.trim() })
|
||||
);
|
||||
setShareEmail("");
|
||||
} else {
|
||||
setShareError(result.error || t('calendar.shareCalendar.error'));
|
||||
}
|
||||
} catch (err) {
|
||||
setShareError(
|
||||
err instanceof Error ? err.message : t('calendar.shareCalendar.error')
|
||||
);
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setColor(DEFAULT_COLORS[0]);
|
||||
setDescription("");
|
||||
setError(null);
|
||||
setShareEmail("");
|
||||
setShareSuccess(null);
|
||||
setShareError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const title =
|
||||
mode === "create"
|
||||
? t('calendar.createCalendar.title')
|
||||
: t('calendar.editCalendar.title');
|
||||
|
||||
const saveLabel =
|
||||
mode === "create"
|
||||
? t('calendar.createCalendar.create')
|
||||
: t('calendar.editCalendar.save');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={title}
|
||||
rightActions={
|
||||
<>
|
||||
<Button color="neutral" onClick={handleClose} disabled={isLoading}>
|
||||
{t('calendar.event.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="brand"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? "..." : saveLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="calendar-modal__content">
|
||||
{error && <div className="calendar-modal__error">{error}</div>}
|
||||
|
||||
<Input
|
||||
label={t('calendar.createCalendar.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div className="calendar-modal__field">
|
||||
<label className="calendar-modal__label">
|
||||
{t('calendar.createCalendar.color')}
|
||||
</label>
|
||||
<div className="calendar-modal__colors">
|
||||
{DEFAULT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`calendar-modal__color-btn ${
|
||||
color === c ? 'calendar-modal__color-btn--selected' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label={t('calendar.createCalendar.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* Share section - only visible in edit mode */}
|
||||
{mode === "edit" && onShare && (
|
||||
<div className="calendar-modal__share-section">
|
||||
<div className="calendar-modal__share-divider" />
|
||||
<label className="calendar-modal__label">
|
||||
<span className="material-icons">person_add</span>
|
||||
{t('calendar.shareCalendar.title')}
|
||||
</label>
|
||||
|
||||
{shareSuccess && (
|
||||
<div className="calendar-modal__success">{shareSuccess}</div>
|
||||
)}
|
||||
{shareError && (
|
||||
<div className="calendar-modal__error">{shareError}</div>
|
||||
)}
|
||||
|
||||
<div className="calendar-modal__share-input-row">
|
||||
<Input
|
||||
label=""
|
||||
value={shareEmail}
|
||||
onChange={(e) => setShareEmail(e.target.value)}
|
||||
placeholder={t('calendar.shareCalendar.emailPlaceholder')}
|
||||
fullWidth
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleShare();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="brand"
|
||||
onClick={handleShare}
|
||||
disabled={isSharing || !shareEmail.trim()}
|
||||
>
|
||||
{isSharing ? "..." : t('calendar.shareCalendar.share')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="calendar-modal__share-hint">
|
||||
{t('calendar.shareCalendar.hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* DeleteConfirmModal component.
|
||||
* Confirms calendar deletion.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import type { DeleteConfirmModalProps } from "./types";
|
||||
|
||||
export const DeleteConfirmModal = ({
|
||||
isOpen,
|
||||
calendarName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: DeleteConfirmModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onCancel}
|
||||
size={ModalSize.SMALL}
|
||||
title={t('calendar.deleteCalendar.title')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button color="neutral" onClick={onCancel} disabled={isLoading}>
|
||||
{t('calendar.event.cancel')}
|
||||
</Button>
|
||||
<Button color="error" onClick={onConfirm} disabled={isLoading}>
|
||||
{isLoading ? "..." : t('calendar.deleteCalendar.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t('calendar.deleteCalendar.message', { name: calendarName })}</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* SubscriptionUrlModal component.
|
||||
* Displays the subscription URL for iCal export with copy and regenerate options.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import {
|
||||
useCreateSubscriptionToken,
|
||||
useDeleteSubscriptionToken,
|
||||
useSubscriptionToken,
|
||||
} from "../../hooks/useCalendars";
|
||||
|
||||
interface SubscriptionUrlModalProps {
|
||||
isOpen: boolean;
|
||||
caldavPath: string;
|
||||
calendarName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SubscriptionUrlModal = ({
|
||||
isOpen,
|
||||
caldavPath,
|
||||
calendarName,
|
||||
onClose,
|
||||
}: SubscriptionUrlModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||||
const [hasTriedCreate, setHasTriedCreate] = useState(false);
|
||||
|
||||
const { token, tokenError, isLoading } = useSubscriptionToken(caldavPath);
|
||||
const createToken = useCreateSubscriptionToken();
|
||||
const deleteToken = useDeleteSubscriptionToken();
|
||||
|
||||
// Use token from query or from mutation result (whichever is available)
|
||||
const displayToken = token || createToken.data;
|
||||
// Show error from token fetch or from creation failure
|
||||
const hasRealError = tokenError || (createToken.error && hasTriedCreate);
|
||||
const isRegenerating = deleteToken.isPending || createToken.isPending;
|
||||
const showLoading = isLoading || createToken.isPending;
|
||||
|
||||
// Get appropriate error message based on error type
|
||||
const getErrorMessage = (): string => {
|
||||
if (tokenError) {
|
||||
switch (tokenError.type) {
|
||||
case "permission_denied":
|
||||
return t("calendar.subscription.errorPermission");
|
||||
case "network_error":
|
||||
return t("calendar.subscription.errorNetwork");
|
||||
case "server_error":
|
||||
return t("calendar.subscription.errorServer");
|
||||
default:
|
||||
return t("calendar.subscription.error");
|
||||
}
|
||||
}
|
||||
return t("calendar.subscription.error");
|
||||
};
|
||||
|
||||
// Reset hasTriedCreate when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHasTriedCreate(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Create token on first open if none exists (only try once)
|
||||
// We also try to create if there was an error (404 means no token exists)
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
!token &&
|
||||
!isLoading &&
|
||||
!createToken.isPending &&
|
||||
!hasTriedCreate
|
||||
) {
|
||||
setHasTriedCreate(true);
|
||||
createToken.mutate({ caldavPath, calendarName });
|
||||
}
|
||||
}, [isOpen, token, isLoading, createToken, caldavPath, calendarName, hasTriedCreate]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const url = displayToken?.url;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
setShowRegenerateConfirm(false);
|
||||
await deleteToken.mutateAsync(caldavPath);
|
||||
await createToken.mutateAsync({ caldavPath, calendarName });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen && !showRegenerateConfirm}
|
||||
onClose={onClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={t("calendar.subscription.title")}
|
||||
rightActions={
|
||||
<Button color="brand" onClick={onClose}>
|
||||
{t("calendar.subscription.close")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="subscription-modal">
|
||||
<p className="subscription-modal__description">
|
||||
{t("calendar.subscription.description", { name: calendarName })}
|
||||
</p>
|
||||
|
||||
{showLoading ? (
|
||||
<div className="subscription-modal__loading">
|
||||
{t("calendar.subscription.loading")}
|
||||
</div>
|
||||
) : hasRealError && !displayToken ? (
|
||||
<div className="subscription-modal__error">
|
||||
{getErrorMessage()}
|
||||
</div>
|
||||
) : displayToken?.url ? (
|
||||
<>
|
||||
<div className="subscription-modal__url-container">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={displayToken.url}
|
||||
className="subscription-modal__url-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
color="brand"
|
||||
onClick={handleCopy}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{copied
|
||||
? t("calendar.subscription.copied")
|
||||
: t("calendar.subscription.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="subscription-modal__warning">
|
||||
<span className="material-icons subscription-modal__warning-icon">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("calendar.subscription.warning")}</p>
|
||||
</div>
|
||||
|
||||
<div className="subscription-modal__actions">
|
||||
<Button
|
||||
color="neutral"
|
||||
onClick={() => setShowRegenerateConfirm(true)}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{t("calendar.subscription.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Regenerate confirmation modal */}
|
||||
<Modal
|
||||
isOpen={showRegenerateConfirm}
|
||||
onClose={() => setShowRegenerateConfirm(false)}
|
||||
size={ModalSize.SMALL}
|
||||
title={t("calendar.subscription.regenerateConfirm.title")}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
color="neutral"
|
||||
onClick={() => setShowRegenerateConfirm(false)}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{t("calendar.event.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{isRegenerating
|
||||
? "..."
|
||||
: t("calendar.subscription.regenerateConfirm.confirm")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t("calendar.subscription.regenerateConfirm.message")}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user