mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-22 13:19:05 +00:00
feat: improve ICS calendar sync UX and fix room URL matching
- Replace "Test Connection" button with "Force Sync" button (Edit Room only) - Show detailed sync results: total events downloaded vs room matches - Remove emoticons and auto-hide timeout for cleaner UX - Fix room URL matching to use UI_BASE_URL instead of BASE_URL - Replace FaSync icon with LuRefreshCw for consistency - Clear sync results when dialog closes or Force Sync pressed - Update tests to reflect UI_BASE_URL change and exact URL matching 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,9 @@ import {
|
||||
Spinner,
|
||||
Box,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks";
|
||||
|
||||
interface ICSSettingsProps {
|
||||
@@ -26,6 +27,7 @@ interface ICSSettingsProps {
|
||||
icsLastEtag?: string;
|
||||
onChange: (settings: Partial<ICSSettingsData>) => void;
|
||||
isOwner?: boolean;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export interface ICSSettingsData {
|
||||
@@ -52,12 +54,18 @@ export default function ICSSettings({
|
||||
icsLastEtag,
|
||||
onChange,
|
||||
isOwner = true,
|
||||
isEditing = false,
|
||||
}: ICSSettingsProps) {
|
||||
const [syncStatus, setSyncStatus] = useState<
|
||||
"idle" | "syncing" | "success" | "error"
|
||||
>("idle");
|
||||
const [syncMessage, setSyncMessage] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<string>("");
|
||||
const [syncResult, setSyncResult] = useState<{
|
||||
eventsFound: number;
|
||||
totalEvents: number;
|
||||
eventsCreated: number;
|
||||
eventsUpdated: number;
|
||||
} | null>(null);
|
||||
|
||||
// React Query hooks
|
||||
const syncMutation = useRoomIcsSync();
|
||||
@@ -67,46 +75,21 @@ export default function ICSSettings({
|
||||
items: fetchIntervalOptions,
|
||||
});
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!icsUrl || !roomName) return;
|
||||
|
||||
setSyncStatus("syncing");
|
||||
setTestResult("");
|
||||
|
||||
try {
|
||||
// First notify parent to update the room with the ICS URL
|
||||
onChange({
|
||||
ics_url: icsUrl,
|
||||
ics_enabled: true,
|
||||
ics_fetch_interval: icsFetchInterval,
|
||||
});
|
||||
|
||||
// Then trigger a sync
|
||||
const result = await syncMutation.mutateAsync({
|
||||
params: {
|
||||
path: { room_name: roomName },
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status === "success") {
|
||||
setSyncStatus("success");
|
||||
setTestResult(
|
||||
`Successfully synced! Found ${result.events_found} events.`,
|
||||
);
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setTestResult(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setTestResult(err.body?.detail || "Failed to test ICS connection");
|
||||
// Clear sync results when dialog closes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setSyncStatus("idle");
|
||||
setSyncResult(null);
|
||||
setSyncMessage("");
|
||||
}
|
||||
};
|
||||
}, [isEditing]);
|
||||
|
||||
const handleManualSync = async () => {
|
||||
if (!roomName) return;
|
||||
const handleForceSync = async () => {
|
||||
if (!roomName || !isEditing) return;
|
||||
|
||||
// Clear previous results
|
||||
setSyncStatus("syncing");
|
||||
setSyncResult(null);
|
||||
setSyncMessage("");
|
||||
|
||||
try {
|
||||
@@ -116,26 +99,22 @@ export default function ICSSettings({
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status === "success") {
|
||||
if (result.status === "success" || result.status === "unchanged") {
|
||||
setSyncStatus("success");
|
||||
setSyncMessage(
|
||||
`Sync complete! Found ${result.events_found} events, ` +
|
||||
`created ${result.events_created}, updated ${result.events_updated}.`,
|
||||
);
|
||||
setSyncResult({
|
||||
eventsFound: result.events_found || 0,
|
||||
totalEvents: result.total_events || 0,
|
||||
eventsCreated: result.events_created || 0,
|
||||
eventsUpdated: result.events_updated || 0,
|
||||
});
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(err.body?.detail || "Failed to sync calendar");
|
||||
setSyncMessage(err.body?.detail || "Failed to force sync calendar");
|
||||
}
|
||||
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSyncStatus("idle");
|
||||
setSyncMessage("");
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
@@ -198,46 +177,48 @@ export default function ICSSettings({
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{icsUrl && (
|
||||
{icsUrl && isEditing && roomName && (
|
||||
<HStack gap={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
onClick={handleForceSync}
|
||||
disabled={syncStatus === "syncing"}
|
||||
>
|
||||
{syncStatus === "syncing" && <Spinner size="sm" />}
|
||||
Test Connection
|
||||
{syncStatus === "syncing" ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<LuRefreshCw />
|
||||
)}
|
||||
Force Sync
|
||||
</Button>
|
||||
|
||||
{roomName && icsLastSync && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleManualSync}
|
||||
disabled={syncStatus === "syncing"}
|
||||
>
|
||||
<FaSync />
|
||||
Sync Now
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
{syncResult && syncStatus === "success" && (
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
bg={syncStatus === "success" ? "green.50" : "red.50"}
|
||||
bg="green.50"
|
||||
borderLeft="4px solid"
|
||||
borderColor={syncStatus === "success" ? "green.400" : "red.400"}
|
||||
borderColor="green.400"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={syncStatus === "success" ? "green.800" : "red.800"}
|
||||
>
|
||||
{testResult}
|
||||
</Text>
|
||||
<VStack gap={1} align="stretch">
|
||||
<Text fontSize="sm" color="green.800" fontWeight="medium">
|
||||
Sync completed
|
||||
</Text>
|
||||
<Text fontSize="sm" color="green.700">
|
||||
{syncResult.totalEvents} events downloaded,{" "}
|
||||
{syncResult.eventsFound} match this room
|
||||
</Text>
|
||||
{(syncResult.eventsCreated > 0 ||
|
||||
syncResult.eventsUpdated > 0) && (
|
||||
<Text fontSize="sm" color="green.700">
|
||||
{syncResult.eventsCreated} created,{" "}
|
||||
{syncResult.eventsUpdated} updated
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -448,6 +448,7 @@ export default function RoomsList() {
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
|
||||
<Tabs.Trigger value="share">Share</Tabs.Trigger>
|
||||
<Tabs.Trigger value="webhook">Webhook</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" pt={6}>
|
||||
@@ -613,9 +614,134 @@ export default function RoomsList() {
|
||||
<Checkbox.Label>Shared room</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* Webhook Configuration Section */}
|
||||
<Field.Root mt={8}>
|
||||
<Tabs.Content value="calendar" pt={6}>
|
||||
<ICSSettings
|
||||
roomId={editRoomId ?? undefined}
|
||||
roomName={room.name}
|
||||
icsUrl={room.icsUrl}
|
||||
icsEnabled={room.icsEnabled}
|
||||
icsFetchInterval={room.icsFetchInterval}
|
||||
onChange={(settings) => {
|
||||
setRoomInput({
|
||||
...room,
|
||||
icsUrl:
|
||||
settings.ics_url !== undefined
|
||||
? settings.ics_url
|
||||
: room.icsUrl,
|
||||
icsEnabled:
|
||||
settings.ics_enabled !== undefined
|
||||
? settings.ics_enabled
|
||||
: room.icsEnabled,
|
||||
icsFetchInterval:
|
||||
settings.ics_fetch_interval !== undefined
|
||||
? settings.ics_fetch_interval
|
||||
: room.icsFetchInterval,
|
||||
});
|
||||
}}
|
||||
isOwner={true}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="share" pt={6}>
|
||||
<Field.Root>
|
||||
<Checkbox.Root
|
||||
name="zulipAutoPost"
|
||||
checked={room.zulipAutoPost}
|
||||
onCheckedChange={(e) => {
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: "zulipAutoPost",
|
||||
type: "checkbox",
|
||||
checked: e.checked,
|
||||
},
|
||||
};
|
||||
handleRoomChange(syntheticEvent);
|
||||
}}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Label>
|
||||
Automatically post transcription to Zulip
|
||||
</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Zulip stream</Field.Label>
|
||||
<Select.Root
|
||||
value={room.zulipStream ? [room.zulipStream] : []}
|
||||
onValueChange={(e) =>
|
||||
setRoomInput({
|
||||
...room,
|
||||
zulipStream: e.value[0],
|
||||
zulipTopic: "",
|
||||
})
|
||||
}
|
||||
collection={streamCollection}
|
||||
disabled={!room.zulipAutoPost}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Select stream" />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{streamOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Zulip topic</Field.Label>
|
||||
<Select.Root
|
||||
value={room.zulipTopic ? [room.zulipTopic] : []}
|
||||
onValueChange={(e) =>
|
||||
setRoomInput({ ...room, zulipTopic: e.value[0] })
|
||||
}
|
||||
collection={topicCollection}
|
||||
disabled={!room.zulipAutoPost}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Select topic" />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{topicOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="webhook" pt={6}>
|
||||
<Field.Root>
|
||||
<Field.Label>Webhook URL</Field.Label>
|
||||
<Input
|
||||
name="webhookUrl"
|
||||
@@ -718,129 +844,6 @@ export default function RoomsList() {
|
||||
</>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="calendar" pt={6}>
|
||||
<ICSSettings
|
||||
roomId={editRoomId ?? undefined}
|
||||
roomName={room.name}
|
||||
icsUrl={room.icsUrl}
|
||||
icsEnabled={room.icsEnabled}
|
||||
icsFetchInterval={room.icsFetchInterval}
|
||||
onChange={(settings) => {
|
||||
setRoomInput({
|
||||
...room,
|
||||
icsUrl:
|
||||
settings.ics_url !== undefined
|
||||
? settings.ics_url
|
||||
: room.icsUrl,
|
||||
icsEnabled:
|
||||
settings.ics_enabled !== undefined
|
||||
? settings.ics_enabled
|
||||
: room.icsEnabled,
|
||||
icsFetchInterval:
|
||||
settings.ics_fetch_interval !== undefined
|
||||
? settings.ics_fetch_interval
|
||||
: room.icsFetchInterval,
|
||||
});
|
||||
}}
|
||||
isOwner={true}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="share" pt={6}>
|
||||
<Field.Root>
|
||||
<Checkbox.Root
|
||||
name="zulipAutoPost"
|
||||
checked={room.zulipAutoPost}
|
||||
onCheckedChange={(e) => {
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: "zulipAutoPost",
|
||||
type: "checkbox",
|
||||
checked: e.checked,
|
||||
},
|
||||
};
|
||||
handleRoomChange(syntheticEvent);
|
||||
}}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Label>
|
||||
Automatically post transcription to Zulip
|
||||
</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Zulip stream</Field.Label>
|
||||
<Select.Root
|
||||
value={room.zulipStream ? [room.zulipStream] : []}
|
||||
onValueChange={(e) =>
|
||||
setRoomInput({
|
||||
...room,
|
||||
zulipStream: e.value[0],
|
||||
zulipTopic: "",
|
||||
})
|
||||
}
|
||||
collection={streamCollection}
|
||||
disabled={!room.zulipAutoPost}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Select stream" />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{streamOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Zulip topic</Field.Label>
|
||||
<Select.Root
|
||||
value={room.zulipTopic ? [room.zulipTopic] : []}
|
||||
onValueChange={(e) =>
|
||||
setRoomInput({ ...room, zulipTopic: e.value[0] })
|
||||
}
|
||||
collection={topicCollection}
|
||||
disabled={!room.zulipAutoPost}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Select topic" />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{topicOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
|
||||
Reference in New Issue
Block a user