diff --git a/backend b/backend index c5bfe14d..feef4722 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit c5bfe14dc6f6b3417f09c3f2d4a19bb0f4ea4a70 +Subproject commit feef4722e3c8f6c6920e6806fe0404c5b4889b45 diff --git a/scripts/fetchData.ts b/scripts/fetchData.ts index 4553238e..c9eb0f1b 100644 --- a/scripts/fetchData.ts +++ b/scripts/fetchData.ts @@ -163,6 +163,15 @@ const query = gql` totalArea bbox } + aoiGeometryInputAsset { + id + fileSize + file { + name + url + } + mimetype + } } totalCount } diff --git a/src/components/Link/index.tsx b/src/components/Link/index.tsx index 6339cec2..6f61392d 100644 --- a/src/components/Link/index.tsx +++ b/src/components/Link/index.tsx @@ -24,6 +24,11 @@ interface Props extends Omit { target?: string; variant?: Variant; title?: React.ReactNode; + download?: boolean; + // NOTE: For client-side downloads (e.g. a data: URL) where there is no + // media server to set Content-Disposition. Sets the HTML download + // attribute so the browser saves the file with this name. + downloadFileName?: string; } // NOTE: this does not support relative links @@ -34,6 +39,8 @@ function Link(props: Props) { variant = 'transparent', className, title, + download = false, + downloadFileName, ...rest } = props; @@ -56,6 +63,23 @@ function Link(props: Props) { } } + let { target, rel } = rest; + if (download) { + // NOTE: the server is configured to set the Content-Disposition + // header for media files when treat_as_download is set, which + // makes the browser download the file instead of opening it. + // NOTE: media URLs do not have query params so we can safely + // append the query param with `?` + if (typeof href === 'string') { + href = `${href}?treat_as_download=true`; + } + // NOTE: fallback for when the server does not handle + // treat_as_download: open the file in a new tab instead of + // navigating away from the page + target = target ?? '_blank'; + rel = rel ?? 'noopener noreferrer'; + } + return ( {children} diff --git a/src/pages/[locale]/data/index.tsx b/src/pages/[locale]/data/index.tsx index d8d3a3b9..782af27c 100644 --- a/src/pages/[locale]/data/index.tsx +++ b/src/pages/[locale]/data/index.tsx @@ -979,6 +979,7 @@ function Data(props: Props) { href={asset.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} diff --git a/src/pages/[locale]/projects/[id].tsx b/src/pages/[locale]/projects/[id].tsx index c504542b..2c3c1886 100644 --- a/src/pages/[locale]/projects/[id].tsx +++ b/src/pages/[locale]/projects/[id].tsx @@ -1,5 +1,4 @@ import React, { - useCallback, useEffect, useState, useMemo, @@ -27,7 +26,6 @@ import OgMeta from 'components/OgMeta'; import Page from 'components/Page'; import ProjectTypeIcon from 'components/ProjectTypeIcon'; import ImageWrapper from 'components/ImageWrapper'; -import Button from 'components/Button'; import Hero from 'components/Hero'; import Tag from 'components/Tag'; import Card from 'components/Card'; @@ -120,6 +118,7 @@ type Props = { exportGroups: UrlInfo; exportHistory: UrlInfo; exportAreaOfInterest: UrlInfo; + aoiGeometryInputAsset: UrlInfo; exportResults: UrlInfo; exportTasks: UrlInfo; exportUsers: UrlInfo; @@ -144,6 +143,7 @@ function Project(props: Props) { exportGroups, exportHistory, exportAreaOfInterest, + aoiGeometryInputAsset, exportResults, exportTasks, exportUsers, @@ -338,30 +338,40 @@ function Project(props: Props) { transformAoiToGeoJson(aoiGeometry) ), [aoiGeometry]); - const aoiGeometryBlob = useMemo(() => { - if (!aoiGeometryFeature) { - return undefined; + // NOTE: The AOI download is resolved from a priority chain: + // 1. aoiGeometryInputAsset (the original uploaded geometry; not present + // for projects sourced from a tasking manager id) + // 2. exportAreaOfInterest (the generated AOI export file; also covers + // older projects) + // 3. aoiGeometry.bbox (last resort, built client-side as a data URL) + const aoiDownload = useMemo(() => { + if (aoiGeometryInputAsset?.file?.url) { + return { + kind: 'link' as const, + url: aoiGeometryInputAsset.file.url, + mimetype: aoiGeometryInputAsset.mimetype, + fileSize: aoiGeometryInputAsset.fileSize, + }; } - const blob = new Blob( - [JSON.stringify(aoiGeometryFeature, null, 2)], - { type: 'application/geo+json' }, - ); - return blob; - }, [aoiGeometryFeature]); - - const onAoiDownloadClick = useCallback(() => { - if (!aoiGeometryBlob) { - return; + if (exportAreaOfInterest?.file?.url) { + return { + kind: 'link' as const, + url: exportAreaOfInterest.file.url, + mimetype: exportAreaOfInterest.mimetype, + fileSize: exportAreaOfInterest.fileSize, + }; } - const url = URL.createObjectURL(aoiGeometryBlob); - - const a = document.createElement('a'); - a.href = url; - a.download = `area_of_interest_${firebaseId}.geojson`; - a.click(); - - URL.revokeObjectURL(url); - }, [aoiGeometryBlob, firebaseId]); + if (aoiGeometryFeature) { + const geojson = JSON.stringify(aoiGeometryFeature); + return { + kind: 'data' as const, + url: `data:application/geo+json;charset=utf-8,${encodeURIComponent(geojson)}`, + mimetype: 'GEOJSON', + fileSize: new TextEncoder().encode(geojson).length, + }; + } + return undefined; + }, [aoiGeometryInputAsset, exportAreaOfInterest, aoiGeometryFeature]); return ( @@ -632,6 +642,7 @@ function Project(props: Props) { href={exportAggregatedResults?.file?.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -662,6 +673,7 @@ function Project(props: Props) { href={exportAggregatedResultsWithGeometry?.file?.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -690,6 +702,7 @@ function Project(props: Props) { href={exportGroups?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -718,6 +731,7 @@ function Project(props: Props) { href={exportHistory?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -746,6 +760,7 @@ function Project(props: Props) { href={exportResults?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -774,6 +789,7 @@ function Project(props: Props) { href={exportTasks?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -802,73 +818,42 @@ function Project(props: Props) { href={exportUsers?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} )} - {aoiGeometry && ( - -
- GEOJSON -
- {t('download-size', { - size: getFileSizeProperties( - aoiGeometryBlob?.size ?? 0, - ).size, - formatParams: { - size: { - style: 'unit', - unit: getFileSizeProperties( - aoiGeometryBlob?.size ?? 0, - ).unit, - maximumFractionDigits: 1, - }, - }, - })} -
-
- -
- )} - {/* NOTE: If in case there is no aoiGeometry, - we show exportAreaOfInterest if present */} - {!aoiGeometry && exportAreaOfInterest && ( + {aoiDownload && (
- {exportAreaOfInterest?.mimetype} + {aoiDownload.mimetype}
{t('download-size', { size: getFileSizeProperties( - exportAreaOfInterest?.fileSize, + aoiDownload.fileSize, ).size, - formatParams: { size: { style: 'unit', unit: getFileSizeProperties(exportAreaOfInterest?.fileSize).unit, maximumFractionDigits: 1 } }, + formatParams: { size: { style: 'unit', unit: getFileSizeProperties(aoiDownload.fileSize).unit, maximumFractionDigits: 1 } }, })}
{t('download')} @@ -899,6 +884,7 @@ function Project(props: Props) { href={exportHotTaskingManagerGeometries?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')} @@ -929,6 +915,7 @@ function Project(props: Props) { href={exportModerateToHighAgreementYesMaybeGeometries?.file.url} variant="buttonTransparent" className={styles.link} + download > {t('download')}