export function SyncPage() {
const navigate = useNavigate();
const { authState, isOnline } = useAuthState();
const token = authState.accessToken || "";
const [state, actions] = useSynchronization();
const {
failedOperations,
isLoadingFailedOps,
retryFailedOperation,
retryAllFailedOperations,
clearFailedOperation,
} = actions;
const [viewMode, setViewMode] = useState<ViewMode>("preview");
const { rateLimitState, setRateLimit } = useRateLimit();
const { completeStep } = useOnboarding();
// Retry state
const [retryingOperations, setRetryingOperations] = useState<Set<string>>(
new Set(),
);
// Authentication and validation states
const [authError, setAuthError] = useState(false);
const [matchDataError, setMatchDataError] = useState(false);
const [validMatchesError, setValidMatchesError] = useState(false);
// Authentication and data validation check
useEffect(() => {
// Check if user is authenticated
if (!authState.isAuthenticated || !token) {
console.debug("[SyncPage] User not authenticated, showing auth error");
setAuthError(true);
return;
} else {
setAuthError(false);
}
// Check if there are match results to sync
const savedResults = getSavedMatchResults();
if (
!savedResults ||
!Array.isArray(savedResults) ||
savedResults.length === 0
) {
console.debug(
"[SyncPage] No match results found, showing match data error",
);
setMatchDataError(true);
return;
} else {
setMatchDataError(false);
}
// Validate that there are actual matches (not just skipped entries)
const validMatches = savedResults.filter(
(match) => match.status === "matched" || match.status === "manual",
);
if (validMatches.length === 0) {
console.debug(
"[SyncPage] No valid matches found, showing valid matches error",
);
setValidMatchesError(true);
return;
} else {
setValidMatchesError(false);
}
console.debug(
`[SyncPage] Found ${validMatches.length} valid matches for synchronization`,
);
}, [authState.isAuthenticated, token]);
// Sync configuration options
const [syncConfig, setSyncConfig] = useState<SyncConfig>(getSyncConfig());
// Track if we're using a custom threshold
const [isCustomThresholdEnabled, setIsCustomThresholdEnabled] =
useState<boolean>(
![1, 7, 14, 30, 60, 90, 180, 365].includes(syncConfig.autoPauseThreshold),
);
/**
* Toggles a sync configuration option and persists the updated config to storage.
* @param option - The sync configuration option key to toggle.
* @source
*/
const handleToggleOption = (option: keyof SyncConfig) => {
setSyncConfig((prev) => {
const newConfig = {
...prev,
[option]: !prev[option],
};
// Save the updated config to storage
saveSyncConfig(newConfig);
return newConfig;
});
};
/**
* Refreshes the user's AniList library from the server.
* Delegates to utility function with appropriate state handlers.
* @source
*/
const handleLibraryRefresh = () => {
handleLibraryRefreshUtil({
token,
setLibraryLoading,
setLibraryError,
setRetryCount,
setRateLimit,
setUserLibrary,
});
};
// View mode for displaying manga entries
const [displayMode, setDisplayMode] = useState<DisplayMode>("cards");
// State to hold manga matches
const [mangaMatches, setMangaMatches] = useState<MangaMatchResult[]>([]);
// Pagination and loading state
const [visibleItems, setVisibleItems] = useState(20);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// State to hold user's AniList library
const [userLibrary, setUserLibrary] = useState<UserMediaList>({});
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const maxRetries = 3;
// Track initial manga load to show skeletons
const [isInitialMangaLoad, setIsInitialMangaLoad] = useState(true);
// Sorting and filtering options
const [sortOption, setSortOption] = useState<SortOption>({
field: "title",
direction: "asc",
});
const [filters, setFilters] = useState<FilterOptions>({
status: "all", // 'all', 'reading', 'completed', 'planned', 'paused', 'dropped'
changes: "with-changes", // 'all', 'with-changes', 'no-changes'
library: "all", // 'all', 'new', 'existing'
});
// Load manga matches from the app's storage system
useEffect(() => {
const savedResults = getSavedMatchResults();
if (savedResults && Array.isArray(savedResults)) {
console.debug(
`[SyncPage] Loaded ${savedResults.length} match results from storage`,
);
setMangaMatches(savedResults as MangaMatchResult[]);
} else {
captureError(
ErrorType.STORAGE,
"No match results found in storage",
new Error("No match results"),
{},
);
}
setIsInitialMangaLoad(false);
}, []);
// Auto-retry failed operations when app comes online
const isAutoRetryInProgressRef = useRef(false);
useEffect(() => {
const handleAppOnline = async () => {
// Gate: skip if auto-retry is already in progress
if (isAutoRetryInProgressRef.current) {
console.debug(
"[SyncPage] Auto-retry already in progress, skipping duplicate trigger",
);
return;
}
// Bail if sync is already active to avoid competing with active sync
if (state.isActive) {
console.info(
`[SyncPage] App came online, but sync is already active. Skipping auto-retry.`,
);
return;
}
if (failedOperations.length > 0) {
console.info(
`[SyncPage] App came online with ${failedOperations.length} failed operations, attempting retry`,
);
// Set gate to prevent concurrent retries during flapping connectivity
isAutoRetryInProgressRef.current = true;
try {
// Small delay to allow for other reconnection handlers to complete
await new Promise((resolve) => setTimeout(resolve, 500));
const succeeded = await retryAllFailedOperations();
console.info(
`[SyncPage] Retried failed operations: ${succeeded} succeeded`,
);
} catch (err) {
captureError(
ErrorType.NETWORK,
"Error during auto-retry on reconnect",
err instanceof Error ? err : new Error(String(err)),
{},
);
} finally {
// Reset gate after completion
isAutoRetryInProgressRef.current = false;
}
}
};
globalThis.addEventListener("app:online", handleAppOnline);
return () => {
globalThis.removeEventListener("app:online", handleAppOnline);
};
}, [state.isActive, failedOperations.length, retryAllFailedOperations]);
// Handle rate limit errors
type ApiError = {
isRateLimited?: boolean;
status?: number;
retryAfter?: number;
message?: string;
name?: string;
};
/**
* Handles rate limit errors from the AniList API with scheduled retry.
* @param error - The API error containing rate limit information.
* @param controller - AbortController to manage request cancellation.
* @param fetchLibrary - Callback to retry fetching the library.
* @returns Cleanup function to clear timeout if needed.
* @source
*/
const handleRateLimitError = (
error: ApiError,
controller: AbortController,
fetchLibrary: (attempt: number) => void,
) => {
const err = error;
console.warn("[SyncPage] 📛 DETECTED RATE LIMIT:", {
isRateLimited: err.isRateLimited,
status: err.status,
retryAfter: err.retryAfter,
});
const retryDelay = err.retryAfter ? err.retryAfter : 60;
setRateLimit(
true,
retryDelay,
"AniList API rate limit reached. Waiting to retry...",
);
setLibraryLoading(false);
setLibraryError("AniList API rate limit reached. Waiting to retry...");
const timer = setTimeout(() => {
if (!controller.signal.aborted) {
console.info("[SyncPage] Rate limit timeout complete, retrying...");
setLibraryLoading(true);
setLibraryError(null);
fetchLibrary(0);
}
}, retryDelay * 1000);
return () => clearTimeout(timer);
};
/**
* Handles server errors with exponential backoff retry logic.
* @param error - The API error to handle.
* @param attempt - Current retry attempt number.
* @param controller - AbortController to manage request cancellation.
* @param fetchLibrary - Callback to retry fetching the library.
* @returns Cleanup function if retry is scheduled, false otherwise.
* @source
*/
const handleServerError = (
error: ApiError,
attempt: number,
controller: AbortController,
fetchLibrary: (attempt: number) => void,
) => {
const err = error;
const message = err.message || "";
const isServerError =
message.includes("500") ||
message.includes("502") ||
message.includes("503") ||
message.includes("504") ||
message.toLowerCase().includes("network error");
if (!isServerError || attempt >= maxRetries) {
return false;
}
const backoffDelay = Math.pow(2, attempt) * 1000;
setLibraryError(
`AniList server error. Retrying in ${backoffDelay / 1000} seconds (${attempt + 1}/${maxRetries})...`,
);
const timer = setTimeout(() => {
if (!controller.signal.aborted) {
fetchLibrary(attempt + 1);
}
}, backoffDelay);
return () => clearTimeout(timer);
};
/**
* Handles successful library data fetch from AniList API.
* @param library - The user's AniList media list.
* @source
*/
const handleFetchSuccess = (library: UserMediaList) => {
console.info(
`[SyncPage] Loaded ${Object.keys(library).length} entries from user's AniList library`,
);
setUserLibrary(library);
setLibraryLoading(false);
setLibraryError(null);
setRetryCount(0);
};
/**
* Handles errors from library fetch with appropriate retry logic.
* Delegates to specific error handlers for rate limit and server errors.
* @param error - The API error from library fetch.
* @param attempt - Current retry attempt number.
* @param controller - AbortController to manage request cancellation.
* @param fetchLibrary - Callback to retry fetching the library.
* @source
*/
const handleFetchError = (
error: ApiError,
attempt: number,
controller: AbortController,
fetchLibrary: (attempt: number) => void,
) => {
const err = error;
if (err.name === "AbortError") return;
captureError(
ErrorType.NETWORK,
"Failed to load user library",
err instanceof Error ? err : new Error(String(err)),
{ token: !!token },
);
console.debug(
"[SyncPage] Error object structure:",
JSON.stringify(err, null, 2),
);
// Check for rate limiting
if (err.isRateLimited || err.status === 429) {
return handleRateLimitError(err, controller, fetchLibrary);
}
// Check for server error
const serverErrorResult = handleServerError(
err,
attempt,
controller,
fetchLibrary,
);
if (serverErrorResult) {
return serverErrorResult;
}
// Default error handling
const errorMessage =
err.message ||
"Failed to load your AniList library. Synchronization can still proceed, but comparison data will not be shown.";
setLibraryError(errorMessage);
setUserLibrary({});
setLibraryLoading(false);
// Show recovery notification for library fetch failures
showErrorNotification(
createError(
ErrorType.NETWORK,
"Failed to load AniList library",
err instanceof Error ? err : new Error(String(err)),
"LIBRARY_FETCH_FAILED",
ErrorRecoveryAction.RETRY,
"Your sync can still proceed, but comparison data won't be shown. Click retry to load your library.",
),
{
onRetry: () => fetchLibrary(0),
duration: 8000,
},
);
};
// Fetch the user's AniList library for comparison
useEffect(() => {
if (token && mangaMatches.length > 0) {
setLibraryLoading(true);
setLibraryError(null);
const controller = new AbortController();
const fetchLibrary = (attempt = 0) => {
console.debug(
`[SyncPage] Fetching AniList library (attempt ${attempt + 1}/${maxRetries + 1})`,
);
setRetryCount(attempt);
getUserMangaList(token, controller.signal)
.then(handleFetchSuccess)
.catch((error) => {
handleFetchError(error, attempt, controller, fetchLibrary);
});
};
fetchLibrary(0);
return () => controller.abort();
}
}, [token, mangaMatches, maxRetries, setRateLimit]);
// Reset visible items when changing display mode
useEffect(() => {
setVisibleItems(20);
}, [displayMode]);
// Apply filters to manga matches
const filteredMangaMatches = useMemo(() => {
return filterMangaMatches(mangaMatches, filters, userLibrary, syncConfig);
}, [mangaMatches, filters, userLibrary, syncConfig]);
// Apply sorting to filtered manga matches
const sortedMangaMatches = useMemo(() => {
return sortMangaMatches(
filteredMangaMatches,
sortOption,
userLibrary,
syncConfig,
);
}, [filteredMangaMatches, sortOption, userLibrary, syncConfig]);
// Compute all entries to sync (unfiltered, all with changes)
const allEntriesToSync = useMemo(() => {
return prepareAllEntriesToSync(mangaMatches, userLibrary, syncConfig);
}, [mangaMatches, userLibrary, syncConfig]);
// Only sync entries with actual changes
const entriesWithChanges = useMemo(
() => allEntriesToSync.filter((entry) => hasChanges(entry, syncConfig)),
[allEntriesToSync, syncConfig],
);
const totalMatchedManga = useMemo(
() => mangaMatches.filter((match) => match.status !== "skipped").length,
[mangaMatches],
);
const newEntriesCount = useMemo(
() =>
mangaMatches.filter(
(match) => match.selectedMatch && !userLibrary[match.selectedMatch.id],
).length,
[mangaMatches, userLibrary],
);
const manualMatchesCount = useMemo(
() => mangaMatches.filter((match) => match.status === "manual").length,
[mangaMatches],
);
const queuedPercentage = useMemo(() => {
if (totalMatchedManga === 0) {
return 0;
}
return Math.round((entriesWithChanges.length / totalMatchedManga) * 100);
}, [entriesWithChanges.length, totalMatchedManga]);
const heroStats = useMemo(
() => [
{
label: "Ready to sync",
value: entriesWithChanges.length,
helper: "entries queued",
icon: CheckCircle2,
accent:
"from-emerald-400/80 via-emerald-400/10 to-transparent dark:from-emerald-500/60 dark:via-emerald-500/5",
},
{
label: "Total matches",
value: totalMatchedManga,
helper: `${queuedPercentage}% prepared`,
icon: Layers,
accent:
"from-sky-400/70 via-sky-400/10 to-transparent dark:from-sky-500/50 dark:via-sky-500/5",
},
{
label: "New additions",
value: newEntriesCount,
helper: "not yet in AniList",
icon: UserPlus,
accent:
"from-purple-400/80 via-purple-400/10 to-transparent dark:from-purple-500/60 dark:via-purple-500/5",
},
],
[
entriesWithChanges.length,
totalMatchedManga,
newEntriesCount,
manualMatchesCount,
queuedPercentage,
],
);
/**
* Initiates sync view transition when entries with changes are ready.
* @source
*/
const handleStartSync = () => {
if (entriesWithChanges.length === 0) {
return;
}
setViewMode("sync");
};
/**
* Generates button title text based on current sync state.
* @returns Descriptive title or undefined if sync can proceed.
* @source
*/
const getStartSyncButtonTitle = () => {
if (isOnline === false) {
return "Cannot sync while offline";
}
if (state.resumeAvailable) {
return "Resume or discard the interrupted sync first";
}
return undefined;
};
/**
* Handles completion of the sync process and transitions to results view.
* @source
*/
const handleSyncComplete = () => {
completeStep("sync");
setViewMode("results");
};
// Handle sync cancellation
const [wasCancelled, setWasCancelled] = useState(false);
/**
* Handles sync cancellation or navigation back to review.
* Cancels active sync if in progress, otherwise returns to review page.
* @source
*/
const handleCancel = () => {
if (viewMode === "sync") {
// If sync has not started, go back to preview
if (!state.isActive && !state.report) {
setViewMode("preview");
return;
}
actions.cancelSync();
setWasCancelled(true);
setViewMode("results");
return;
}
// Navigate back to the matching page
navigate({ to: "/review" });
};
/**
* Navigates home after viewing sync results, resetting sync state.
* @source
*/
const handleGoHome = () => {
actions.reset();
setWasCancelled(false);
navigate({ to: "/" });
};
/**
* Refreshes the user's AniList library from the server.
* @source
*/
const refreshUserLibrary = () => {
refreshUserLibraryUtil({
token,
setLibraryLoading,
setLibraryError,
setRetryCount,
setRateLimit,
setUserLibrary,
});
};
/**
* Navigates back to the matching review page after sync completion.
* @source
*/
const handleBackToReview = () => {
actions.reset();
setWasCancelled(false);
refreshUserLibrary();
setViewMode("preview");
};
/**
* Resumes sync from the last checkpoint.
* @source
*/
const handleResumeSync = () => {
if (!state.resumeAvailable) {
console.warn("[SyncPage] ⚠️ No resume checkpoint available");
return;
}
const remainingCount = state.resumeMetadata?.remainingMediaIds.length || 0;
console.info(
`[SyncPage] ▶️ Resuming sync from checkpoint (${remainingCount} entries remaining)`,
);
actions.resumeSync(allEntriesToSync, token);
setViewMode("sync");
};
/**
* Discards the sync checkpoint and clears resume state.
* @source
*/
const handleDiscardCheckpoint = () => {
console.info("[SyncPage] 🗑️ Discarding sync checkpoint");
actions.reset();
// Show toast notification (if toast system is available)
console.log("Sync checkpoint discarded. Starting fresh.");
};
/**
* Checks if an error message indicates a logical conflict.
* @param errorMsg - The error message to check.
* @returns True if the error indicates a conflict.
* @source
*/
const isConflictError = (errorMsg: string): boolean => {
const normalized = errorMsg.toLowerCase();
return (
normalized.includes("conflict") ||
normalized.includes("differs") ||
normalized.includes("outdated") ||
normalized.includes("mismatch")
);
};
/**
* Shows appropriate error notification based on conflict detection.
* @param operationId - Optional operation ID for retry callback.
* @param error - The error that occurred.
* @source
*/
const showSyncRetryError = (
operationId: string | null,
error: unknown,
): void => {
const errorMsg = error instanceof Error ? error.message : String(error);
const isConflict = isConflictError(errorMsg);
showErrorNotification(
createError(
isConflict ? ErrorType.SERVER : ErrorType.NETWORK,
isConflict
? "This entry conflicts with AniList's current state"
: "Failed to retry sync operation",
error instanceof Error ? error : new Error(String(error)),
isConflict ? "SYNC_CONFLICT" : "SYNC_RETRY_FAILED",
isConflict ? ErrorRecoveryAction.NONE : ErrorRecoveryAction.RETRY,
isConflict
? "The entry has changed on AniList since this sync started. Open the item to review the current state and resolve the conflict."
: "The operation could not be retried. You can try again or skip this entry.",
),
{
...(isConflict
? { duration: 8000 }
: {
onRetry: operationId
? () => handleRetryOperation(operationId)
: handleRetryAll,
duration: 6000,
}),
},
);
};
/**
* Handles retrying a single failed sync operation.
* Updates retry state and calls the retry handler.
* Detects logical conflicts and displays conflict-specific recovery guidance.
* @param operationId - The ID of the failed operation to retry.
* @source
*/
const handleRetryOperation = async (operationId: string) => {
setRetryingOperations((prev) => new Set([...prev, operationId]));
try {
const success = await retryFailedOperation(operationId);
if (success) {
console.log("Operation succeeded");
} else {
console.log("Operation still failed");
// Get the failed operation to check error details for conflicts
const failedOp = failedOperations.find((op) => op.id === operationId);
const errorMsg = failedOp?.error || "";
if (isConflictError(errorMsg)) {
// Show conflict-specific error with NONE action (no auto-retry)
showErrorNotification(
createError(
ErrorType.UNKNOWN,
"This entry conflicts with AniList's current state",
new Error("Sync conflict detected"),
"SYNC_CONFLICT",
ErrorRecoveryAction.NONE,
"The entry has changed on AniList since this sync started. Open the item to review the current state and resolve the conflict.",
),
{ duration: 8000 },
);
} else {
// Show error notification for general failed retry
showErrorNotification(
createError(
ErrorType.UNKNOWN,
"Failed to retry sync operation",
new Error("Retry operation failed"),
"SYNC_RETRY_FAILED",
ErrorRecoveryAction.RETRY,
"The operation could not be retried. You can try again or skip this entry.",
),
{
onRetry: () => handleRetryOperation(operationId),
duration: 6000,
},
);
}
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
captureError(
ErrorType.UNKNOWN,
isConflictError(errorMsg)
? "Sync conflict detected"
: "Error retrying operation",
error instanceof Error ? error : new Error(String(error)),
{ operationId },
);
showSyncRetryError(operationId, error);
} finally {
setRetryingOperations((prev) => {
const next = new Set(prev);
next.delete(operationId);
return next;
});
}
};
/**
* Shows appropriate error notification for batch retry based on conflict detection.
* @param error - The error that occurred.
* @source
*/
const showBatchRetryError = (error: unknown): void => {
const errorMsg = error instanceof Error ? error.message : String(error);
const isConflict = isConflictError(errorMsg);
showErrorNotification(
createError(
isConflict ? ErrorType.SERVER : ErrorType.NETWORK,
isConflict
? "Some entries conflict with AniList's current state"
: "Failed to retry all operations",
error instanceof Error ? error : new Error(String(error)),
isConflict ? "SYNC_BATCH_CONFLICT" : "SYNC_RETRY_ALL_FAILED",
isConflict ? ErrorRecoveryAction.NONE : ErrorRecoveryAction.RETRY,
isConflict
? "Some entries have changed on AniList. Review the failed operations individually to resolve conflicts."
: "Some operations could not be retried. Check your connection and try again.",
),
{
...(isConflict
? { duration: 8000 }
: { onRetry: handleRetryAll, duration: 6000 }),
},
);
};
/**
* Handles retrying all failed sync operations at once.
* Detects conflicts and displays appropriate recovery messaging.
* @source
*/
const handleRetryAll = async () => {
try {
const succeeded = await retryAllFailedOperations();
console.log(`Retried all operations: ${succeeded} succeeded`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
captureError(
ErrorType.UNKNOWN,
isConflictError(errorMsg)
? "Sync conflicts detected"
: "Error retrying all operations",
error instanceof Error ? error : new Error(String(error)),
);
showBatchRetryError(error);
}
};
/**
* Handles clearing a single failed operation from the retry queue.
* @param operationId - The ID of the operation to clear.
* @source
*/
const handleClearOperation = (operationId: string) => {
clearFailedOperation(operationId);
};
/**
* Handles sync reset from error boundary.
* @source
*/
const handleSyncReset = () => {
actions.reset();
setViewMode("preview");
setRetryingOperations(new Set());
};
/**
* Handles retrying failed operations from error boundary.
* @source
*/
const handleRetryFailedFromBoundary = async () => {
try {
await handleRetryAll();
} catch (error) {
captureError(
ErrorType.UNKNOWN,
"Failed to retry operations from error boundary",
error instanceof Error ? error : new Error(String(error)),
{},
);
}
};
/**
* Handles canceling sync from error boundary.
* @source
*/
const handleCancelSyncFromBoundary = () => {
actions.cancelSync();
setViewMode("preview");
};
/**
* Renders a badge indicating manga status change from Kenmei to AniList.
* @param statusWillChange - Whether the status will be updated.
* @param userEntry - Current AniList entry for the manga.
* @param kenmei - Kenmei manga data with status.
* @param syncConfig - Sync configuration options.
* @returns JSX badge element or null if no change.
* @source
*/
const renderStatusBadge = (
statusWillChange: boolean,
userEntry: UserMediaEntry | undefined,
kenmei: KenmeiManga,
syncConfig: SyncConfig,
) => {
if (!statusWillChange) return null;
const fromStatus = userEntry?.status || "None";
const toStatus = getEffectiveStatus(kenmei, syncConfig);
if (fromStatus === toStatus) return null;
return (
<Badge
variant="outline"
className="border-blue-400/70 bg-blue-50/60 px-2 py-0 text-[10px] text-blue-600 shadow-sm dark:border-blue-500/40 dark:bg-blue-900/40 dark:text-blue-300"
>
{fromStatus} → {toStatus}
</Badge>
);
};
/**
* Renders a badge showing progress (chapters) changes from Kenmei to AniList.
* @param progressWillChange - Whether progress will be updated.
* @param userEntry - Current AniList entry for the manga.
* @param kenmei - Kenmei manga data with chapter count.
* @param syncConfig - Sync configuration options.
* @returns JSX badge element or null if no change.
* @source
*/
const renderProgressBadge = (
progressWillChange: boolean,
userEntry: UserMediaEntry | undefined,
kenmei: KenmeiManga,
syncConfig: SyncConfig,
) => {
if (!progressWillChange) return null;
const fromProgress = userEntry?.progress || 0;
let toProgress: number;
if (syncConfig.prioritizeAniListProgress) {
if (userEntry?.progress && userEntry.progress > 0) {
toProgress = Math.max(
kenmei.chaptersRead || 0,
userEntry.progress || 0,
);
} else {
toProgress = kenmei.chaptersRead || 0;
}
} else {
toProgress = kenmei.chaptersRead || 0;
}
if (fromProgress === toProgress) return null;
return (
<Badge
variant="outline"
className="border-green-400/70 bg-green-50/60 px-2 py-0 text-[10px] text-green-600 shadow-sm dark:border-green-500/40 dark:bg-green-900/30 dark:text-green-300"
>
{fromProgress} → {toProgress} ch
</Badge>
);
};
/**
* Renders a badge showing score/rating changes from Kenmei to AniList.
* @param scoreWillChange - Whether score will be updated.
* @param userEntry - Current AniList entry for the manga.
* @param kenmei - Kenmei manga data with score.
* @returns JSX badge element or null if no change.
* @source
*/
const renderScoreBadge = (
scoreWillChange: boolean,
userEntry: UserMediaEntry | undefined,
kenmei: KenmeiManga,
) => {
if (!scoreWillChange) return null;
const fromScore = userEntry?.score || 0;
const toScore = kenmei.score || 0;
if (fromScore === toScore) return null;
return (
<Badge
variant="outline"
className="border-amber-400/70 bg-amber-50/60 px-2 py-0 text-[10px] text-amber-600 shadow-sm dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-300"
>
{fromScore} → {toScore}/10
</Badge>
);
};
/**
* Renders a badge indicating privacy setting changes for the manga entry.
* @param userEntry - Current AniList entry for the manga.
* @param syncConfig - Sync configuration options.
* @returns JSX badge element or null if no privacy change.
* @source
*/
const renderPrivacyBadge = (
userEntry: UserMediaEntry | undefined,
syncConfig: SyncConfig,
) => {
const shouldShowBadge = userEntry
? syncConfig.setPrivate && !userEntry.private
: syncConfig.setPrivate;
if (!shouldShowBadge) return null;
return (
<Badge
variant="outline"
className="border-purple-400/70 bg-purple-50/60 px-2 py-0 text-[10px] text-purple-600 shadow-sm dark:border-purple-500/40 dark:bg-purple-900/30 dark:text-purple-300"
>
{userEntry ? "Yes" : "No"}
</Badge>
);
};
/**
* Renders privacy display text with styling reflecting privacy status and pending changes.
* @param userEntry - Current AniList entry for the manga.
* @param syncConfig - Sync configuration options.
* @param isCurrentAniList - Whether displaying current AniList data.
* @returns Formatted privacy display text.
* @source
*/
const renderPrivacyDisplay = (
userEntry: UserMediaEntry | undefined,
syncConfig: SyncConfig,
isCurrentAniList: boolean,
) => {
let privacyClass = "text-xs font-medium";
const willChange = userEntry
? syncConfig.setPrivate && !userEntry.private
: syncConfig.setPrivate;
if (isCurrentAniList) {
if (willChange) {
privacyClass += " text-muted-foreground line-through";
}
return (
<span className={privacyClass}>
{userEntry?.private ? "Yes" : "No"}
</span>
);
} else {
if (willChange) {
privacyClass += " text-blue-700 dark:text-blue-300";
}
const privacyDisplay =
syncConfig.setPrivate || userEntry?.private ? "Yes" : "No";
return <span className={privacyClass}>{privacyDisplay}</span>;
}
};
/**
* Renders progress display for after-sync state with current/total chapters.
* @param userEntry - Current AniList entry for the manga.
* @param kenmei - Kenmei manga data with chapter count.
* @param anilist - AniList manga data with total chapters.
* @param syncConfig - Sync configuration options.
* @param progressWillChange - Whether progress will be updated.
* @returns Formatted progress text.
* @source
*/
const renderAfterSyncProgress = (
userEntry: UserMediaEntry | undefined,
kenmei: KenmeiManga,
anilist: AniListManga | undefined,
syncConfig: SyncConfig,
progressWillChange: boolean,
) => {
let afterSyncProgress: number;
if (syncConfig.prioritizeAniListProgress) {
if (userEntry?.progress && userEntry.progress > 0) {
afterSyncProgress = Math.max(
kenmei.chaptersRead || 0,
userEntry.progress || 0,
);
} else {
afterSyncProgress = kenmei.chaptersRead || 0;
}
} else {
afterSyncProgress = kenmei.chaptersRead || 0;
}
return (
<span
className={`text-xs font-medium ${
progressWillChange ? "text-blue-700 dark:text-blue-300" : ""
}`}
>
{afterSyncProgress} ch
{anilist?.chapters ? ` / ${anilist.chapters}` : ""}
</span>
);
};
/**
* Renders score display for after-sync state with highlighting for changes.
* @param userEntry - Current AniList entry for the manga.
* @param kenmei - Kenmei manga data with score.
* @param scoreWillChange - Whether score will be updated.
* @returns Formatted score text.
* @source
*/
const renderAfterSyncScore = (
userEntry: UserMediaEntry | undefined,
kenmei: KenmeiManga,
scoreWillChange: boolean,
) => {
let scoreDisplay: string;
if (scoreWillChange) {
scoreDisplay = kenmei.score ? `${kenmei.score}/10` : "None";
} else {
scoreDisplay = userEntry?.score ? `${userEntry.score}/10` : "None";
}
return (
<span
className={`text-xs font-medium ${
scoreWillChange ? "text-blue-700 dark:text-blue-300" : ""
}`}
>
{scoreDisplay}
</span>
);
};
/**
* Renders manga cover image with status badges for new entries and completed items.
* @param anilist - AniList manga data with cover image.
* @param isNewEntry - Whether this is a new entry being added.
* @param isCompleted - Whether this manga is marked as completed.
* @returns JSX element with cover image and status badges.
* @source
*/
const renderMangaCover = (
anilist: AniListManga | undefined,
isNewEntry: boolean,
isCompleted: boolean,
) => {
return (
<div className="relative flex h-[200px] w-full shrink-0 items-center justify-center bg-slate-50 pl-3 sm:w-auto sm:bg-transparent dark:bg-slate-900/50 sm:dark:bg-transparent">
{anilist?.coverImage?.large || anilist?.coverImage?.medium ? (
<motion.div
layout="position"
animate={{
transition: { type: false },
}}
>
<img
src={anilist?.coverImage?.large || anilist?.coverImage?.medium}
alt={anilist?.title?.romaji || ""}
className="h-full w-[145px] rounded-sm object-cover"
/>
</motion.div>
) : (
<div className="flex h-[200px] items-center justify-center rounded-sm bg-slate-200 dark:bg-slate-800">
<span className="text-muted-foreground text-xs">No Cover</span>
</div>
)}
{/* Status Badges */}
<div className="absolute left-4 top-2 flex flex-col gap-1">
{isNewEntry && <Badge className="bg-emerald-500">New</Badge>}
{isCompleted && (
<Badge
variant="outline"
className="border-amber-500 text-amber-700 dark:text-amber-400"
>
Completed
</Badge>
)}
</div>
</div>
);
};
/**
* Renders change indicator badges for status, progress, and score updates.
* @param statusWillChange - Whether status will be updated.
* @param progressWillChange - Whether progress will be updated.
* @param scoreWillChange - Whether score will be updated.
* @returns JSX element with change badges or null if no changes.
* @source
*/
const renderChangeBadges = (
statusWillChange: boolean,
progressWillChange: boolean,
scoreWillChange: boolean,
userEntry: UserMediaEntry | undefined,
syncConfig: SyncConfig,
) => {
return (
<div className="mt-1 flex flex-wrap gap-1">
{statusWillChange && (
<Badge
variant="outline"
className="border-blue-400/70 bg-blue-50/60 px-1.5 py-0 text-xs text-blue-600 shadow-sm dark:border-blue-500/50 dark:bg-blue-900/40 dark:text-blue-300"
>
Status
</Badge>
)}
{progressWillChange && (
<Badge
variant="outline"
className="border-green-400/70 bg-green-50/60 px-1.5 py-0 text-xs text-green-600 shadow-sm dark:border-green-500/50 dark:bg-green-900/30 dark:text-green-300"
>
Progress
</Badge>
)}
{scoreWillChange && (
<Badge
variant="outline"
className="border-amber-400/70 bg-amber-50/60 px-1.5 py-0 text-xs text-amber-600 shadow-sm dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-300"
>
Score
</Badge>
)}
{userEntry
? syncConfig.setPrivate &&
!userEntry.private && (
<Badge
variant="outline"
className="border-purple-400/70 bg-purple-50/60 px-1.5 py-0 text-xs text-purple-600 shadow-sm dark:border-purple-500/50 dark:bg-purple-900/30 dark:text-purple-300"
>
Privacy
</Badge>
)
: syncConfig.setPrivate && (
<Badge
variant="outline"
className="border-purple-400/70 bg-purple-50/60 px-1.5 py-0 text-xs text-purple-600 shadow-sm dark:border-purple-500/50 dark:bg-purple-900/30 dark:text-purple-300"
>
Privacy
</Badge>
)}
</div>
);
};
/**
* Renders current AniList entry data with status, progress, score, and privacy information.
* Displays data with visual indication of pending changes.
* @param userEntry - Current AniList entry for the manga.
* @param anilist - AniList manga data.
* @param statusWillChange - Whether status will be updated.
* @param progressWillChange - Whether progress will be updated.
* @param scoreWillChange - Whether score will be updated.
* @param syncConfig - Sync configuration options.
* @returns JSX element displaying formatted AniList entry information.
* @source
*/
const renderCurrentAniListData = (
userEntry: UserMediaEntry | undefined,
anilist: AniListManga | undefined,
statusWillChange: boolean,
progressWillChange: boolean,
scoreWillChange: boolean,
syncConfig: SyncConfig,
) => {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">Status:</span>
<span
className={`text-xs font-medium ${statusWillChange ? "text-muted-foreground line-through" : ""}`}
>
{userEntry?.status || "None"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">Progress:</span>
<span
className={`text-xs font-medium ${progressWillChange ? "text-muted-foreground line-through" : ""}`}
>
{userEntry?.progress || 0} ch
{anilist?.chapters ? ` / ${anilist.chapters}` : ""}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">Score:</span>
<span
className={`text-xs font-medium ${scoreWillChange ? "text-muted-foreground line-through" : ""}`}
>
{userEntry?.score ? `${userEntry.score}/10` : "None"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">Private:</span>
{renderPrivacyDisplay(userEntry, syncConfig, true)}
</div>
</div>
);
};
// If any error condition is true, show the appropriate error message
if (authError || matchDataError || validMatchesError) {
return (
<ErrorStateDisplay
authError={authError}
matchDataError={matchDataError}
validMatchesError={validMatchesError}
/>
);
}
// If no manga matches are loaded yet, show loading state
if (isInitialMangaLoad && mangaMatches.length === 0) {
return (
<motion.div
className="container mx-auto flex h-full max-w-full flex-col px-4 py-6 md:px-6"
variants={pageVariants}
initial="hidden"
animate="visible"
>
<motion.div className="mb-8 space-y-4">
<h1 className="text-3xl font-semibold tracking-tight">
Sync Preview
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Loading your matched manga...
</p>
</motion.div>
<motion.div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<motion.div key={`skeleton-stat-${index + 1}`}>
<SkeletonCard />
</motion.div>
))}
</motion.div>
<motion.div className="mt-8">
<SkeletonList items={8} />
</motion.div>
</motion.div>
);
}
if (mangaMatches.length === 0) {
return (
<AnimatePresence>
<EmptyState
icon={<History className="h-10 w-10" />}
title="No matches to sync"
description="Complete the matching process first to see sync preview."
actionLabel="Go to Matching"
onAction={() => navigate({ to: "/review" })}
variant="info"
/>
</AnimatePresence>
);
}
// If library is loading, show loading state
if (libraryLoading) {
return (
<LoadingStateDisplay
type="library"
isRateLimited={rateLimitState.isRateLimited}
retryCount={retryCount}
maxRetries={maxRetries}
/>
);
}
// Render preview view
const renderPreviewView = () => {
return (
<motion.div
className="space-y-6"
key="preview"
initial="hidden"
animate="visible"
exit="exit"
variants={staggerContainerVariants}
>
<motion.div variants={cardVariants} className="space-y-6">
{/* Offline indicator */}
{!isOnline && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800/60 dark:bg-amber-900/20 dark:text-amber-300">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">You are currently offline</p>
<p className="text-xs text-amber-700 dark:text-amber-400">
Sync operations will be queued and retried when your
connection is restored.
</p>
</div>
</div>
</div>
)}
{/* Resume notification for interrupted syncs */}
{state.resumeAvailable && state.resumeMetadata && (
<SyncResumeNotification
remainingCount={state.resumeMetadata.remainingMediaIds.length}
totalCount={
state.progress?.total ||
state.resumeMetadata.remainingMediaIds.length
}
lastSyncTime={state.resumeMetadata.timestamp}
onResume={handleResumeSync}
onDiscard={handleDiscardCheckpoint}
/>
)}
{/* Failed operations panel */}
{failedOperations.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45 }}
className="relative overflow-hidden rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/40 dark:bg-red-950/20"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
<div>
<h3 className="font-medium text-red-900 dark:text-red-300">
Failed Operations ({failedOperations.length})
</h3>
<p className="text-xs text-red-700 dark:text-red-400">
These operations failed and can be retried
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={handleRetryAll}
disabled={isLoadingFailedOps}
className="border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
>
<RefreshCw className="mr-2 h-4 w-4" />
Retry All
</Button>
</div>
<div className="mt-4 max-h-96 space-y-2 overflow-y-auto">
{failedOperations.map((op) => {
const isRetrying = retryingOperations.has(op.id);
const payload = op.payload as Record<string, unknown>;
const canRetry = op.retryCount < MAX_RETRY_ATTEMPTS;
return (
<div
key={op.id}
className="flex items-start justify-between gap-3 rounded-md bg-white/50 p-3 text-sm dark:bg-slate-800/30"
>
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-900 dark:text-slate-100">
{(payload.title as string) || "Unknown"}
</p>
<p className="truncate text-xs text-red-600 dark:text-red-400">
{op.error}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Retried {op.retryCount}/{MAX_RETRY_ATTEMPTS} times
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleRetryOperation(op.id)}
disabled={isRetrying || !canRetry}
className="h-8 w-8 p-0"
>
{isRetrying ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleClearOperation(op.id)}
className="h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
</motion.div>
)}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45 }}
className="relative overflow-hidden rounded-3xl border border-slate-200/60 bg-white/80 p-6 shadow-xl backdrop-blur-xl dark:border-slate-800/70 dark:bg-slate-950/60"
>
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="bg-linear-to-r absolute -top-24 left-1/2 h-56 w-96 -translate-x-1/2 rounded-full from-blue-400/50 to-indigo-400/50 blur-3xl dark:from-blue-500/25 dark:to-indigo-500/25" />
<div className="bg-linear-to-br absolute -bottom-16 -right-12 h-40 w-40 rounded-full from-slate-200/70 to-transparent blur-3xl dark:from-slate-800/40" />
</div>
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-xl space-y-3">
<h1 className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">
Finalize your AniList library updates
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Review detected changes, tune your syncing preferences, and
push a polished update to AniList.
</p>
</div>
<div className="grid w-full gap-3 sm:grid-cols-3 md:w-auto">
{heroStats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="relative overflow-hidden rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm backdrop-blur-lg dark:border-slate-800/70 dark:bg-slate-950/70"
>
<div
className={`bg-linear-to-br pointer-events-none absolute inset-0 ${stat.accent} opacity-80`}
/>
<div className="relative flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="pr-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{stat.label}
</span>
<Icon className="h-4 w-4 text-slate-400 dark:text-slate-500" />
</div>
<span className="text-2xl font-semibold text-slate-900 dark:text-slate-100">
{stat.value}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{stat.helper}
</span>
</div>
</div>
);
})}
</div>
</div>
</motion.section>
<Card className="border border-slate-200/70 bg-white/80 shadow-2xl backdrop-blur-xl dark:border-slate-800/70 dark:bg-slate-950/60">
<CardHeader className="space-y-2">
<CardTitle className="bg-linear-to-r from-slate-900 via-slate-700 to-slate-900 bg-clip-text text-2xl font-semibold text-transparent dark:from-slate-100 dark:via-slate-300 dark:to-slate-100">
Sync Preview
</CardTitle>
<CardDescription className="text-sm text-slate-600 dark:text-slate-400">
Review the changes that will be applied to your AniList account
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/80 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/40">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Sync readiness
</span>
<div className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{entriesWithChanges.length} of {totalMatchedManga}{" "}
entries prepared
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Automatically excludes skipped matches and completed
entries preserved per your settings.
</p>
</div>
<div className="flex w-full items-center gap-3 sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-blue-200/80 bg-white/80 text-sm font-semibold text-blue-600 dark:border-blue-700/80 dark:bg-blue-900/20 dark:text-blue-300">
{queuedPercentage}%
</div>
<div className="min-w-[140px] flex-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800/60">
<div
className="bg-linear-to-r h-full rounded-full from-blue-500 via-indigo-500 to-purple-500 transition-all duration-300"
style={{
width: `${Math.min(queuedPercentage, 100)}%`,
}}
/>
</div>
</div>
</div>
</div>
</div>
<SyncConfigurationPanel
syncConfig={syncConfig}
setSyncConfig={setSyncConfig}
isCustomThresholdEnabled={isCustomThresholdEnabled}
setIsCustomThresholdEnabled={setIsCustomThresholdEnabled}
handleToggleOption={handleToggleOption}
/>
<ChangesSummary
entriesWithChanges={entriesWithChanges.length}
libraryLoading={libraryLoading}
libraryError={libraryError}
isRateLimited={rateLimitState.isRateLimited}
onLibraryRefresh={handleLibraryRefresh}
userLibrary={userLibrary}
mangaMatches={mangaMatches}
syncConfig={syncConfig}
/>
<ViewControls
displayMode={displayMode}
setDisplayMode={setDisplayMode}
sortOption={sortOption}
setSortOption={setSortOption}
filters={filters}
setFilters={setFilters}
/>
{sortedMangaMatches.length !== mangaMatches.length && (
<div className="flex items-center justify-between rounded-xl border border-slate-200/70 bg-slate-50/70 px-3 py-2 text-sm shadow-sm dark:border-slate-800/60 dark:bg-slate-900/50">
<div>
Showing{" "}
<span className="font-medium">
{sortedMangaMatches.length}
</span>{" "}
of{" "}
<span className="font-medium">
{
mangaMatches.filter(
(match) => match.status !== "skipped",
).length
}
</span>{" "}
manga
{Object.values(filters).some((v) => v !== "all") && (
<span className="text-muted-foreground ml-1">
(filtered)
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 rounded-full border border-transparent px-3 text-xs hover:border-slate-300 hover:bg-slate-100 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
onClick={() => {
setSortOption({ field: "title", direction: "asc" });
setFilters({
status: "all",
changes: "with-changes",
library: "all",
});
}}
>
Clear Filters
</Button>
</div>
)}
<div className="max-h-[60vh] overflow-y-auto">
<AnimatePresence
initial={false}
mode="wait"
key={displayMode}
>
{displayMode === "cards"
? renderCardsView()
: renderCompactView()}
</AnimatePresence>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3 border-t border-slate-200/60 pt-6 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800/80">
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span
className={`inline-flex h-2 w-2 rounded-full ${entriesWithChanges.length > 0 ? "bg-emerald-500" : "bg-amber-500"}`}
></span>
{entriesWithChanges.length > 0
? `${entriesWithChanges.length} entries ready to sync`
: "No actionable changes detected yet"}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
variant="outline"
onClick={handleCancel}
className="w-full border-slate-300/70 text-slate-700 hover:border-slate-400 hover:bg-slate-100 sm:w-auto dark:border-slate-700/70 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-900"
>
Cancel
</Button>
<Button
onClick={handleStartSync}
disabled={
entriesWithChanges.length === 0 ||
libraryLoading ||
state.resumeAvailable ||
!isOnline
}
className="bg-linear-to-r group relative w-full overflow-hidden rounded-md from-blue-500 via-indigo-500 to-purple-500 px-6 py-2 font-semibold text-white shadow-lg transition hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto"
data-onboarding="sync-button"
title={getStartSyncButtonTitle()}
>
<span className="absolute inset-0 bg-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<span className="relative flex items-center justify-center gap-2">
<Sparkles className="h-4 w-4" />
Launch Sync
</span>
</Button>
</div>
</CardFooter>
</Card>
</motion.div>
</motion.div>
);
};
// Render cards view
const renderCardsView = () => {
return (
<motion.div
key="cards-view"
variants={fadeVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={viewModeTransition}
className="grid grid-cols-1 gap-4"
>
<AnimatePresence initial={false}>
{sortedMangaMatches
.slice(0, visibleItems)
.map((match, index) => renderCardItem(match, index))}
</AnimatePresence>
{renderLoadMoreButton()}
</motion.div>
);
};
// Render compact view
const renderCompactView = () => {
return (
<motion.div
key="compact-view"
variants={fadeVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={viewModeTransition}
className="space-y-1 overflow-hidden rounded-2xl border border-slate-200/70 bg-white/70 shadow-sm backdrop-blur-sm dark:border-slate-800/60 dark:bg-slate-950/50"
>
<AnimatePresence initial={false}>
{sortedMangaMatches
.slice(0, visibleItems)
.map((match, index) => renderCompactItem(match, index))}
</AnimatePresence>
{renderLoadMoreButtonCompact()}
</motion.div>
);
};
// Render individual card item
const renderCardItem = (match: MangaMatchResult, index: number) => {
const kenmei = match.kenmeiManga;
const anilist = match.selectedMatch;
const userEntry = anilist ? userLibrary[anilist.id] : undefined;
const {
statusWillChange,
progressWillChange,
scoreWillChange,
isNewEntry,
isCompleted,
changeCount,
} = calculateSyncChanges(kenmei, userEntry, syncConfig);
return (
<motion.div
key={`${anilist?.id}-${index}`}
layout="position"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
layoutId={undefined}
>
<Card className="group overflow-hidden border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-blue-200/70 hover:shadow-xl dark:border-slate-800/60 dark:bg-slate-950/60">
<div className="flex flex-col sm:flex-row">
{renderMangaCover(anilist, isNewEntry, isCompleted)}
<div className="flex-1 p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="line-clamp-2 max-w-[580px] text-base font-semibold">
{anilist?.title.romaji || kenmei.title}
</h3>
{changeCount > 0 && !isCompleted ? (
renderChangeBadges(
statusWillChange,
progressWillChange,
scoreWillChange,
userEntry,
syncConfig,
)
) : (
<div className="mt-1">
<Badge
variant="outline"
className="border-slate-200/70 bg-slate-100/70 px-1.5 py-0 text-xs text-slate-500 dark:border-slate-700/60 dark:bg-slate-800/50 dark:text-slate-400"
>
{isCompleted ? "Preserving Completed" : "No Changes"}
</Badge>
</div>
)}
</div>
{changeCount > 0 && !isCompleted && (
<div className="rounded-full bg-blue-100/80 px-2 py-1 text-xs font-medium text-blue-600 shadow-sm dark:bg-blue-900/30 dark:text-blue-300">
{changeCount} change{changeCount === 1 ? "" : "s"}
</div>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm">
<div className="rounded-xl border border-slate-200/60 bg-white/70 p-3 shadow-sm dark:border-slate-800/60 dark:bg-slate-900/40">
<h4 className="text-muted-foreground mb-2 text-xs font-medium">
{isNewEntry ? "Not in Library" : "Current AniList"}
</h4>
{isNewEntry ? (
<div className="text-muted-foreground py-4 text-center text-xs">
New addition to your library
</div>
) : (
renderCurrentAniListData(
userEntry,
anilist,
statusWillChange,
progressWillChange,
scoreWillChange,
syncConfig,
)
)}
</div>
<div className="rounded-xl border border-blue-100/60 bg-blue-50/70 p-3 shadow-sm dark:border-blue-900/40 dark:bg-blue-900/20">
<h4 className="mb-2 text-xs font-medium text-blue-600 dark:text-blue-300">
After Sync
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-blue-500 dark:text-blue-400">
Status:
</span>
<span
className={`text-xs font-medium ${statusWillChange ? "text-blue-700 dark:text-blue-300" : ""}`}
>
{getEffectiveStatus(kenmei, syncConfig)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-blue-500 dark:text-blue-400">
Progress:
</span>
{renderAfterSyncProgress(
userEntry,
kenmei,
anilist,
syncConfig,
progressWillChange,
)}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-blue-500 dark:text-blue-400">
Score:
</span>
{renderAfterSyncScore(userEntry, kenmei, scoreWillChange)}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-blue-500 dark:text-blue-400">
Private:
</span>
{renderPrivacyDisplay(userEntry, syncConfig, false)}
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
</motion.div>
);
};
// Render individual compact item
const renderCompactItem = (match: MangaMatchResult, index: number) => {
const kenmei = match.kenmeiManga;
const anilist = match.selectedMatch;
const userEntry = anilist ? userLibrary[anilist.id] : undefined;
const {
statusWillChange,
progressWillChange,
scoreWillChange,
isNewEntry,
isCompleted,
changeCount,
} = calculateSyncChanges(kenmei, userEntry, syncConfig);
const baseRowClasses =
"group flex items-center rounded-xl px-3 py-2 transition-colors duration-200";
let backgroundClass = "";
if (isCompleted) {
backgroundClass = "bg-amber-50/70 dark:bg-amber-950/20";
} else if (isNewEntry) {
backgroundClass = "bg-emerald-50/70 dark:bg-emerald-950/20";
} else if (index % 2 === 0) {
backgroundClass = "bg-white/70 dark:bg-slate-900/40";
} else {
backgroundClass = "bg-white/60 dark:bg-slate-900/30";
}
return (
<motion.div
key={
(anilist?.id ? String(anilist.id) : "unknown-" + index) + "-" + index
}
layout="position"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
layoutId={undefined}
>
<div
className={`${baseRowClasses} ${backgroundClass} hover:bg-blue-50/70 dark:hover:bg-slate-900/60`}
>
<div className="mr-3 flex shrink-0 items-center pl-2">
{anilist &&
(anilist.coverImage?.large || anilist.coverImage?.medium) ? (
<motion.div
layout="position"
animate={{
transition: { type: false },
}}
>
<img
src={anilist.coverImage?.large || anilist.coverImage?.medium}
alt={anilist.title.romaji || ""}
className="h-12 w-8 rounded-sm object-cover"
/>
</motion.div>
) : (
<div className="flex h-12 w-8 items-center justify-center rounded-sm bg-slate-200 dark:bg-slate-800">
<span className="text-muted-foreground text-[8px]">
No Cover
</span>
</div>
)}
</div>
<div className="mr-2 min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{anilist?.title.romaji || kenmei.title}
</div>
<div className="mt-0.5 flex items-center gap-1">
{isNewEntry && (
<Badge className="bg-emerald-500/80 px-2 py-0 text-[10px] text-white shadow-sm">
New
</Badge>
)}
{isCompleted && (
<Badge
variant="outline"
className="border-amber-400/70 bg-amber-50/60 px-2 py-0 text-[10px] text-amber-600 shadow-sm dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-300"
>
Completed
</Badge>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{!isNewEntry && !isCompleted && (
<>
{renderStatusBadge(
statusWillChange,
userEntry,
kenmei,
syncConfig,
)}
{renderProgressBadge(
progressWillChange,
userEntry,
kenmei,
syncConfig,
)}
{renderScoreBadge(scoreWillChange, userEntry, kenmei)}
{renderPrivacyBadge(userEntry, syncConfig)}
{changeCount === 0 && (
<span className="px-1 text-[10px] text-slate-500 dark:text-slate-400">
No Changes
</span>
)}
</>
)}
{isNewEntry && (
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Adding to Library
</span>
)}
{isCompleted && (
<span className="text-[10px] font-medium text-amber-600 dark:text-amber-400">
Preserving Completed
</span>
)}
</div>
</div>
</motion.div>
);
};
// Render load more button for cards view
const renderLoadMoreButton = () => {
if (sortedMangaMatches.length > visibleItems) {
return (
<div className="py-4 text-center">
<Button
onClick={() => {
setIsLoadingMore(true);
const newValue = Math.min(
visibleItems + 20,
sortedMangaMatches.length,
);
setTimeout(() => {
setVisibleItems(newValue);
setIsLoadingMore(false);
}, 300);
}}
variant="outline"
className="gap-2"
disabled={isLoadingMore}
>
{isLoadingMore && (
<div className="text-primary inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
)}
{isLoadingMore
? "Loading..."
: `Load More (${visibleItems} of ${sortedMangaMatches.length})`}
</Button>
</div>
);
}
if (
sortedMangaMatches.length > 0 &&
sortedMangaMatches.length <= visibleItems
) {
return (
<div className="py-4 text-center">
<span className="text-muted-foreground text-xs">
All items loaded
</span>
</div>
);
}
return null;
};
// Render load more button for compact view
const renderLoadMoreButtonCompact = () => {
if (sortedMangaMatches.length === 0) return null;
return (
<div className="bg-muted/20 py-3 text-center">
{sortedMangaMatches.length > visibleItems ? (
<Button
onClick={() => {
setIsLoadingMore(true);
const newValue = Math.min(
visibleItems + 20,
sortedMangaMatches.length,
);
setTimeout(() => {
setVisibleItems(newValue);
setIsLoadingMore(false);
}, 300);
}}
variant="outline"
size="sm"
className="gap-2"
disabled={isLoadingMore}
>
{isLoadingMore && (
<div className="text-primary inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
)}
{isLoadingMore
? "Loading..."
: `Load More (${visibleItems} of ${sortedMangaMatches.length})`}
</Button>
) : (
<span className="text-muted-foreground text-xs">
All items loaded
</span>
)}
</div>
);
};
// Render sync view
const renderSyncView = () => {
return (
<motion.div
variants={pageVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<SyncManager
entries={entriesWithChanges}
token={token || ""}
onComplete={handleSyncComplete}
onCancel={handleCancel}
autoStart={false}
syncState={state}
syncActions={{
...actions,
startSync: (entries, token, _unused, displayOrderMediaIds) =>
actions.startSync(entries, token, _unused, displayOrderMediaIds),
}}
incrementalSync={syncConfig.incrementalSync}
onIncrementalSyncChange={(value) => {
const newConfig = { ...syncConfig, incrementalSync: value };
setSyncConfig(newConfig);
saveSyncConfig(newConfig);
}}
displayOrderMediaIds={entriesWithChanges
.filter(Boolean)
.map((e) => e.mediaId)}
/>
</motion.div>
);
};
// Render results view
const renderResultsView = () => {
if (state.report || wasCancelled) {
return (
<motion.div
variants={pageVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{state.report ? (
<SyncResultsView
report={state.report}
onClose={handleGoHome}
onExportErrors={() =>
state.report && exportSyncErrorLog(state.report)
}
/>
) : (
<div className="flex min-h-[300px] flex-col items-center justify-center">
<div className="mb-4">
<Loader2 className="h-10 w-10 animate-spin text-blue-500" />
</div>
<div className="text-lg font-medium text-blue-700 dark:text-blue-300">
Loading synchronization results...
</div>
</div>
)}
<div className="mt-6 flex justify-center gap-4">
<Button onClick={handleGoHome} variant="default">
Go Home
</Button>
<Button onClick={handleBackToReview} variant="outline">
Back to Sync Review
</Button>
</div>
{wasCancelled && (
<div className="mt-4 text-center text-amber-600 dark:text-amber-400">
Synchronization was cancelled. No further entries will be
processed.
</div>
)}
</motion.div>
);
}
return (
<motion.div
variants={pageVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<Card className="mx-auto w-full max-w-md p-6 text-center">
<CardContent>
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-red-500" />
<h3 className="text-lg font-medium">Synchronization Error</h3>
<p className="mt-2 text-sm text-slate-500">
{state.error ||
"An unknown error occurred during synchronization."}
</p>
</CardContent>
<CardFooter className="justify-center gap-4">
<Button onClick={handleGoHome}>Go Home</Button>
<Button onClick={handleBackToReview} variant="outline">
Back to Sync Review
</Button>
</CardFooter>
</Card>
</motion.div>
);
};
// Render the appropriate view based on state
const renderContent = () => {
switch (viewMode) {
case "preview":
return renderPreviewView();
case "sync":
return renderSyncView();
case "results":
return renderResultsView();
}
};
return (
<div className="relative min-h-0 overflow-hidden">
<SyncErrorBoundary
onReset={handleSyncReset}
onRetryFailed={handleRetryFailedFromBoundary}
onCancelSync={handleCancelSyncFromBoundary}
>
<div className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute left-[8%] top-[-10%] h-64 w-64 rounded-full bg-blue-200/50 blur-3xl dark:bg-blue-500/20" />
<div className="absolute right-[-12%] top-1/3 h-80 w-80 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/15" />
<div className="h-104 w-160 bg-linear-to-t absolute bottom-[-20%] left-1/2 -translate-x-1/2 from-slate-100 via-transparent to-transparent opacity-80 dark:from-slate-900/40" />
</div>
<motion.div
className="container relative z-10 py-10"
initial="hidden"
animate="visible"
variants={pageVariants}
>
<AnimatePresence mode="wait">{renderContent()}</AnimatePresence>
</motion.div>
</SyncErrorBoundary>
</div>
);
}
Sync page component for the Kenmei to AniList sync tool.
Handles synchronization preview, configuration, execution, and results display for the user.