centralize formatdate function, redirect back after login remove cart functionality
This commit is contained in:
parent
65d0a32cb5
commit
30769fc118
17 changed files with 251 additions and 318 deletions
|
|
@ -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(minutes=24)
|
||||||
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", "*")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}</>;
|
||||||
|
|
|
||||||
17
frontend/src/components/icons/ChevronDownIcon.tsx
Normal file
17
frontend/src/components/icons/ChevronDownIcon.tsx
Normal 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;
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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';
|
||||||
|
|
||||||
interface BoardCardProps {
|
interface BoardCardProps {
|
||||||
board: Board;
|
board: Board;
|
||||||
|
|
@ -7,92 +8,73 @@ 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
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<svg
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
width="16"
|
</svg>
|
||||||
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"
|
<svg
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
width="16"
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
height="16"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</Link>
|
fill="none"
|
||||||
<button
|
stroke="currentColor"
|
||||||
onClick={handleDelete}
|
strokeWidth="2"
|
||||||
className="text-gray-400 hover:text-red-400 transition-colors"
|
strokeLinecap="round"
|
||||||
title="Delete board"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<svg
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<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>
|
||||||
width="16"
|
</svg>
|
||||||
height="16"
|
</button>
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
46
frontend/src/utils/dateFormat.ts
Normal file
46
frontend/src/utils/dateFormat.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue