diff --git a/backend/app/config.py b/backend/app/config.py index 2ccef83..134daf7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ class Config: SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SQLALCHEMY_TRACK_MODIFICATIONS = False 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) CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") diff --git a/frontend/src/components/CardAttachments.tsx b/frontend/src/components/CardAttachments.tsx index 277b549..e9de87f 100644 --- a/frontend/src/components/CardAttachments.tsx +++ b/frontend/src/components/CardAttachments.tsx @@ -4,6 +4,7 @@ import FileIcon from './icons/FileIcon'; import DownloadIcon from './icons/DownloadIcon'; import EyeIcon from './icons/EyeIcon'; import { SecureImage } from './SecureImage'; +import { formatDateOnly } from '../utils/dateFormat'; interface CardAttachmentsProps { cardId?: number; @@ -42,14 +43,6 @@ export function CardAttachments({ cardId }: CardAttachmentsProps) { 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/'); return ( @@ -111,7 +104,8 @@ export function CardAttachments({ cardId }: CardAttachmentsProps) {

{attachment.original_name}

- {formatFileSize(attachment.file_size)} • {formatDate(attachment.created_at)} + {formatFileSize(attachment.file_size)} •{' '} + {formatDateOnly(attachment.created_at)}

diff --git a/frontend/src/components/CardComments.tsx b/frontend/src/components/CardComments.tsx index 0156ed3..848f0d2 100644 --- a/frontend/src/components/CardComments.tsx +++ b/frontend/src/components/CardComments.tsx @@ -1,6 +1,7 @@ import { FormEvent, useState } from 'react'; import MessageSquareIcon from './icons/MessageSquareIcon'; import PlusIcon from './icons/PlusIcon'; +import { formatDateTime } from '../utils/dateFormat'; interface CardCommentsProps { card: any; @@ -21,16 +22,6 @@ export function CardComments({ const [editingCommentId, setEditingCommentId] = useState(null); 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) => { e.preventDefault(); if (!newCommentText.trim()) return; @@ -126,7 +117,7 @@ export function CardComments({

{comment.user?.username || 'Unknown'}

-

{formatDate(comment.created_at)}

+

{formatDateTime(comment.created_at)}

diff --git a/frontend/src/components/CardSidebar.tsx b/frontend/src/components/CardSidebar.tsx index ddb5d4a..9143f42 100644 --- a/frontend/src/components/CardSidebar.tsx +++ b/frontend/src/components/CardSidebar.tsx @@ -1,18 +1,10 @@ +import { formatDateTime } from '../utils/dateFormat'; + interface CardSidebarProps { card: any; } 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 (
@@ -20,11 +12,11 @@ export function CardSidebar({ card }: CardSidebarProps) {

Due Date

-

{card.due ? formatDate(card.due) : 'No due date'}

+

{card.due ? formatDateTime(card.due) : 'No due date'}

Last Activity

-

{formatDate(card.date_last_activity)}

+

{formatDateTime(card.date_last_activity)}

Position

diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index c0868f1..e4a7f23 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,4 +1,4 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { useApp } from '../context/AppContext'; interface ProtectedRouteProps { @@ -7,6 +7,7 @@ interface ProtectedRouteProps { export function ProtectedRoute({ children }: ProtectedRouteProps) { const { token, loading } = useApp(); + const location = useLocation(); if (loading) { return ( @@ -17,7 +18,8 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { } if (!token) { - return ; + // Pass the current location as a redirect query parameter + return ; } return <>{children}; diff --git a/frontend/src/components/icons/ChevronDownIcon.tsx b/frontend/src/components/icons/ChevronDownIcon.tsx new file mode 100644 index 0000000..599348d --- /dev/null +++ b/frontend/src/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,17 @@ +const ChevronDownIcon = () => ( + + + +); + +export default ChevronDownIcon; diff --git a/frontend/src/components/kanban/BoardCard.tsx b/frontend/src/components/kanban/BoardCard.tsx index 7445d9a..7ee6c13 100644 --- a/frontend/src/components/kanban/BoardCard.tsx +++ b/frontend/src/components/kanban/BoardCard.tsx @@ -1,5 +1,6 @@ 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 { board: Board; @@ -7,92 +8,73 @@ interface BoardCardProps { } export function BoardCard({ board, onDelete }: BoardCardProps) { - const navigate = useNavigate(); - const handleDelete = (e: React.MouseEvent) => { e.preventDefault(); - e.stopPropagation(); if (window.confirm(`Are you sure you want to delete "${board.name}"?`)) { 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 ( -
navigate(`/boards/${board.id}`)} className="block group cursor-pointer"> -
-
-

+
+
+

+ {board.name} -

-
- e.stopPropagation()} - className="text-gray-400 hover:text-blue-400 transition-colors" - title="Edit board" + +

+
+ + - - - - - - + + -
+ + + +
+
- {board.description && ( -

{board.description}

- )} + {board.description && ( +

{board.description}

+ )} -
- - Last activity: {formatDate(board.date_last_activity)} - -
+
+ + Last activity: {formatRelativeTime(board.date_last_activity)} +
); diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx index 1cbca5b..5cce0c7 100644 --- a/frontend/src/components/kanban/KanbanColumn.tsx +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -68,7 +68,7 @@ export function KanbanColumn({ }; return ( -
+
@@ -133,7 +133,7 @@ export function KanbanColumn({ >
{cards.map((card) => ( onOpenCardModal(card)} /> diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 6fc794f..25d4c2a 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -1,19 +1,12 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { User, CartItem } from '../types'; +import { User } from '../types'; interface AppContextValue { user: User | null; token: string | null; - cart: CartItem[]; loading: boolean; login: (userData: User, authToken: string) => 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 { @@ -29,7 +22,7 @@ export { AppContext }; export function AppProvider({ children }: AppProviderProps) { const [user, setUser] = useState(null); const [token, setToken] = useState(null); - const [cart, setCart] = useState([]); + const [cart, setCart] = useState([]); const [loading, setLoading] = useState(true); // Load user and token from localStorage on mount @@ -82,58 +75,14 @@ export function AppProvider({ children }: AppProviderProps) { 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 ( {children} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8b6c90c..fa54de6 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -42,7 +42,8 @@ api.interceptors.response.use( localStorage.removeItem('user'); 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); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index acdcc07..c962cdb 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -12,7 +12,7 @@ export function useAuth() { const { withLoader } = useLoader(); const { addNotification } = useToast(); - const handleLogin = async (email: string, password: string) => { + const handleLogin = async (email: string, password: string, redirectPath?: string | null) => { try { const response = await withLoader(() => loginApi(email, password), 'Logging in...'); @@ -38,8 +38,9 @@ export function useAuth() { duration: 3000, }); - // Navigate to boards - navigate('/boards'); + // Navigate to the redirect path from query parameter or default to /boards + const redirectTo = redirectPath || '/boards'; + navigate(redirectTo); return user; } catch (err: any) { diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx index 2f07c02..72a576a 100644 --- a/frontend/src/pages/CardDetail.tsx +++ b/frontend/src/pages/CardDetail.tsx @@ -17,6 +17,7 @@ import Trash2Icon from '../components/icons/Trash2Icon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import Edit2Icon from '../components/icons/Edit2Icon'; import { NarrowPageLayout } from '@/components/NarrowPageLayout'; +import { formatDateTime } from '../utils/dateFormat'; export function CardDetail() { const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>(); @@ -76,16 +77,6 @@ export function CardDetail() { 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 (
@@ -112,7 +103,7 @@ export function CardDetail() {

- In list • Created {formatDate(card.created_at)} + In list • Created {formatDateTime(card.created_at)}

-
- ); - } - - return ( -
-

Shopping Cart

- -
- {cart.map((item) => ( -
- {item.image_url && ( - {item.name} - )} - -
-

{item.name}

-

${item.price}

-
- -
- - {item.quantity} - -
- -

- ${(item.price * item.quantity).toFixed(2)} -

- - -
- ))} -
- -
-
- Total:{' '} - ${cartTotal.toFixed(2)} -
- -
- - -
-
-
- ); -} diff --git a/frontend/src/pages/EpicDetail.tsx b/frontend/src/pages/EpicDetail.tsx index 200a70f..367e443 100644 --- a/frontend/src/pages/EpicDetail.tsx +++ b/frontend/src/pages/EpicDetail.tsx @@ -1,14 +1,18 @@ import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; import { WidePageLayout } from '../components/WidePageLayout'; import RichTextContent from '../components/RichTextContent'; import useEpicDetail from '../hooks/useEpicDetail'; import Edit2Icon from '../components/icons/Edit2Icon'; import Trash2Icon from '../components/icons/Trash2Icon'; +import ChevronRightIcon from '../components/icons/ChevronRightIcon'; +import ChevronDownIcon from '../components/icons/ChevronDownIcon'; export function EpicDetail() { const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>(); const navigate = useNavigate(); const { epic, deleteEpic } = useEpicDetail(epicId || '0'); + const [isContentExpanded, setIsContentExpanded] = useState(false); const handleDelete = async () => { if (!epic) return; @@ -37,6 +41,7 @@ export function EpicDetail() { const defaultColor = '#3b82f6'; const color = epic.color || defaultColor; + const hasContent = epic.content && Array.isArray(epic.content) && epic.content.length > 0; return (
@@ -96,11 +101,53 @@ export function EpicDetail() {
)} - {/* Rich Text Content */} - {epic.content && Array.isArray(epic.content) && epic.content.length > 0 && ( + {/* Rich Text Content - Collapsible */} + {hasContent && (
-

Details

- +
+

Details

+ +
+ +
+ {isContentExpanded ? ( + + ) : ( + <> +
+ +
+
+
+ +
+ + )} +
)} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 4f5508f..d87dc44 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; const loginSchema = z.object({ @@ -16,6 +16,8 @@ type LoginFormData = z.infer; export default function Login() { const { login: handleLogin } = useAuth(); + const [searchParams] = useSearchParams(); + const redirectPath = searchParams.get('redirect'); const { register, @@ -28,7 +30,7 @@ export default function Login() { const onSubmit = async (data: LoginFormData) => { try { - await handleLogin(data.email, data.password); + await handleLogin(data.email, data.password, redirectPath); } catch (err) { // Error is handled by the hook (toast shown) console.error(err); diff --git a/frontend/src/pages/WikiDetail.tsx b/frontend/src/pages/WikiDetail.tsx index 8521662..40504a6 100644 --- a/frontend/src/pages/WikiDetail.tsx +++ b/frontend/src/pages/WikiDetail.tsx @@ -1,15 +1,18 @@ import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; import { WidePageLayout } from '../components/WidePageLayout'; import RichTextContent from '../components/RichTextContent'; import useWikiDetail from '../hooks/useWikiDetail'; import Edit2Icon from '../components/icons/Edit2Icon'; import Trash2Icon from '../components/icons/Trash2Icon'; import ChevronRightIcon from '../components/icons/ChevronRightIcon'; +import ChevronDownIcon from '../components/icons/ChevronDownIcon'; export function WikiDetail() { const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>(); const navigate = useNavigate(); const { wiki, deleteWiki } = useWikiDetail(wikiId || '0'); + const [isContentExpanded, setIsContentExpanded] = useState(false); const handleDelete = async () => { if (!wiki) return; @@ -19,7 +22,7 @@ export function WikiDetail() { await deleteWiki(); navigate(`/boards/${boardId}/wikis`); } 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 (
{/* Header */} @@ -88,11 +93,53 @@ export function WikiDetail() {
)} - {/* Rich Text Content */} - {wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && ( + {/* Rich Text Content - Collapsible */} + {hasContent && (
-

Content

- +
+

Content

+ +
+ +
+ {isContentExpanded ? ( + + ) : ( + <> +
+ +
+
+
+ +
+ + )} +
)} diff --git a/frontend/src/utils/dateFormat.ts b/frontend/src/utils/dateFormat.ts new file mode 100644 index 0000000..7563204 --- /dev/null +++ b/frontend/src/utils/dateFormat.ts @@ -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', + }); +}