diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 609a31e1a..91ffda358 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,11 +1,14 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import CloseIcon from '@mui/icons-material/Close'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; + import styles from './PageEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; @@ -15,7 +18,7 @@ interface FileItem { pageCount: number; thumbnail: string; size: number; - splitBefore?: boolean; + modifiedAt?: number | string | Date; } interface FileThumbnailProps { @@ -29,6 +32,7 @@ interface FileThumbnailProps { onViewFile: (fileId: string) => void; onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; + onDownloadFile?: (fileId: string) => void; toolMode?: boolean; isSupported?: boolean; } @@ -36,36 +40,93 @@ interface FileThumbnailProps { const FileThumbnail = ({ file, index, - totalFiles, selectedFiles, - selectionMode, onToggleFile, onDeleteFile, onViewFile, onSetStatus, onReorderFiles, - toolMode = false, + onDownloadFile, isSupported = true, }: FileThumbnailProps) => { const { t } = useTranslation(); - const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); - - // Drag and drop state + const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); + const [actionsWidth, setActionsWidth] = useState(undefined); + const [showActions, setShowActions] = useState(false); - // Find the actual File object that corresponds to this FileItem - const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size); + // Resolve the actual File object for pin/unpin operations + const actualFile = useMemo(() => { + return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); + }, [activeFiles, file.name, file.size]); + const isPinned = actualFile ? isFilePinned(actualFile) : false; - const formatFileSize = (bytes: number) => { + const downloadSelectedFile = useCallback(() => { + // Prefer parent-provided handler if available + if (typeof onDownloadFile === 'function') { + onDownloadFile(file.id); + return; + } + + // Fallback: attempt to download using the File object if provided + const maybeFile = (file as unknown as { file?: File }).file; + if (maybeFile instanceof File) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(maybeFile); + link.download = maybeFile.name || file.name || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + return; + } + + // If we can't find a way to download, surface a status message + onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item'); + }, [file, onDownloadFile, onSetStatus, t]); + const handleRef = useRef(null); + + // ---- Selection ---- + const isSelected = selectedFiles.includes(file.id); + + // ---- Meta formatting ---- + const prettySize = useMemo(() => { + const bytes = file.size ?? 0; if (bytes === 0) return '0 B'; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - }; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }, [file.size]); - // Setup drag and drop using @atlaskit/pragmatic-drag-and-drop + const extUpper = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toUpperCase(); + }, [file.name]); + + const pageLabel = useMemo( + () => + file.pageCount > 0 + ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` + : '', + [file.pageCount] + ); + + const dateLabel = useMemo(() => { + const d = + file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback + if (Number.isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(d); + }, [file.modifiedAt]); + + // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { if (!element) return; @@ -111,252 +172,234 @@ const FileThumbnail = ({ dragCleanup(); dropCleanup(); }; - }, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]); + }, [file.id, file.name, selectedFiles, onReorderFiles]); + + // Update dropdown width on resize + useEffect(() => { + const update = () => { + if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); + }; + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + // Close the actions dropdown when hovering outside this file card (and its dropdown) + useEffect(() => { + if (!showActions) return; + + const isInsideCard = (target: EventTarget | null) => { + const container = dragElementRef.current; + if (!container) return false; + return target instanceof Node && container.contains(target); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + const handleTouchStart = (e: TouchEvent) => { + // On touch devices, close if the touch target is outside the card + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('touchstart', handleTouchStart); + }; + }, [showActions]); + + // ---- Card interactions ---- + const handleCardClick = () => { + if (!isSupported) return; + onToggleFile(file.id); + }; + return (
-
e.stopPropagation()} - onDragStart={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - { - event.stopPropagation(); - if (isSupported) { - onToggleFile(file.id); - } - }} - onClick={(e) => e.stopPropagation()} - disabled={!isSupported} - size="sm" - /> + {/* Header bar */} +
+ {/* Logo/checkbox area */} +
+ {isSupported ? ( + onToggleFile(file.id)} + color="var(--checkbox-checked-bg)" + /> + ) : ( +
+ + {t('unsupported', 'Unsupported')} + +
+ )}
- {/* File content area */} -
- {/* Stacked file effect - multiple shadows to simulate pages */} -
+ {index + 1} +
+ + {/* Kebab menu */} + { + e.stopPropagation(); + setShowActions((v) => !v); }} > + + +
+ + {/* Actions overlay */} + {showActions && ( +
e.stopPropagation()} + > + + + + +
+ + +
+ )} + + {/* Title + meta line */} +
+ + {file.name} + + + {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} + {dateLabel} + {extUpper ? ` - ${extUpper} file` : ''} + {pageLabel ? ` - ${pageLabel}` : ''} + +
+ + {/* Preview area */} +
+
{file.name} { - // Hide broken image if blob URL was revoked - const img = e.target as HTMLImageElement; + const img = e.currentTarget; img.style.display = 'none'; + img.parentElement?.setAttribute('data-thumb-missing', 'true'); }} style={{ - maxWidth: '100%', - maxHeight: '100%', + maxWidth: '80%', + maxHeight: '80%', objectFit: 'contain', - borderRadius: 2, + borderRadius: 0, + background: '#ffffff', + border: '1px solid var(--border-default)', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + alignSelf: 'start' }} />
- {/* Page count badge - only show for PDFs */} - {file.pageCount > 0 && ( - - {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} - + {/* Pin indicator (bottom-left) */} + {isPinned && ( + + + )} - {/* Unsupported badge */} - {!isSupported && ( - -{t("fileManager.unsupported", "Unsupported")} - - )} - - {/* File name overlay */} - - {file.name} - - - {/* Hover controls */} -
- - {actualFile && ( - - { - e.stopPropagation(); - if (isFilePinned(actualFile)) { - unpinFile(actualFile); - onSetStatus(`Unpinned ${file.name}`); - } else { - pinFile(actualFile); - onSetStatus(`Pinned ${file.name}`); - } - }} - > - {isFilePinned(actualFile) ? ( - - ) : ( - - )} - - - )} - - - { - e.stopPropagation(); - onDeleteFile(file.id); - onSetStatus(`Closed ${file.name}`); - }} - > - - - -
- - + {/* Drag handle (span wrapper so we can attach a ref reliably) */} + + +
- - {/* File info */} -
- - {file.name} - - - {formatFileSize(file.size)} - -
-
); }; -export default FileThumbnail; \ No newline at end of file +export default React.memo(FileThumbnail); diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 8b1c84638..851d81517 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -1,67 +1,265 @@ -/* Page container hover effects - optimized for smooth scrolling */ -.pageContainer { - transition: transform 0.2s ease-in-out; - /* Enable hardware acceleration for smoother scrolling */ - will-change: transform; - transform: translateZ(0); - backface-visibility: hidden; +/* ========================= + NEW styles for card UI + ========================= */ + + .card { + background: var(--file-card-bg); + border-radius: 0.0625rem; + cursor: pointer; + transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; + max-width: 100%; + max-height: 100%; + overflow: hidden; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + .card:hover { + box-shadow: var(--shadow-md); + } + .card[data-selected="true"] { + box-shadow: var(--shadow-sm); + } + + /* While dragging */ + .card.dragging, + .card:global(.dragging) { + outline: 1px solid var(--border-strong); + box-shadow: var(--shadow-md); + transform: none !important; + } + + /* -------- Header -------- */ + .header { + height: 2.25rem; + border-radius: 0.0625rem 0.0625rem 0 0; + display: grid; + grid-template-columns: 44px 1fr 44px; + align-items: center; + padding: 0 6px; + user-select: none; + background: var(--bg-toolbar); + color: var(--text-primary); + border-bottom: 1px solid var(--border-default); + } + .headerResting { + background: #3B4B6E; /* dark blue for unselected in light mode */ + color: #FFFFFF; + border-bottom: 1px solid var(--border-default); + } + .headerSelected { + background: var(--header-selected-bg); + color: var(--header-selected-fg); + border-bottom: 1px solid var(--header-selected-bg); + } + + /* Selected border color in light mode */ + :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: var(--card-selected-border); + } + + /* Reserve space for checkbox instead of logo */ + .logoMark { + margin-left: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + } + + .headerIndex { + text-align: center; + font-weight: 500; + font-size: 18px; + letter-spacing: 0.04em; + } + + .kebab { + justify-self: end; + color: inherit; + } + + /* Menu dropdown */ + .menuDropdown { + min-width: 210px; + } + + /* -------- Title / Meta -------- */ + .title { + line-height: 1.2; + color: var(--text-primary); + } + .meta { + margin-top: 2px; + color: var(--text-secondary); + } + + /* -------- Preview area -------- */ + .previewBox { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--file-card-bg); + } + .previewPaper { + width: 100%; + height: calc(100% - 6px); + min-height: 9rem; + justify-content: center; + display: grid; + position: relative; + overflow: hidden; + background: var(--file-card-bg); + } + + /* Thumbnail fallback */ + .previewPaper[data-thumb-missing="true"]::after { + content: "No preview"; + position: absolute; inset: 0; + display: grid; place-items: center; + color: var(--text-secondary); + font-weight: 600; font-size: 12px; + } + + /* Drag handle grip */ + .dragHandle { + position: absolute; + bottom: 6px; + right: 6px; + color: var(--text-secondary); + z-index: 1; + cursor: grab; + display: inline-flex; + } + + /* Reduced motion */ + @media (prefers-reduced-motion: reduce) { + .card, + .menuDropdown { + transition: none !important; + } + } + + /* ========================= + DARK MODE OVERRIDES + ========================= */ + :global([data-mantine-color-scheme="dark"]) .card { + outline-color: #3A4047; /* deselected stroke */ + } + :global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] { + outline-color: #4B525A; /* selected stroke (subtle grey) */ + } + :global([data-mantine-color-scheme="dark"]) .headerResting { + background: #1F2329; /* requested default unselected color */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); /* #3A4047 */ + } + :global([data-mantine-color-scheme="dark"]) .headerSelected { + background: var(--tool-header-border); /* #3A4047 */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); + } + :global([data-mantine-color-scheme="dark"]) .title { + color: #D0D6DC; /* title text */ + } + :global([data-mantine-color-scheme="dark"]) .meta { + color: #6B7280; /* subtitle text */ + } + + /* Light mode selected header stroke override */ + :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: #3B4B6E; + } + + /* ========================= + (Optional) legacy styles from your + previous component kept here to + avoid breaking other imports. + They are not used by the new card. + ========================= */ + + .pageContainer { + transition: transform 0.2s ease-in-out; + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; + } + .pageContainer:hover { transform: scale(1.02) translateZ(0); } + .pageContainer:hover .pageNumber { opacity: 1 !important; } + .pageContainer:hover .pageHoverControls { opacity: 1 !important; } + .checkboxContainer { transform: none !important; transition: none !important; } + + .pageMoveAnimation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } + .pageMoving { z-index: 10; transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); } + + .multiDragIndicator { + position: fixed; + background: rgba(59, 130, 246, 0.9); + color: white; + padding: 8px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + backdrop-filter: blur(4px); + } + + @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } + .pulse { animation: pulse 1s infinite; } + + .actionsOverlay { + position: absolute; + left: 0; + top: 44px; /* just below header */ + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-default); + z-index: 20; + overflow: hidden; + animation: slideDown 140ms ease-out; + color: var(--text-primary); + } + @keyframes slideDown { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } + + .actionRow { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + text-align: left; + } + .actionRow:hover { background: var(--hover-bg); } + .actionDanger { color: var(--text-brand-accent); } + .actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; } + +.pinIndicator { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 1; + color: rgba(0, 0, 0, 0.35); /* match drag handle color */ } -.pageContainer:hover { - transform: scale(1.02) translateZ(0); -} - -.pageContainer:hover .pageNumber { - opacity: 1 !important; -} - -.pageContainer:hover .pageHoverControls { - opacity: 1 !important; -} - -/* Checkbox container - prevent transform inheritance */ -.checkboxContainer { - transform: none !important; - transition: none !important; -} - -/* Page movement animations */ -.pageMoveAnimation { - transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} - -.pageMoving { - z-index: 10; - transform: scale(1.05); - box-shadow: 0 10px 30px rgba(0,0,0,0.3); -} - -/* Multi-page drag indicator */ -.multiDragIndicator { - position: fixed; - background: rgba(59, 130, 246, 0.9); +.unsupportedPill { + margin-left: 1.75rem; + background: #6B7280; color: white; - padding: 8px 12px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; - pointer-events: none; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - transform: translate(-50%, -50%); - backdrop-filter: blur(4px); + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + min-width: 80px; + height: 20px; } - -/* Animations */ -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.pulse { - animation: pulse 1s infinite; -} \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 229c3d362..9c41b35e0 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -27,7 +27,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [ ) : ( )} - Read + Viewer
), value: "viewer", diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index b16ed1228..449b07c61 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -161,8 +161,8 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb } lastToggleTime.current = currentTime; - // Easter egg: Activate rainbow mode after 6 rapid toggles - if (toggleCount.current >= 6) { + // Easter egg: Activate rainbow mode after 10 rapid toggles + if (toggleCount.current >= 10) { setThemeMode('rainbow'); console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!'); console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!'); diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index a8efa179e..6643ca580 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -162,10 +162,22 @@ --landing-drop-inner-paper-bg: #BBDEFB; --landing-drop-inner-paper-border: #90CAF9; + /* selected file header colors */ + --header-selected-bg: #1E88E5; /* light mode selected header matches dark */ + --header-selected-fg: #FFFFFF; + --file-card-bg: #FFFFFF; /* file card background (light/dark paired) */ + /* shadows */ --drop-shadow-color: rgba(0, 0, 0, 0.08); --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); + + /* Light mode card hover and selection */ + --header-hover-bg: #3B4B6E; /* same family as selected, a touch muted for hover */ + --card-selected-border: #3FAFFF; /* slightly more blue than dark mode header */ + --checkbox-border: #2F83BF; + --checkbox-checked-bg: #3FAFFF; + --checkbox-tick: #FFFFFF; } [data-mantine-color-scheme="dark"] { @@ -272,6 +284,12 @@ --landing-drop-inner-paper-bg: #2A3441; --landing-drop-inner-paper-border: #3A4451; + /* selected file header colors for dark */ + --header-selected-bg: #1E88E5; + --header-selected-fg: #FFFFFF; + /* file card background (dark) */ + --file-card-bg: #1F2329; + /* shadows */ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04);