centralize formatdate function, redirect back after login remove cart functionality

This commit is contained in:
david 2026-04-12 16:59:14 +03:00
parent 65d0a32cb5
commit 2f53b8f4bb
17 changed files with 233 additions and 320 deletions

View file

@ -8,7 +8,7 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12) JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")

View file

@ -4,6 +4,7 @@ import FileIcon from './icons/FileIcon';
import DownloadIcon from './icons/DownloadIcon'; import DownloadIcon from './icons/DownloadIcon';
import EyeIcon from './icons/EyeIcon'; import EyeIcon from './icons/EyeIcon';
import { SecureImage } from './SecureImage'; import { SecureImage } from './SecureImage';
import { formatDateOnly } from '../utils/dateFormat';
interface CardAttachmentsProps { interface CardAttachmentsProps {
cardId?: number; cardId?: number;
@ -42,14 +43,6 @@ export function CardAttachments({ cardId }: CardAttachmentsProps) {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}; };
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const isImage = (mimeType: string) => mimeType.startsWith('image/'); const isImage = (mimeType: string) => mimeType.startsWith('image/');
return ( return (
@ -111,7 +104,8 @@ export function CardAttachments({ cardId }: CardAttachmentsProps) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{attachment.original_name}</p> <p className="text-white font-medium truncate">{attachment.original_name}</p>
<p className="text-gray-400 text-sm mt-1"> <p className="text-gray-400 text-sm mt-1">
{formatFileSize(attachment.file_size)} {formatDate(attachment.created_at)} {formatFileSize(attachment.file_size)} {' '}
{formatDateOnly(attachment.created_at)}
</p> </p>
</div> </div>

View file

@ -1,6 +1,7 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import MessageSquareIcon from './icons/MessageSquareIcon'; import MessageSquareIcon from './icons/MessageSquareIcon';
import PlusIcon from './icons/PlusIcon'; import PlusIcon from './icons/PlusIcon';
import { formatDateTime } from '../utils/dateFormat';
interface CardCommentsProps { interface CardCommentsProps {
card: any; card: any;
@ -21,16 +22,6 @@ export function CardComments({
const [editingCommentId, setEditingCommentId] = useState<number | null>(null); const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editedCommentText, setEditedCommentText] = useState(''); const [editedCommentText, setEditedCommentText] = useState('');
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleAddComment = async (e: FormEvent) => { const handleAddComment = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newCommentText.trim()) return; if (!newCommentText.trim()) return;
@ -126,7 +117,7 @@ export function CardComments({
</div> </div>
<div> <div>
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p> <p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
<p className="text-gray-400 text-xs">{formatDate(comment.created_at)}</p> <p className="text-gray-400 text-xs">{formatDateTime(comment.created_at)}</p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -1,18 +1,10 @@
import { formatDateTime } from '../utils/dateFormat';
interface CardSidebarProps { interface CardSidebarProps {
card: any; card: any;
} }
export function CardSidebar({ card }: CardSidebarProps) { export function CardSidebar({ card }: CardSidebarProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-gray-800 rounded-lg p-6"> <div className="bg-gray-800 rounded-lg p-6">
@ -20,11 +12,11 @@ export function CardSidebar({ card }: CardSidebarProps) {
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="text-gray-400 text-sm">Due Date</p> <p className="text-gray-400 text-sm">Due Date</p>
<p className="text-white">{card.due ? formatDate(card.due) : 'No due date'}</p> <p className="text-white">{card.due ? formatDateTime(card.due) : 'No due date'}</p>
</div> </div>
<div> <div>
<p className="text-gray-400 text-sm">Last Activity</p> <p className="text-gray-400 text-sm">Last Activity</p>
<p className="text-white">{formatDate(card.date_last_activity)}</p> <p className="text-white">{formatDateTime(card.date_last_activity)}</p>
</div> </div>
<div> <div>
<p className="text-gray-400 text-sm">Position</p> <p className="text-gray-400 text-sm">Position</p>

View file

@ -1,4 +1,4 @@
import { Navigate } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
interface ProtectedRouteProps { interface ProtectedRouteProps {
@ -7,6 +7,7 @@ interface ProtectedRouteProps {
export function ProtectedRoute({ children }: ProtectedRouteProps) { export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { token, loading } = useApp(); const { token, loading } = useApp();
const location = useLocation();
if (loading) { if (loading) {
return ( return (
@ -17,7 +18,8 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
} }
if (!token) { if (!token) {
return <Navigate to="/login" replace />; // Pass the current location as a redirect query parameter
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
} }
return <>{children}</>; return <>{children}</>;

View file

@ -0,0 +1,17 @@
const ChevronDownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
);
export default ChevronDownIcon;

View file

@ -1,5 +1,8 @@
import { Board } from '../../types/kanban'; import { Board } from '../../types/kanban';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatRelativeTime } from '../../utils/dateFormat';
import Edit2Icon from '../icons/Edit2Icon';
import Trash2Icon from '../icons/Trash2Icon';
interface BoardCardProps { interface BoardCardProps {
board: Board; board: Board;
@ -7,92 +10,51 @@ interface BoardCardProps {
} }
export function BoardCard({ board, onDelete }: BoardCardProps) { export function BoardCard({ board, onDelete }: BoardCardProps) {
const navigate = useNavigate();
const handleDelete = (e: React.MouseEvent) => { const handleDelete = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (window.confirm(`Are you sure you want to delete "${board.name}"?`)) { if (window.confirm(`Are you sure you want to delete "${board.name}"?`)) {
onDelete(board.id); onDelete(board.id);
} }
}; };
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
return ( return (
<div onClick={() => navigate(`/boards/${board.id}`)} className="block group cursor-pointer"> <div className="group bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-all hover:shadow-lg hover:-translate-y-1 border border-gray-700">
<div className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-all hover:shadow-lg hover:-translate-y-1 border border-gray-700"> <div className="flex justify-between items-start mb-3">
<div className="flex justify-between items-start mb-3"> <h3 className="text-xl font-bold text-white">
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors"> <Link to={`/boards/${board.id}`} className="hover:text-blue-400 transition-colors">
{board.name} {board.name}
</h3> </Link>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> </h3>
<Link <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
to={`/boards/${board.id}/edit`} <Link
onClick={(e) => e.stopPropagation()} to={`/boards/${board.id}/edit`}
className="text-gray-400 hover:text-blue-400 transition-colors" className="text-gray-400 hover:text-blue-400 transition-colors"
title="Edit board" title="Edit board"
> >
<svg <span className="w-4 h-4">
xmlns="http://www.w3.org/2000/svg" <Edit2Icon />
width="16" </span>
height="16" </Link>
viewBox="0 0 24 24" <button
fill="none" onClick={handleDelete}
stroke="currentColor" className="text-gray-400 hover:text-red-400 transition-colors"
strokeWidth="2" title="Delete board"
strokeLinecap="round" >
strokeLinejoin="round" <span className="w-4 h-4">
> <Trash2Icon />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> </span>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> </button>
</svg>
</Link>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-400 transition-colors"
title="Delete board"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div> </div>
</div>
{board.description && ( {board.description && (
<p className="text-gray-400 text-sm mb-4 line-clamp-2">{board.description}</p> <p className="text-gray-400 text-sm mb-4 line-clamp-2">{board.description}</p>
)} )}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-gray-500"> <span className="text-gray-500">
Last activity: {formatDate(board.date_last_activity)} Last activity: {formatRelativeTime(board.date_last_activity)}
</span> </span>
</div>
</div> </div>
</div> </div>
); );

View file

@ -68,7 +68,7 @@ export function KanbanColumn({
}; };
return ( return (
<div className="bg-gray-800 rounded-lg py-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col max-h-[calc(100vh-280px)]"> <div className="bg-gray-800 rounded-lg py-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col max-h-[calc(100vh-280px)] pr-2">
<div className="mb-4 px-4"> <div className="mb-4 px-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
@ -133,7 +133,7 @@ export function KanbanColumn({
> >
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`} className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] pl-4 pr-2 overflow-auto scrollbar-custom`}
> >
{cards.map((card) => ( {cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} /> <KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />

View file

@ -1,19 +1,12 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, CartItem } from '../types'; import { User } from '../types';
interface AppContextValue { interface AppContextValue {
user: User | null; user: User | null;
token: string | null; token: string | null;
cart: CartItem[];
loading: boolean; loading: boolean;
login: (userData: User, authToken: string) => void; login: (userData: User, authToken: string) => void;
logout: () => void; logout: () => void;
addToCart: (product: CartItem) => void;
removeFromCart: (productId: number) => void;
updateCartQuantity: (productId: number, quantity: number) => void;
clearCart: () => void;
cartTotal: number;
cartItemCount: number;
} }
interface AppProviderProps { interface AppProviderProps {
@ -29,7 +22,7 @@ export { AppContext };
export function AppProvider({ children }: AppProviderProps) { export function AppProvider({ children }: AppProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [cart, setCart] = useState<CartItem[]>([]); const [cart, setCart] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
// Load user and token from localStorage on mount // Load user and token from localStorage on mount
@ -82,58 +75,14 @@ export function AppProvider({ children }: AppProviderProps) {
setCart([]); setCart([]);
}; };
const addToCart = (product: CartItem) => {
setCart((prevCart: CartItem[]) => {
const existingItem = prevCart.find((item) => item.id === product.id);
if (existingItem) {
return prevCart.map((item) =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prevCart, { ...product, quantity: 1 }];
});
};
const removeFromCart = (productId: number) => {
setCart((prevCart: CartItem[]) => prevCart.filter((item) => item.id !== productId));
};
const updateCartQuantity = (productId: number, quantity: number) => {
if (quantity <= 0) {
removeFromCart(productId);
return;
}
setCart((prevCart: CartItem[]) =>
prevCart.map((item) => (item.id === productId ? { ...item, quantity } : item))
);
};
const clearCart = () => {
setCart([]);
};
const cartTotal = cart.reduce(
(total: number, item: CartItem) => total + item.price * item.quantity,
0
);
const cartItemCount = cart.reduce((total: number, item: CartItem) => total + item.quantity, 0);
return ( return (
<AppContext.Provider <AppContext.Provider
value={{ value={{
user, user,
token, token,
cart,
loading, loading,
login, login,
logout, logout,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
cartTotal,
cartItemCount,
}} }}
> >
{children} {children}

View file

@ -42,7 +42,8 @@ api.interceptors.response.use(
localStorage.removeItem('user'); localStorage.removeItem('user');
if (!['/login', '/register'].includes(window.location.pathname)) { if (!['/login', '/register'].includes(window.location.pathname)) {
window.location.href = '/login'; const currentPath = window.location.pathname;
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
} }
} }
return Promise.reject(error); return Promise.reject(error);

View file

@ -12,7 +12,7 @@ export function useAuth() {
const { withLoader } = useLoader(); const { withLoader } = useLoader();
const { addNotification } = useToast(); const { addNotification } = useToast();
const handleLogin = async (email: string, password: string) => { const handleLogin = async (email: string, password: string, redirectPath?: string | null) => {
try { try {
const response = await withLoader(() => loginApi(email, password), 'Logging in...'); const response = await withLoader(() => loginApi(email, password), 'Logging in...');
@ -38,8 +38,9 @@ export function useAuth() {
duration: 3000, duration: 3000,
}); });
// Navigate to boards // Navigate to the redirect path from query parameter or default to /boards
navigate('/boards'); const redirectTo = redirectPath || '/boards';
navigate(redirectTo);
return user; return user;
} catch (err: any) { } catch (err: any) {

View file

@ -17,6 +17,7 @@ import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import { NarrowPageLayout } from '@/components/NarrowPageLayout'; import { NarrowPageLayout } from '@/components/NarrowPageLayout';
import { formatDateTime } from '../utils/dateFormat';
export function CardDetail() { export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>(); const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
@ -76,16 +77,6 @@ export function CardDetail() {
return null; return null;
} }
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return ( return (
<NarrowPageLayout> <NarrowPageLayout>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@ -112,7 +103,7 @@ export function CardDetail() {
</button> </button>
</div> </div>
<p className="text-gray-400 text-sm mt-1"> <p className="text-gray-400 text-sm mt-1">
In list Created {formatDate(card.created_at)} In list Created {formatDateTime(card.created_at)}
</p> </p>
</div> </div>
<button <button

View file

@ -1,129 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
export default function Cart() {
const navigate = useNavigate();
const { cart, removeFromCart, updateCartQuantity, clearCart, cartTotal } = useApp();
const { createOrder } = useApi();
const handleCheckout = async () => {
if (cart.length === 0) return;
const shippingAddress = prompt('Enter shipping address:');
if (!shippingAddress) return;
try {
await createOrder({
items: cart.map((item) => ({
id: item.id.toString(),
product_id: item.id.toString(),
quantity: item.quantity,
price: item.price,
})),
status: 'pending',
total_amount: cartTotal,
created_at: new Date().toISOString(),
shipping_address: shippingAddress,
});
clearCart();
navigate('/orders');
} catch (error) {
console.error(error);
alert('Failed to create order. Please try again.');
}
};
if (cart.length === 0) {
return (
<div className="text-center py-12">
<h1 className="text-3xl font-bold text-white mb-4">Shopping Cart</h1>
<p className="text-gray-400 mb-8">Your cart is empty</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
);
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">Shopping Cart</h1>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
{cart.map((item) => (
<div
key={item.id}
className="p-4 border-b border-gray-700 last:border-b-0 flex items-center gap-4"
>
{item.image_url && (
<img
src={item.image_url}
alt={item.name}
className="w-20 h-20 object-cover rounded"
/>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-white">{item.name}</h3>
<p className="text-blue-400 font-bold">${item.price}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateCartQuantity(item.id, item.quantity - 1)}
className="bg-gray-700 hover:bg-gray-600 text-white w-8 h-8 rounded transition-colors"
>
-
</button>
<span className="text-white w-8 text-center">{item.quantity}</span>
<button
onClick={() => updateCartQuantity(item.id, item.quantity + 1)}
className="bg-gray-700 hover:bg-gray-600 text-white w-8 h-8 rounded transition-colors"
>
+
</button>
</div>
<p className="text-white font-bold min-w-[100px] text-right">
${(item.price * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
className="text-red-400 hover:text-red-300 p-2 transition-colors"
aria-label="Remove item"
>
</button>
</div>
))}
</div>
<div className="mt-6 flex justify-between items-center">
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${cartTotal.toFixed(2)}</span>
</div>
<div className="flex gap-4">
<button
onClick={clearCart}
className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Clear Cart
</button>
<button
onClick={handleCheckout}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Checkout
</button>
</div>
</div>
</div>
);
}

View file

@ -1,14 +1,18 @@
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout'; import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent'; import RichTextContent from '../components/RichTextContent';
import useEpicDetail from '../hooks/useEpicDetail'; import useEpicDetail from '../hooks/useEpicDetail';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
import ChevronDownIcon from '../components/icons/ChevronDownIcon';
export function EpicDetail() { export function EpicDetail() {
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>(); const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { epic, deleteEpic } = useEpicDetail(epicId || '0'); const { epic, deleteEpic } = useEpicDetail(epicId || '0');
const [isContentExpanded, setIsContentExpanded] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
if (!epic) return; if (!epic) return;
@ -37,6 +41,7 @@ export function EpicDetail() {
const defaultColor = '#3b82f6'; const defaultColor = '#3b82f6';
const color = epic.color || defaultColor; const color = epic.color || defaultColor;
const hasContent = epic.content && Array.isArray(epic.content) && epic.content.length > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -96,11 +101,53 @@ export function EpicDetail() {
</div> </div>
)} )}
{/* Rich Text Content */} {/* Rich Text Content - Collapsible */}
{epic.content && Array.isArray(epic.content) && epic.content.length > 0 && ( {hasContent && (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-white mb-2">Details</h2> <div className="flex items-center justify-between mb-2">
<RichTextContent content={epic.content} /> <h2 className="text-lg font-semibold text-white">Details</h2>
<button
onClick={() => setIsContentExpanded(!isContentExpanded)}
className="text-blue-500 hover:text-blue-400 text-sm font-medium flex items-center gap-1 transition-colors"
>
{isContentExpanded ? (
<>
<span className="w-4 h-4">
<ChevronDownIcon />
</span>
Collapse
</>
) : (
<>
<span className="w-4 h-4">
<ChevronRightIcon />
</span>
Expand
</>
)}
</button>
</div>
<div className="relative">
{isContentExpanded ? (
<RichTextContent content={epic.content} />
) : (
<>
<div className="max-h-[200px] overflow-hidden relative">
<RichTextContent content={epic.content} />
</div>
<div className="h-24 bg-gradient-to-t from-gray-800 to-transparent absolute bottom-0 left-0 right-0 pointer-events-none -mt-20" />
<div className="mt-4 pt-4">
<button
onClick={() => setIsContentExpanded(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg text-sm font-medium transition-colors"
>
Read More
</button>
</div>
</>
)}
</div>
</div> </div>
)} )}

View file

@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
const loginSchema = z.object({ const loginSchema = z.object({
@ -16,6 +16,8 @@ type LoginFormData = z.infer<typeof loginSchema>;
export default function Login() { export default function Login() {
const { login: handleLogin } = useAuth(); const { login: handleLogin } = useAuth();
const [searchParams] = useSearchParams();
const redirectPath = searchParams.get('redirect');
const { const {
register, register,
@ -28,7 +30,7 @@ export default function Login() {
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
try { try {
await handleLogin(data.email, data.password); await handleLogin(data.email, data.password, redirectPath);
} catch (err) { } catch (err) {
// Error is handled by the hook (toast shown) // Error is handled by the hook (toast shown)
console.error(err); console.error(err);

View file

@ -1,15 +1,18 @@
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout'; import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent'; import RichTextContent from '../components/RichTextContent';
import useWikiDetail from '../hooks/useWikiDetail'; import useWikiDetail from '../hooks/useWikiDetail';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ChevronRightIcon from '../components/icons/ChevronRightIcon'; import ChevronRightIcon from '../components/icons/ChevronRightIcon';
import ChevronDownIcon from '../components/icons/ChevronDownIcon';
export function WikiDetail() { export function WikiDetail() {
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>(); const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0'); const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
const [isContentExpanded, setIsContentExpanded] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
if (!wiki) return; if (!wiki) return;
@ -19,7 +22,7 @@ export function WikiDetail() {
await deleteWiki(); await deleteWiki();
navigate(`/boards/${boardId}/wikis`); navigate(`/boards/${boardId}/wikis`);
} catch { } catch {
// Error is already handled by hook with toast // Error is already handled by the hook with toast
} }
} }
}; };
@ -36,6 +39,8 @@ export function WikiDetail() {
); );
} }
const hasContent = wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -88,11 +93,53 @@ export function WikiDetail() {
</div> </div>
)} )}
{/* Rich Text Content */} {/* Rich Text Content - Collapsible */}
{wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && ( {hasContent && (
<div className="mb-6 pb-6 border-b border-gray-700"> <div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-2">Content</h2> <div className="flex items-center justify-between mb-2">
<RichTextContent content={wiki.content} /> <h2 className="text-lg font-semibold text-white">Content</h2>
<button
onClick={() => setIsContentExpanded(!isContentExpanded)}
className="text-blue-500 hover:text-blue-400 text-sm font-medium flex items-center gap-1 transition-colors"
>
{isContentExpanded ? (
<>
<span className="w-4 h-4">
<ChevronDownIcon />
</span>
Collapse
</>
) : (
<>
<span className="w-4 h-4">
<ChevronRightIcon />
</span>
Expand
</>
)}
</button>
</div>
<div className="relative">
{isContentExpanded ? (
<RichTextContent content={wiki.content} />
) : (
<>
<div className="max-h-[200px] overflow-hidden relative">
<RichTextContent content={wiki.content} />
</div>
<div className="h-24 bg-gradient-to-t from-gray-800 to-transparent absolute bottom-0 left-0 right-0 pointer-events-none -mt-20" />
<div className="mt-4 pt-4">
<button
onClick={() => setIsContentExpanded(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg text-sm font-medium transition-colors"
>
Read More
</button>
</div>
</>
)}
</div>
</div> </div>
)} )}

View file

@ -0,0 +1,46 @@
/**
* Date formatting utility functions
* Use these instead of creating inline formatDate functions in components
*/
/**
* Format date as relative time (e.g., "Just now", "5m ago", "2h ago", "3d ago", or full date)
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Format date with full date and time (e.g., "Jan 1, 2024, 2:30 PM")
*/
export function formatDateTime(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Format date only (e.g., "Jan 1, 2024")
*/
export function formatDateOnly(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}