(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:
Nathan Panchout
2026-01-25 20:34:40 +01:00
parent cfae08451a
commit fcfa56e4f3
5 changed files with 652 additions and 0 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};