Quick save

This commit is contained in:
Andras Schmelczer 2026-02-07 22:19:44 +00:00
parent e5d5819098
commit 2906b01734
25 changed files with 1070 additions and 237 deletions

View file

@ -1,31 +1,37 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
type Tab = 'login' | 'register';
type View = 'login' | 'register' | 'forgot';
export default function AuthModal({
onClose,
onLogin,
onRegister,
onForgotPassword,
loading,
error,
onClearError,
initialTab = 'login',
}: {
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string, name?: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
onClearError: () => void;
initialTab?: 'login' | 'register';
}) {
const [tab, setTab] = useState<Tab>('login');
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [resetSent, setResetSent] = useState(false);
const switchTab = useCallback(
(newTab: Tab) => {
setTab(newTab);
const switchView = useCallback(
(newView: View) => {
setView(newView);
setResetSent(false);
onClearError();
},
[onClearError]
@ -35,66 +41,73 @@ export default function AuthModal({
async (e: React.FormEvent) => {
e.preventDefault();
try {
if (tab === 'login') {
if (view === 'login') {
await onLogin(email, password);
} else {
onClose();
} else if (view === 'register') {
await onRegister(email, password, name || undefined);
onClose();
} else {
await onForgotPassword(email);
setResetSent(true);
}
onClose();
} catch {
// Error is handled by the hook
}
},
[tab, email, password, name, onLogin, onRegister, onClose]
[view, email, password, name, onLogin, onRegister, onForgotPassword, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
{tab === 'login' ? 'Log in' : 'Create account'}
</h2>
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'login'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('login')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'register'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('register')}
>
Register
</button>
</div>
{/* Tabs (hidden in forgot view) */}
{view !== 'forgot' && (
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
view === 'login'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchView('login')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
view === 'register'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchView('register')}
>
Register
</button>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{tab === 'register' && (
{view === 'register' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
@ -103,7 +116,7 @@ export default function AuthModal({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="Your name (optional)"
/>
</div>
@ -118,35 +131,70 @@ export default function AuthModal({
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
placeholder={tab === 'register' ? 'Min 8 characters' : 'Your password'}
/>
</div>
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
</button>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>

View file

@ -0,0 +1,30 @@
import { ChevronIcon } from './icons/ChevronIcon';
interface CollapsibleGroupHeaderProps {
name: string;
expanded: boolean;
onToggle: () => void;
className?: string;
children?: React.ReactNode;
}
export function CollapsibleGroupHeader({
name,
expanded,
onToggle,
className = '',
children,
}: CollapsibleGroupHeaderProps) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-between ${className}`}
>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
</button>
);
}

View file

@ -1,14 +1,16 @@
import { useState, useCallback } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
export default function Header({
activePage,
@ -17,8 +19,11 @@ export default function Header({
onToggleTheme,
onExport,
exporting,
onSaveSearch,
savingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
}: {
activePage: Page;
@ -27,17 +32,34 @@ export default function Header({
onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(() => {
navigator.clipboard.writeText(window.location.href).then(() => {
const url = window.location.href;
const onSuccess = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
const tabClass = (page: Page) =>
@ -67,20 +89,30 @@ export default function Header({
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
{user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
Saved
</button>
)}
</nav>
</div>
<div className="flex items-center gap-2">
{activePage === 'dashboard' && (
<>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
{onSaveSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
</button>
)}
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
@ -97,17 +129,34 @@ export default function Header({
</>
)}
</button>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
</>
)}
{user ? (
<UserMenu user={user} onLogout={onLogout} />
) : (
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Register
</button>
</>
)}
<button
onClick={onToggleTheme}

View file

@ -0,0 +1,95 @@
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({
onClose,
onSave,
saving,
error,
}: {
onClose: () => void;
onSave: (name: string) => Promise<void>;
saving: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || saving) return;
try {
await onSave(name.trim());
onClose();
} catch {
// Error displayed in modal
}
},
[name, saving, onSave, onClose]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function TrashIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}