Auth added and UI improvements
This commit is contained in:
+21
-1
@@ -1,14 +1,25 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { A, useLocation, useNavigate } from '@solidjs/router';
|
||||
import { Show } from 'solid-js';
|
||||
import { authStore } from './store/authStore';
|
||||
|
||||
interface AppContainerProps {
|
||||
children?: any;
|
||||
}
|
||||
|
||||
const AppContainer: Component<AppContainerProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-50/50">
|
||||
{/* Top Navigation Bar */}
|
||||
<Show when={location.pathname !== '/login'}>
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print">
|
||||
<div class="max-w-[1800px] w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
@@ -41,9 +52,18 @@ const AppContainer: Component<AppContainerProps> = (props) => {
|
||||
</A>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</Show>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class="max-w-[1800px] w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
|
||||
+101
-25
@@ -7,7 +7,7 @@ import IgnoredLinesList from './components/IgnoredLinesList';
|
||||
import Toast from './components/Toast';
|
||||
import { extractItemsFromCSV } from './utils/csv-parser';
|
||||
import type { ExtractedItem } from './utils/csv-parser';
|
||||
import { FileSpreadsheet, Download } from 'lucide-solid';
|
||||
import { FileSpreadsheet, Download, Plus, FilePlus, ArrowLeft } from 'lucide-solid';
|
||||
import { appStore } from './store/appStore';
|
||||
import CloudLoadModal from './components/CloudLoadModal';
|
||||
|
||||
@@ -23,10 +23,12 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
||||
const ignoredLines = appStore.ignoredLines;
|
||||
const setIgnoredLines = appStore.setIgnoredLines;
|
||||
const estimateItems = appStore.estimateItems;
|
||||
const scopes = appStore.scopes;
|
||||
|
||||
const [showToast] = createSignal(false);
|
||||
const [toastMessage] = createSignal('');
|
||||
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||
const [mode, setMode] = createSignal<'initial' | 'new-selection'>('initial');
|
||||
|
||||
const handleFileSelect = (csvText: string) => {
|
||||
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
|
||||
@@ -34,42 +36,116 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
||||
setIgnoredLines(ignored);
|
||||
};
|
||||
|
||||
const handleNewBlankEstimate = () => {
|
||||
appStore.resetEstimate();
|
||||
// Add a default empty scope to trigger the builder view
|
||||
appStore.setScopes([{
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: 'Main Scope',
|
||||
managementFee: 0,
|
||||
managementFeeType: 'percent',
|
||||
managementFeeQuantity: 0,
|
||||
managementFeeRate: 0,
|
||||
deliveryFee: 0,
|
||||
deliveryFeeType: 'percent',
|
||||
deliveryFeeQuantity: 0,
|
||||
deliveryFeeRate: 0,
|
||||
useManagementLogic: 'simple',
|
||||
useDeliveryLogic: 'simple'
|
||||
}]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center py-10 px-4 font-sans border border-gray-200 rounded-xl bg-gray-50/50 backdrop-blur-sm print:bg-transparent print:border-none print:p-0">
|
||||
{/* Header - Hidden after upload or load */}
|
||||
<Show when={items().length === 0 && estimateItems.length === 0}>
|
||||
<Show when={items().length === 0 && estimateItems.length === 0 && scopes.length === 0}>
|
||||
<div class="max-w-4xl w-full bg-white rounded-xl shadow-sm p-8 mb-8 border border-gray-100 no-print">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">Item Parser & Extractor</h1>
|
||||
<p class="text-gray-600">Upload your CSV to extract "Item" fields and split quantities.</p>
|
||||
</div>
|
||||
<div class="text-blue-600 text-4xl">
|
||||
<FileSpreadsheet class="w-12 h-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileUpload onFileSelect={handleFileSelect} />
|
||||
<Show when={mode() === 'initial'}>
|
||||
<div class="flex flex-col items-center justify-center space-y-8 py-12">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="text-4xl font-black text-gray-900 mb-3 tracking-tight lowercase">EstiMaker</h1>
|
||||
<p class="text-gray-500 text-lg">Start a new project or continue where you left off.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col items-center justify-center">
|
||||
<div class="flex items-center w-full max-w-sm mb-6">
|
||||
<div class="flex-1 h-px bg-gray-200"></div>
|
||||
<span class="px-4 text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">OR</span>
|
||||
<div class="flex-1 h-px bg-gray-200"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-2xl">
|
||||
<button
|
||||
onClick={() => setMode('new-selection')}
|
||||
class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-blue-400 hover:bg-blue-50/30 transition-all group"
|
||||
>
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
|
||||
<Plus class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold text-gray-800">New Estimate</h3>
|
||||
<p class="text-gray-500 text-sm mt-1">Create from CSV or scratch</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowLoadModal(true)}
|
||||
class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-green-400 hover:bg-green-50/30 transition-all group"
|
||||
>
|
||||
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center text-green-600 group-hover:scale-110 transition-transform">
|
||||
<Download class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold text-gray-800">Load Estimate</h3>
|
||||
<p class="text-gray-500 text-sm mt-1">Open from cloud storage</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowLoadModal(true)}
|
||||
class="flex items-center justify-center gap-3 w-full max-w-sm px-6 py-4 bg-white text-gray-700 rounded-2xl font-bold shadow-sm border border-gray-200 hover:bg-gray-50 hover:border-gray-300 hover:shadow transition-all"
|
||||
>
|
||||
<Download class="w-5 h-5 text-blue-600" /> Load Saved Estimate
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={mode() === 'new-selection'}>
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setMode('initial')}
|
||||
class="absolute -top-4 -left-4 p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<ArrowLeft class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between mb-8 pt-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">Create New Estimate</h1>
|
||||
<p class="text-gray-600">Choose how you want to start your estimate.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="p-6 bg-blue-50/50 rounded-2xl border border-blue-100">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<FileSpreadsheet class="w-6 h-6 text-blue-600" />
|
||||
<h3 class="font-bold text-gray-900">Item Extractor</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">Upload a CSV file to automatically extract items and quantities.</p>
|
||||
<FileUpload onFileSelect={handleFileSelect} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNewBlankEstimate}
|
||||
class="flex flex-col items-start p-6 bg-gray-50 rounded-2xl border border-gray-200 hover:border-gray-300 hover:bg-white transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<FilePlus class="w-6 h-6 text-gray-600" />
|
||||
<h3 class="font-bold text-gray-900">Blank Estimate</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">Start with a completely empty slate and build manually.</p>
|
||||
<div class="mt-auto w-full py-3 bg-white border border-gray-200 rounded-xl text-center font-bold text-gray-700 group-hover:bg-gray-50 transition-colors">
|
||||
Start Blank
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Results */}
|
||||
<Show
|
||||
when={items().length > 0 || estimateItems.length > 0}
|
||||
when={items().length > 0 || estimateItems.length > 0 || scopes.length > 0}
|
||||
fallback={
|
||||
<div class="text-gray-400 text-center mt-12 opacity-50">
|
||||
<p>Results will appear here</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Show, For, createEffect } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X } from 'lucide-solid';
|
||||
import { X, User } from 'lucide-solid';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
import { appStore } from '../store/appStore';
|
||||
import { normalizeScopes } from '../utils/scope-utils';
|
||||
@@ -85,6 +85,12 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
|
||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
{new Date(record.created).toLocaleDateString()}
|
||||
</p>
|
||||
<Show when={record.items?.savedBy}>
|
||||
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
|
||||
<User class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">{record.items.savedBy}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadFromCloud(record)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show, For } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X, History, CheckCircle2, Clock } from 'lucide-solid';
|
||||
import { X, History, CheckCircle2, Clock, User } from 'lucide-solid';
|
||||
import { appStore } from '../../store/appStore';
|
||||
|
||||
interface HistoryModalProps {
|
||||
@@ -59,6 +59,12 @@ const HistoryModal = (props: HistoryModalProps) => {
|
||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
|
||||
The current tip of the project
|
||||
</p>
|
||||
<Show when={appStore.latestSnapshot()?.savedBy}>
|
||||
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
|
||||
<User class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {appStore.latestSnapshot().savedBy}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +108,11 @@ const HistoryModal = (props: HistoryModalProps) => {
|
||||
↳ {sum.fieldChanges} field adjustments
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={sum.scopeMetadataUpdated}>
|
||||
<p class="text-[9px] text-blue-600 ml-1 font-bold">
|
||||
↳ Scope Description/Notes updated
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -128,6 +139,12 @@ const HistoryModal = (props: HistoryModalProps) => {
|
||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
|
||||
{new Date(snapshot.timestamp || Date.now()).toLocaleString()}
|
||||
</p>
|
||||
<Show when={snapshot.savedBy}>
|
||||
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
|
||||
<User class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {snapshot.savedBy}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +186,11 @@ const HistoryModal = (props: HistoryModalProps) => {
|
||||
↳ {sum.fieldChanges} field adjustments
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={sum.scopeMetadataUpdated}>
|
||||
<p class="text-[9px] text-blue-600 ml-1 font-bold">
|
||||
↳ Scope Description/Notes updated
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -74,13 +74,14 @@ export function useEstimateSync(
|
||||
jobInfo: { ...jobInfo },
|
||||
generalEstimateNotes: generalEstimateNotes(),
|
||||
generalPreNotes: generalPreNotes(),
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
savedBy: pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown'
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const calculateChangeSummary = (prevData: any, newData: any) => {
|
||||
const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number }> = {};
|
||||
const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number, scopeMetadataUpdated: boolean }> = {};
|
||||
|
||||
const prevItems = prevData.items || [];
|
||||
const newItems = newData.items || [];
|
||||
@@ -104,6 +105,19 @@ export function useEstimateSync(
|
||||
let removed = 0;
|
||||
let updated = 0;
|
||||
let fields = 0;
|
||||
let scopeMetadataUpdated = false;
|
||||
|
||||
// Check Scope Metadata Changes
|
||||
const prevScope = (prevData.scopes || []).find((s: any) => s.id === scopeId);
|
||||
const currentScope = (newData.scopes || []).find((s: any) => s.id === scopeId);
|
||||
if (prevScope && currentScope) {
|
||||
['bidDescription', 'estimatorNotes', 'name', 'managementFee', 'deliveryFee', 'useManagementLogic', 'useDeliveryLogic'].forEach(k => {
|
||||
if (String(currentScope[k]) !== String(prevScope[k])) {
|
||||
fields++;
|
||||
scopeMetadataUpdated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const scopePrevMap = new Map(scopePrevItems.map((i: any) => [i.id, i]));
|
||||
const scopeNewMap = new Map(scopeNewItems.map((i: any) => [i.id, i]));
|
||||
@@ -114,7 +128,7 @@ export function useEstimateSync(
|
||||
added++;
|
||||
} else {
|
||||
let changed = false;
|
||||
['description', 'quantity', 'unit', 'price', 'type'].forEach(k => {
|
||||
['description', 'quantity', 'unitPrice', 'markup', 'markupType', 'contingency', 'contingencyType'].forEach(k => {
|
||||
const v1 = (item as any)[k];
|
||||
const v2 = (prev as any)[k];
|
||||
if (String(v1) !== String(v2)) {
|
||||
@@ -132,8 +146,15 @@ export function useEstimateSync(
|
||||
}
|
||||
});
|
||||
|
||||
if (added > 0 || removed > 0 || updated > 0 || fields > 0) {
|
||||
(summary as any)[scopeId] = { scopeName, itemsAdded: added, itemsRemoved: removed, itemsUpdated: updated, fieldChanges: fields };
|
||||
if (added > 0 || removed > 0 || updated > 0 || fields > 0 || scopeMetadataUpdated) {
|
||||
summary[scopeId] = {
|
||||
scopeName: String(scopeName),
|
||||
itemsAdded: added,
|
||||
itemsRemoved: removed,
|
||||
itemsUpdated: updated,
|
||||
fieldChanges: fields,
|
||||
scopeMetadataUpdated
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,7 +168,8 @@ export function useEstimateSync(
|
||||
const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
|
||||
name,
|
||||
items: { ...data, changeSummary: null },
|
||||
history: []
|
||||
history: [],
|
||||
userId: pb.authStore.model?.id
|
||||
});
|
||||
|
||||
setShowSaveModal(false);
|
||||
@@ -184,7 +206,8 @@ export function useEstimateSync(
|
||||
|
||||
await pb.collection(COLLECTIONS.ESTIMATES).update(id, {
|
||||
items: newTip,
|
||||
history: [...updatedHistory, record.items]
|
||||
history: [...updatedHistory, record.items],
|
||||
userId: pb.authStore.model?.id
|
||||
});
|
||||
|
||||
setHistory([...updatedHistory, record.items]);
|
||||
|
||||
+25
-2
@@ -1,10 +1,13 @@
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { Router, Route, useNavigate, useLocation } from '@solidjs/router';
|
||||
import { createEffect, Show } from 'solid-js';
|
||||
import './index.css';
|
||||
import AppContainer from './AppContainer';
|
||||
import ItemExtractor from './ItemExtractor';
|
||||
import TemplateCreator from './views/TemplateCreator';
|
||||
import StandardPricing from './views/StandardPricing';
|
||||
import Login from './views/Login';
|
||||
import { authStore } from './store/authStore';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
@@ -14,8 +17,28 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthGuard(props: { children: any }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
createEffect(() => {
|
||||
if (!authStore.isAuthenticated() && location.pathname !== '/login') {
|
||||
navigate('/login', { replace: true });
|
||||
} else if (authStore.isAuthenticated() && location.pathname === '/login') {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={authStore.isAuthenticated() || location.pathname === '/login'}>
|
||||
{props.children}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
render(() => (
|
||||
<Router root={AppContainer}>
|
||||
<Router root={(props) => <AuthGuard><AppContainer>{props.children}</AppContainer></AuthGuard>}>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/" component={ItemExtractor as any} />
|
||||
<Route path="/templates" component={TemplateCreator} />
|
||||
<Route path="/standard-pricing" component={StandardPricing} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { pb } from '../utils/db';
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||
const [currentUser, setCurrentUser] = createSignal(pb.authStore.model);
|
||||
|
||||
// Listen for auth changes from PocketBase
|
||||
pb.authStore.onChange((_, model) => {
|
||||
setIsAuthenticated(pb.authStore.isValid);
|
||||
setCurrentUser(model);
|
||||
});
|
||||
|
||||
export const authStore = {
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
login: async (email: string, pass: string) => {
|
||||
return await pb.collection('users').authWithPassword(email, pass);
|
||||
},
|
||||
logout: () => {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { authStore } from '../store/authStore';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authStore.login(email(), password());
|
||||
// Successful login, navigate to home
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.message || 'Failed to login. Please check your credentials.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 bg-white p-8 rounded-xl shadow-lg border border-gray-100">
|
||||
<div>
|
||||
<h2 class="mt-2 text-center text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
Sign in to EstiMaker
|
||||
</h2>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error() && (
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md relative text-sm" role="alert">
|
||||
<span class="block sm:inline">{error()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="email-address" class="sr-only">Email address</label>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading()}
|
||||
class="group relative w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors"
|
||||
>
|
||||
{isLoading() ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user