diff --git a/.docker/rootfs-local/minio/.gitignore b/.docker/rootfs-local/minio/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.docker/rootfs-local/minio/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/api/src/Controller/Invited/Api/Gallery/DownloadGalleryImagesController.php b/api/src/Controller/Invited/Api/Gallery/DownloadGalleryImagesController.php index 494b0b7..207bf77 100644 --- a/api/src/Controller/Invited/Api/Gallery/DownloadGalleryImagesController.php +++ b/api/src/Controller/Invited/Api/Gallery/DownloadGalleryImagesController.php @@ -15,9 +15,9 @@ use OpenApi\Attributes as OA; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; @@ -87,24 +87,24 @@ public function __invoke(Request $request): Response if ($this->defaultStorage->fileExists($location)) { $contentStream = $this->defaultStorage->readStream($location); - $zipFile = FileHelper::createTempFile('gallery.zip', 'application/zip'); - file_put_contents($zipFile->getPathname(), $contentStream); - - return $this->file($zipFile, $zipFile->getClientOriginalName()); + // $zipFile = FileHelper::createTempFile('gallery.zip', 'application/zip'); + // file_put_contents($zipFile->getPathname(), $contentStream); + // + // return $this->file($zipFile, $zipFile->getClientOriginalName()); // https://dev.to/rubenrubiob/serve-a-file-stream-in-symfony-3ei3 - // return new StreamedResponse( - // static function () use ($contentStream): void { - // fpassthru($contentStream); - // }, - // Response::HTTP_OK, - // [ - // 'Content-Transfer-Encoding', 'binary', - // 'Content-Type' => 'application/zip', - // 'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="gallery.zip"', - // 'Content-Length' => $this->defaultStorage->fileSize($location), - // ] - // ); + return new StreamedResponse( + static function () use ($contentStream): void { + fpassthru($contentStream); + }, + Response::HTTP_OK, + [ + 'Content-Transfer-Encoding', 'binary', + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, 'gallery.zip'), + 'Content-Type' => 'application/zip', + 'Content-Length' => $this->defaultStorage->fileSize($location), + ], + ); } if (\count($files) > $this->appDownloadSyncMax) { diff --git a/api/src/Controller/Invited/Api/Gallery/ShowGalleryImageController.php b/api/src/Controller/Invited/Api/Gallery/ShowGalleryImageController.php index 425ca38..28fbe21 100644 --- a/api/src/Controller/Invited/Api/Gallery/ShowGalleryImageController.php +++ b/api/src/Controller/Invited/Api/Gallery/ShowGalleryImageController.php @@ -7,9 +7,10 @@ use OpenApi\Attributes as OA; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -32,14 +33,32 @@ public function __construct( #[OA\Tag('Invited/Gallery')] public function __invoke(#[MapEntity(id: 'file_id')] File $file): Response { - $content = $this->defaultStorage->read($file->getPath()); + $location = $file->getPath(); - $response = new Response(); - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $file->getOriginalFilename()); - $response->headers->set('Content-Disposition', $disposition); - $response->headers->set('Content-Type', $file->getMimeType()); - $response->setContent($content); + // $content = $this->defaultStorage->read($location); - return $response; + // $response = new Response(); + // $disposition = $response->headers->makeDisposition(HeaderUtils::DISPOSITION_INLINE, $file->getOriginalFilename()); + // $response->headers->set('Content-Disposition', $disposition); + // $response->headers->set('Content-Type', $file->getMimeType()); + // $response->setContent($content); + + // return $response; + + $contentStream = $this->defaultStorage->readStream($location); + + // https://dev.to/rubenrubiob/serve-a-file-stream-in-symfony-3ei3 + return new StreamedResponse( + static function () use ($contentStream): void { + fpassthru($contentStream); + }, + Response::HTTP_OK, + [ + 'Content-Transfer-Encoding', 'binary', + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_INLINE, $file->getOriginalFilename()), + 'Content-Type' => $file->getMimeType(), + 'Content-Length' => $this->defaultStorage->fileSize($location), + ], + ); } } diff --git a/api/src/Entity/GalleryDownload.php b/api/src/Entity/GalleryDownload.php index 9469c96..580c1a0 100644 --- a/api/src/Entity/GalleryDownload.php +++ b/api/src/Entity/GalleryDownload.php @@ -77,6 +77,11 @@ public function setStateDownloading(int $countDone = 0): void $this->stateContext = ['countDone' => $countDone]; } + public function setStateSaveZip(): void + { + $this->state = GalleryDownloadState::SAVE_ZIP; + } + public function setStateCaching(): void { $this->state = GalleryDownloadState::CACHING; diff --git a/api/src/Entity/GalleryDownloadState.php b/api/src/Entity/GalleryDownloadState.php index 8312fb6..eec1647 100644 --- a/api/src/Entity/GalleryDownloadState.php +++ b/api/src/Entity/GalleryDownloadState.php @@ -7,6 +7,7 @@ enum GalleryDownloadState: string case PENDING = 'pending'; case CREATE_ZIP = 'create_zip'; case DOWNLOADING = 'downloading'; // with extra info on how many of how many + case SAVE_ZIP = 'save_zip'; // save zip to disk (will take a while) case CACHING = 'caching'; // uploading to remote location // case COMPLETED = 'completed'; // entity is deleted when completed } diff --git a/api/src/MessageHandler/PrepareHugeGalleryDownloadHandler.php b/api/src/MessageHandler/PrepareHugeGalleryDownloadHandler.php index 8c5b0b3..a10e22f 100644 --- a/api/src/MessageHandler/PrepareHugeGalleryDownloadHandler.php +++ b/api/src/MessageHandler/PrepareHugeGalleryDownloadHandler.php @@ -97,6 +97,11 @@ public function __invoke(PrepareHugeGalleryDownload $message): void ]); } + $galleryDownload->setStateSaveZip(); + $this->em->flush(); + + // Closing actually saves the additions, this can take a while for many files + // TODO: Do this in a batch job $zip->close(); unset($zip); diff --git a/pwa/package.json b/pwa/package.json index 0c61bb9..1d5ba2f 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -41,6 +41,7 @@ "browserslist": "^4.24.3", "browserslist-to-esbuild": "^2.1.1", "clsx": "^2.1.1", + "dayjs": "^1.11.13", "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^9.1.0", diff --git a/pwa/pnpm-lock.yaml b/pwa/pnpm-lock.yaml index 338e5bb..97b5624 100644 --- a/pwa/pnpm-lock.yaml +++ b/pwa/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 eslint: specifier: ^8.57.1 version: 8.57.1 @@ -2022,6 +2025,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -5927,6 +5933,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.13: {} + debug@3.2.7: dependencies: ms: 2.1.3 diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index 65d9a0d..b431a16 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -53,6 +53,15 @@ function App() { } + future={{ + // https://reactrouter.com/6.28.1/upgrading/future#v7_relativesplatpath + v7_relativeSplatPath: true, + v7_startTransition: true, + v7_fetcherPersist: true, + v7_normalizeFormMethod: true, + v7_partialHydration: true, + v7_skipActionErrorRevalidation: true, + }} /> diff --git a/pwa/src/api/invited/gallery/useDownloadState.ts b/pwa/src/api/invited/gallery/useDownloadState.ts index 74dcad6..7dff820 100644 --- a/pwa/src/api/invited/gallery/useDownloadState.ts +++ b/pwa/src/api/invited/gallery/useDownloadState.ts @@ -1,7 +1,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import axios from 'axios'; -type GalleryDownloadState = 'pending' | 'create_zip' | 'downloading' | 'caching'; +type GalleryDownloadState = 'pending' | 'create_zip' | 'downloading' | 'save_zip' | 'caching'; export interface DownloadCheckAsyncProcess { message: string; diff --git a/pwa/src/assets/logo-gdrive.svg b/pwa/src/assets/logo-gdrive.svg new file mode 100644 index 0000000..2da644c --- /dev/null +++ b/pwa/src/assets/logo-gdrive.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/pwa/src/assets/logo-polarsteps.svg b/pwa/src/assets/logo-polarsteps.svg index fa78b82..53862c3 100644 --- a/pwa/src/assets/logo-polarsteps.svg +++ b/pwa/src/assets/logo-polarsteps.svg @@ -1,5 +1,5 @@ - + diff --git a/pwa/src/components/homepage/Gallery.tsx b/pwa/src/components/homepage/Gallery.tsx index 652e516..6841bdc 100644 --- a/pwa/src/components/homepage/Gallery.tsx +++ b/pwa/src/components/homepage/Gallery.tsx @@ -6,6 +6,7 @@ import ImageLazyLoad, { aspectRatio, ImageLazyLoadProps } from '#/components/com import blurHashMap from '#/img/blurhash-map.json'; import image from '#/img/Fotos.jpg'; import LogoPolarsteps from '#/assets/logo-polarsteps.svg?react'; +import LogoGDrive from '#/assets/logo-gdrive.svg?react'; import { GalleryImage as GalleryImageType } from '#/components/types.ts'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAuthenticationContext } from '#/utils/authentication.tsx'; @@ -26,8 +27,10 @@ import Checkbox from '#/components/common/Checkbox.tsx'; import useDownloadGalleryImages from '#/api/invited/gallery/useDownloadGalleryImages.ts'; import { downloadBlob } from '#/utils/download.ts'; import { faInstagram } from '@fortawesome/free-brands-svg-icons'; -import { AxiosProgressEvent } from 'axios'; +import axios, { AxiosProgressEvent } from 'axios'; import useDownloadState, { DownloadCheckAsyncProcess } from '#/api/invited/gallery/useDownloadState.ts'; +import * as dayjs from 'dayjs'; +import { Markup } from 'interweave'; interface Props { id?: string; @@ -58,16 +61,16 @@ export default function Gallery({ id, isLast }: Props) {

{t('homepage.gallery.title')}

-

+

{t('homepage.gallery.text')}

-
+
Instagram (@travel.za.world)
-
+
Polarsteps (Robine) @@ -75,6 +78,9 @@ export default function Gallery({ id, isLast }: Props) { Polarsteps (Manuele)
+

+ {t('homepage.gallery.downloadGDriveInstead')} +

{authentication ? (
@@ -355,6 +361,10 @@ function GalleryAndDownload({ files }: GalleryAndDownloadProps) { return `${Number.parseFloat((bytes / (baseLog ** magnitude)).toString()).toFixed(decimalPlaces)} ${sizes[magnitude]}`; }, []); + const downloadParams = new URLSearchParams({ + fileIds: Array.isArray(lastRequestedFileIds) ? lastRequestedFileIds.join(',') : lastRequestedFileIds, + }); + return ( <>
+ + Google Drive + @@ -390,10 +403,13 @@ function GalleryAndDownload({ files }: GalleryAndDownloadProps) {

{[ `${bytesToHumanReadableFileSize(progress.loaded)}${progress.total !== undefined ? ` / ${bytesToHumanReadableFileSize(progress.total)}` : ''}`, - progress.estimated !== undefined ? `${Math.ceil( progress.estimated)} sekunden` : undefined, + progress.estimated !== undefined ? `${dayjs.duration(progress.estimated, 'seconds').humanize()} übrig` : undefined, progress.rate !== undefined ? `${bytesToHumanReadableFileSize(progress.rate)}/s` : undefined, ].filter(Boolean).join(' | ')}

+

+ +

)}
@@ -452,6 +468,8 @@ function AsyncDownload({ hash, onFinish }: { hash: string; onFinish: () => void return t('homepage.gallery.downloadAsyncCreateZip'); case 'downloading': return t('homepage.gallery.downloadAsyncDownloading', {current: data.context.countDone, total: data.fileCount}) + case 'save_zip': + return t('homepage.gallery.downloadAsyncSaveZip'); case 'caching': return t('homepage.gallery.downloadAsyncCaching'); default: @@ -468,8 +486,10 @@ function AsyncDownload({ hash, onFinish }: { hash: string; onFinish: () => void return 0.05; case 'downloading': const imageProgressDone = data.context.countDone / data.fileCount; - return 0.05 + Math.round(imageProgressDone * (90 - 5)) / 100; - case 'caching': + return 0.05 + Math.round(imageProgressDone * (80 - 5)) / 100; + case 'save_zip': + return 0.85; + case 'caching': return 0.95; default: return 1; @@ -479,10 +499,10 @@ function AsyncDownload({ hash, onFinish }: { hash: string; onFinish: () => void return ( <> -

{getMessageForState(downloadState.data)}

{Math.ceil(progress * 100 * 100) / 100}%
+

{getMessageForState(downloadState.data)}

); } diff --git a/pwa/src/main.tsx b/pwa/src/main.tsx index c8d19f8..5337dbd 100644 --- a/pwa/src/main.tsx +++ b/pwa/src/main.tsx @@ -3,6 +3,10 @@ import App from './App'; import './index.css'; import { init as initI18n, currentLanguage } from './utils/i18n'; import axios from 'axios'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/de'; (async () => { // Set different base URLs for client and server @@ -10,6 +14,10 @@ import axios from 'axios'; await initI18n(currentLanguage()); + dayjs.locale(currentLanguage()); + dayjs.extend(duration); + dayjs.extend(relativeTime); + const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Failed to find the root element'); ReactDOM.createRoot(rootElement).render( diff --git a/pwa/src/translations/app.de.json b/pwa/src/translations/app.de.json index f9eaf22..641e08b 100644 --- a/pwa/src/translations/app.de.json +++ b/pwa/src/translations/app.de.json @@ -89,8 +89,11 @@ "homepage.gallery.downloadAsyncPending": "Download wird bald erstellt ...", "homepage.gallery.downloadAsyncCreateZip": "ZIP wird erstellt ...", "homepage.gallery.downloadAsyncDownloading": "Datei {{current}} von {{total}} wurde der ZIP Datei hinzugefügt ...", + "homepage.gallery.downloadAsyncSaveZip": "ZIP Datei wird gespeichert ...", "homepage.gallery.downloadAsyncCaching": "Erstellte ZIP Datei wird zwischengespeichert für den nächsten ...", "homepage.gallery.downloadAsyncReady": "ZIP Datei ist bereit zum Download und wird in kürze automatisch heruntergeladen", + "homepage.gallery.downloadAlternative": "Falls der Download stehen bleibt, klicke hier um die Datei direkt zu downloaden.", + "homepage.gallery.downloadGDriveInstead": "Falls ihr Probleme mit dem herunterladen habt, könnt ihr unten auch auf den \"Google Drive\" Knopf drücken. Dann könnt ihr die Fotos stattdessen über Google Drive herunterladen. Nachteil ist einfach, dass die Fotos nicht sortiert sind.", "admin.login.title": "Admin Login", "admin.login.username": "Benutzername", "admin.login.password": "Passwort",