From 75f9cd81d1587185d2057217d2ab10044e34ae89 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Tue, 26 Aug 2025 13:36:32 +0100 Subject: [PATCH] Split file editor and page editor styling, Restore Ethans changes (sorry Ethan) --- .../fileEditor/FileEditor.module.css | 276 ++++++++++++ .../src/components/fileEditor/FileEditor.tsx | 6 +- .../fileEditor/FileEditorThumbnail.tsx | 407 ++++++++++++++++++ .../pageEditor/PageEditor.module.css | 22 +- 4 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/fileEditor/FileEditor.module.css create mode 100644 frontend/src/components/fileEditor/FileEditorThumbnail.tsx diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css new file mode 100644 index 000000000..344959b80 --- /dev/null +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -0,0 +1,276 @@ +/* ========================= + FileEditor Card UI Styles + ========================= */ + +.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; +} + +/* 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; +} + +/* Actions Overlay */ +.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; +} + +/* Pin indicator */ +.pinIndicator { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 1; + color: rgba(0, 0, 0, 0.35); +} + +/* Unsupported file indicator */ +.unsupportedPill { + margin-left: 1.75rem; + background: #6B7280; + color: white; + 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; +} + +/* 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; +} \ No newline at end of file diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index df1197ab9..eee1eb1a7 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -12,8 +12,8 @@ import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; -import styles from '../pageEditor/PageEditor.module.css'; -import FileThumbnail from '../pageEditor/FileThumbnail'; +import styles from './FileEditor.module.css'; +import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; @@ -527,7 +527,7 @@ const FileEditor = ({ if (!fileItem) return null; return ( - void; + onDeleteFile: (fileId: string) => void; + onViewFile: (fileId: string) => void; + onSetStatus: (status: string) => void; + onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; + onDownloadFile?: (fileId: string) => void; + toolMode?: boolean; + isSupported?: boolean; +} + +const FileEditorThumbnail = ({ + file, + index, + selectedFiles, + onToggleFile, + onDeleteFile, + onViewFile, + onSetStatus, + onReorderFiles, + onDownloadFile, + isSupported = true, +}: FileEditorThumbnailProps) => { + const { t } = useTranslation(); + 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); + + // 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 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', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }, [file.size]); + + 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; + + dragElementRef.current = element; + + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + type: 'file', + fileId: file.id, + fileName: file.name, + selectedFiles: [file.id] // Always drag only this file, ignore selection state + }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + } + }); + + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: 'file', + fileId: file.id + }), + canDrop: ({ source }) => { + const sourceData = source.data; + return sourceData.type === 'file' && sourceData.fileId !== file.id; + }, + onDrop: ({ source }) => { + const sourceData = source.data; + if (sourceData.type === 'file' && onReorderFiles) { + const sourceFileId = sourceData.fileId as string; + const selectedFileIds = sourceData.selectedFiles as string[]; + onReorderFiles(sourceFileId, file.id, selectedFileIds); + } + } + }); + + return () => { + dragCleanup(); + dropCleanup(); + }; + }, [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 ( +
+ {/* Header bar */} +
+ {/* Logo/checkbox area */} +
+ {isSupported ? ( + onToggleFile(file.id)} + color="var(--checkbox-checked-bg)" + /> + ) : ( +
+ + {t('unsupported', 'Unsupported')} + +
+ )} +
+ + {/* Centered index */} +
+ {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.thumbnail && ( + {file.name} { + const img = e.currentTarget; + img.style.display = 'none'; + img.parentElement?.setAttribute('data-thumb-missing', 'true'); + }} + style={{ + maxWidth: '80%', + maxHeight: '80%', + objectFit: 'contain', + borderRadius: 0, + background: '#ffffff', + border: '1px solid var(--border-default)', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + alignSelf: 'start' + }} + /> + )} +
+ + {/* Pin indicator (bottom-left) */} + {isPinned && ( + + + + )} + + {/* Drag handle (span wrapper so we can attach a ref reliably) */} + + + +
+
+ ); +}; + +export default React.memo(FileEditorThumbnail); \ No newline at end of file diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 373c9e218..ab2fd691b 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -57,9 +57,25 @@ 0%, 100% { opacity: 1; } - .actionRow:hover { background: var(--hover-bg); } - .actionDanger { color: var(--text-brand-accent); } - .actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; } + 50% { + opacity: 0.5; + } +} + +/* Action styles */ +.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;