openreader/src/components/PrivacyModal.tsx
Richard R 1deb8c21f7 feat(ui): add five new light themes and redesign settings modal
Add five new light theme options (lavender, rose, sand, sky, slate) with dedicated color palettes. Redesign Settings modal with sidebar navigation replacing tabs, add visual theme color selector showing preview swatches, and introduce new icons (KeyIcon, PaletteIcon, UserIcon, DocumentIcon). Also update PrivacyModal to render conditionally based on authEnabled and add privacy policy link. Update tests to reflect new UI structure.
2026-03-19 10:24:16 -06:00

247 lines
9.1 KiB
TypeScript

'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
} from '@headlessui/react';
import { updateAppConfig, getAppConfig } from '@/lib/client/dexie';
interface PrivacyModalProps {
onAccept?: () => void;
}
function PrivacyModalBody({ origin }: { origin: string }) {
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service Operator</div>
<div className="mt-1">
This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>.
</div>
</div>
<p className="leading-relaxed">
We value your privacy. This application uses strictly necessary cookies for authentication
and anonymous analytics to improve performance. Your documents are stored securely and encrypted at rest.
</p>
<p className="leading-relaxed">
For full details on data collection, processing, and your rights, please review our complete{' '}
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">Privacy Policy</a>.
</p>
</div>
);
}
export function PrivacyModal({ onAccept }: PrivacyModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [origin, setOrigin] = useState('');
const [agreed, setAgreed] = useState(false);
const checkPrivacyAccepted = useCallback(async () => {
const config = await getAppConfig();
if (!config?.privacyAccepted) {
setIsOpen(true);
}
}, []);
useEffect(() => {
checkPrivacyAccepted().catch((err) => {
console.error('Privacy acceptance check failed:', err);
});
}, [checkPrivacyAccepted]);
useEffect(() => {
if (typeof window === 'undefined') return;
setOrigin(window.location.origin);
}, []);
const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true });
setIsOpen(false);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('openreader:privacyAccepted'));
}
onAccept?.();
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[60]" onClose={() => { }}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} />
<div className="mt-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
<div className="flex h-6 items-center">
<input
id="privacy-agree"
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base"
/>
</div>
<div className="text-sm leading-6">
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
I have read and agree to the
</label>{' '}
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
Privacy Policy
</a>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={!agreed}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out enabled:hover:scale-[1.04]"
onClick={handleAccept}
>
Continue
</Button>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
/**
* Function to programmatically show the privacy popup
* This can be called from signin/signup components
*/
export function showPrivacyModal(options?: { authEnabled?: boolean }): void {
// Create a temporary container for the popup
const container = document.createElement('div');
container.id = 'privacy-modal-container';
document.body.appendChild(container);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
void options;
// Import React and render the popup
import('react-dom/client').then(({ createRoot }) => {
import('react').then((React) => {
const root = createRoot(container);
const PopupWrapper = () => {
const [show, setShow] = useState(true);
const handleClose = () => {
setShow(false);
};
return (
<Transition
appear
show={show}
as={Fragment}
afterLeave={() => {
root.unmount();
container.remove();
}}
>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} />
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleClose}
>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
};
root.render(React.createElement(PopupWrapper));
});
});
}