diff --git a/backend/src/modules/organizations/index.ts b/backend/src/modules/organizations/index.ts index 7ed21193d..f6d3c5fbe 100644 --- a/backend/src/modules/organizations/index.ts +++ b/backend/src/modules/organizations/index.ts @@ -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, }) diff --git a/backend/src/modules/organizations/schema.ts b/backend/src/modules/organizations/schema.ts index 9a3ea06e4..312b4f7f7 100644 --- a/backend/src/modules/organizations/schema.ts +++ b/backend/src/modules/organizations/schema.ts @@ -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(), }), diff --git a/frontend/package.json b/frontend/package.json index 35eb27240..c36129c1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts index b626a4da5..1f82c9a6f 100644 --- a/frontend/src/lib/query-client.ts +++ b/frontend/src/lib/query-client.ts @@ -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'; @@ -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'); } diff --git a/frontend/src/modules/attachments/attachment-render.tsx b/frontend/src/modules/attachments/attachment-render.tsx index 97399e730..4ee6a0aec 100644 --- a/frontend/src/modules/attachments/attachment-render.tsx +++ b/frontend/src/modules/attachments/attachment-render.tsx @@ -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]); @@ -50,9 +53,9 @@ export const AttachmentRender = ({ ) : ( {altName} ))} - {type.includes('audio') && } - {type.includes('video') && } - {type.includes('pdf') && } + {type.includes('audio') && } + {type.includes('video') && } + {type.includes('pdf') && } ); diff --git a/frontend/src/modules/attachments/attachment-thumb.tsx b/frontend/src/modules/attachments/attachment-thumb.tsx index 695a742ad..3727c7435 100644 --- a/frontend/src/modules/attachments/attachment-thumb.tsx +++ b/frontend/src/modules/attachments/attachment-thumb.tsx @@ -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 = ({ url: baseUrl, contentType, name, openDialog }) => { +const AttachmentThumb: React.FC = ({ 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 {name}; - if (contentType.includes('video')) return ; - if (contentType.includes('pdf')) return ; - if (contentType.includes('audio')) return ; - - return ; + return ; }; - return ( -
- {renderIcon()} -
- ); + return renderIcon(); }; export default AttachmentThumb; diff --git a/frontend/src/modules/attachments/carousel.tsx b/frontend/src/modules/attachments/carousel.tsx index f4cc9c2e0..15480b964 100644 --- a/frontend/src/modules/attachments/carousel.tsx +++ b/frontend/src/modules/attachments/carousel.tsx @@ -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; } @@ -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); @@ -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; @@ -57,7 +64,7 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS resetScroll: false, search: (prev) => ({ ...prev, - attachmentPreview: currentSlide, + attachmentPreview: currentSlide?.src, }), }); }, [current]); @@ -75,6 +82,44 @@ const AttachmentsCarousel = ({ slides = [], isDialog = false, slide = 0, saveInS api.on('select', () => setCurrent(api.selectedScrollSnap())); }} > + {slides[current] && isDialog && ( +
+ {slides[current].name && ( +

+ {slides[current].fileType && } + {slides[current].name} +

+ )} +
+ + {slides[current].src.startsWith('http') && ( + + )} + + {slides[current].src.startsWith('http') && ( + + )} + + +
+ )} + {slides?.map(({ src, fileType = 'image' }, idx) => { return ( diff --git a/frontend/src/modules/attachments/file-placeholder.tsx b/frontend/src/modules/attachments/file-placeholder.tsx new file mode 100644 index 000000000..567d10010 --- /dev/null +++ b/frontend/src/modules/attachments/file-placeholder.tsx @@ -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 ; + if (fileType.includes('image')) return ; + if (fileType.includes('video')) return ; + if (fileType.includes('pdf')) return ; + if (fileType.includes('audio')) return ; + return ; +}; + +export default FilePlaceholder; diff --git a/frontend/src/modules/attachments/helpers.tsx b/frontend/src/modules/attachments/helpers.tsx index c45fc5058..52cac93d1 100644 --- a/frontend/src/modules/attachments/helpers.tsx +++ b/frontend/src/modules/attachments/helpers.tsx @@ -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, @@ -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(
@@ -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, }, diff --git a/frontend/src/modules/attachments/render-audio.tsx b/frontend/src/modules/attachments/render-audio.tsx index 7b647793f..1632302f6 100644 --- a/frontend/src/modules/attachments/render-audio.tsx +++ b/frontend/src/modules/attachments/render-audio.tsx @@ -1,7 +1,7 @@ import MediaThemeSutroAudio from 'player.style/sutro-audio/react'; -const RenderAudio = ({ src }: { src: string }) => ( - +const RenderAudio = ({ src, className }: { src: string; className?: string }) => ( + {/* biome-ignore lint/a11y/useMediaCaption: by author */} diff --git a/frontend/src/modules/attachments/render-pdf.tsx b/frontend/src/modules/attachments/render-pdf.tsx index 0d01d10cf..b6c819c48 100644 --- a/frontend/src/modules/attachments/render-pdf.tsx +++ b/frontend/src/modules/attachments/render-pdf.tsx @@ -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(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 (
{Array.from(new Array(numPages || 0), (_el, index) => ( - + ))}
diff --git a/frontend/src/modules/attachments/render-video.tsx b/frontend/src/modules/attachments/render-video.tsx index 35cdb9bef..c0b7bb402 100644 --- a/frontend/src/modules/attachments/render-video.tsx +++ b/frontend/src/modules/attachments/render-video.tsx @@ -1,7 +1,7 @@ import MediaThemeSutro from 'player.style/sutro/react'; -const RenderVideo = ({ src }: { src: string }) => ( - +const RenderVideo = ({ src, className }: { src: string; className?: string }) => ( + {/* biome-ignore lint/a11y/useMediaCaption: by author */} diff --git a/frontend/src/modules/attachments/table/columns.tsx b/frontend/src/modules/attachments/table/columns.tsx index eb163cda3..368b7b5f8 100644 --- a/frontend/src/modules/attachments/table/columns.tsx +++ b/frontend/src/modules/attachments/table/columns.tsx @@ -2,7 +2,8 @@ import type { Attachment } from '~/types/common'; import { config } from 'config'; import type { TFunction } from 'i18next'; -import { CopyCheckIcon, CopyIcon } from 'lucide-react'; +import { CopyCheckIcon, CopyIcon, Download } from 'lucide-react'; +import useDownloader from 'react-use-downloader'; import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; import AttachmentThumb from '~/modules/attachments/attachment-thumb'; import { formatBytes } from '~/modules/attachments/table/helpers'; @@ -28,8 +29,17 @@ export const useColumns = ( visible: true, sortable: false, width: 32, - renderCell: ({ row: { url, filename, contentType }, rowIdx }) => ( - openDialog(rowIdx)} contentType={contentType} /> + renderCell: ({ row: { url, filename, contentType }, rowIdx, tabIndex }) => ( + ), }, { @@ -47,21 +57,53 @@ export const useColumns = ( }), }, { - key: 'URL', + key: 'url', name: '', visible: true, sortable: false, width: 32, renderCell: ({ row, tabIndex }) => { const { copyToClipboard, copied } = useCopyToClipboard(); + if (!row.url.startsWith('http')) return -; + const shareLink = `${config.backendUrl}/${row.organizationId}/attachments/${row.id}/link`; return ( - ); }, }, + { + key: 'download', + name: '', + visible: true, + sortable: false, + width: 32, + renderCell: ({ row, tabIndex }) => { + const { download } = useDownloader(); + if (!row.url.startsWith('http')) return -; + return ( + + ); + }, + }, { key: 'filename', name: t('common:filename'), diff --git a/frontend/src/modules/attachments/table/table.tsx b/frontend/src/modules/attachments/table/table.tsx index 957bd8aa9..935f795e0 100644 --- a/frontend/src/modules/attachments/table/table.tsx +++ b/frontend/src/modules/attachments/table/table.tsx @@ -59,7 +59,7 @@ const BaseDataTable = memo( (slideNum: number) => openAttachmentDialog( slideNum, - rows.map((el) => ({ src: el.url, fileType: el.contentType })), + rows.map((el) => ({ src: el.url, filename: el.filename, name: el.name, fileType: el.contentType })), true, { removeCallback }, ), @@ -97,7 +97,7 @@ const BaseDataTable = memo( useEffect(() => { if (!attachmentPreview) return dialog.remove(true, 'attachment-file-preview'); if (!rows || rows.length === 0) return; - const slides = rows.map((el) => ({ src: el.url, fileType: el.contentType })); + const slides = rows.map((el) => ({ src: el.url, filename: el.filename, name: el.name, fileType: el.contentType })); const slideIndex = slides.findIndex((slide) => slide.src === attachmentPreview); // If the slide exists in the slides array, reopen the dialog diff --git a/frontend/src/modules/common/blocknote/helpers.tsx b/frontend/src/modules/common/blocknote/helpers.tsx index 5d614317d..fdb8c99f4 100644 --- a/frontend/src/modules/common/blocknote/helpers.tsx +++ b/frontend/src/modules/common/blocknote/helpers.tsx @@ -119,8 +119,9 @@ export const updateSourcesFromDataUrl = (elementId: string, openPreviewDialog = if (!url) continue; url = DOMPurify.sanitize(url); + const filename = url.split('/').pop() || 'File'; - urls.push({ src: url, fileType: contentType }); + urls.push({ src: url, filename, name: filename, fileType: contentType }); switch (contentType) { case 'image': { diff --git a/frontend/src/modules/common/blocknote/index.tsx b/frontend/src/modules/common/blocknote/index.tsx index 40fbc9227..96b61b105 100644 --- a/frontend/src/modules/common/blocknote/index.tsx +++ b/frontend/src/modules/common/blocknote/index.tsx @@ -221,8 +221,10 @@ export const BlockNote = ({ // Collect attachments based on valid file types editor.forEachBlock(({ type, props }) => { const blockUrl = getUrlFromProps(props); + if (allowedTypes.includes(type) && blockUrl && blockUrl.length > 0) { - newAttachments.push({ src: blockUrl, fileType: type }); + const filename = blockUrl.split('/').pop() || 'File'; + newAttachments.push({ src: blockUrl, filename, name: filename, fileType: type }); } return true; }); diff --git a/frontend/src/modules/marketing/about/features.tsx b/frontend/src/modules/marketing/about/features.tsx index 72e35f3e2..6ff679081 100644 --- a/frontend/src/modules/marketing/about/features.tsx +++ b/frontend/src/modules/marketing/about/features.tsx @@ -29,9 +29,9 @@ const Feature = ({ icon, invertClass, index }: FeatureProps) => { return (
- {title} + {title}

{t(title)}

-

{t(text)}

+

{t(text)}

); diff --git a/frontend/src/modules/marketing/about/integrations.tsx b/frontend/src/modules/marketing/about/integrations.tsx index 4c2ecdac1..99a028615 100644 --- a/frontend/src/modules/marketing/about/integrations.tsx +++ b/frontend/src/modules/marketing/about/integrations.tsx @@ -49,7 +49,7 @@ const Integrations = () => { {name} {name} diff --git a/frontend/src/modules/marketing/about/showcase.tsx b/frontend/src/modules/marketing/about/showcase.tsx index af5fd4b6e..58ceb0568 100644 --- a/frontend/src/modules/marketing/about/showcase.tsx +++ b/frontend/src/modules/marketing/about/showcase.tsx @@ -9,8 +9,14 @@ const DeviceMockup = lazy(() => import('~/modules/marketing/device-mockup')); const showcaseItems = [{ id: 'raak', url: 'https://raak.dev' }]; // Slides for light and dark themes -const lightSlides = [{ src: '/static/images/showcases/raak-1.png' }, { src: '/static/images/showcases/raak-2.png' }]; -const darkSlides = [{ src: '/static/images/showcases/raak-1-dark.png' }, { src: '/static/images/showcases/raak-2-dark.png' }]; +const lightSlides = [ + { src: '/static/images/showcases/raak-1.png', fileType: 'image/png' }, + { src: '/static/images/showcases/raak-2.png', fileType: 'image/png' }, +]; +const darkSlides = [ + { src: '/static/images/showcases/raak-1-dark.png', fileType: 'image/png' }, + { src: '/static/images/showcases/raak-2-dark.png', fileType: 'image/png' }, +]; const Showcase = () => { const { t } = useTranslation(); diff --git a/frontend/src/modules/marketing/about/why.tsx b/frontend/src/modules/marketing/about/why.tsx index 82790e298..77c8039d5 100644 --- a/frontend/src/modules/marketing/about/why.tsx +++ b/frontend/src/modules/marketing/about/why.tsx @@ -8,14 +8,14 @@ const whyItems = [{ id: 'implementation-ready' }, { id: 'prebuilt-endpoints' }, // Slides for light and dark themes const lightSlides = [ - { src: '/static/screenshots/system-page.png' }, - { src: '/static/screenshots/org-page.png' }, - { src: '/static/screenshots/settings.png' }, + { src: '/static/screenshots/system-page.png', name: 'System page', filename: 'system-page.png', fileType: 'image/png' }, + { src: '/static/screenshots/org-page.png', name: 'Organization page', filename: 'org-page.png', fileType: 'image/png' }, + { src: '/static/screenshots/settings.png', name: 'User settings page', filename: 'settings.png', fileType: 'image/png' }, ]; const darkSlides = [ - { src: '/static/screenshots/system-page-dark.png' }, - { src: '/static/screenshots/org-page-dark.png' }, - { src: '/static/screenshots/settings-dark.png' }, + { src: '/static/screenshots/system-page-dark.png', name: 'System page', filename: 'system-page-dark.png', fileType: 'image/png' }, + { src: '/static/screenshots/org-page-dark.png', name: 'Organization page', filename: 'org-page-dark.png', fileType: 'image/png' }, + { src: '/static/screenshots/settings-dark.png', name: 'User settings page', filename: 'settings-dark.png', fileType: 'image/png' }, ]; const Why = () => { diff --git a/frontend/src/modules/marketing/device-mockup/frame.tsx b/frontend/src/modules/marketing/device-mockup/frame.tsx index 1d630d1bc..7f1259de3 100644 --- a/frontend/src/modules/marketing/device-mockup/frame.tsx +++ b/frontend/src/modules/marketing/device-mockup/frame.tsx @@ -3,7 +3,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'pc'; interface DeviceFrameProps { type: DeviceType; inView: boolean; - renderCarousel: (isDialog: boolean, className: string) => React.ReactElement; + renderCarousel: (className: string) => React.ReactElement; } const DeviceFrame = ({ type, inView, renderCarousel }: DeviceFrameProps) => { @@ -15,14 +15,14 @@ const DeviceFrame = ({ type, inView, renderCarousel }: DeviceFrameProps) => {
-
{inView && renderCarousel(false, 'rounded-[2rem]')}
+
{inView && renderCarousel('rounded-[2rem]')}
); case 'pc': return (
-
{inView && renderCarousel(false, 'rounded-t-[.5rem]')}
+
{inView && renderCarousel('rounded-t-[.5rem]')}
@@ -36,9 +36,7 @@ const DeviceFrame = ({ type, inView, renderCarousel }: DeviceFrameProps) => {
-
- {inView && renderCarousel(false, 'rounded-[1rem]')} -
+
{inView && renderCarousel('rounded-[1rem]')}
); default: diff --git a/frontend/src/modules/marketing/device-mockup/index.tsx b/frontend/src/modules/marketing/device-mockup/index.tsx index 5ca72e4a2..8b6e0e419 100644 --- a/frontend/src/modules/marketing/device-mockup/index.tsx +++ b/frontend/src/modules/marketing/device-mockup/index.tsx @@ -9,8 +9,8 @@ import DeviceFrame from './frame'; type DeviceType = 'mobile' | 'tablet' | 'pc'; interface DeviceMockupProps { - lightSlides?: { src: string }[]; - darkSlides?: { src: string }[]; + lightSlides?: { src: string; name?: string }[]; + darkSlides?: { src: string; name?: string }[]; className?: string; type: DeviceType; } @@ -31,12 +31,8 @@ const DeviceMockup = ({ lightSlides, darkSlides, type, className }: DeviceMockup { - return isDialog ? ( - - ) : ( - - ); + renderCarousel={(className) => { + return ; }} />
diff --git a/frontend/src/modules/marketing/footer.tsx b/frontend/src/modules/marketing/footer.tsx index 5fb84a00c..63770c308 100644 --- a/frontend/src/modules/marketing/footer.tsx +++ b/frontend/src/modules/marketing/footer.tsx @@ -46,7 +46,9 @@ export function Credits({ className }: { className?: string }) { return (
- © {currentYear}. {productName} {t('common:is_built_by')} {companyName}. +

+ © {currentYear}. {productName} {t('common:is_built_by', { companyName })}. +

); } diff --git a/frontend/src/modules/organizations/invites/invites-count.tsx b/frontend/src/modules/organizations/invites/invites-count.tsx index 2338d913a..e6d3d2489 100644 --- a/frontend/src/modules/organizations/invites/invites-count.tsx +++ b/frontend/src/modules/organizations/invites/invites-count.tsx @@ -21,8 +21,8 @@ export const InvitedUsers = ({ invitesInfo }: Props) => { , { className: 'max-w-full lg:max-w-4xl', - title: t('common:invites_table_title'), - description: t('common:invites_table_text', { entity: t('common:organization').toLowerCase() }), + title: t('common:pending_invitations'), + description: t('common:pending_invitations.text', { entity: t('common:organization').toLowerCase() }), id: 'invited-users-info', scrollableOverlay: true, side: 'right', diff --git a/frontend/src/modules/organizations/invites/table/columns.tsx b/frontend/src/modules/organizations/invites/table/columns.tsx index 486d4183d..2bc956c75 100644 --- a/frontend/src/modules/organizations/invites/table/columns.tsx +++ b/frontend/src/modules/organizations/invites/table/columns.tsx @@ -49,18 +49,9 @@ export const useColumns = () => { minWidth: 100, }, - { - key: 'expiredAt', - name: t('common:expires_at'), - sortable: true, - visible: true, - renderHeaderCell: HeaderCell, - renderCell: ({ row }) => dateShort(row.expiredAt), - minWidth: 80, - }, { key: 'createdAt', - name: t('common:created_at'), + name: t('common:invited_at'), sortable: true, visible: !isMobile, renderHeaderCell: HeaderCell, @@ -69,13 +60,22 @@ export const useColumns = () => { }, { key: 'createdBy', - name: t('common:created_by'), + name: t('common:invited_by'), sortable: true, visible: !isMobile, renderHeaderCell: HeaderCell, renderCell: ({ row }) => row.createdBy, minWidth: 80, }, + { + key: 'expiresAt', + name: t('common:expires_at'), + sortable: true, + visible: true, + renderHeaderCell: HeaderCell, + renderCell: ({ row }) => dateShort(row.expiresAt), + minWidth: 80, + }, ]; return cols; diff --git a/frontend/src/modules/organizations/invites/table/index.tsx b/frontend/src/modules/organizations/invites/table/index.tsx index 213e2a1c6..c0d4cc6fc 100644 --- a/frontend/src/modules/organizations/invites/table/index.tsx +++ b/frontend/src/modules/organizations/invites/table/index.tsx @@ -13,7 +13,7 @@ export interface InvitesInfoProps { info: OrganizationInvitesInfo[]; } -export type InvitesInfoSearch = { q?: string; order?: 'asc' | 'desc'; sort: 'expiredAt' | 'createdAt' | 'createdBy' }; +export type InvitesInfoSearch = { q?: string; order?: 'asc' | 'desc'; sort: 'expiresAt' | 'createdAt' | 'createdBy' }; export const InvitesInfoTable = ({ info }: InvitesInfoProps) => { const { search, setSearch } = useSearchParams({ saveDataInSearch: false }); diff --git a/frontend/src/modules/organizations/invites/table/table-header.tsx b/frontend/src/modules/organizations/invites/table/table-header.tsx index a1f882720..3e8ff711f 100644 --- a/frontend/src/modules/organizations/invites/table/table-header.tsx +++ b/frontend/src/modules/organizations/invites/table/table-header.tsx @@ -1,4 +1,3 @@ -import ColumnsView from '~/modules/common/data-table/columns-view'; import TableCount from '~/modules/common/data-table/table-count'; import { FilterBarActions, FilterBarContent, TableFilterBar } from '~/modules/common/data-table/table-filter-bar'; import { TableHeaderContainer } from '~/modules/common/data-table/table-header-container'; @@ -8,7 +7,7 @@ import type { BaseTableHeaderProps, BaseTableMethods, OrganizationInvitesInfo } type InvitesInfoTableHeaderProps = BaseTableMethods & BaseTableHeaderProps; -export const InvitesInfoHeader = ({ total, q, setSearch, columns, setColumns, clearSelection }: InvitesInfoTableHeaderProps) => { +export const InvitesInfoHeader = ({ total, q, setSearch, clearSelection }: InvitesInfoTableHeaderProps) => { const isFiltered = !!q; // Drop selected Rows on search const onSearch = (searchString: string) => { @@ -35,9 +34,6 @@ export const InvitesInfoHeader = ({ total, q, setSearch, columns, setColumns, cl - - {/* Columns view */} - ); }; diff --git a/frontend/src/modules/organizations/newsletter-draft.tsx b/frontend/src/modules/organizations/newsletter-draft.tsx index 3e36eb43b..3ec4f92dd 100644 --- a/frontend/src/modules/organizations/newsletter-draft.tsx +++ b/frontend/src/modules/organizations/newsletter-draft.tsx @@ -1,19 +1,8 @@ -import { config } from 'config'; - import { useEffect } from 'react'; import { useFormWithDraft } from '~/hooks/use-draft-form'; -import { i18n } from '~/lib/i18n'; import { updateSourcesFromDataUrl } from '~/modules/common/blocknote/helpers'; -import Logo from '~/modules/common/logo'; -import { useUserStore } from '~/store/user'; - -const link = 'text-[#0366d6] text-xs leading-[1.13rem] cursor-pointer'; const OrganizationsNewsletterDraft = () => { - const { - user: { language: lng }, - } = useUserStore(); - const form = useFormWithDraft('send-org-newsletter'); useEffect(() => { @@ -22,7 +11,6 @@ const OrganizationsNewsletterDraft = () => { return (
-

{i18n.t('backend:email.newsletter_title', { orgName: 'Organization Name', lng })}

{form.getValues('subject')}

{ // biome-ignore lint/security/noDangerouslySetInnerHtml: blackNote html dangerouslySetInnerHTML={{ __html: form.getValues('content') }} /> - {i18n.t('backend:email.unsubscribe', { lng })}
- - - - {i18n.t('backend:email.author_email')}{i18n.t('backend:email.support_email')} - - -
- {config.name}・{config.company.streetAddress}・{config.company.city}・{config.company.country}, {config.company.postcode} -
); }; diff --git a/frontend/src/modules/organizations/table/index.tsx b/frontend/src/modules/organizations/table/index.tsx index 7d01d119a..4f5776738 100644 --- a/frontend/src/modules/organizations/table/index.tsx +++ b/frontend/src/modules/organizations/table/index.tsx @@ -83,8 +83,8 @@ const OrganizationsTable = () => { }, { - id: 'draft', - label: 'common:draft', + id: 'preview', + label: 'common:preview', element: , }, ]; diff --git a/locales/en/about.json b/locales/en/about.json index 3236694b8..1a4bf5573 100644 --- a/locales/en/about.json +++ b/locales/en/about.json @@ -77,7 +77,7 @@ "title_5": "Faster together", "title_6": "Pricing (conceptual)", "title_7": "FAQs", - "call_to_action": "Join a small but dedicated club of devs based in 🇪🇺 Europe. Work on your own app & contribute pieces to Cella. Sounds interesting?", + "call_to_action": "Join a small but dedicated club. Work on your own app & contribute pieces to Cella. Sounds interesting?", "showcase": "Showcase", "showcase.text": "Don't just take our word for it — 🧐 explore what we're building with Cella.", "showcase.title_1": "raak.dev", diff --git a/locales/en/common.json b/locales/en/common.json index a673e4419..5f30c30b1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -200,15 +200,15 @@ "invite_by_email": "Invite with email addresses", "invite_by_name": "Invite by searching for users", "invite_create_account": "Sign up to accept invitation", - "invites_table_title": "Invites Pending Acceptance", - "invites_table_text": "View all users invited to join {{entity}}.", + "pending_invitations": "Pending invitations", + "pending_invitations.text": "Manage users invited to join this {{entity}}.", "invite_members.text": "Invited members will receive an email to accept the invitation.", "invite_members_search.text": "Search for users to invite within {{appName}} who share a membership with you or are part of the same entity.", "invite_only.text": "{{appName}} is currently invite-only. Can't wait? Contact us for an invite.", "invite_sign_in": "Sign in to accept the invitation", "invite_sign_in_or_up": "Sign in or sign up to accept the invitation", "invite_users.text": "Invited users will receive an email to accept the invitation.", - "is_built_by": "is built by", + "is_built_by": "is built by {{companyName}}", "join": "Join", "joined": "Joined", "keep_menu_open": "Keep open", @@ -303,6 +303,7 @@ "placeholder.your_email": "Your email", "plan": "Plan", "previous": "Previous", + "preview": "Preview", "pricing": "Pricing", "pricing_plan": "Pricing plan", "privacy": "Privacy", @@ -456,5 +457,7 @@ "year": "year", "your_role": "Your role", "decline": "Decline", - "with": "With" + "with": "With", + "invited_at": "Invited", + "invited_by": "Invited by" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81b42a8e1..4c87f928d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,9 @@ importers: react-resizable-panels: specifier: ^2.1.7 version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-use-downloader: + specifier: ^1.2.8 + version: 1.2.8(react@19.0.0) recharts: specifier: ^2.15.0 version: 2.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -9711,6 +9714,12 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-use-downloader@1.2.8: + resolution: {integrity: sha512-JYb9esgw2TmODSCmSEFSEd/lJ0xtO50ugOu2T/PaO0JNYvscBqJT0uY452p7N5mf37jqSET7Q5DxcgixH1631Q==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: ^17.0.2 || ^18 + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -21255,6 +21264,10 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + react-use-downloader@1.2.8(react@19.0.0): + dependencies: + react: 19.0.0 + react@19.0.0: {} read-cache@1.0.0: