Skip to content

Commit

Permalink
Merge branch 'development' of https://github.com/cellajs/cella into f…
Browse files Browse the repository at this point in the history
…eature/development-secure-http2
  • Loading branch information
LemonardoD committed Jan 22, 2025
2 parents 8151661 + 77522ff commit 8b90e12
Show file tree
Hide file tree
Showing 32 changed files with 247 additions and 127 deletions.
2 changes: 1 addition & 1 deletion backend/src/modules/organizations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ const organizationsRoutes = app
name: usersTable.name,
email: tokensTable.email,
userId: tokensTable.userId,
expiredAt: tokensTable.expiresAt,
expiresAt: tokensTable.expiresAt,
createdAt: tokensTable.createdAt,
createdBy: tokensTable.createdBy,
})
Expand Down
2 changes: 1 addition & 1 deletion backend/src/modules/organizations/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const invitesInfoSchema = z.array(
email: z.string(),
name: z.string().nullable(),
userId: z.string().nullable(),
expiredAt: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
createdBy: z.string().nullable(),
}),
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"react-lazy-with-preload": "^2.2.1",
"react-pdf": "^9.2.1",
"react-resizable-panels": "^2.1.7",
"react-use-downloader": "^1.2.8",
"recharts": "^2.15.0",
"slugify": "1.6.6",
"sonner": "^1.7.2",
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/lib/query-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FetchInfiniteQueryOptions, type FetchQueryOptions, onlineManager } from '@tanstack/react-query';
import { CancelledError, type FetchInfiniteQueryOptions, type FetchQueryOptions, onlineManager } from '@tanstack/react-query';
import i18next from 'i18next';
import { ApiError } from '~/lib/api';
import { i18n } from '~/lib/i18n';
Expand All @@ -17,8 +17,13 @@ const fallbackMessages = (t: (typeof i18n)['t']) => ({
});

export const onError = (error: Error) => {
// Ignore cancellation error
if (error instanceof CancelledError) {
return console.debug('Ignoring CancelledError');
}

// Handle network error (e.g., connection refused)
if (error instanceof Error && error.message === 'Failed to fetch') {
// Handle network error (e.g., connection refused)
createToast(i18n.t('common:error.network_error'), 'error');
}

Expand Down
13 changes: 8 additions & 5 deletions frontend/src/modules/attachments/attachment-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ export const AttachmentRender = ({
togglePanState,
}: AttachmentRenderProps) => {
const isMobile = useBreakpoints('max', 'sm');
const sanitizedSource = DOMPurify.sanitize(source);

const sanitizedSource = DOMPurify.sanitize(source);
const localUrl = useLocalFile(sanitizedSource, type);

// Use either remote URL or local URL
const url = useMemo(() => {
// Use direct URL for static images
if (sanitizedSource.startsWith('/static/')) return sanitizedSource;

// Use either remote URL or local URL pointing to indedexedDB
return sanitizedSource.startsWith('http') ? sanitizedSource : localUrl;
}, [sanitizedSource, localUrl]);

Expand All @@ -50,9 +53,9 @@ export const AttachmentRender = ({
) : (
<img src={url} alt={altName} className={`${itemClassName} w-full h-full`} />
))}
{type.includes('audio') && <RenderAudio src={url} />}
{type.includes('video') && <RenderVideo src={url} />}
{type.includes('pdf') && <RenderPDF file={url} />}
{type.includes('audio') && <RenderAudio src={url} className="w-[80vw] mx-auto -mt-48 h-20" />}
{type.includes('video') && <RenderVideo src={url} className="aspect-video max-h-[90vh] mx-auto" />}
{type.includes('pdf') && <RenderPDF file={url} className="w-[95vw] m-auto h-[95vh] overflow-auto" />}
</Suspense>
</div>
);
Expand Down
27 changes: 8 additions & 19 deletions frontend/src/modules/attachments/attachment-thumb.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,32 @@
import { File, FileAudio, FileText, FileVideo } from 'lucide-react';
import type React from 'react';
import { useMemo } from 'react';
import FilePlaceholder from './file-placeholder';
import { useLocalFile } from './use-local-file';

interface AttachmentThumbProps {
url: string;
contentType: string;
name: string;
openDialog: () => void;
}

const AttachmentThumb: React.FC<AttachmentThumbProps> = ({ url: baseUrl, contentType, name, openDialog }) => {
const AttachmentThumb: React.FC<AttachmentThumbProps> = ({ url: baseUrl, contentType, name }) => {
const localUrl = useLocalFile(baseUrl, contentType);

// Use either remote URL or local URL
const url = useMemo(() => {
return baseUrl.startsWith('http') ? `${baseUrl}?width=100&format=avif` : localUrl;
if (baseUrl.startsWith('http')) return `${baseUrl}?width=100&format=avif`;
if (baseUrl.startsWith('/static/')) return baseUrl;
return localUrl;
}, [baseUrl, localUrl]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') openDialog();
};

const renderIcon = (iconSize = 24) => {
const renderIcon = () => {
if (!url) return;
if (contentType.includes('image'))
return <img src={url} draggable="false" alt={name} className="h-8 w-8 bg-muted rounded-md object-cover" loading="lazy" decoding="async" />;
if (contentType.includes('video')) return <FileVideo size={iconSize} />;
if (contentType.includes('pdf')) return <FileText size={iconSize} />;
if (contentType.includes('audio')) return <FileAudio size={iconSize} />;

return <File size={iconSize} />;
return <FilePlaceholder fileType={contentType} />;
};

return (
<div className="cursor-pointer w-full flex justify-center" onClick={openDialog} onKeyDown={handleKeyDown} aria-label={`Preview ${name}`}>
{renderIcon()}
</div>
);
return renderIcon();
};

export default AttachmentThumb;
53 changes: 49 additions & 4 deletions frontend/src/modules/attachments/carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { useLocation, useNavigate } from '@tanstack/react-router';
import Autoplay from 'embla-carousel-autoplay';
import { Download, ExternalLink, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import useDownloader from 'react-use-downloader';
import { useEventListener } from '~/hooks/use-event-listener';
import { AttachmentRender } from '~/modules/attachments/attachment-render';
import { openAttachmentDialog } from '~/modules/attachments/helpers';
import { dialog } from '~/modules/common/dialoger/state';
import { Carousel as BaseCarousel, CarouselContent, CarouselDots, CarouselItem, CarouselNext, CarouselPrevious } from '~/modules/ui/carousel';
import { cn } from '~/utils/cn';
import { Button } from '../ui/button';
import FilePlaceholder from './file-placeholder';

interface CarouselPropsBase {
slide?: number;
slides?: { src: string; fileType?: string }[];
slides?: { src: string; name?: string; filename?: string; fileType?: string }[];
classNameContainer?: string;
}

Expand All @@ -35,6 +40,8 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS
const itemClass = isDialog ? 'object-contain' : '';
const autoplay = Autoplay({ delay: 4000, stopOnInteraction: true, stopOnMouseEnter: true });

const { download } = useDownloader();

useEventListener('toggleCarouselDrag', (e) => {
const shouldWatchDrag = e.detail && slides.length > 1;
setWatchDrag(shouldWatchDrag);
Expand All @@ -43,10 +50,10 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS
useEffect(() => {
if (!saveInSearchParams || slides.length === 0) return;

const currentSlide = slides[current] ? slides[current].src : undefined;
const currentSlide = slides[current] ? slides[current] : undefined;

// Only navigate if the current slide is different from the attachmentPreview
if (currentSlide === attachmentPreview) return;
if (currentSlide?.src === attachmentPreview) return;

// Decide whether to replace the history entry based on whether the attachmentPreview is already set
const useReplace = attachmentPreview !== undefined;
Expand All @@ -57,7 +64,7 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS
resetScroll: false,
search: (prev) => ({
...prev,
attachmentPreview: currentSlide,
attachmentPreview: currentSlide?.src,
}),
});
}, [current]);
Expand All @@ -75,6 +82,44 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS
api.on('select', () => setCurrent(api.selectedScrollSnap()));
}}
>
{slides[current] && isDialog && (
<div className="fixed z-10 top-0 left-0 w-full flex gap-2 p-3 text-center sm:text-left bg-background/60 backdrop-blur-sm">
{slides[current].name && (
<h2 className="text-base tracking-tight flex ml-1 items-center gap-2 leading-6 h-6">
{slides[current].fileType && <FilePlaceholder fileType={slides[current].fileType} iconSize={16} strokeWidth={2} />}
{slides[current].name}
</h2>
)}
<div className="grow" />

{slides[current].src.startsWith('http') && (
<Button
variant="ghost"
size="icon"
className="-my-1 w-8 h-8 opacity-70 hover:opacity-100"
onClick={() => window.open(slides[current].src, '_blank')}
>
<ExternalLink className="h-5 w-5" strokeWidth={1.5} />
</Button>
)}

{slides[current].src.startsWith('http') && (
<Button
variant="ghost"
size="icon"
className="-my-1 w-8 h-8 opacity-70 hover:opacity-100"
onClick={() => download(slides[current].src, slides[current].filename || 'file')}
>
<Download className="h-5 w-5" strokeWidth={1.5} />
</Button>
)}

<Button variant="ghost" size="icon" className="-my-1 w-8 h-8 opacity-70 hover:opacity-100" onClick={() => dialog.remove()}>
<X className="h-6 w-6" strokeWidth={1.5} />
</Button>
</div>
)}

<CarouselContent className="h-full">
{slides?.map(({ src, fileType = 'image' }, idx) => {
return (
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/modules/attachments/file-placeholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { File, FileAudio, FileImage, FileText, FileVideo } from 'lucide-react';

interface Props {
fileType: string | undefined;
iconSize?: number;
strokeWidth?: number;
className?: string;
}

const FilePlaceholder = ({ fileType, iconSize = 20, strokeWidth = 1.5, className }: Props) => {
if (!fileType) return <File size={iconSize} />;
if (fileType.includes('image')) return <FileImage size={iconSize} strokeWidth={strokeWidth} className={className} />;
if (fileType.includes('video')) return <FileVideo size={iconSize} strokeWidth={strokeWidth} className={className} />;
if (fileType.includes('pdf')) return <FileText size={iconSize} strokeWidth={strokeWidth} className={className} />;
if (fileType.includes('audio')) return <FileAudio size={iconSize} strokeWidth={strokeWidth} className={className} />;
return <File size={iconSize} />;
};

export default FilePlaceholder;
7 changes: 4 additions & 3 deletions frontend/src/modules/attachments/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { createToast } from '~/lib/toasts';
import AttachmentsCarousel from '~/modules/attachments/carousel';
import { type DialogT, dialog } from '~/modules/common/dialoger/state';

export type Attachments = { src: string; fileType?: string };
// TODO: this is a duplicate type?
export type Attachments = { src: string; filename?: string; name?: string; fileType?: string };

export const openAttachmentDialog = (
attachment: number,
Expand All @@ -14,7 +15,7 @@ export const openAttachmentDialog = (
) => {
if (!onlineManager.isOnline()) return createToast(t('common:action.offline.text'), 'warning');

const { title, removeCallback } = dialogOptions || {};
const { removeCallback } = dialogOptions || {};
dialog(
<div className="flex flex-wrap relative -z-[1] h-screen justify-center p-2 grow">
<AttachmentsCarousel slides={attachments} isDialog slide={attachment} saveInSearchParams={saveInSearchParams} />
Expand All @@ -24,7 +25,7 @@ export const openAttachmentDialog = (
drawerOnMobile: false,
className: 'min-w-full h-screen border-0 p-0 rounded-none flex flex-col mt-0',
headerClassName: 'absolute p-4 w-full backdrop-blur-sm bg-background/50',
title: title ?? t('common:view_item', { item: t('common:attachment').toLowerCase() }),
hideClose: true,
autoFocus: true,
removeCallback,
},
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/modules/attachments/render-audio.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import MediaThemeSutroAudio from 'player.style/sutro-audio/react';

const RenderAudio = ({ src }: { src: string }) => (
<MediaThemeSutroAudio className="w-[70%] p-2 max-h-10">
const RenderAudio = ({ src, className }: { src: string; className?: string }) => (
<MediaThemeSutroAudio className={className}>
{/* biome-ignore lint/a11y/useMediaCaption: by author */}
<audio slot="media" src={src} playsInline crossOrigin="anonymous" />
</MediaThemeSutroAudio>
Expand Down
30 changes: 25 additions & 5 deletions frontend/src/modules/attachments/render-pdf.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,46 @@
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import { useState } from 'react';
import { useEffect, useState } from 'react';

pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();

const options = { cMapUrl: '/cmaps/' };

type PDFFile = string | File | Blob | null;

export default function RenderPDF({ file, className }: { file: PDFFile; className?: string }) {
export default function RenderPDF({ file, className }: { file: string; className?: string }) {
const [numPages, setNumPages] = useState<number | null>(null);
const [scale, setScale] = useState(1); // Scale for fitting the page

const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
};

// Adjust scale based on container width
const adjustScale = (width: number) => {
const desiredWidth = width - 60;
const naturalWidth = 612; // Default PDF page width in points
setScale(desiredWidth / naturalWidth);
};

useEffect(() => {
const handleResize = () => {
adjustScale(window.innerWidth - 60);
};

// Adjust scale on window resize
window.addEventListener('resize', handleResize);
handleResize();

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

return (
<div className={className}>
<Document file={file} options={options} onLoadSuccess={onDocumentLoadSuccess}>
{Array.from(new Array(numPages || 0), (_el, index) => (
<Page key={`page_${index + 1}`} pageNumber={index + 1} />
<Page key={`page_${index + 1}`} pageNumber={index + 1} scale={scale} />
))}
</Document>
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/modules/attachments/render-video.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import MediaThemeSutro from 'player.style/sutro/react';

const RenderVideo = ({ src }: { src: string }) => (
<MediaThemeSutro className="w-full p-4">
const RenderVideo = ({ src, className }: { src: string; className?: string }) => (
<MediaThemeSutro className={className}>
{/* biome-ignore lint/a11y/useMediaCaption: by author */}
<video slot="media" src={src} playsInline crossOrigin="anonymous" />
</MediaThemeSutro>
Expand Down
Loading

0 comments on commit 8b90e12

Please sign in to comment.