diff --git a/web/package.json b/web/package.json index 8be1cdb..a022376 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,9 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "eslint-plugin-tailwindcss": "^3.15.1", + "embla-carousel": "^8.5.1", + "embla-carousel-auto-height": "^8.5.1", + "embla-carousel-react": "^8.5.1", "eslint-plugin-unused-imports": "^4.0.1", "fuse.js": "^7.0.0", "globals": "^15.8.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c9c3b55..8ed6743 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -89,6 +89,15 @@ importers: cmdk: specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + embla-carousel: + specifier: ^8.5.1 + version: 8.5.1 + embla-carousel-auto-height: + specifier: ^8.5.1 + version: 8.5.1(embla-carousel@8.5.1) + embla-carousel-react: + specifier: ^8.5.1 + version: 8.5.1(react@18.3.1) eslint-plugin-tailwindcss: specifier: ^3.15.1 version: 3.17.5(tailwindcss@3.4.3) @@ -2151,8 +2160,26 @@ packages: electron-to-chromium@1.5.2: resolution: {integrity: sha512-kc4r3U3V3WLaaZqThjYz/Y6z8tJe+7K0bbjUVo3i+LWIypVdMx5nXCkwRe6SWbY6ILqLdc1rKcKmr3HoH7wjSQ==} - electron-to-chromium@1.5.73: - resolution: {integrity: sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==} + electron-to-chromium@1.5.75: + resolution: {integrity: sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==} + + embla-carousel-auto-height@8.5.1: + resolution: {integrity: sha512-pH0LlCEX6D2uNf0zuEHPL14YCnlJK+xIlhjcWNy53TG+9qDPgUUwBLBoAdbWro+8/MzqzVf+kHDgsy25jkzu4g==} + peerDependencies: + embla-carousel: 8.5.1 + + embla-carousel-react@8.5.1: + resolution: {integrity: sha512-z9Y0K84BJvhChXgqn2CFYbfEi6AwEr+FFVVKm/MqbTQ2zIzO1VQri6w67LcfpVF0AjbhwVMywDZqY4alYkjW5w==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.5.1: + resolution: {integrity: sha512-n7VSoGIiiDIc4MfXF3ZRTO59KDp820QDuyBDGlt5/65+lumPHxX2JLz0EZ23hZ4eg4vZGUXwMkYv02fw2JVo/A==} + peerDependencies: + embla-carousel: 8.5.1 + + embla-carousel@8.5.1: + resolution: {integrity: sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5646,7 +5673,7 @@ snapshots: browserslist@4.24.3: dependencies: caniuse-lite: 1.0.30001689 - electron-to-chromium: 1.5.73 + electron-to-chromium: 1.5.75 node-releases: 2.0.19 update-browserslist-db: 1.1.1(browserslist@4.24.3) @@ -5865,7 +5892,23 @@ snapshots: electron-to-chromium@1.5.2: {} - electron-to-chromium@1.5.73: {} + electron-to-chromium@1.5.75: {} + + embla-carousel-auto-height@8.5.1(embla-carousel@8.5.1): + dependencies: + embla-carousel: 8.5.1 + + embla-carousel-react@8.5.1(react@18.3.1): + dependencies: + embla-carousel: 8.5.1 + embla-carousel-reactive-utils: 8.5.1(embla-carousel@8.5.1) + react: 18.3.1 + + embla-carousel-reactive-utils@8.5.1(embla-carousel@8.5.1): + dependencies: + embla-carousel: 8.5.1 + + embla-carousel@8.5.1: {} emoji-regex@8.0.0: {} diff --git a/web/src/components/media/MediaCarousel.tsx b/web/src/components/media/MediaCarousel.tsx new file mode 100644 index 0000000..dc57232 --- /dev/null +++ b/web/src/components/media/MediaCarousel.tsx @@ -0,0 +1,97 @@ +import '../../embla.css'; + +import AutoHeight from 'embla-carousel-auto-height'; +import useEmblaCarousel from 'embla-carousel-react'; +import { Dispatch, FC, SetStateAction, useEffect } from 'react'; +import { FaArrowLeft, FaArrowRight } from 'react-icons/fa6'; + +import { MediaMetaData } from './MediaMetaData'; +import { BareMediaPreview } from './MediaPreview'; + +export const MediaCarousel: FC<{ + ids: number[]; + mediaId: number; + setMediaId: Dispatch>; +}> = ({ ids, mediaId, setMediaId }) => { + const [emblaReference, emblaApi] = useEmblaCarousel( + { + loop: true, + }, + [AutoHeight()] + ); + + emblaApi?.on('select', () => { + const selected = emblaApi.selectedScrollSnap(); + + if (selected !== undefined) { + setMediaId(ids[selected]); + } + }); + + useEffect(() => { + if (mediaId !== undefined) { + const index = ids.indexOf(mediaId); + + // TODO: If this useEffect wasn't triggered by emblaApi.on('select'), + // then we should set the scrollTo(index, true) boolean to true to not animate the scroll + emblaApi?.scrollTo(index); + } + }); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + emblaApi?.scrollPrev(); + } else if (event.key === 'ArrowRight') { + emblaApi?.scrollNext(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }); + + return ( +
+
+
+ {ids.map((id) => ( +
+ +
+ ))} +
+
+
+ + + +
+ + +
+
+
+ ); +}; diff --git a/web/src/components/media/MediaGallery.tsx b/web/src/components/media/MediaGallery.tsx index c3e9a29..092be62 100644 --- a/web/src/components/media/MediaGallery.tsx +++ b/web/src/components/media/MediaGallery.tsx @@ -1,23 +1,56 @@ -import { FC } from 'react'; +'use client'; +import * as Dialog from '@radix-ui/react-dialog'; +import { FC, useState } from 'react'; + +import { useMedia } from '@/api/media'; + +import { MediaCarousel } from './MediaCarousel'; import { MediaPreview } from './MediaPreview'; export const MediaGallery: FC<{ media_ids: number[]; }> = ({ media_ids }) => { + const [mediaId, setMediaId] = useState(); + return (
- {media_ids.length > 0 ? ( -
- {media_ids.map((media_id) => ( - - ))} -
- ) : ( -
- No media -
- )} + + {media_ids.length > 0 ? ( +
+ {media_ids.map((media_id) => ( + { + setMediaId(media_id); + }} + > + + + ))} +
+ ) : ( +
+ No media +
+ )} + + + + + {useMedia(mediaId).data?.description} + + + + +
); }; diff --git a/web/src/components/media/MediaMetaData.tsx b/web/src/components/media/MediaMetaData.tsx new file mode 100644 index 0000000..b75d1d3 --- /dev/null +++ b/web/src/components/media/MediaMetaData.tsx @@ -0,0 +1,30 @@ +import TimeAgo from 'javascript-time-ago'; +import en from 'javascript-time-ago/locale/en'; +import { FC } from 'react'; + +import { useMedia } from '@/api/media'; + +import { BaseInput } from '../input/BaseInput'; + +TimeAgo.addDefaultLocale(en); +const timeAgo = new TimeAgo('en-US'); + +export const MediaMetaData: FC<{ + mediaId: number; +}> = ({ mediaId }) => { + const media = useMedia(mediaId).data; + + return ( + <> + + Created: {timeAgo.format(new Date(media?.created_at||''))} + {media?.updated_at !== media?.created_at && ( + Updated: {timeAgo.format(new Date(media?.updated_at||''))} + )} + + ); +}; diff --git a/web/src/components/media/MediaPreview.tsx b/web/src/components/media/MediaPreview.tsx index 7d6d0fe..9300435 100644 --- a/web/src/components/media/MediaPreview.tsx +++ b/web/src/components/media/MediaPreview.tsx @@ -15,6 +15,64 @@ import { ErrorBoundary } from '@/components/ErrorBoundary'; import { Button } from '../ui/Button'; +export const BareMediaPreview: FC<{ + media_id?: number; +}> = ({ media_id }) => { + const { data: instanceSettings } = useInstanceSettings(); + const { data: media } = useMedia(media_id); + + const mediaUrl = (() => { + const link = media?.url; + + if (link?.includes(':')) { + return link; + } + + if (!instanceSettings) { + return; + } + + return ( + instanceSettings.modules.storage.endpoint_url + + '/' + + instanceSettings.modules.storage.bucket + + '/' + + link + ); + })(); + + return ( +
+ {match(media?.kind) + .with( + 'webp', + 'image/webp', + 'png', + 'image/png', + 'svg', + 'image/svg+xml', + 'jpeg', + 'jpg', + 'image/jpeg', + 'image/gif', + () => + ) + .with('mp4', 'video/mp4', () => ( + + )) + .with('stl', 'model/stl', () => ( + + )) + .otherwise(() => ( +
+ Unknown file type + {media?.kind} +
+ ))} +
+ ); +}; + export const MediaPreview: FC<{ variant?: 'small' | 'default'; media_id?: number; @@ -109,7 +167,7 @@ export const MediaPreview: FC<{ return (
- {match(fileType) - .with( - 'webp', - 'image/webp', - 'png', - 'image/png', - 'svg', - 'image/svg+xml', - 'jpeg', - 'jpg', - 'image/jpeg', - 'image/gif', - 'gif', - () => - ) - .with('mp4', 'video/mp4', () => ( - - )) - .with('stl', 'model/stl', () => ( - - )) - .otherwise(() => ( -
- Unknown file type - {fileType} -
- ))} + {isPending && (
Uploading... diff --git a/web/src/embla.css b/web/src/embla.css new file mode 100644 index 0000000..9d79ef6 --- /dev/null +++ b/web/src/embla.css @@ -0,0 +1,21 @@ +.embla { + max-width: 48rem; + margin: auto; + --slide-height: 19rem; + --slide-spacing: 1rem; + --slide-size: 100%; +} +.embla__viewport { + overflow: hidden; +} +.embla__container { + display: flex; + touch-action: pan-y pinch-zoom; + margin-left: calc(var(--slide-spacing) * -1); +} +.embla__slide { + transform: translate3d(0, 0, 0); + flex: 0 0 var(--slide-size); + min-width: 0; + padding-left: var(--slide-spacing); +}