d1e7b4902c
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import clsx from 'clsx';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } 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 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 [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 {
|
|
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);
|
|
};
|
|
|
|
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 && <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>
|
|
) : (
|
|
<>
|
|
<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}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<LegalLinks />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Toast />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProfilePage;
|