forked from akai/readest
302363a9fd
* 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>
375 lines
12 KiB
TypeScript
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;
|