diff --git a/cypress/e2e/builder/item/download/export-zip.cy.ts b/cypress/e2e/builder/item/download/export-zip.cy.ts
index dfb71e4a1..a5a9b619e 100644
--- a/cypress/e2e/builder/item/download/export-zip.cy.ts
+++ b/cypress/e2e/builder/item/download/export-zip.cy.ts
@@ -9,7 +9,9 @@ import {
import {
buildDownloadButtonId,
buildExportAsZipButtonId,
+ buildItemCard,
buildItemsGridMoreButtonSelector,
+ buildPublicExportZipButtonId,
} from '../../../../../src/config/selectors';
import { HOME_PATH, buildItemPath } from '../../utils';
@@ -61,3 +63,61 @@ describe('Export Folder as ZIP', () => {
);
});
});
+
+describe('Download public folder as ZIP', () => {
+ const publicVisibility = (item: { path: string }) => ({
+ id: 'public-visibility-id',
+ type: 'public' as const,
+ itemPath: item.path,
+ createdAt: new Date().toISOString(),
+ });
+ const buildAdminExportHref = (itemId: string) =>
+ `/public/folders/${itemId}/export`;
+
+ it('logged out visitor can download a public folder and its subfolder', () => {
+ const parent = PackedFolderItemFactory();
+ const parentWithVisibility = {
+ ...parent,
+ public: publicVisibility(parent),
+ };
+ const child = PackedFolderItemFactory({ parentItem: parent });
+ const childWithVisibility = {
+ ...child,
+ public: publicVisibility(parent),
+ };
+ cy.setUpApi({
+ items: [parentWithVisibility, childWithVisibility],
+ currentMember: null,
+ });
+
+ // subfolder card menu
+ cy.visit(buildItemPath(parent.id));
+ cy.get(buildItemsGridMoreButtonSelector(child.id)).click();
+ cy.get(`[role="menu"] #${buildPublicExportZipButtonId(child.id)}`)
+ .should('have.attr', 'href')
+ .and('eq', buildAdminExportHref(child.id));
+ cy.get('body').type('{esc}');
+
+ // folder header menu
+ cy.get(`[aria-label="More"]`).first().click();
+ cy.get(`[role="menu"] #${buildPublicExportZipButtonId(parent.id)}`)
+ .should('have.attr', 'href')
+ .and('eq', buildAdminExportHref(parent.id));
+ // logged out visitors do not use the emailed export
+ cy.get(`[role="menu"] #${buildExportAsZipButtonId(parent.id)}`).should(
+ 'not.exist',
+ );
+ });
+
+ it('is not shown on a non-public folder', () => {
+ const parent = PackedFolderItemFactory();
+ const child = PackedFolderItemFactory({ parentItem: parent });
+ cy.setUpApi({ items: [parent, child], currentMember: null });
+ cy.visit(buildItemPath(parent.id));
+ // the menus are not rendered at all, so the zip button cannot be reached
+ cy.get(`#${buildItemCard(child.id)}`).should('exist');
+ cy.get(buildItemsGridMoreButtonSelector(child.id)).should('not.exist');
+ cy.get(`#${buildPublicExportZipButtonId(child.id)}`).should('not.exist');
+ cy.get(`#${buildPublicExportZipButtonId(parent.id)}`).should('not.exist');
+ });
+});
diff --git a/cypress/e2e/builder/item/pin/pinItem.cy.ts b/cypress/e2e/builder/item/pin/pinItem.cy.ts
index 931822eb1..8fbb432f5 100644
--- a/cypress/e2e/builder/item/pin/pinItem.cy.ts
+++ b/cypress/e2e/builder/item/pin/pinItem.cy.ts
@@ -4,6 +4,7 @@ import {
ITEM_HEADER_ID,
PIN_ITEM_BUTTON_CLASS,
buildItemsGridMoreButtonSelector,
+ buildPublicExportZipButtonId,
} from '../../../../../src/config/selectors';
import { HOME_PATH, buildItemPath } from '../../utils';
@@ -27,7 +28,10 @@ describe('Anonymous', () => {
});
it("Can see item but can't pin", () => {
cy.get(`#${ITEM_HEADER_ID}`).should('be.visible');
- cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).should('not.exist');
+ // logged out visitors can only download the zip of a public folder
+ cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).click();
+ cy.get(`#${buildPublicExportZipButtonId(itemId)}`).should('be.visible');
+ cy.get(`.${PIN_ITEM_BUTTON_CLASS}`).should('not.exist');
});
});
diff --git a/src/config/selectors.ts b/src/config/selectors.ts
index c9725e1e5..3ef7bcde1 100644
--- a/src/config/selectors.ts
+++ b/src/config/selectors.ts
@@ -501,6 +501,8 @@ export const CONFIRM_MEMBERSHIP_DELETE_BUTTON_ID =
'confirmDeleteMembershipButton';
export const buildDownloadButtonId = (itemId: string): string =>
`download-button-id-${itemId}`;
+export const buildPublicExportZipButtonId = (itemId: string): string =>
+ `public-export-zip-button-id-${itemId}`;
export const buildExportAsZipButtonId = (itemId: string): string =>
`export-zip-button-id-${itemId}`;
export const CUSTOM_APP_URL_ID = 'customAppURLId';
diff --git a/src/locales/de/builder.json b/src/locales/de/builder.json
index 3360dfd94..b00d31aa2 100644
--- a/src/locales/de/builder.json
+++ b/src/locales/de/builder.json
@@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Verknüpfung erstellen",
"ITEM_MENU_FLAG_MENU_ITEM": "Melden",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Als ZIP exportieren",
+ "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIP herunterladen",
"ITEM_METADATA_CREATED_AT_TITLE": "Hergestellt in",
"ITEM_METADATA_CREATOR_TITLE": "Urheber",
"ITEM_METADATA_LINK_TITLE": "Verknüpfung",
diff --git a/src/locales/en/builder.json b/src/locales/en/builder.json
index a3a0d35a1..b0d33840f 100644
--- a/src/locales/en/builder.json
+++ b/src/locales/en/builder.json
@@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Create Shortcut",
"ITEM_MENU_FLAG_MENU_ITEM": "Flag",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Export as ZIP",
+ "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Download ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Creation",
"ITEM_METADATA_CREATOR_TITLE": "Creator",
"ITEM_METADATA_LINK_TITLE": "Link",
diff --git a/src/locales/es/builder.json b/src/locales/es/builder.json
index adf216e5f..60a2f7e59 100644
--- a/src/locales/es/builder.json
+++ b/src/locales/es/builder.json
@@ -129,6 +129,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Crear acceso directo",
"ITEM_MENU_FLAG_MENU_ITEM": "Bandera",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exportar como ZIP",
+ "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Descargar ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Creado en",
"ITEM_METADATA_CREATOR_TITLE": "Creador",
"ITEM_METADATA_LINK_TITLE": "Enlace",
diff --git a/src/locales/fr/builder.json b/src/locales/fr/builder.json
index d4b38459f..88c7c9f37 100644
--- a/src/locales/fr/builder.json
+++ b/src/locales/fr/builder.json
@@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Créer un Raccourci",
"ITEM_MENU_FLAG_MENU_ITEM": "Signaler",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exporter en ZIP",
+ "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Télécharger le ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Créé à",
"ITEM_METADATA_CREATOR_TITLE": "Créateur",
"ITEM_METADATA_LINK_TITLE": "Lien",
diff --git a/src/locales/ja/builder.json b/src/locales/ja/builder.json
index bf8f8ed9d..77fa8e025 100644
--- a/src/locales/ja/builder.json
+++ b/src/locales/ja/builder.json
@@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "ショートカットを作成する",
"ITEM_MENU_FLAG_MENU_ITEM": "フラグ",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "ZIPファイルとしてエクスポート",
+ "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIPをダウンロード",
"ITEM_METADATA_CREATED_AT_TITLE": "創造",
"ITEM_METADATA_CREATOR_TITLE": "クリエイター",
"ITEM_METADATA_LINK_TITLE": "リンク",
diff --git a/src/modules/builder/components/common/PublicExportZipButton.tsx b/src/modules/builder/components/common/PublicExportZipButton.tsx
new file mode 100644
index 000000000..abb3c44d9
--- /dev/null
+++ b/src/modules/builder/components/common/PublicExportZipButton.tsx
@@ -0,0 +1,55 @@
+import { type JSX } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { ListItemIcon, MenuItem } from '@mui/material';
+
+import { PackageIcon } from 'lucide-react';
+
+import { NS } from '@/config/constants';
+import { buildPublicExportZipButtonId } from '@/config/selectors';
+import type { GenericItem } from '@/openapi/client';
+
+type Props = {
+ itemId: GenericItem['id'];
+
+ /**
+ * ui context the button is located
+ */
+ dataUmamiContext?: string;
+
+ /**
+ * called when the download starts, e.g. to close the parent menu
+ */
+ onClick?: () => void;
+};
+
+/**
+ * Download a public folder as zip, for visitors who are not logged in
+ * The export is served by the admin app, which also serves this client,
+ * so the path is absolute without a host
+ */
+const PublicExportZipButton = ({
+ itemId,
+ dataUmamiContext,
+ onClick,
+}: Props): JSX.Element => {
+ const { t } = useTranslation(NS.Builder);
+
+ return (
+
+ );
+};
+
+export default PublicExportZipButton;
diff --git a/src/modules/builder/components/item/header/Actions.tsx b/src/modules/builder/components/item/header/Actions.tsx
index 04d025d28..6a544e1f0 100644
--- a/src/modules/builder/components/item/header/Actions.tsx
+++ b/src/modules/builder/components/item/header/Actions.tsx
@@ -15,6 +15,7 @@ import type { PackedItem } from '@/openapi/client';
import { ActionButton } from '@/ui/types';
import ExportRawZipButton from '~builder/components/common/ExportRawZipButton';
+import PublicExportZipButton from '~builder/components/common/PublicExportZipButton';
import useModalStatus from '~builder/components/hooks/useModalStatus';
import DownloadButton from '~builder/components/main/DownloadButton';
@@ -43,7 +44,7 @@ const internalId = 'menu';
const Actions = ({ item }: Props): JSX.Element[] | null => {
const { copyOpen } = useSearch({ from: '/builder/items/$itemId' });
const { t } = useTranslation(NS.Common, { keyPrefix: 'ARIA' });
- const { data: member } = hooks.useCurrentMember();
+ const { data: member, isPending: isMemberPending } = hooks.useCurrentMember();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const handleClick = (event: MouseEvent): void => {
@@ -68,10 +69,47 @@ const Actions = ({ item }: Props): JSX.Element[] | null => {
const canAdmin =
item.permission && PermissionLevelCompare.gte(item.permission, 'admin');
- if (!member?.id) {
+ const moreButton = (
+
+
+
+ );
+
+ // wait for the current member to avoid showing the logged out menu
+ if (isMemberPending) {
return null;
}
+ if (!member?.id) {
+ // logged out visitors can only download the zip of a public folder
+ if (item.type !== 'folder' || !item.public) {
+ return null;
+ }
+ return [
+ moreButton,
+ ,
+ ];
+ }
+
const downloadButton =
item.type === 'folder' ? (
{
onClose={closeCreateShortcutModal}
open={isCreateShortcutOpen}
/>,
-
-
- ,
+ moreButton,
)}
+ {!account && isPublicFolder && (
+
+ )}
{account?.id ? : false}
>
diff --git a/src/modules/builder/langs.ts b/src/modules/builder/langs.ts
index 95f4d0f96..c7a16e30a 100644
--- a/src/modules/builder/langs.ts
+++ b/src/modules/builder/langs.ts
@@ -241,6 +241,8 @@ export const BUILDER = {
ITEM_MENU_DUPLICATE_MENU_ITEM: 'ITEM_MENU_DUPLICATE_MENU_ITEM',
ITEM_MENU_FLAG_MENU_ITEM: 'ITEM_MENU_FLAG_MENU_ITEM',
ITEM_MENU_EXPORT_RAW_MENU_ITEM: 'ITEM_MENU_EXPORT_RAW_MENU_ITEM',
+ ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM:
+ 'ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM',
ITEM_METADATA_CREATED_AT_TITLE: 'ITEM_METADATA_CREATED_AT_TITLE',
ITEM_METADATA_LINK_TITLE: 'ITEM_METADATA_LINK_TITLE',
ITEM_METADATA_SIZE_TITLE: 'ITEM_METADATA_SIZE_TITLE',