a30efe49c1
The labels (Added to your library / Waiting to be processed / Processing… / Failed) went through `_(activityStatusLabel(item.status))`, a dynamic key the i18next-scanner cannot extract — so non-English locales rendered them in English. Inline the four literal `_()` calls into the JSX so the scanner picks them up. Translates the two missing keys in all 33 non-English locales. Also sweeps three pre-existing untranslated System Dictionary keys that were introduced in #4219. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { MdContentCopy, MdRefresh, MdCheck, MdClose, MdAdd } from 'react-icons/md';
|
|
import { RiSendPlaneLine } from 'react-icons/ri';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { fetchWithAuth } from '@/utils/fetch';
|
|
import { getAPIBaseUrl } from '@/services/environment';
|
|
import { isInboxDrainEnabled, setInboxDrainEnabled } from '@/services/send/devicePrefs';
|
|
import { navigateToLogin } from '@/utils/nav';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import type { DBSendAllowedSender, DBSendInboxItem } from '@/types/sendRecords';
|
|
import SubPageHeader from '../SubPageHeader';
|
|
import { BoxedList, SectionTitle, SettingLabel, SettingsSwitchRow } from '../primitives';
|
|
|
|
interface SendToReadestFormProps {
|
|
onBack: () => void;
|
|
}
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
/** Pull the editable slug out of a `{slug}-{token}@domain` address. */
|
|
function slugOf(address: string): string {
|
|
const local = address.split('@')[0] ?? '';
|
|
const dash = local.lastIndexOf('-');
|
|
return dash > 0 ? local.slice(0, dash) : local;
|
|
}
|
|
|
|
/** The fixed `-{token}@domain` part shown after the editable slug. */
|
|
function suffixOf(address: string): string {
|
|
return address.slice(slugOf(address).length);
|
|
}
|
|
|
|
const SendToReadestForm: React.FC<SendToReadestFormProps> = ({ onBack }) => {
|
|
const _ = useTranslation();
|
|
const router = useRouter();
|
|
const { user } = useAuth();
|
|
const apiBase = getAPIBaseUrl();
|
|
|
|
const [address, setAddress] = useState<string>('');
|
|
const [senders, setSenders] = useState<DBSendAllowedSender[]>([]);
|
|
const [activity, setActivity] = useState<DBSendInboxItem[]>([]);
|
|
const [newEmail, setNewEmail] = useState('');
|
|
const [slugInput, setSlugInput] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [drainEnabled, setDrainEnabled] = useState(() => isInboxDrainEnabled());
|
|
// Editing affordances stay collapsed once configured, keeping the panel
|
|
// minimal; the refresh / plus icons reveal the input rows.
|
|
const [editingAddress, setEditingAddress] = useState(false);
|
|
const [addingSender, setAddingSender] = useState(false);
|
|
|
|
const toast = (message: string, type: 'info' | 'error' | 'success' = 'info') =>
|
|
eventDispatcher.dispatch('toast', { message, type, timeout: 2500 });
|
|
|
|
const toggleDrain = () => {
|
|
const next = !drainEnabled;
|
|
setDrainEnabled(next);
|
|
setInboxDrainEnabled(next);
|
|
};
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [addrRes, sendersRes, inboxRes] = await Promise.all([
|
|
fetchWithAuth(`${apiBase}/send/address`, { method: 'GET' }),
|
|
fetchWithAuth(`${apiBase}/send/senders`, { method: 'GET' }),
|
|
fetchWithAuth(`${apiBase}/send/inbox`, { method: 'GET' }),
|
|
]);
|
|
const addrData = (await addrRes.json()) as { address: string };
|
|
const sendersData = (await sendersRes.json()) as { senders: DBSendAllowedSender[] };
|
|
const inboxData = (await inboxRes.json()) as { items: DBSendInboxItem[] };
|
|
setAddress(addrData.address);
|
|
setSlugInput(slugOf(addrData.address));
|
|
setSenders(sendersData.senders);
|
|
setActivity(inboxData.items);
|
|
} catch {
|
|
toast(_('Could not load Send to Readest settings'), 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [apiBase]);
|
|
|
|
useEffect(() => {
|
|
if (user) void load();
|
|
}, [user, load]);
|
|
|
|
const copyAddress = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(address);
|
|
toast(_('Address copied'), 'success');
|
|
} catch {
|
|
toast(_('Could not copy address'), 'error');
|
|
}
|
|
};
|
|
|
|
// Saving issues a fresh address: the chosen slug + a new token suffix.
|
|
// (Leaving the slug unchanged and saving is also how you rotate a leaked
|
|
// address — the token is always regenerated.)
|
|
const saveAddress = async () => {
|
|
const slug = slugInput.trim();
|
|
if (!slug) {
|
|
toast(_('Enter a name for your address'), 'error');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetchWithAuth(`${apiBase}/send/address`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ slug }),
|
|
});
|
|
const data = (await res.json()) as { address: string };
|
|
setAddress(data.address);
|
|
setSlugInput(slugOf(data.address));
|
|
setEditingAddress(false);
|
|
toast(_('Address updated'), 'success');
|
|
} catch (err) {
|
|
toast(err instanceof Error ? err.message : _('Could not update address'), 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const addSender = async () => {
|
|
const email = newEmail.trim().toLowerCase();
|
|
if (!EMAIL_RE.test(email)) {
|
|
toast(_('Enter a valid email address'), 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetchWithAuth(`${apiBase}/send/senders`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
const data = (await res.json()) as { sender: DBSendAllowedSender };
|
|
setSenders((prev) => [...prev.filter((s) => s.id !== data.sender.id), data.sender]);
|
|
setNewEmail('');
|
|
setAddingSender(false);
|
|
} catch {
|
|
toast(_('Could not add sender'), 'error');
|
|
}
|
|
};
|
|
|
|
const approveSender = async (id: string) => {
|
|
try {
|
|
await fetchWithAuth(`${apiBase}/send/senders`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id }),
|
|
});
|
|
setSenders((prev) => prev.map((s) => (s.id === id ? { ...s, status: 'approved' } : s)));
|
|
} catch {
|
|
toast(_('Could not approve sender'), 'error');
|
|
}
|
|
};
|
|
|
|
const removeSender = async (id: string) => {
|
|
try {
|
|
await fetchWithAuth(`${apiBase}/send/senders`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id }),
|
|
});
|
|
setSenders((prev) => prev.filter((s) => s.id !== id));
|
|
} catch {
|
|
toast(_('Could not remove sender'), 'error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className='my-4 w-full'>
|
|
<SubPageHeader
|
|
parentLabel={_('Integrations')}
|
|
currentLabel={_('Send to Readest')}
|
|
description={_('Email books and articles straight into your library.')}
|
|
onBack={onBack}
|
|
/>
|
|
|
|
{!user ? (
|
|
<div className='flex flex-col items-center gap-4 px-6 py-16 text-center'>
|
|
<span className='bg-base-200 text-base-content/60 flex h-16 w-16 items-center justify-center rounded-full'>
|
|
<RiSendPlaneLine className='h-7 w-7' />
|
|
</span>
|
|
<p className='text-base-content/70 max-w-xs text-sm leading-relaxed'>
|
|
{_('Sign in to send books and articles to your library.')}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='btn btn-contrast btn-sm'
|
|
onClick={() => navigateToLogin(router)}
|
|
>
|
|
{_('Sign in')}
|
|
</button>
|
|
</div>
|
|
) : loading ? (
|
|
<div className='space-y-6' aria-busy='true'>
|
|
{[
|
|
{ key: 'address', card: 'h-28' },
|
|
{ key: 'senders', card: 'h-32' },
|
|
{ key: 'activity', card: 'h-24' },
|
|
].map((section) => (
|
|
<div key={section.key} className='w-full'>
|
|
<div className='skeleton mb-2 h-4 w-36' />
|
|
<div className={`skeleton w-full rounded-xl ${section.card}`} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className='space-y-6'>
|
|
<div className='w-full'>
|
|
<SectionTitle className='mb-2'>{_('Your inbound address')}</SectionTitle>
|
|
<div className='card eink-bordered border-base-200 bg-base-100 divide-base-200 overflow-hidden border'>
|
|
<div className='flex items-center gap-2 px-4 py-3'>
|
|
<code className='min-w-0 flex-1 truncate text-sm'>{address}</code>
|
|
{address && (
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-sm eink-bordered'
|
|
onClick={() => setEditingAddress((v) => !v)}
|
|
aria-label={_('Change address name')}
|
|
>
|
|
<MdRefresh className='h-4 w-4' />
|
|
</button>
|
|
)}
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-sm eink-bordered'
|
|
onClick={copyAddress}
|
|
aria-label={_('Copy address')}
|
|
>
|
|
<MdContentCopy className='h-4 w-4' />
|
|
</button>
|
|
</div>
|
|
{(editingAddress || !address) && (
|
|
<div className='border-base-200 flex items-center gap-2 border-t px-4 py-3'>
|
|
<input
|
|
type='text'
|
|
className='input input-sm input-bordered eink-bordered min-w-0 flex-1'
|
|
value={slugInput}
|
|
onChange={(e) => setSlugInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void saveAddress();
|
|
}}
|
|
aria-label={_('Customize your address name')}
|
|
placeholder={_('your-name')}
|
|
/>
|
|
<span className='text-base-content/55 shrink-0 text-xs'>{suffixOf(address)}</span>
|
|
<button
|
|
type='button'
|
|
className='btn btn-contrast btn-sm'
|
|
onClick={saveAddress}
|
|
disabled={saving || !slugInput.trim()}
|
|
>
|
|
<MdRefresh className='h-4 w-4' />
|
|
{_('Save')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className='text-base-content/65 mt-1 ps-4 text-[0.8em] leading-relaxed'>
|
|
{_('Email a book or document to this address from an approved sender below.')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className='w-full'>
|
|
<SectionTitle className='mb-2'>{_('Approved senders')}</SectionTitle>
|
|
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
|
|
<div className='divide-base-200 divide-y'>
|
|
{senders.length === 0 && (
|
|
<div className='text-base-content/60 px-4 py-3 text-sm'>
|
|
{_('No approved senders yet. Add an email to let it send to your library.')}
|
|
</div>
|
|
)}
|
|
{senders.map((sender) => (
|
|
<div key={sender.id} className='flex items-center gap-3 px-4 py-3'>
|
|
<div className='flex min-w-0 flex-1 flex-col'>
|
|
<SettingLabel>{sender.email}</SettingLabel>
|
|
{sender.status === 'pending' && (
|
|
<span className='text-warning text-[0.8em]'>{_('Pending approval')}</span>
|
|
)}
|
|
</div>
|
|
{sender.status === 'pending' && (
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-sm eink-bordered'
|
|
onClick={() => approveSender(sender.id)}
|
|
aria-label={_('Approve')}
|
|
>
|
|
<MdCheck className='h-4 w-4' />
|
|
</button>
|
|
)}
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-sm eink-bordered'
|
|
onClick={() => setAddingSender((v) => !v)}
|
|
aria-label={_('Add a sender')}
|
|
>
|
|
<MdAdd className='h-4 w-4' />
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='btn btn-ghost btn-sm eink-bordered'
|
|
onClick={() => removeSender(sender.id)}
|
|
aria-label={_('Remove')}
|
|
>
|
|
<MdClose className='h-4 w-4' />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{(addingSender || senders.length === 0) && (
|
|
<div className='border-base-200 flex items-center gap-2 border-t px-4 py-3'>
|
|
<input
|
|
type='email'
|
|
className='input input-sm input-bordered eink-bordered min-w-0 flex-1'
|
|
placeholder={_('name@example.com')}
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void addSender();
|
|
}}
|
|
/>
|
|
<button type='button' className='btn btn-contrast btn-sm' onClick={addSender}>
|
|
{_('Add')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='w-full'>
|
|
<SectionTitle className='mb-2'>{_('Recent activity')}</SectionTitle>
|
|
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
|
|
<div className='divide-base-200 divide-y'>
|
|
{activity.length === 0 && (
|
|
<div className='text-base-content/60 px-4 py-3 text-sm'>
|
|
{_('Nothing sent yet. Email a book to your address above.')}
|
|
</div>
|
|
)}
|
|
{activity.map((item) => (
|
|
<div key={item.id} className='flex items-center gap-3 px-4 py-3'>
|
|
<div className='flex min-w-0 flex-1 flex-col'>
|
|
<SettingLabel className='!line-clamp-1'>
|
|
{item.filename || item.url || _('Untitled')}
|
|
</SettingLabel>
|
|
<span className='text-base-content/60 text-[0.8em]'>
|
|
{item.status === 'done' && _('Added to your library')}
|
|
{item.status === 'pending' && _('Waiting to be processed')}
|
|
{item.status === 'claimed' && _('Processing…')}
|
|
{item.status === 'failed' && (item.error || _('Failed'))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<BoxedList
|
|
title={_('This device')}
|
|
description={_('Download and import books emailed to your address.')}
|
|
>
|
|
<SettingsSwitchRow
|
|
label={_('Process incoming items on this device')}
|
|
checked={drainEnabled}
|
|
onChange={toggleDrain}
|
|
/>
|
|
</BoxedList>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SendToReadestForm;
|