Files
readest/apps/readest-app/src/app/user/page.tsx
T
Huang Xin 302363a9fd feat(sync): per-category sync gates + Manage Sync UI (#4099)
* feat(sync): add per-category sync gates + Manage Sync UI

The user can now enable / disable each sync category independently
in Settings → Data Sync (User page). The map syncs across devices
via the bundled `settings` replica, defaults to enabled so the
preference is opt-out, and applies on both push (`replicaPublish`,
legacy `useSync`) and pull (`useReplicaPull`, `useSync`) without
backfilling on re-enable.

Categories:
- `book` / `progress` / `note` — gate the legacy `SyncClient` paths
- `dictionary` / `font` / `texture` / `opds_catalog` — gate the
  replica-sync pulls + publishes for those kinds
- `settings` — togglable, but force-on while `dictionary` is enabled
  because the dictionary's `providerOrder` / `providerEnabled` /
  `webSearches` live in the bundled settings replica. The UI
  shows the locked toggle as blue (enabled) with a hint instead of
  greying it out, since the underlying state IS on.

UI:
- New `SyncCategoriesSection` lists every category with a
  description and a daisyUI toggle.
- New `Manage Sync` blue action on the User page (second slot,
  right after `Manage Subscription`); also surfaces inside the
  library `Advanced Settings` menu, deep-linking via
  `/user?section=sync`.
- `SyncPassphraseSection` moved into the Manage Sync panel
  alongside the categories list. `Unlock now` button removed —
  the gate fires automatically on first encrypted push/pull and
  the manual unlock affordance was confusing.

Adjacent cleanups:
- `LangPanel` Dictionaries card gets `overflow-hidden` so the
  hover highlight clips to the card's rounded corners.
- `FontPanel` gear icon replaced with a `Manage Fonts` row that
  matches the `Manage Dictionaries` pattern.

i18n: extracted + translated 31 in-scope locales for the new
strings (`Manage Sync`, `Data Sync`, `Manage Fonts`, plus the
category copy block).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate new sync-categories strings across 30 locales

Adds translations for the four strings extracted after the latest
SyncCategoriesSection iteration:

- `App settings` — toggle label for the bundled-settings sync gate
- `Theme, highlight colours, integrations (KOSync, Readwise,
  Hardcover), and dictionary order` — description under that toggle
- `Required while Dictionaries sync is enabled` — hint shown when
  the toggle is locked because dictionary sync depends on settings
- `Unavailable` — `(Unavailable)` suffix on disabled translator
  providers; was missing from most locales until i18next-scanner
  picked it up this run

Product names (KOSync, Readwise, Hardcover) left in Latin script.
`Dictionaries` references in the third string reuse each locale's
existing translation. `pt-BR` and `uz` deliberately untouched (out
of the in-scope set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): fill in pt-BR for the new sync-categories strings

`pt-BR` is a registered, shipped locale (Portuguese (Brasil)) that
fell through the gaps in earlier batch runs because it isn't listed
in the i18n skill's locale-reference table. The fallback chain
`pt-BR → pt → en` softened the impact, but BR-specific phrasing
needs its own translations for the 20 new keys this PR added.

`uz` stays excluded — that locale isn't registered anywhere
(missing from i18next-scanner.config.cjs, src/i18n/i18n.ts, and
TRANSLATED_LANGS), so its translation file is dead code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate uz for the new sync-categories strings

`uz` is a registered locale (listed in `i18n-langs.json` and
`TRANSLATED_LANGS` as `'Oʻzbek'`) but earlier batch translation
runs excluded it because the i18n skill's static locale-reference
table was incomplete. Filling in the 20 strings this PR added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent): update i18n skill

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:52:09 +02:00

375 lines
12 KiB
TypeScript

'use client';
import clsx from 'clsx';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useUserActions } from '@/hooks/useUserActions';
import { useAvailablePlans } from '@/hooks/useAvailablePlans';
import type { PlanType } from '@/types/quota';
import { navigateToLibrary } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { getPlanDetails } from './utils/plan';
import { Toast } from '@/components/Toast';
import {
purchaseIAPProduct,
restoreIAPPurchases,
getSubscriptionSuccessUrl as getIAPSubscriptionSuccessUrl,
} from '@/libs/payment/iap/client';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
import {
createStripeCheckoutSession,
redirectToStripeCheckout,
createStripePortalSession,
redirectToStripePortal,
handleStripeCheckoutError,
getSubscriptionSuccessUrl as getStripeSubscriptionSuccessUrl,
type StripeAvailablePlan,
} from '@/libs/payment/stripe/client';
import LegalLinks from '@/components/LegalLinks';
import Spinner from '@/components/Spinner';
import ProfileHeader from './components/Header';
import UserInfo from './components/UserInfo';
import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import SharedLinksSection from './components/SharedLinksSection';
import { SyncPassphraseSection } from './components/SyncPassphraseSection';
import { SyncCategoriesSection } from './components/SyncCategoriesSection';
import Checkout from './components/Checkout';
type CheckoutState = {
clientSecret: string;
sessionId: string;
planName: string;
};
const ProfilePage = () => {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { token, user, refresh } = useAuth();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const [loading, setLoading] = useState(false);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [showStorageManager, setShowStorageManager] = useState(false);
const [showSharedLinksManager, setShowSharedLinksManager] = useState(false);
const searchParams = useSearchParams();
const [showSyncManager, setShowSyncManager] = useState(
() => searchParams?.get('section') === 'sync',
);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
sessionId: '',
planName: '',
});
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!mounted) return;
const isAuthenticated = user && token && appService;
if (isAuthenticated) return;
const timer = setTimeout(() => {
router.push('/auth?redirect=/library');
}, 1000);
return () => clearTimeout(timer);
}, [mounted, user, token, appService, router]);
useTheme({ systemUIVisible: false });
const { quotas, userProfilePlan = 'free' } = useQuotaStats();
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
useUserActions();
const { availablePlans, iapAvailable } = useAvailablePlans({
hasIAP: appService?.hasIAP || false,
onError: useCallback(
(message: string) => {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _(message),
});
},
[_],
),
});
const handleGoBack = () => {
if (showEmbeddedCheckout) {
setShowEmbeddedCheckout(false);
} else if (showStorageManager) {
setShowStorageManager(false);
refresh();
} else if (showSharedLinksManager) {
setShowSharedLinksManager(false);
} else if (showSyncManager) {
setShowSyncManager(false);
} else {
navigateToLibrary(router);
}
};
const handleStripeSubscribe = async (productId?: string, planType: PlanType = 'subscription') => {
if (!productId) return;
setLoading(true);
try {
const { sessionId, clientSecret, url } = await createStripeCheckoutSession(
productId,
planType,
);
const foundPlan = availablePlans.find((plan) => plan.productId === productId);
if (!foundPlan) {
throw new Error(`Plan not found for product ID: ${productId}`);
}
const selectedPlan = foundPlan as StripeAvailablePlan;
const planName = selectedPlan.product?.name || selectedPlan.productName;
const isEmbeddedCheckout = isTauriAppPlatform();
if (isEmbeddedCheckout && sessionId && clientSecret) {
setShowEmbeddedCheckout(true);
setCheckoutState({
planName,
clientSecret,
sessionId,
});
} else {
await redirectToStripeCheckout(url);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
handleStripeCheckoutError(errorMessage);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to create checkout session'),
});
} finally {
setLoading(false);
}
};
const handleCheckoutSuccess = useCallback(
(sessionId: string) => {
setShowEmbeddedCheckout(false);
router.push(getStripeSubscriptionSuccessUrl(sessionId));
},
[router],
);
const handleIAPSubscribe = async (productId?: string) => {
if (!productId) return;
setLoading(true);
try {
const purchase = await purchaseIAPProduct(productId);
if (purchase) {
router.push(getIAPSubscriptionSuccessUrl(purchase));
}
} catch (error) {
console.error('IAP purchase error:', error);
} finally {
setLoading(false);
}
};
const handleIAPRestorePurchase = async () => {
setLoading(true);
try {
const purchases = await restoreIAPPurchases();
if (purchases.length > 0) {
const restoredSubscriptions = purchases
.filter((p) => !isPurchaseProduct(p.productId))
.sort((a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime());
const purchase = restoredSubscriptions[0];
if (!purchase) {
throw new Error('No subscription found in restored purchases');
}
router.push(getIAPSubscriptionSuccessUrl(purchase));
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No purchases found to restore.'),
});
}
} catch (error) {
console.error('Failed to restore purchases:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to restore purchases.'),
});
}
setLoading(false);
};
const handleManageSubscription = async () => {
setLoading(true);
try {
const url = await createStripePortalSession();
await redirectToStripePortal(url);
} catch (error) {
console.error('Error creating portal session:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to manage subscription.'),
});
} finally {
setLoading(false);
}
};
const handleDeleteWithMessage = () => {
handleConfirmDelete(_('Failed to delete user. Please try again later.'));
};
const handleManageStorage = () => {
setShowStorageManager(true);
};
const handleManageSharedLinks = () => {
setShowSharedLinksManager(true);
};
const handleManageSync = () => {
setShowSyncManager(true);
};
if (!mounted) {
return null;
}
if (!user || !token || !appService) {
return (
<div className='mx-auto max-w-4xl px-4 py-8'>
<div className='overflow-hidden rounded-lg shadow-md'>
<div className='flex min-h-[300px] items-center justify-center p-6'>
<div className='text-base-content animate-pulse'>{_('Loading profile...')}</div>
</div>
</div>
</div>
);
}
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'] || '-';
const userEmail = user?.email || '';
const userPlanDetails =
getPlanDetails(userProfilePlan, availablePlans) || getPlanDetails('free', availablePlans);
return (
<div
className={clsx(
'bg-base-100 full-height inset-0 select-none overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<div
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading className='text-gray-900' />
</div>
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={userPlanDetails}
/>
{!showStorageManager && !showSharedLinksManager && !showSyncManager && (
<UsageStats quotas={quotas} />
)}
</div>
{showStorageManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<StorageManager />
</div>
) : showSharedLinksManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<SharedLinksSection />
</div>
) : showSyncManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<SyncCategoriesSection />
<SyncPassphraseSection />
</div>
) : (
<>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={
appService.hasIAP && iapAvailable
? handleIAPSubscribe
: handleStripeSubscribe
}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
iapAvailable={iapAvailable}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
onManageStorage={handleManageStorage}
onManageSharedLinks={handleManageSharedLinks}
onManageSync={handleManageSync}
/>
</div>
</>
)}
<LegalLinks />
</div>
</div>
)}
</div>
<Toast />
</div>
</div>
);
};
export default ProfilePage;