merge: pull PR48 auth and UX into pre-release
This commit is contained in:
+61
-11
@@ -1,23 +1,73 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Editor } from './pages/Editor';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Profile } from './pages/Profile';
|
||||
import { Login } from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import { PasswordResetRequest } from './pages/PasswordResetRequest';
|
||||
import { PasswordResetConfirm } from './pages/PasswordResetConfirm';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { UploadProvider } from './context/UploadContext';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<UploadProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</UploadProvider>
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<UploadProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/reset-password" element={<PasswordResetRequest />} />
|
||||
<Route path="/reset-password-confirm" element={<PasswordResetConfirm />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/editor/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Editor />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+117
-16
@@ -7,6 +7,22 @@ export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
// Re-export axios for type checking
|
||||
export { default as axios } from 'axios';
|
||||
export const isAxiosError = axios.isAxiosError;
|
||||
|
||||
// Export api instance for direct use
|
||||
export { api as default };
|
||||
|
||||
// JWT Token Management
|
||||
const TOKEN_KEY = 'excalidash-access-token';
|
||||
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
||||
|
||||
const getAuthToken = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// CSRF Token Management
|
||||
let csrfToken: string | null = null;
|
||||
let csrfHeaderName: string = "x-csrf-token";
|
||||
@@ -50,12 +66,40 @@ export const clearCsrfToken = (): void => {
|
||||
csrfToken = null;
|
||||
};
|
||||
|
||||
// Add request interceptor to include CSRF token
|
||||
// Add request interceptor to include JWT and CSRF tokens
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Only add CSRF token for state-changing methods
|
||||
// Auth endpoints that require authentication (need JWT token)
|
||||
const authenticatedAuthEndpoints = [
|
||||
'/auth/me',
|
||||
'/auth/profile',
|
||||
'/auth/change-password',
|
||||
];
|
||||
|
||||
// Auth endpoints that don't require authentication (login, register, etc.)
|
||||
const publicAuthEndpoints = [
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/refresh',
|
||||
'/auth/password-reset-request',
|
||||
'/auth/password-reset-confirm',
|
||||
];
|
||||
|
||||
const isAuthenticatedAuthEndpoint = config.url && authenticatedAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint));
|
||||
const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint));
|
||||
const isAuthEndpoint = config.url?.startsWith('/auth/');
|
||||
|
||||
// Add JWT token to all requests except public auth endpoints
|
||||
if (!isPublicAuthEndpoint) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add CSRF token for state-changing methods (except public auth endpoints)
|
||||
const method = config.method?.toUpperCase();
|
||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) {
|
||||
await ensureCsrfToken();
|
||||
if (csrfToken) {
|
||||
config.headers[csrfHeaderName] = csrfToken;
|
||||
@@ -66,10 +110,47 @@ api.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Add response interceptor to handle CSRF token errors
|
||||
// Add response interceptor to handle auth and CSRF token errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Handle 401 Unauthorized (invalid/expired JWT)
|
||||
if (error.response?.status === 401) {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (refreshToken && !error.config.url?.includes('/auth/')) {
|
||||
try {
|
||||
const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
localStorage.setItem(TOKEN_KEY, refreshResponse.data.accessToken);
|
||||
|
||||
// Update refresh token if rotation returned a new one
|
||||
if (refreshResponse.data.refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.data.refreshToken);
|
||||
}
|
||||
|
||||
// Retry original request with new token
|
||||
error.config.headers.Authorization = `Bearer ${refreshResponse.data.accessToken}`;
|
||||
return api(error.config);
|
||||
} catch {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem('excalidash-user');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else {
|
||||
// No refresh token or auth endpoint, redirect to login
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem('excalidash-user');
|
||||
if (!error.config.url?.includes('/auth/')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get a 403 with CSRF error, clear token and retry once
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
@@ -99,7 +180,14 @@ const coerceTimestamp = (value: string | number | Date): number => {
|
||||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||
};
|
||||
|
||||
const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
|
||||
type TimestampValue = string | number | Date;
|
||||
|
||||
interface HasTimestamps {
|
||||
createdAt: TimestampValue;
|
||||
updatedAt: TimestampValue;
|
||||
}
|
||||
|
||||
const deserializeTimestamps = <T extends HasTimestamps>(
|
||||
data: T
|
||||
): T & { createdAt: number; updatedAt: number } => ({
|
||||
...data,
|
||||
@@ -107,11 +195,19 @@ const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
|
||||
updatedAt: coerceTimestamp(data.updatedAt),
|
||||
});
|
||||
|
||||
const deserializeDrawingSummary = (drawing: any): DrawingSummary =>
|
||||
deserializeTimestamps(drawing);
|
||||
const deserializeDrawingSummary = (drawing: unknown): DrawingSummary => {
|
||||
if (typeof drawing !== 'object' || drawing === null) {
|
||||
throw new Error('Invalid drawing data');
|
||||
}
|
||||
return deserializeTimestamps(drawing as HasTimestamps & DrawingSummary);
|
||||
};
|
||||
|
||||
const deserializeDrawing = (drawing: any): Drawing =>
|
||||
deserializeTimestamps(drawing);
|
||||
const deserializeDrawing = (drawing: unknown): Drawing => {
|
||||
if (typeof drawing !== 'object' || drawing === null) {
|
||||
throw new Error('Invalid drawing data');
|
||||
}
|
||||
return deserializeTimestamps(drawing as HasTimestamps & Drawing);
|
||||
};
|
||||
|
||||
export function getDrawings(
|
||||
search?: string,
|
||||
@@ -129,7 +225,7 @@ export async function getDrawings(
|
||||
collectionId?: string | null,
|
||||
options?: { includeData?: boolean }
|
||||
) {
|
||||
const params: any = {};
|
||||
const params: Record<string, string> = {};
|
||||
if (search) params.search = search;
|
||||
if (collectionId !== undefined)
|
||||
params.collectionId = collectionId === null ? "null" : collectionId;
|
||||
@@ -152,8 +248,10 @@ export const createDrawing = async (
|
||||
collectionId?: string | null
|
||||
) => {
|
||||
const response = await api.post<{ id: string }>("/drawings", {
|
||||
name,
|
||||
collectionId,
|
||||
name: name || "Untitled Drawing",
|
||||
collectionId: collectionId ?? null,
|
||||
elements: [],
|
||||
appState: {},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -197,12 +295,15 @@ export const deleteCollection = async (id: string) => {
|
||||
|
||||
// --- Library ---
|
||||
|
||||
export const getLibrary = async () => {
|
||||
const response = await api.get<{ items: any[] }>("/library");
|
||||
// Library items are Excalidraw library items - dynamic structure from Excalidraw
|
||||
type LibraryItem = Record<string, unknown>;
|
||||
|
||||
export const getLibrary = async (): Promise<LibraryItem[]> => {
|
||||
const response = await api.get<{ items: LibraryItem[] }>("/library");
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateLibrary = async (items: any[]) => {
|
||||
const response = await api.put<{ items: any[] }>("/library", { items });
|
||||
export const updateLibrary = async (items: LibraryItem[]): Promise<LibraryItem[]> => {
|
||||
const response = await api.put<{ items: LibraryItem[] }>("/library", { items });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut } from 'lucide-react';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { Logo } from './Logo';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collection[];
|
||||
@@ -120,6 +121,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { logout, user } = useAuth();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -127,7 +130,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
|
||||
const [isTrashDragOver, setIsTrashDragOver] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setContextMenu(null);
|
||||
@@ -284,6 +286,19 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<span className="min-w-0 flex-1 text-left">Trash</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/profile')}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
selectedCollectionId === 'PROFILE'
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<User size={18} />
|
||||
<span className="min-w-0 flex-1 text-left">Profile</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={clsx(
|
||||
@@ -296,6 +311,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<SettingsIcon size={18} />
|
||||
<span className="min-w-0 flex-1 text-left">Settings</span>
|
||||
</button>
|
||||
|
||||
{/* User info and logout */}
|
||||
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||
{user && (
|
||||
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
||||
<div className="truncate">{user.email}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-rose-300 dark:border-rose-700 bg-white dark:bg-neutral-900 text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 cursor-pointer"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className="min-w-0 flex-1 text-left">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = 'excalidash-access-token';
|
||||
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
||||
const USER_KEY = 'excalidash-user';
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const storedUser = localStorage.getItem(USER_KEY);
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
if (storedUser && storedToken) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
setUser(userData);
|
||||
|
||||
// Verify token is still valid by fetching user info
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storedToken}`,
|
||||
},
|
||||
});
|
||||
setUser(response.data.user);
|
||||
} catch (error) {
|
||||
// Token invalid, try refresh
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
localStorage.setItem(TOKEN_KEY, refreshResponse.data.accessToken);
|
||||
const userResponse = await axios.get(`${API_URL}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshResponse.data.accessToken}`,
|
||||
},
|
||||
});
|
||||
setUser(userResponse.data.user);
|
||||
} catch {
|
||||
// Refresh failed, clear auth but don't navigate during initial load
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
// No refresh token, clear auth
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
// Clear auth on error
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/auth/login`, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const { user: userData, accessToken, refreshToken } = response.data;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
|
||||
setUser(userData);
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const message =
|
||||
typeof error.response?.data === 'object' &&
|
||||
error.response.data !== null &&
|
||||
'message' in error.response.data &&
|
||||
typeof error.response.data.message === 'string'
|
||||
? error.response.data.message
|
||||
: 'Login failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
throw error instanceof Error ? error : new Error('Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/auth/register`, {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
const { user: userData, accessToken, refreshToken } = response.data;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
|
||||
setUser(userData);
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const message =
|
||||
typeof error.response?.data === 'object' &&
|
||||
error.response.data !== null &&
|
||||
'message' in error.response.data &&
|
||||
typeof error.response.data.message === 'string'
|
||||
? error.response.data.message
|
||||
: 'Registration failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
throw error instanceof Error ? error : new Error('Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
// Navigate to login - use setTimeout to ensure Router is ready
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { DrawingCard } from '../components/DrawingCard';
|
||||
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react';
|
||||
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload, CheckSquare, Square, ArrowUp, ArrowDown, ChevronDown, FileText, Calendar, Clock } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { DrawingSummary, Collection } from '../types';
|
||||
@@ -73,6 +73,7 @@ export const Dashboard: React.FC = () => {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false);
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
@@ -256,38 +257,35 @@ export const Dashboard: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [sortedDrawings]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
const handleSortFieldChange = (field: SortField) => {
|
||||
setSortConfig(current => {
|
||||
if (current.field === field) return { ...current, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
||||
const defaultDirection = field === 'name' ? 'asc' : 'desc';
|
||||
return { field, direction: defaultDirection };
|
||||
// If changing field, use default direction for that field
|
||||
if (current.field !== field) {
|
||||
const defaultDirection = field === 'name' ? 'asc' : 'desc';
|
||||
return { field, direction: defaultDirection };
|
||||
}
|
||||
// If same field, keep current direction
|
||||
return current;
|
||||
});
|
||||
setShowSortMenu(false);
|
||||
};
|
||||
|
||||
const SortButton = ({ field, label }: { field: SortField; label: string }) => {
|
||||
const isActive = sortConfig.field === field;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(field)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700
|
||||
${isActive
|
||||
? 'bg-indigo-100 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5'
|
||||
: 'bg-white dark:bg-neutral-900 text-slate-600 dark:text-neutral-400 hover:bg-slate-50 dark:hover:bg-neutral-800 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'asc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
|
||||
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'desc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
const handleSortDirectionToggle = () => {
|
||||
setSortConfig(current => ({
|
||||
...current,
|
||||
direction: current.direction === 'asc' ? 'desc' : 'asc'
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const isTrashView = selectedCollectionId === 'trash';
|
||||
const sortOptions: { field: SortField; label: string; icon: React.ReactNode }[] = [
|
||||
{ field: 'name', label: 'Name', icon: <FileText size={16} /> },
|
||||
{ field: 'createdAt', label: 'Date Created', icon: <Calendar size={16} /> },
|
||||
{ field: 'updatedAt', label: 'Date Modified', icon: <Clock size={16} /> },
|
||||
];
|
||||
|
||||
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
|
||||
|
||||
const isTrashView = selectedCollectionId === 'trash';
|
||||
const handleCreateDrawing = async () => {
|
||||
if (isTrashView) return;
|
||||
try {
|
||||
@@ -513,6 +511,19 @@ export const Dashboard: React.FC = () => {
|
||||
}, [selectedCollectionId, collections]);
|
||||
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
// Deselect all
|
||||
setSelectedIds(new Set());
|
||||
setLastSelectedId(null);
|
||||
} else {
|
||||
// Select all
|
||||
const allIds = new Set(sortedDrawings.map(d => d.id));
|
||||
setSelectedIds(allIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetCollectionId: string | null) => {
|
||||
e.preventDefault();
|
||||
@@ -685,15 +696,86 @@ export const Dashboard: React.FC = () => {
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1 overflow-x-auto no-scrollbar">
|
||||
<SortButton field="name" label="Name" />
|
||||
<SortButton field="createdAt" label="Date Created" />
|
||||
<SortButton field="updatedAt" label="Date Modified" />
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSortMenu(!showSortMenu);
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 whitespace-nowrap h-[42px] w-[180px]",
|
||||
"bg-white dark:bg-neutral-900 text-slate-700 dark:text-neutral-300 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
)}
|
||||
>
|
||||
<span className="text-indigo-600 dark:text-indigo-400 flex-shrink-0">{currentSortOption.icon}</span>
|
||||
<span className="whitespace-nowrap flex-1 text-left">{currentSortOption.label}</span>
|
||||
<ChevronDown size={16} className="text-slate-400 dark:text-neutral-500 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{showSortMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
|
||||
<div className="absolute top-full left-0 mt-2 z-50 bg-white dark:bg-neutral-800 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] py-1 min-w-[180px]">
|
||||
{sortOptions.map((option) => (
|
||||
<button
|
||||
key={option.field}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSortFieldChange(option.field);
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 text-sm text-left flex items-center gap-2 transition-colors",
|
||||
sortConfig.field === option.field
|
||||
? "bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 font-bold"
|
||||
: "text-slate-600 dark:text-neutral-300 hover:bg-slate-50 dark:hover:bg-neutral-700 hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||
)}
|
||||
>
|
||||
<span className="text-indigo-600 dark:text-indigo-400">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
{sortConfig.field === option.field && (
|
||||
<span className="ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSortDirectionToggle}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 h-[42px] min-w-[42px]",
|
||||
"bg-white dark:bg-neutral-900 text-indigo-600 dark:text-indigo-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
)}
|
||||
title={sortConfig.direction === 'asc' ? 'Sort Ascending' : 'Sort Descending'}
|
||||
>
|
||||
{sortConfig.direction === 'asc' ? (
|
||||
<ArrowUp size={18} />
|
||||
) : (
|
||||
<ArrowDown size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
disabled={sortedDrawings.length === 0}
|
||||
className={clsx(
|
||||
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
||||
sortedDrawings.length > 0
|
||||
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-indigo-600 dark:text-indigo-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
|
||||
)}
|
||||
title={allSelected ? "Deselect All" : "Select All"}
|
||||
>
|
||||
{allSelected ? <CheckSquare size={20} /> : <Square size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={!hasSelection}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, Loader2 } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
|
||||
import '@excalidraw/excalidraw/index.css';
|
||||
import debounce from 'lodash/debounce';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { Toaster, toast } from 'sonner';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { getUserIdentity } from '../utils/identity';
|
||||
import { getUserIdentity, type UserIdentity } from '../utils/identity';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { reconcileElements } from '../utils/sync';
|
||||
import { exportFromEditor } from '../utils/exportUtils';
|
||||
import type { UserIdentity } from '../utils/identity';
|
||||
import * as api from '../api';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
|
||||
@@ -84,17 +85,42 @@ const UIOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to generate initials from a name
|
||||
const getInitialsFromName = (name: string): string => {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase().padEnd(2, name[0] || 'U');
|
||||
};
|
||||
|
||||
// Helper function to generate a color from a string (consistent hash)
|
||||
const getColorFromString = (str: string): string => {
|
||||
const COLORS = [
|
||||
"#ef4444", "#f97316", "#f59e0b", "#84cc16", "#22c55e", "#10b981",
|
||||
"#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6",
|
||||
"#a855f7", "#d946ef", "#ec4899", "#f43f5e",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
};
|
||||
|
||||
export const Editor: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const { user } = useAuth();
|
||||
const [drawingName, setDrawingName] = useState('Drawing Editor');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [initialData, setInitialData] = useState<any>(null);
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(true);
|
||||
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
|
||||
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${drawingName} - ExcaliDash`;
|
||||
@@ -103,8 +129,67 @@ export const Editor: React.FC = () => {
|
||||
};
|
||||
}, [drawingName]);
|
||||
|
||||
// Auto-hide header based on mouse movement
|
||||
useEffect(() => {
|
||||
if (!autoHideEnabled || isRenaming) {
|
||||
setIsHeaderVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isInTriggerZone = false;
|
||||
|
||||
const handleMouseMove = throttle((e: MouseEvent) => {
|
||||
const wasInTriggerZone = isInTriggerZone;
|
||||
isInTriggerZone = e.clientY < 5;
|
||||
|
||||
if (isInTriggerZone) {
|
||||
// Mouse is in trigger zone - show header
|
||||
setIsHeaderVisible(true);
|
||||
if (hideTimeout !== null) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
} else if (wasInTriggerZone) {
|
||||
// Mouse just left trigger zone - start hide timer
|
||||
if (hideTimeout !== null) clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
setIsHeaderVisible(false);
|
||||
}, 2000);
|
||||
}
|
||||
// If mouse is already out of trigger zone and moving, don't reset timer
|
||||
}, 100);
|
||||
|
||||
// Show header initially
|
||||
setIsHeaderVisible(true);
|
||||
|
||||
// Hide after initial delay if mouse doesn't move to top
|
||||
hideTimeout = setTimeout(() => {
|
||||
setIsHeaderVisible(false);
|
||||
}, 3000);
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
if (hideTimeout !== null) clearTimeout(hideTimeout);
|
||||
};
|
||||
}, [autoHideEnabled, isRenaming]);
|
||||
|
||||
// Use authenticated user identity or fallback to generated identity
|
||||
const [me] = useState<UserIdentity>(() => {
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
initials: getInitialsFromName(user.name),
|
||||
color: getColorFromString(user.id),
|
||||
};
|
||||
}
|
||||
return getUserIdentity();
|
||||
});
|
||||
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
const [me] = useState(getUserIdentity());
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const lastCursorEmit = useRef<number>(0);
|
||||
@@ -803,7 +888,12 @@ export const Editor: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
|
||||
<header className="h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10">
|
||||
<header
|
||||
className={clsx(
|
||||
"h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300",
|
||||
isHeaderVisible ? "translate-y-0" : "-translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
@@ -843,6 +933,22 @@ export const Editor: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Auto-hide Toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoHideEnabled(!autoHideEnabled);
|
||||
if (!autoHideEnabled) {
|
||||
setIsHeaderVisible(true);
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-lg text-gray-600 dark:text-gray-300 transition-colors"
|
||||
title={autoHideEnabled ? "Disable auto-hide" : "Enable auto-hide"}
|
||||
>
|
||||
{autoHideEnabled ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-700" />
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -899,7 +1005,13 @@ export const Editor: React.FC = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 w-full relative" style={{ height: 'calc(100vh - 3.5rem)' }}>
|
||||
<div
|
||||
className="flex-1 w-full relative transition-all duration-300"
|
||||
style={{
|
||||
height: isHeaderVisible ? 'calc(100vh - 3.5rem)' : '100vh',
|
||||
marginTop: isHeaderVisible ? '3.5rem' : '0'
|
||||
}}
|
||||
>
|
||||
{initialData ? (
|
||||
<Excalidraw
|
||||
key={id}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Logo } from '../components/Logo';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to login';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 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}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 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"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Logo } from '../components/Logo';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
export const PasswordResetConfirm: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Invalid reset link. Please request a new password reset.');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setError('Invalid reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/auth/password-reset-confirm`, {
|
||||
token,
|
||||
password,
|
||||
});
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to reset password';
|
||||
if (axios.isAxiosError(err)) {
|
||||
if (err.response?.status === 404) {
|
||||
message = 'Password reset feature is not enabled on this server';
|
||||
} else if (err.response?.data?.message) {
|
||||
message = err.response.data.message;
|
||||
} else if (err.response?.data?.error) {
|
||||
message = err.response.data.error;
|
||||
} else if (err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Password reset successful
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your password has been reset. Redirecting to login...
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="New password (min 8 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="sr-only">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !token}
|
||||
className="group relative w-full flex justify-center py-2 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"
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Logo } from '../components/Logo';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
export const PasswordResetRequest: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/auth/password-reset-request`, { email });
|
||||
setSuccess(true);
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to send reset email';
|
||||
if (axios.isAxiosError(err)) {
|
||||
if (err.response?.status === 404) {
|
||||
message = 'Password reset feature is not enabled on this server';
|
||||
} else if (err.response?.data?.message) {
|
||||
message = err.response.data.message;
|
||||
} else if (err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Check your email
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
If an account with that email exists, a password reset link has been sent.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 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"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import * as api from '../api';
|
||||
import type { Collection } from '../types';
|
||||
import { User, Lock, Save, X } from 'lucide-react';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
export const Profile: React.FC = () => {
|
||||
const { user: authUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// User info state
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
// Password change state
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const collectionsData = await api.getCollections();
|
||||
setCollections(collectionsData);
|
||||
|
||||
// Fetch user info
|
||||
if (authUser) {
|
||||
setName(authUser.name);
|
||||
setEmail(authUser.email);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [authUser]);
|
||||
|
||||
const handleSelectCollection = (id: string | null | undefined) => {
|
||||
if (id === undefined) navigate('/');
|
||||
else if (id === null) navigate('/collections?id=unorganized');
|
||||
else navigate(`/collections?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCreateCollection = async (name: string) => {
|
||||
await api.createCollection(name);
|
||||
const newCollections = await api.getCollections();
|
||||
setCollections(newCollections);
|
||||
};
|
||||
|
||||
const handleEditCollection = async (id: string, name: string) => {
|
||||
setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c));
|
||||
await api.updateCollection(id, name);
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (id: string) => {
|
||||
setCollections(prev => prev.filter(c => c.id !== id));
|
||||
await api.deleteCollection(id);
|
||||
};
|
||||
|
||||
const handleUpdateName = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const response = await api.api.put<{ user: { id: string; email: string; name: string; createdAt: string; updatedAt: string } }>('/auth/profile', { name: name.trim() });
|
||||
setSuccess('Name updated successfully');
|
||||
// Update auth context - refresh user data
|
||||
if (response.data?.user) {
|
||||
// Update localStorage with new user data
|
||||
localStorage.setItem('excalidash-user', JSON.stringify(response.data.user));
|
||||
// Reload to update auth context
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to update name';
|
||||
if (api.isAxiosError(err)) {
|
||||
if (err.response?.data?.message) {
|
||||
message = err.response.data.message;
|
||||
} else if (err.response?.data?.error) {
|
||||
message = err.response.data.error;
|
||||
}
|
||||
}
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError('All password fields are required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await api.api.post('/auth/change-password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
setSuccess('Password changed successfully');
|
||||
setShowPasswordForm(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
// Logout user to force re-login with new password
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to change password';
|
||||
if (api.isAxiosError(err)) {
|
||||
if (err.response?.data?.message) {
|
||||
message = err.response.data.message;
|
||||
} else if (err.response?.data?.error) {
|
||||
message = err.response.data.error;
|
||||
}
|
||||
}
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
collections={collections}
|
||||
selectedCollectionId="PROFILE"
|
||||
onSelectCollection={handleSelectCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onEditCollection={handleEditCollection}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
>
|
||||
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
Profile
|
||||
</h1>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-xl">
|
||||
<p className="text-green-800 dark:text-green-200 font-medium">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-xl">
|
||||
<p className="text-red-800 dark:text-red-200 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information Section */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 bg-indigo-50 dark:bg-neutral-800 rounded-xl flex items-center justify-center border-2 border-indigo-100 dark:border-neutral-700">
|
||||
<User size={24} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Personal Information</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-slate-50 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-600 dark:text-neutral-400 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-neutral-500">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1 px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 font-medium"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateName}
|
||||
disabled={loading || !name.trim() || name === authUser?.name}
|
||||
className="px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Change Section */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-rose-50 dark:bg-neutral-800 rounded-xl flex items-center justify-center border-2 border-rose-100 dark:border-neutral-700">
|
||||
<Lock size={24} className="text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Change Password</h2>
|
||||
</div>
|
||||
{!showPasswordForm && (
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
className="px-4 py-2 bg-rose-600 dark:bg-rose-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPasswordForm && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium"
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="flex-1 px-6 py-3 bg-rose-600 dark:bg-rose-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Logo } from '../components/Logo';
|
||||
|
||||
export const Register: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate('/');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to register';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
sign in to your existing account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="sr-only">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 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"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -92,20 +92,56 @@ export const Settings: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.get('/export', { responseType: 'blob' });
|
||||
const blob = new Blob([response.data], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `excalidash-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
alert('Failed to export data. Please try again.');
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-indigo-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-indigo-100 dark:border-neutral-700 group-hover:border-indigo-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
<Database size={32} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Export Data (.sqlite)</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download full database backup</p>
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Export Data (JSON)</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download drawings as JSON</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export?format=db`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.get('/export?format=db', { responseType: 'blob' });
|
||||
const blob = new Blob([response.data], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `excalidash-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
if (error && typeof error === 'object' && 'response' in error && error.response?.status === 403) {
|
||||
alert('Database export is not available. Please use JSON export instead.');
|
||||
} else {
|
||||
alert('Failed to export data. Please try again.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-blue-100 dark:border-neutral-700 group-hover:border-blue-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
@@ -113,12 +149,28 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Export Data (.db)</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download Prisma .db format</p>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download Prisma .db format (disabled)</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export/json`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.get('/export/json', { responseType: 'blob' });
|
||||
const blob = new Blob([response.data], { type: 'application/zip' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `excalidraw-drawings-${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
alert('Failed to export data. Please try again.');
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-emerald-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-emerald-100 dark:border-neutral-700 group-hover:border-emerald-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
|
||||
Reference in New Issue
Block a user