✨(sharing) use UI Kit native sharing modal
This commit is contained in:
@@ -15,6 +15,7 @@ export const CalendarItemMenu = ({
|
||||
onOpenChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onShare,
|
||||
onImport,
|
||||
onSubscription,
|
||||
}: CalendarItemMenuProps) => {
|
||||
@@ -29,6 +30,14 @@ export const CalendarItemMenu = ({
|
||||
},
|
||||
];
|
||||
|
||||
if (onShare) {
|
||||
items.push({
|
||||
label: t("calendar.list.share"),
|
||||
icon: <span className="material-icons">person_add</span>,
|
||||
callback: onShare,
|
||||
});
|
||||
}
|
||||
|
||||
if (onImport) {
|
||||
items.push({
|
||||
label: t("calendar.list.import"),
|
||||
@@ -52,7 +61,7 @@ export const CalendarItemMenu = ({
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [t, onEdit, onDelete, onImport, onSubscription]);
|
||||
}, [t, onEdit, onDelete, onShare, onImport, onSubscription]);
|
||||
|
||||
return (
|
||||
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -279,36 +279,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__share-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
&__share-divider {
|
||||
height: 1px;
|
||||
background-color: var(--c--theme--colors--greyscale-200);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
// ============================================================================
|
||||
// Share Modal Styles (close button position fix)
|
||||
// ============================================================================
|
||||
|
||||
&__share-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
|
||||
> div:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__share-hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
.c__modal__scroller:has(.c__share-modal) .c__modal__close .c__button {
|
||||
top: 0.75rem !important;
|
||||
right: 0.75rem !important;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useCalendarContext } from "../../contexts";
|
||||
|
||||
import { CalendarModal } from "./CalendarModal";
|
||||
import { CalendarShareModal } from "./CalendarShareModal";
|
||||
import { DeleteConfirmModal } from "./DeleteConfirmModal";
|
||||
import { ImportEventsModal } from "./ImportEventsModal";
|
||||
import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
|
||||
@@ -25,20 +26,21 @@ export const CalendarList = () => {
|
||||
createCalendar,
|
||||
updateCalendar,
|
||||
deleteCalendar,
|
||||
shareCalendar,
|
||||
calendarRef,
|
||||
} = useCalendarContext();
|
||||
|
||||
const {
|
||||
modalState,
|
||||
deleteState,
|
||||
shareModalState,
|
||||
isMyCalendarsExpanded,
|
||||
openMenuUrl,
|
||||
handleOpenCreateModal,
|
||||
handleOpenEditModal,
|
||||
handleCloseModal,
|
||||
handleSaveCalendar,
|
||||
handleShareCalendar,
|
||||
handleOpenShareModal,
|
||||
handleCloseShareModal,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
@@ -49,7 +51,6 @@ export const CalendarList = () => {
|
||||
createCalendar,
|
||||
updateCalendar,
|
||||
deleteCalendar,
|
||||
shareCalendar,
|
||||
});
|
||||
|
||||
// Subscription modal state
|
||||
@@ -147,6 +148,7 @@ export const CalendarList = () => {
|
||||
onMenuToggle={handleMenuToggle}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onShare={handleOpenShareModal}
|
||||
onImport={handleOpenImportModal}
|
||||
onSubscription={handleOpenSubscriptionModal}
|
||||
onCloseMenu={handleCloseMenu}
|
||||
@@ -163,7 +165,12 @@ export const CalendarList = () => {
|
||||
calendar={modalState.calendar}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveCalendar}
|
||||
onShare={handleShareCalendar}
|
||||
/>
|
||||
|
||||
<CalendarShareModal
|
||||
isOpen={shareModalState.isOpen}
|
||||
calendar={shareModalState.calendar}
|
||||
onClose={handleCloseShareModal}
|
||||
/>
|
||||
|
||||
<DeleteConfirmModal
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CalendarListItem = ({
|
||||
onMenuToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onShare,
|
||||
onImport,
|
||||
onSubscription,
|
||||
onCloseMenu,
|
||||
@@ -53,6 +54,9 @@ export const CalendarListItem = ({
|
||||
}
|
||||
onEdit={() => onEdit(calendar)}
|
||||
onDelete={() => onDelete(calendar)}
|
||||
onShare={
|
||||
onShare ? () => onShare(calendar) : undefined
|
||||
}
|
||||
onImport={
|
||||
onImport ? () => onImport(calendar) : undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* CalendarModal component.
|
||||
* Handles creation and editing of calendars, including sharing.
|
||||
* Handles creation and editing of calendars.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -22,7 +22,6 @@ export const CalendarModal = ({
|
||||
calendar,
|
||||
onClose,
|
||||
onSave,
|
||||
onShare,
|
||||
}: CalendarModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
@@ -31,12 +30,6 @@ export const CalendarModal = ({
|
||||
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) {
|
||||
@@ -50,9 +43,6 @@ export const CalendarModal = ({
|
||||
setDescription("");
|
||||
}
|
||||
setError(null);
|
||||
setShareEmail("");
|
||||
setShareSuccess(null);
|
||||
setShareError(null);
|
||||
}
|
||||
}, [isOpen, mode, calendar]);
|
||||
|
||||
@@ -74,47 +64,11 @@ export const CalendarModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -186,50 +140,6 @@ export const CalendarModal = ({
|
||||
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,218 @@
|
||||
/**
|
||||
* CalendarShareModal component.
|
||||
* Wraps the UI Kit ShareModal for managing calendar sharing via CalDAV.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareModal } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
import { useCalendarContext } from "../../contexts";
|
||||
import { useAuth } from "../../../auth/Auth";
|
||||
import {
|
||||
addToast,
|
||||
ToasterItem,
|
||||
} from "../../../ui/components/toaster/Toaster";
|
||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||
|
||||
interface CalendarShareModalProps {
|
||||
isOpen: boolean;
|
||||
calendar: CalDavCalendar | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ShareUser = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type ShareAccess = {
|
||||
id: string;
|
||||
role: string;
|
||||
user: ShareUser;
|
||||
can_delete?: boolean;
|
||||
};
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const CalendarShareModal = ({
|
||||
isOpen,
|
||||
calendar,
|
||||
onClose,
|
||||
}: CalendarShareModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { caldavService, shareCalendar } = useCalendarContext();
|
||||
const { user } = useAuth();
|
||||
const [accesses, setAccesses] = useState<ShareAccess[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<ShareUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const buildAccesses = useCallback(
|
||||
(sharees: ShareAccess[]) => {
|
||||
const ownerAccess: ShareAccess | null = user
|
||||
? {
|
||||
id: "owner",
|
||||
role: "owner",
|
||||
can_delete: false,
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.email,
|
||||
email: user.email,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
return ownerAccess ? [ownerAccess, ...sharees] : sharees;
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const fetchSharees = useCallback(async () => {
|
||||
if (!calendar) return;
|
||||
|
||||
const result = await caldavService.getCalendarSharees(calendar.url);
|
||||
if (result.success && result.data) {
|
||||
const shareeAccesses = result.data.map((sharee) => {
|
||||
const email = sharee.href.replace(/^mailto:/, "");
|
||||
return {
|
||||
id: sharee.href,
|
||||
role: "read-write",
|
||||
user: {
|
||||
id: sharee.href,
|
||||
full_name: sharee.displayName || email,
|
||||
email,
|
||||
},
|
||||
};
|
||||
});
|
||||
setAccesses(buildAccesses(shareeAccesses));
|
||||
} else {
|
||||
setAccesses(buildAccesses([]));
|
||||
}
|
||||
}, [calendar, caldavService, buildAccesses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && calendar) {
|
||||
fetchSharees();
|
||||
}
|
||||
if (!isOpen) {
|
||||
setAccesses([]);
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [isOpen, calendar, fetchSharees]);
|
||||
|
||||
const handleSearchUsers = useCallback((query: string) => {
|
||||
if (EMAIL_REGEX.test(query.trim())) {
|
||||
const email = query.trim();
|
||||
setSearchResults([
|
||||
{ id: email, email, full_name: email },
|
||||
]);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInviteUser = useCallback(
|
||||
async (users: ShareUser[]) => {
|
||||
if (!calendar || users.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = users[0];
|
||||
const result = await shareCalendar(calendar.url, user.email);
|
||||
if (result.success) {
|
||||
addToast(
|
||||
<ToasterItem>
|
||||
{t("calendar.shareCalendar.success", {
|
||||
email: user.email,
|
||||
})}
|
||||
</ToasterItem>,
|
||||
);
|
||||
await fetchSharees();
|
||||
} else {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
{result.error || t("calendar.shareCalendar.error")}
|
||||
</ToasterItem>,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
{t("calendar.shareCalendar.error")}
|
||||
</ToasterItem>,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSearchResults([]);
|
||||
}
|
||||
},
|
||||
[calendar, shareCalendar, fetchSharees, t],
|
||||
);
|
||||
|
||||
const handleDeleteAccess = useCallback(
|
||||
async (access: ShareAccess) => {
|
||||
if (!calendar) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const shareeHref = access.id.startsWith("mailto:")
|
||||
? access.id
|
||||
: `mailto:${access.user.email}`;
|
||||
const result = await caldavService.unshareCalendar(
|
||||
calendar.url,
|
||||
shareeHref,
|
||||
);
|
||||
if (result.success) {
|
||||
await fetchSharees();
|
||||
} else {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
{result.error || t("calendar.shareCalendar.error")}
|
||||
</ToasterItem>,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
{t("calendar.shareCalendar.error")}
|
||||
</ToasterItem>,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[calendar, caldavService, fetchSharees, t],
|
||||
);
|
||||
|
||||
const invitationRoles = [
|
||||
{ label: t("roles.editor"), value: "read-write" },
|
||||
];
|
||||
|
||||
const getAccessRoles = useCallback(
|
||||
(access: ShareAccess) => {
|
||||
if (access.role === "owner") {
|
||||
return [{ label: t("roles.owner"), value: "owner" }];
|
||||
}
|
||||
return [{ label: t("roles.editor"), value: "read-write" }];
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<ShareModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
modalTitle={t("calendar.shareCalendar.title")}
|
||||
accesses={accesses}
|
||||
getAccessRoles={getAccessRoles}
|
||||
onDeleteAccess={handleDeleteAccess}
|
||||
searchUsersResult={searchResults}
|
||||
onSearchUsers={handleSearchUsers}
|
||||
onInviteUser={handleInviteUser}
|
||||
searchPlaceholder={t("calendar.shareCalendar.emailPlaceholder")}
|
||||
invitationRoles={invitationRoles}
|
||||
hideInvitations
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
CalDavCalendarCreate,
|
||||
CalDavCalendarUpdate,
|
||||
} from "../../../services/dav/types/caldav-service";
|
||||
import type { CalendarModalState, DeleteState } from "../types";
|
||||
import type { CalendarModalState, DeleteState, ShareModalState } from "../types";
|
||||
|
||||
interface UseCalendarListStateProps {
|
||||
createCalendar: (
|
||||
@@ -21,17 +21,12 @@ interface UseCalendarListStateProps {
|
||||
options: CalDavCalendarUpdate
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
deleteCalendar: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
shareCalendar: (
|
||||
url: string,
|
||||
email: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export const useCalendarListState = ({
|
||||
createCalendar,
|
||||
updateCalendar,
|
||||
deleteCalendar,
|
||||
shareCalendar,
|
||||
}: UseCalendarListStateProps) => {
|
||||
// Modal states
|
||||
const [modalState, setModalState] = useState<CalendarModalState>({
|
||||
@@ -46,6 +41,11 @@ export const useCalendarListState = ({
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const [shareModalState, setShareModalState] = useState<ShareModalState>({
|
||||
isOpen: false,
|
||||
calendar: null,
|
||||
});
|
||||
|
||||
const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true);
|
||||
const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(null);
|
||||
|
||||
@@ -100,15 +100,14 @@ export const useCalendarListState = ({
|
||||
[modalState, createCalendar, updateCalendar]
|
||||
);
|
||||
|
||||
const handleShareCalendar = useCallback(
|
||||
async (email: string): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!modalState.calendar) {
|
||||
return { success: false, error: 'No calendar selected' };
|
||||
}
|
||||
return shareCalendar(modalState.calendar.url, email);
|
||||
},
|
||||
[modalState.calendar, shareCalendar]
|
||||
);
|
||||
// Share modal handlers
|
||||
const handleOpenShareModal = useCallback((calendar: CalDavCalendar) => {
|
||||
setShareModalState({ isOpen: true, calendar });
|
||||
}, []);
|
||||
|
||||
const handleCloseShareModal = useCallback(() => {
|
||||
setShareModalState({ isOpen: false, calendar: null });
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
const handleOpenDeleteModal = useCallback((calendar: CalDavCalendar) => {
|
||||
@@ -164,6 +163,7 @@ export const useCalendarListState = ({
|
||||
// Modal state
|
||||
modalState,
|
||||
deleteState,
|
||||
shareModalState,
|
||||
|
||||
// Expansion state
|
||||
isMyCalendarsExpanded,
|
||||
@@ -174,7 +174,10 @@ export const useCalendarListState = ({
|
||||
handleOpenEditModal,
|
||||
handleCloseModal,
|
||||
handleSaveCalendar,
|
||||
handleShareCalendar,
|
||||
|
||||
// Share modal handlers
|
||||
handleOpenShareModal,
|
||||
handleCloseShareModal,
|
||||
|
||||
// Delete handlers
|
||||
handleOpenDeleteModal,
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface CalendarModalProps {
|
||||
calendar?: CalDavCalendar | null;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, color: string, description?: string) => Promise<void>;
|
||||
onShare?: (email: string) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +23,7 @@ export interface CalendarItemMenuProps {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onShare?: () => void;
|
||||
onImport?: () => void;
|
||||
onSubscription?: () => void;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export interface CalendarListItemProps {
|
||||
onMenuToggle: (url: string) => void;
|
||||
onEdit: (calendar: CalDavCalendar) => void;
|
||||
onDelete: (calendar: CalDavCalendar) => void;
|
||||
onShare?: (calendar: CalDavCalendar) => void;
|
||||
onImport?: (calendar: CalDavCalendar) => void;
|
||||
onSubscription?: (calendar: CalDavCalendar) => void;
|
||||
onCloseMenu: () => void;
|
||||
@@ -64,6 +65,14 @@ export interface CalendarModalState {
|
||||
calendar: CalDavCalendar | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the share modal.
|
||||
*/
|
||||
export interface ShareModalState {
|
||||
isOpen: boolean;
|
||||
calendar: CalDavCalendar | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the delete confirmation.
|
||||
*/
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"showCalendar": "Show calendar",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"share": "Share",
|
||||
"import": "Import events",
|
||||
"subscription": "Subscription URL",
|
||||
"options": "Options"
|
||||
@@ -809,6 +810,7 @@
|
||||
"showCalendar": "Afficher le calendrier",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"share": "Partager",
|
||||
"import": "Importer des événements",
|
||||
"subscription": "URL d'abonnement",
|
||||
"options": "Options"
|
||||
@@ -1182,6 +1184,7 @@
|
||||
"showCalendar": "Agenda tonen",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"share": "Delen",
|
||||
"import": "Evenementen importeren",
|
||||
"subscription": "Abonnements-URL",
|
||||
"options": "Opties"
|
||||
|
||||
Reference in New Issue
Block a user