diff --git a/cypress/e2e/media_library_bunny_spec_test_backend.js b/cypress/e2e/media_library_bunny_spec_test_backend.js new file mode 100644 index 000000000000..cbfb46e776f0 --- /dev/null +++ b/cypress/e2e/media_library_bunny_spec_test_backend.js @@ -0,0 +1,62 @@ +import { login, newPost } from '../utils/steps'; + +describe('Test Backend Bunny Media Library', () => { + const bunnyListResponse = [ + { + Guid: '1', + StorageZoneName: 'cmt-docs', + Path: '/', + ObjectName: 'kitten.jpg', + Length: 1024, + LastChanged: '2024-01-01T00:00:00Z', + IsDirectory: false, + DateCreated: '2024-01-01T00:00:00Z', + StorageZoneId: 1, + }, + ]; + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('shows Bunny login prompt when credentials are missing', () => { + newPost(); + cy.contains('button', 'Choose an image').click(); + + cy.contains('h2', 'Bunny.net Media Library').should('be.visible'); + cy.contains('button', 'Login with Bunny').should('be.visible'); + }); + + it('lists and inserts Bunny files when credentials exist', () => { + cy.window().then(win => { + win.localStorage.setItem('bunny_auth_key', 'storage-zone-password'); + win.localStorage.setItem('bunny_storage_zone_name', 'cmt-docs'); + }); + + cy.intercept('GET', 'https://storage.bunnycdn.com/**', { + statusCode: 200, + body: bunnyListResponse, + headers: { + 'content-type': 'application/json', + }, + }).as('bunnyListFiles'); + + newPost(); + cy.contains('button', 'Choose an image').click(); + + cy.wait('@bunnyListFiles'); + cy.contains('div', 'kitten.jpg').click(); + cy.contains('button', 'Insert (1)').click(); + + cy.get('[id^="image-field"]').should('have.value', 'https://cmt-docs-cdn.b-cdn.net/kitten.jpg'); + }); +}); diff --git a/dev-test/config.yml b/dev-test/config.yml index f985684151a7..0cffbe51bc7d 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -1,288 +1,152 @@ +local_backend: true + backend: - name: test-repo + name: git-gateway -site_url: https://example.com +display_url: http://localhost:1313 +logo_url: /media/brand/logo.svg +media_folder: static/media/uploads +public_folder: /media/uploads -publish_mode: editorial_workflow -media_folder: assets/uploads +media_library: + name: bunny + config: + storage_zone_name: cmt-docs + cdn_url_prefix: https://cmt-docs-cdn.b-cdn.net -collections: # A list of collections the CMS should be able to edit - - name: 'posts' # Used in routes, ie.: /admin/collections/:slug/edit - label: 'Posts' # Used in the UI - label_singular: 'Post' # Used in the UI, ie: "New Post" - description: > - The description is a great place for tone setting, high level information, and editing - guidelines that are specific to a collection. - folder: '_posts' - slug: '{{year}}-{{month}}-{{day}}-{{slug}}' - summary: '{{title}} -- {{year}}/{{month}}/{{day}}' - create: true # Allow users to create new documents in this collection - editor: - visualEditing: true - sortable_fields: - - title - - { field: date, default_sort: desc } - - draft - view_filters: - - label: Posts With Index - field: title - pattern: 'This is post #' - - label: Posts Without Index - field: title - pattern: front matter post - - label: Drafts - field: draft - pattern: true - view_groups: - - label: Year - field: date - pattern: \d{4} - - label: Drafts - field: draft - fields: # The fields each document in this collection have - - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } - - { label: 'Draft', name: 'draft', widget: 'boolean', default: false } - - { - label: 'Publish Date', - name: 'date', - widget: 'datetime', - format: 'YYYY-MM-DD HH:mm', - default: '{{now}}', - } - - label: 'Cover Image' - name: 'image' - widget: 'image' - required: false - tagname: '' +slug: + encoding: ascii + clean_accents: true - - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } +aliases: + - &VISIBLE_IN_CMS {name: visibleInCMS, widget: hidden, default: true} + - &TEXT {name: text, widget: string, i18n: true, required: false} + - &DESCRIPTION {name: description, label: description, widget: text, i18n: true, required: false} + - &HREF {name: href, widget: string, i18n: true, required: false} + - &TITLE {name: title, widget: string, i18n: true} + - &TITLE_OPTIONAL {name: title, widget: string, i18n: true, required: false} + - &BUTTON {name: button, widget: object, collapsed: true, i18n: true, fields: [ + *TEXT, + *HREF + ]} + - &IMAGE_OBJECT_FIELDS [ + {name: src, widget: image, i18n: duplicate, required: false}, + {name: alt, widget: string, i18n: true, required: false}, + *TITLE_OPTIONAL, + ] + - &IMAGE_OBJECT {name: image, widget: object, i18n: true, collapsed: true, fields: *IMAGE_OBJECT_FIELDS} + - &TEXT_COLUMNS {label: Text Columns, name: text-columns, i18n: true,widget: object, fields: [ + {name: type, widget: hidden, default: text-columns}, + {name: smallTitle, label: Small Title, widget: string, i18n: true, required: false, hint: "Optional uppercase title above main heading"}, + *TITLE, + {name: textLeft, label: Text Left, widget: text, i18n: true, required: false}, + {name: textRight, label: Text Right, widget: text, i18n: true, required: true}, + *BUTTON, + ]} + - &ICON_SELECT {label: Icon, name: icon, widget: select, i18n: duplicate, options: [ac_unit_600, account_balance_600, add_2_700, alternate_email_600, approval_delegation_600, arrow_backward_700, arrow_downward_700, arrow_forward_700, arrow_outward_700, bedtime_600, block_600, boat_bus_600, bullet-point_600, business_center_600, calendar_month_600, call_600, castle_600, celebration_600, check_700, circle_600, clock_loader_60_600, close_700, Collegium_non-colored, delete_700, diamond_600, directions_run_600, diversity_1_600, diversity_4_600, Facebook_apple-sf-regular-2, filter_vintage_600, flights_and_hotels_600, group_600, handshake_600, hot_tub_600, icon-placeholder, icon-placeholder-1, image_600, info_700, Instagram_apple-sf-regular, keyboard_arrow_down_1000, keyboard_arrow_down_700, keyboard_arrow_right_1000, keyboard_arrow_right_700, keyboard_arrow_up_1000, keyboard_arrow_up_700, light_mode_600, live_help_600, live_help_700, location_on_600, moon_stars_600, Parking_600, payment_arrow_down_600, personal_bag_600, play_600, pool_600, restaurant_600, routine_600, sauna_600, sentiment_calm_600, spa_600, Sparkling, star_600, star_shine_600, TikTok_apple-sf-regular, verified_user_600, WhatsApp, YouTube_apple-sf-regular]} + - &ANCHOR_CARDS {label: Anchor Cards, name: anchor-cards, widget: object, fields: [ + {name: type, widget: hidden, default: anchor-cards}, + {label: Small Title, name: smallTitle, widget: string, i18n: true, required: false}, + *TITLE_OPTIONAL, + *DESCRIPTION, + {label: Cards, name: cards, widget: list, i18n: true, max: 4, fields: [ + *ICON_SELECT, + *TITLE, + *DESCRIPTION, + *BUTTON + ]} + ]} + - &MODULES {name: modules, label: Modules, widget: list, i18n: true, types: [*TEXT_COLUMNS, *ANCHOR_CARDS]} + - &SINGLE_FIELDS [ + *TITLE, + *DESCRIPTION, + *IMAGE_OBJECT, + *MODULES, + *VISIBLE_IN_CMS + ] - - name: 'restaurants' # Used in routes, ie.: /admin/collections/:slug/edit - label: 'Restaurants' # Used in the UI - label_singular: 'Restaurant' # Used in the UI, ie: "New Post" - description: > - Restaurants is an entry type used for testing galleries, relations and other widgets. - The tests must be written in such way that adding new fields does not affect previous flows. - folder: '_restaurants' - slug: '{{year}}-{{month}}-{{day}}-{{slug}}' - summary: '{{title}} -- {{year}}/{{month}}/{{day}}' - create: true # Allow users to create new documents in this collection +collections: + - name: posts + label: Posts + label_singular: post + folder: content/collegium/posts + create: true + slug: "{{slug}}" + filter: {field: visibleInCMS, value: true} editor: - visualEditing: true - fields: # The fields each document in this collection have - - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } - - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } - - { name: 'gallery', widget: 'image', choose_url: true, media_library: {config: {multiple: true, max_files: 999}}} - - { name: 'post', widget: relation, collection: posts, multiple: true, search_fields: [ "title" ], display_fields: [ "title" ], value_field: "{{slug}}", filters: [ {field: "draft", values: [false]} ] } - - name: authors - label: Authors - label_singular: 'Author' - widget: list - fields: - - { label: 'Name', name: 'name', widget: 'string', hint: 'First and Last' } - - { label: 'Description', name: 'description', widget: 'markdown' } + preview: false - - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit - label: 'FAQ' # Used in the UI - folder: '_faqs' - create: true # Allow users to create new documents in this collection - fields: # The fields each document in this collection have - - { label: 'Question', name: 'title', widget: 'string', tagname: 'h1' } - - { label: 'Answer', name: 'body', widget: 'markdown' } + fields: [ + {name: title, widget: string}, + {name: description, widget: text}, + {name: image, widget: image}, + {name: body, widget: markdown}, + *VISIBLE_IN_CMS, + ] - - name: 'settings' - label: 'Settings' - delete: false # Prevent users from deleting documents in this collection + - name: pages + label: Pages + label_singular: page + folder: content/collegium + create: true + i18n: true + slug: "{{slug}}" + filter: {field: visibleInCMS, value: true} + fields: *SINGLE_FIELDS + + - name: general + label: General editor: preview: false files: - - name: 'general' - label: 'Site Settings' - file: '_data/settings.json' - description: 'General Site Settings' - fields: - - { label: 'Global title', name: 'site_title', widget: 'string' } - - label: 'Post Settings' - name: posts - widget: 'object' - fields: - - { - label: 'Number of posts on frontpage', - name: front_limit, - widget: number, - min: 1, - max: 10, - } - - { label: 'Default Author', name: author, widget: string } - - { - label: 'Default Thumbnail', - name: thumb, - widget: image, - class: 'thumb', - required: false, - } - - - name: 'authors' - label: 'Authors' - file: '_data/authors.yml' - description: 'Author descriptions' - fields: - - name: authors - label: Authors - label_singular: 'Author' - widget: list - fields: - - { label: 'Name', name: 'name', widget: 'string', hint: 'First and Last' } - - { label: 'Description', name: 'description', widget: 'markdown' } + - label: Site settings + name: site-settings + file: config/collegium.toml + fields: [ + {name: title, widget: string}, + {name: params, widget: object, fields: [ + {name: description, widget: text}, + {name: image, widget: image}, + ]}, + ] + - label: Footer + name: footer + file: data/collegium/footer.json + fields: [ + {label: Columns, name: columns, widget: list, fields: [ + *TITLE, + *HREF, + {label: Items, name: items, widget: list, fields: [ + *TEXT, + *HREF, + ]}, + ]}, + {label: Support, name: support, widget: object, required: false, fields: [ + *TITLE_OPTIONAL, + *DESCRIPTION, + *BUTTON, + ]}, + {label: Related Sites Label, name: relatedSitesLabel, widget: string, required: false}, + {label: Related Sites, name: relatedSites, widget: list, fields: [ + *HREF, + {label: Logo, name: logo, widget: object, fields: *IMAGE_OBJECT_FIELDS}, + ]}, + ] - - name: 'kitchenSink' # all the things in one entry, for documentation and quick testing - label: 'Kitchen Sink' - folder: '_sink' - create: true - fields: - - label: 'Related Post' - name: 'post' - widget: 'relationKitchenSinkPost' - collection: 'posts' - display_fields: ['title', 'datetime'] - search_fields: ['title', 'body'] - value_field: 'title' - - { label: 'Title', name: 'title', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true } - - { label: 'Map', name: 'map', widget: 'map' } - - { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' } - - { label: 'Number', name: 'number', widget: 'number', hint: 'To infinity and beyond!' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - { - label: 'Select multiple', - name: 'select_multiple', - widget: 'select', - options: ['a', 'b', 'c'], - multiple: true, - } - - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } - - { label: 'Color', name: 'color', widget: 'color' } - - label: 'Object' - name: 'object' - widget: 'object' - collapsed: true - fields: - - label: 'Related Post' - name: 'post' - widget: 'relationKitchenSinkPost' - collection: 'posts' - search_fields: ['title', 'body'] - value_field: 'title' - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: false } - - { label: 'Text', name: 'text', widget: 'text' } - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - label: 'List' - name: 'list' - widget: 'list' - fields: - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean' } - - { label: 'Text', name: 'text', widget: 'text' } - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - label: 'Object' - name: 'object' - widget: 'object' - fields: - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean' } - - { label: 'Text', name: 'text', widget: 'text' } - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - label: 'List' - name: 'list' - widget: 'list' - fields: - - label: 'Related Post' - name: 'post' - widget: 'relationKitchenSinkPost' - collection: 'posts' - search_fields: ['title', 'body'] - value_field: 'title' - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean' } - - { label: 'Text', name: 'text', widget: 'text' } - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } - - label: 'Object' - name: 'object' - widget: 'object' - fields: - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean' } - - { label: 'Text', name: 'text', widget: 'text' } - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - { - label: 'Select', - name: 'select', - widget: 'select', - options: ['a', 'b', 'c'], - } - - label: 'Typed List' - name: 'typed_list' - widget: 'list' - types: - - label: 'Type 1 Object' - name: 'type_1_object' - widget: 'object' - fields: - - { label: 'String', name: 'string', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean' } - - { label: 'Text', name: 'text', widget: 'text' } - - label: 'Type 2 Object' - name: 'type_2_object' - widget: 'object' - fields: - - { label: 'Number', name: 'number', widget: 'number' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } - - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - - label: 'Type 3 Object' - name: 'type_3_object' - widget: 'object' - fields: - - { label: 'Image', name: 'image', widget: 'image' } - - { label: 'File', name: 'file', widget: 'file' } - - name: pages # a nested collection - label: Pages - label_singular: 'Page' - folder: _pages - create: true - nested: { depth: 100, subfolders: false } - fields: - - label: Title - name: title - widget: string - meta: { path: { widget: string, label: 'Path', index_file: 'index' } } + - label: Header + name: header + file: data/collegium/header.json + fields: + - label: Announcement strip + name: announcementStrip + widget: object + fields: + - {label: Text, name: text, widget: string, required: false, maxlength: 100, hint: "Max 100 characters"} + - label: CTA + name: cta + widget: object + required: false + fields: + - {label: Text, name: text, widget: string, required: false} + - {label: Href, name: href, widget: string, required: false} + - {label: Target, name: target, widget: string, required: false, hint: "e.g. _blank"} diff --git a/package-lock.json b/package-lock.json index e0fb877f909c..825bfc7fde5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11758,6 +11758,10 @@ "resolved": "packages/decap-cms-locales", "link": true }, + "node_modules/decap-cms-media-library-bunny": { + "resolved": "packages/decap-cms-media-library-bunny", + "link": true + }, "node_modules/decap-cms-media-library-cloudinary": { "resolved": "packages/decap-cms-media-library-cloudinary", "link": true @@ -33860,6 +33864,7 @@ "codemirror": "^5.46.0", "create-react-class": "^15.7.0", "decap-cms-app": "^3.10.1", + "decap-cms-media-library-bunny": "^0.1.0", "decap-cms-media-library-cloudinary": "^3.1.0", "decap-cms-media-library-uploadcare": "^3.0.2", "file-loader": "^6.2.0", @@ -33982,7 +33987,7 @@ "dependencies": { "common-tags": "^1.8.0", "js-base64": "^3.0.0", - "minimatch": "^7.4.8", + "minimatch": "^7.0.0", "path-browserify": "^1.0.1", "semaphore": "^1.1.0", "what-the-diff": "^0.6.0" @@ -34028,7 +34033,7 @@ "gotrue-js": "^0.9.24", "ini": "^2.0.0", "jwt-decode": "^3.0.0", - "minimatch": "^7.4.8" + "minimatch": "^7.0.0" }, "peerDependencies": { "@emotion/react": "^11.11.1", @@ -34323,6 +34328,20 @@ "version": "3.5.1", "license": "MIT" }, + "packages/decap-cms-media-library-bunny": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "decap-cms-ui-default": "^3.0.0" + }, + "peerDependencies": { + "decap-cms-lib-util": "^3.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + } + }, "packages/decap-cms-media-library-cloudinary": { "version": "3.1.0", "license": "MIT", diff --git a/packages/decap-cms-media-library-bunny/ARCHITECTURE.md b/packages/decap-cms-media-library-bunny/ARCHITECTURE.md new file mode 100644 index 000000000000..c6c0528fa0a2 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/ARCHITECTURE.md @@ -0,0 +1,395 @@ +# Technical Architecture + +This document describes the architecture and implementation details of the Bunny.net media library integration. + +## Project Structure + +``` +packages/decap-cms-media-library-bunny/ +├── src/ +│ ├── index.js # Main entry point, implements MediaLibraryInstance +│ ├── types.ts # TypeScript type definitions +│ ├── api/ +│ │ ├── client.ts # BunnyClient - HTTP client for API +│ │ └── fileManager.ts # BunnyFileManager - High-level file operations +│ ├── components/ +│ │ ├── BunnyWidget.tsx # Main widget container component +│ │ ├── FileGrid.tsx # File grid display component +│ │ ├── FileBrowser.tsx # Breadcrumb navigation component +│ │ └── FileUpload.tsx # Upload with drag-and-drop component +│ └── __tests__/ +│ ├── client.test.ts # API client tests +│ └── fileManager.test.ts # File manager tests +├── dist/ # Build output (CommonJS + ESM) +├── package.json +├── webpack.config.js # Webpack configuration +├── README.md # User documentation +├── SETUP.md # Setup guide +├── TESTING.md # Testing guide +└── ARCHITECTURE.md # This file +``` + +## Data Flow + +### Initialization Flow + +``` +Decap CMS + ↓ +init() called with config + ↓ +Validate configuration + ↓ +Create BunnyFileManager instance + ↓ +Return MediaLibraryInstance object + ↓ +Ready for show() calls +``` + +### File Browsing Flow + +``` +User clicks image field button + ↓ +show() called on MediaLibraryInstance + ↓ +BunnyWidget renders to DOM modal + ↓ +useEffect loads files from currentPath + ↓ +BunnyFileManager.getFilesWithUrls() + ↓ +BunnyClient.listFiles() → HTTP GET to Bunny API + ↓ +Parse response, add public URLs + ↓ +FileGrid renders file list +``` + +### File Selection & Insertion + +``` +User clicks file (single or multiple) + ↓ +State updated: selectedFiles Set + ↓ +Checkbox/visual indication displayed + ↓ +User clicks "Insert" button + ↓ +onInsert(url) callback called + ↓ +URL(s) passed back to Decap CMS + ↓ +Modal closes automatically + ↓ +URL inserted into field +``` + +### File Upload Flow + +``` +User drags files to drop zone (or clicks) + ↓ +onUpload() triggered with File objects + ↓ +For each file: + - Set isUploading state + - BunnyFileManager.uploadFile() + - BunnyClient.uploadFile() → HTTP PUT to Bunny API + - Update progress + - Add to URL list + ↓ +Reload file list + ↓ +Auto-insert if single file + single-select mode +``` + +## Key Components + +### 1. BunnyClient (src/api/client.ts) + +Low-level HTTP client for Bunny.net Storage API. + +**Key Methods:** +- `listFiles(path)` - Lists files in a directory +- `uploadFile(filePath, blob)` - Uploads a file +- `deleteFile(filePath)` - Deletes a file +- `generatePublicUrl(cdnPrefix, filePath)` - Creates CDN URL + +**Features:** +- Automatic `AccessKey` header injection +- Error handling with descriptive messages +- Regional endpoint support (us, eu, asia, sydney) + +### 2. BunnyFileManager (src/api/fileManager.ts) + +High-level file management abstraction. + +**Key Methods:** +- `listFiles(path)` - Lists directory contents +- `getFilesWithUrls(path, imagesOnly)` - Lists with public URLs +- `uploadFile(filePath, blob, fileName)` - Uploads returning URL +- `deleteFile(filePath)` - Deletes file +- `filterImageFiles(files)` - Image-only filtering +- `normalizePath(path)` - Path normalization +- `getParentPath(path)` - Parent directory calculation + +**Features:** +- Client-side image filtering +- URL generation with proper formatting +- Path normalization and validation + +### 3. BunnyWidget (src/components/BunnyWidget.tsx) + +Main React component - orchestrates all UI and logic. + +**State Management:** +```typescript +currentPath: string // Current directory +files: AddressedMediaFile[] // Files in current directory +selectedFiles: Set // Selected file URLs +isLoading: boolean // Loading state +error: string | null // Error messages +uploadProgress: number // Upload progress % +isUploading: boolean // Uploading state +``` + +**Key Handlers:** +- `handleNavigate(path)` - Navigate to path +- `handleSelectFile(url)` - Toggle file selection +- `handleFileDoubleClick(file)` - Open folder or insert file +- `handleUpload(files)` - Handle file uploads +- `handleDeleteFile(path)` - Delete file with confirmation +- `handleInsertSelected()` - Insert selected files + +### 4. FileGrid Component + +Displays files in responsive grid layout. + +**Features:** +- Auto-sorting (directories first) +- Image preview thumbnails +- File metadata (size, date) +- Checkbox/radio selection +- Delete button on hover +- Responsive grid (CSS Grid) + +### 5. FileBrowser Component + +Navigation breadcrumbs and controls. + +**Features:** +- Breadcrumb trail to navigate +- Back button to parent directory +- Path display for reference +- Disabled state when at root + +### 6. FileUpload Component + +Drag-and-drop file upload interface. + +**Features:** +- Drag-and-drop support +- Click to select files +- Progress bar with percentage +- Current path indicator +- Multiple file support + +## API Integration + +### Bunny.net Storage API Endpoints + +**List Files:** +``` +GET /storage-zone-name/path/ +Authorization: AccessKey +``` + +Response example: +```json +[ + { + "Guid": "abc-123", + "StorageZoneName": "zone", + "Path": "/", + "ObjectName": "image.jpg", + "Length": 102400, + "LastChanged": "2024-01-15T10:30:00Z", + "IsDirectory": false, + "DateCreated": "2024-01-01T00:00:00Z", + "StorageZoneId": 12345 + } +] +``` + +**Upload File:** +``` +PUT /storage-zone-name/path/filename.jpg +Authorization: AccessKey +Content-Type: application/octet-stream +[binary file data] +``` + +**Delete File/Folder:** +``` +DELETE /storage-zone-name/path/filename.jpg +Authorization: AccessKey +``` + +## Type System + +### Core Types (src/types.ts) + +- `BunnyFile` - File metadata from API +- `AddressedMediaFile` - File with public URL +- `BunnyConfig` - Configuration object +- `MediaLibraryInstance` - Decap CMS interface + +## Error Handling + +Errors are caught at multiple levels: + +1. **API Level** - BunnyClient validates responses +2. **Manager Level** - BunnyFileManager adds context +3. **Component Level** - BunnyWidget catches and displays +4. **UI Level** - Error messages shown to user + +Error messages include: +- Human-readable descriptions +- Suggestions for fixes where applicable +- Console logging for debugging + +## Security Considerations + +### Credentials Handling + +- API key stored in browser memory only +- Passed via headers for each request +- Never logged or exposed in console +- Use environment variables in production + +### CORS + +- Bunny.net allows cross-origin requests with proper headers +- No pre-flight required for simple requests +- File uploads handled via PUT with binary data + +### File Access + +- Files can only be accessed via authenticated requests +- Storage zone must be properly configured in Bunny.net +- CDN URLs are public (immutable after upload) + +## Performance Considerations + +### Optimization Techniques + +1. **Lazy Loading** - Images use `loading="lazy"` +2. **Memoization** - `useMemo` for sorted file list +3. **Debouncing** - Drag interactions debounced +4. **Virtualization** - Not needed for MVP (suitable for <1000 files) + +### Known Limitations + +- All files loaded at once (no pagination) +- No search indexing (linear search) +- Images not transformed (use Bunny CDN for that) + +### Future Improvements + +- Implement pagination +- Add search with fuzzy matching +- Implement virtual listing for large folders +- Cache folder contents +- Add prefetching for navigation + +## Testing Strategy + +### Unit Tests + +- API client HTTP handling +- File manager path manipulation +- Image filtering logic + +### Integration Tests + +- Widget component state management +- File operations (list, upload, delete) +- Navigation and selection + +### E2E Tests + +- Full user workflows +- Error scenarios +- Multiple browser compatibility + +## Browser Support + +- Chrome/Edge 90+ +- Firefox 88+ +- Safari 15+ +- Mobile browsers (iOS Safari 15+, Chrome Android) + +### Polyfills Required + +- `Promise` - For async operations +- Fetch API - Modern browsers only + +## Build Configuration + +### Webpack + +- Entry: `src/index.js` +- Output: CommonJS UMD + ESM +- Transforms: Babel (ES2017 target) +- Minification: Production builds + +### Babel + +- Preset: `@babel/preset-typescript` +- Allows TypeScript syntax in .tsx files +- Transpiles to ES2017 (CommonJS) + +## Dependencies + +### Runtime + +- `react` - UI framework (peer dependency) +- `react-dom/client` - React root API + +### Dev + +- Standard Decap CMS monorepo toolchain +- Babel for transpilation +- Jest for testing +- Webpack for bundling + +## Contributing Guidelines + +### Adding Features + +1. Add types to `src/types.ts` +2. Implement in appropriate component +3. Add tests in `src/__tests__/` +4. Update documentation +5. Test in browser + +### Code Style + +- Use TypeScript/React patterns from codebase +- Inline styles for CSS (no external CSS files) +- JSDoc comments for public APIs +- Descriptive variable names + +### Testing New Functionality + +1. Unit test: Isolated logic +2. Integration test: Component interaction +3. E2E test: User workflow +4. Browser test: Multiple browsers + +--- + +**For questions or discussions**, open an issue on the [Decap CMS GitHub repository](https://github.com/decaporg/decap-cms/issues). diff --git a/packages/decap-cms-media-library-bunny/CHANGELOG.md b/packages/decap-cms-media-library-bunny/CHANGELOG.md new file mode 100644 index 000000000000..c146a0745ef0 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/CHANGELOG.md @@ -0,0 +1,90 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2024-01-19 + +### Added + +- Initial release of Bunny.net media library integration for Decap CMS +- File browsing and navigation with breadcrumb trail +- Single and multiple file selection +- File upload with drag-and-drop support +- File deletion with confirmation +- Image preview thumbnails +- Client-side image filtering for image widgets +- Responsive UI that works on desktop and mobile +- Comprehensive documentation (Setup, Testing, Architecture guides) +- Unit tests for API client and file manager +- TypeScript type definitions +- CommonJS and ESM builds + +### Features (MVP) + +- **File Browsing**: Navigate storage zone directories with breadcrumbs +- **File Selection**: Single or multiple file selection mode +- **File Upload**: Drag-and-drop or click-to-upload with progress tracking +- **File Deletion**: Delete files with confirmation dialog +- **Image Filtering**: Automatic filtering to images when using image widget +- **Public URLs**: Automatic CDN URL generation for inserted files +- **Error Handling**: User-friendly error messages and recovery + +### Limitations (Future Enhancements) + +- No full-text search (filename sorting only) +- No pagination (loads all files in folder at once) +- No image transformations (use Bunny CDN separately) +- No folder creation from UI +- No batch operations (delete multiple, etc) + +### Documentation + +- `README.md` - User documentation and feature list +- `SETUP.md` - Step-by-step setup guide with examples +- `TESTING.md` - Testing procedures and test scenarios +- `ARCHITECTURE.md` - Technical architecture and code organization + +### Browser Support + +- Chrome/Edge 90+ +- Firefox 88+ +- Safari 15+ +- Mobile browsers + +### Known Issues + +None + +--- + +## Planned for Future Releases + +### 0.2.0 + +- [ ] Full-text search across file names +- [ ] Pagination for large folders +- [ ] Cached file listings +- [ ] Folder creation from UI +- [ ] Batch delete operations + +### 0.3.0 + +- [ ] Image transformation options (resize, crop, etc via Bunny CDN) +- [ ] File metadata editing +- [ ] Tagging system +- [ ] Recent files section + +### 1.0.0 + +- [ ] Stabilized API +- [ ] Full feature parity with Cloudinary (where applicable) +- [ ] Community feedback integration + +--- + +**Contributors**: Initial implementation by Decap CMS team + +**License**: MIT diff --git a/packages/decap-cms-media-library-bunny/QUICKSTART.md b/packages/decap-cms-media-library-bunny/QUICKSTART.md new file mode 100644 index 000000000000..bcb3f3e5121c --- /dev/null +++ b/packages/decap-cms-media-library-bunny/QUICKSTART.md @@ -0,0 +1,320 @@ +# Quick Start Example + +This is a complete working example of how to use the Bunny.net media library with Decap CMS. + +## Step 1: Install Dependencies + +```bash +npm install decap-cms-app decap-cms-media-library-bunny decap-cms-backend-test +``` + +## Step 2: Create Admin Page + +Create `public/admin/index.html`: + +```html + + + + + + + Content Manager + + + + + + +``` + +## Step 3: Create Admin Config + +Create `public/admin/config.js`: + +```javascript +import DecapCMS from 'decap-cms-app'; +import BunnyMediaLibrary from 'decap-cms-media-library-bunny'; + +// Register the media library +DecapCMS.registerMediaLibrary(BunnyMediaLibrary); + +// Configure Decap CMS +const config = { + backend: { + name: 'test', // Using test backend for this example + }, + + // Media library configuration + media_library: { + name: 'bunny', + config: { + storage_zone_name: process.env.REACT_APP_BUNNY_STORAGE_ZONE, + api_key: process.env.REACT_APP_BUNNY_API_KEY, + cdn_url_prefix: process.env.REACT_APP_BUNNY_CDN_URL, + root_path: '/cms-media/', + }, + }, + + // Content collections + collections: [ + { + name: 'blog', + label: 'Blog Posts', + folder: 'content/blog', + create: true, + slug: '{{slug}}', + fields: [ + { + name: 'title', + label: 'Title', + widget: 'string', + required: true, + }, + { + name: 'description', + label: 'Description', + widget: 'text', + required: false, + }, + { + name: 'featured_image', + label: 'Featured Image', + widget: 'image', + required: true, + }, + { + name: 'body', + label: 'Content', + widget: 'markdown', + }, + { + name: 'gallery', + label: 'Image Gallery', + widget: 'list', + required: false, + fields: [ + { + name: 'image', + label: 'Image', + widget: 'image', + }, + { + name: 'caption', + label: 'Caption', + widget: 'string', + }, + ], + }, + ], + }, + { + name: 'pages', + label: 'Pages', + folder: 'content/pages', + create: true, + slug: '{{slug}}', + fields: [ + { + name: 'title', + label: 'Title', + widget: 'string', + }, + { + name: 'hero_image', + label: 'Hero Image', + widget: 'image', + }, + { + name: 'body', + label: 'Page Content', + widget: 'markdown', + }, + ], + }, + ], +}; + +// Initialize CMS with config +DecapCMS.init({ config }); +``` + +## Step 4: Set Environment Variables + +Create `.env.local`: + +```env +REACT_APP_BUNNY_STORAGE_ZONE=my-storage-zone +REACT_APP_BUNNY_API_KEY=your-storage-zone-password +REACT_APP_BUNNY_CDN_URL=https://my-storage-zone.b-cdn.net +``` + +## Step 5: Update package.json + +Add this to your `package.json`: + +```json +{ + "scripts": { + "admin": "cp -r public/admin/* node_modules/decap-cms-app/dist/admin/", + "dev": "npm run admin && react-scripts start" + } +} +``` + +## Step 6: Create Bunny.net Folders + +Before testing, create a storage zone and folder structure in Bunny.net: + +``` +/storage-zone-root/ + ├── cms-media/ + │ ├── blog/ + │ │ ├── post-1-hero.jpg + │ │ └── gallery/ + │ │ ├── image-1.jpg + │ │ └── image-2.jpg + │ └── pages/ + │ └── homepage-hero.jpg +``` + +## Step 7: Start Your App + +```bash +npm run dev +``` + +Visit `http://localhost:3000/admin` to access the CMS. + +## Usage Walkthrough + +### Uploading an Image + +1. Navigate to **Blog Posts** +2. Click **New Blog Post** +3. Fill in the **Title** field +4. Click the **Featured Image** field +5. Media library opens +6. Either: + - Drag an image into the drop zone, or + - Click to select an image from your computer +7. Select the uploaded image +8. Click **Insert** +9. Image URL is inserted into the field + +### Using Image Gallery + +1. Scroll to the **Image Gallery** section +2. Click **Add item** +3. Click the **Image** field under the new item +4. Media library opens +5. Upload or select an image +6. Click **Insert** +7. Repeat for each image you want to add + +### Managing Files + +**Browse:** Use breadcrumbs to navigate folders + +**Delete:** Hover over a file and click the trash icon + +**Select Multiple:** Click multiple images to select several at once + +## Example Configuration with All Features + +Here's a more complete example with nested fields: + +```javascript +{ + name: 'projects', + label: 'Projects', + folder: 'content/projects', + create: true, + fields: [ + { + name: 'title', + label: 'Project Title', + widget: 'string', + }, + { + name: 'thumbnail', + label: 'Project Thumbnail', + widget: 'image', + hint: '500x300px recommended', + }, + { + name: 'content', + label: 'Content', + widget: 'object', + fields: [ + { + name: 'description', + label: 'Description', + widget: 'markdown', + }, + { + name: 'screenshots', + label: 'Screenshots', + widget: 'list', + fields: [ + { + name: 'image', + label: 'Screenshot', + widget: 'image', + }, + { + name: 'title', + label: 'Screenshot Title', + widget: 'string', + }, + ], + }, + ], + }, + ], +} +``` + +## Troubleshooting + +### Media Library Won't Open + +- Check browser console for errors +- Verify environment variables are set correctly +- Ensure Bunny.net credentials are valid + +### Images Not Loading + +- Verify CDN URL is accessible from your network +- Check that storage zone exists in Bunny.net +- Ensure uploaded files are in the correct location + +### Uploads Failing + +- Verify API key is the **Storage Zone Password**, not your Account API Key +- Check that the storage zone is actively running +- Ensure sufficient quota in your Bunny.net account + +### Performance Issues + +- Consider archiving old files to a separate storage zone +- Organize files into subdirectories +- Use Bunny CDN for global performance + +## Next Steps + +- Check [SETUP.md](./SETUP.md) for detailed setup instructions +- Read [TESTING.md](./TESTING.md) for testing procedures +- Review [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details +- Explore [Bunny.net documentation](https://docs.bunny.net) for more features + +## Support + +For issues or questions: +1. Check the troubleshooting guides above +2. Review Bunny.net docs at https://docs.bunny.net +3. Open an issue on [Decap CMS GitHub](https://github.com/decaporg/decap-cms/issues) + +--- + +**Happy content managing with Bunny.net!** 🎉 diff --git a/packages/decap-cms-media-library-bunny/README.md b/packages/decap-cms-media-library-bunny/README.md new file mode 100644 index 000000000000..063d0363619d --- /dev/null +++ b/packages/decap-cms-media-library-bunny/README.md @@ -0,0 +1,110 @@ +# Decap CMS Media Library - Bunny.net + +A media library integration for [Decap CMS](https://decapcms.org/) to use [Bunny.net](https://bunny.net/) Storage as your media library. + +## Features + +- Browse files and folders in your Bunny Storage zone +- Upload single or multiple files +- Delete files and folders +- Image preview for supported formats +- Directory navigation with breadcrumb trail +- Client-side image filtering with `imagesOnly` support + +## Installation + +Install the package as a dependency in your Decap CMS project: + +```bash +npm install decap-cms-media-library-bunny +# or +yarn add decap-cms-media-library-bunny +``` + +## Configuration + +### 1. Register the plugin in your CMS config file + +In your Decap CMS setup file (usually `admin/index.js` or `admin.ts`): + +```javascript +import DecapCMS from 'decap-cms-app'; +import BunnyMediaLibrary from 'decap-cms-media-library-bunny'; + +DecapCMS.registerMediaLibrary(BunnyMediaLibrary); +``` + +### 2. Add media library configuration to `config.yml` + +```yaml +media_library: + name: bunny + config: + storage_zone_name: your-storage-zone-name + api_key: your_api_key_here + cdn_url_prefix: https://your-storage-zone.b-cdn.net +``` + +### Configuration Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `storage_zone_name` | String | Yes | Your Bunny Storage zone name | +| `api_key` | String | Yes | Your Bunny API key (storage zone password) | +| `cdn_url_prefix` | String | Yes | The CDN URL prefix for your storage zone | +| `root_path` | String | No | Default root path within the storage zone (default: `/`) | + +## Usage + +Once configured, the Decap CMS media library will display a file browser for your Bunny Storage zone. You can: + +- Click on folders to navigate +- Upload files via drag-and-drop or file picker +- Select files to insert into your content +- Delete files using the delete button +- Use breadcrumbs to navigate back to parent folders + +### In your collection configuration + +Media files will use URLs from your Bunny Storage zone: + +```yaml +collections: + - name: blog + label: Blog + folder: content/blog + create: true + fields: + - name: featured_image + label: Featured Image + widget: image +``` + +## Security + +**Important**: Never commit your API key to version control. Consider: + +- Using environment variables in your build process +- Using Decap CMS's [manual widget override](https://decapcms.org/docs/beta-features/#manual-initialization) for local config +- Using a backend proxy service to handle authentication + +## Limitations (MVP Version) + +- **OAuth subdirectory limitation**: Authentication currently only works for CMS deployed at domain root. For subdirectory deployments (e.g., `example.com/admin/`), users must manually navigate to the subdirectory after authentication. This is a Bunny.net OAuth limitation that has been reported to their team. +- No search functionality (coming in future versions) +- No pagination (suitable for small-to-medium numbers of files) +- Client-side only image filtering (no server-side optimization) +- No image transformations (configure those via Bunny CDN) + +## Future Enhancements + +- Full-text search across file names +- Pagination for large folders +- Image transformation options +- Batch operations (delete multiple files) +- Folder creation from UI +- Integration with Bunny CDN features + +## License + +MIT diff --git a/packages/decap-cms-media-library-bunny/SETUP.md b/packages/decap-cms-media-library-bunny/SETUP.md new file mode 100644 index 000000000000..5120cb8cfda7 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/SETUP.md @@ -0,0 +1,207 @@ +# Bunny.net Media Library Setup Guide + +This guide will help you set up the Bunny.net media library integration with Decap CMS. + +## Prerequisites + +- Decap CMS v3.0.0 or later +- A Bunny.net account with at least one Storage Zone +- Node.js 14+ + +## Step-by-Step Setup + +### 1. Get Your Bunny.net Credentials + +1. Log in to your [Bunny.net account](https://bunny.net) +2. Navigate to **Storage** → **Storage Zones** +3. Either select an existing storage zone or create a new one +4. Note the following information: + - **Storage Zone Name** - The name of your storage zone + - **Storage Zone Password** - Your API key (found in Zone Settings) + - **CDN URL** - The HTTP pull zone URL (format: `https://zonename.b-cdn.net`) + +### 2. Install the Package + +```bash +npm install decap-cms-media-library-bunny +# or +yarn add decap-cms-media-library-bunny +``` + +### 3. Register the Plugin + +In your Decap CMS admin configuration file (usually `admin/index.js` or `admin.ts`): + +```javascript +import DecapCMS from 'decap-cms-app'; +import BunnyMediaLibrary from 'decap-cms-media-library-bunny'; + +// Register the media library +DecapCMS.registerMediaLibrary(BunnyMediaLibrary); + +// Initialize Decap CMS +DecapCMS.init(); +``` + +### 4. Configure in config.yml + +Add the media library configuration to your Decap CMS `config.yml`: + +```yaml +media_library: + name: bunny + config: + storage_zone_name: your-storage-zone-name + api_key: your_storage_zone_password + cdn_url_prefix: https://your-storage-zone.b-cdn.net + root_path: / # Optional: default root folder for uploads +``` + +### 5. (Optional) Environment Variables + +For security, use environment variables instead of hardcoding credentials: + +**In your build process:** + +```javascript +media_library: + name: bunny + config: + storage_zone_name: ${BUNNY_STORAGE_ZONE} + api_key: ${BUNNY_API_KEY} + cdn_url_prefix: ${BUNNY_CDN_URL} +``` + +**Set environment variables in your CI/CD:** + +```bash +export BUNNY_STORAGE_ZONE="your-zone-name" +export BUNNY_API_KEY="your-api-key" +export BUNNY_CDN_URL="https://your-zone.b-cdn.net" +``` + +## Usage + +Once configured, the media library will be available in your Decap CMS editor: + +### In Collection Fields + +```yaml +collections: + - name: blog + label: Blog + folder: content/blog + fields: + - name: featured_image + label: Featured Image + widget: image + + - name: gallery + label: Image Gallery + widget: list + fields: + - name: image + label: Image + widget: image +``` + +### Managing Files + +**Navigate:** Click breadcrumbs or use the Back button to navigate folders + +**Upload:** Drag files into the drop zone or click to select + +**Delete:** Hover over a file and click the 🗑️ button + +**Select:** Click files to select (single) or check multiple files (if supported) + +**Insert:** Click the "Insert" button to add selected files to your field + +## Advanced Configuration + +### Setting a Default Upload Directory + +```yaml +media_library: + name: bunny + config: + storage_zone_name: my-zone + api_key: my-api-key + cdn_url_prefix: https://my-zone.b-cdn.net + root_path: /blog/images/ # All uploads go here +``` + +### Image Filtering + +The media library automatically filters to show only images when used with the `image` widget: + +- Supported formats: jpg, jpeg, png, gif, webp, svg, ico, bmp +- When `image` widget is used, only images are shown +- When used with generic properties, all files are shown + +## Troubleshooting + +### "API Key Invalid" Error + +- Verify your `storage_zone_name` matches exactly in Bunny.net +- Check your `api_key` is the **Storage Zone Password**, not the API Key +- Ensure credentials are correctly set in environment variables + +### "Failed to Load Files" Error + +- Verify network connectivity to Bunny.net +- Check that your storage zone exists and is active +- Ensure CORS is properly configured (Bunny.net allows cross-origin requests with proper headers) + +### Slow File Loading + +- This is normal for storage zones with many files +- Consider limiting number of files in a folder +- Or create subdirectories to organize content + +### Images Not Displaying in Preview + +- Verify your CDN URL is accessible from your network +- Check that the CDN pull zone is properly configured +- Ensure files are uploaded to the correct storage zone + +## Security Best Practices + +1. **Never commit credentials** to version control +2. **Use environment variables** for sensitive information +3. **Restrict storage zone access** in Bunny.net account settings +4. **Use a dedicated storage zone** for CMS media +5. **Enable CDN caching** for better performance + +## Performance Tips + +- Keep folder structures organized (splits load) +- Use descriptive file names (aids in searching/sorting) +- Archive old files to separate storage zones +- Enable Bunny CDN for fast global access + +## Limitations (MVP) + +- No full-text search (filename sorting only) +- No pagination (loads all files at once) +- No image transformations (use Bunny CDN for that) +- No folder creation from UI +- No batch operations + +## Next Steps + +- See [README.md](./README.md) for feature list and limitations +- Check [Bunny.net API Docs](https://docs.bunny.net/reference/storage-api) for API details +- Report issues on [GitHub Issues](https://github.com/decaporg/decap-cms/issues) + +## Support + +For issues or questions: + +1. Check this troubleshooting guide +2. Review Bunny.net documentation at https://docs.bunny.net +3. Open an issue on the Decap CMS GitHub repository + +--- + +**Enjoy managing your media library with Bunny.net!** diff --git a/packages/decap-cms-media-library-bunny/TESTING.md b/packages/decap-cms-media-library-bunny/TESTING.md new file mode 100644 index 000000000000..49019fc61817 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/TESTING.md @@ -0,0 +1,255 @@ +# Integration Testing Guide + +This guide explains how to test the Bunny.net media library integration with Decap CMS. + +## Quick Start Testing + +### 1. Use the Dev-Test Config + +The repo includes a `dev-test` folder with a sample config. You can use it to test the integration: + +```bash +# From root of decap-cms repo +cd dev-test +# Update config.yml with your Bunny.net credentials +``` + +### 2. Create a Test Config + +Create `dev-test/config.yml`: + +```yaml +backend: + name: test + +media_library: + name: bunny + config: + storage_zone_name: test-zone + api_key: your-storage-password + cdn_url_prefix: https://test-zone.b-cdn.net + +collections: + - name: pages + label: Pages + folder: content + create: true + fields: + - name: title + label: Title + widget: string + + - name: hero_image + label: Hero Image + widget: image + + - name: gallery + label: Gallery + widget: list + fields: + - name: image + label: Image + widget: image + - name: caption + label: Caption + widget: string +``` + +### 3. Test Integration + +#### Via npm workspace + +```bash +# From repository root +npm run develop -w packages/decap-cms-media-library-bunny +``` + +This will start the package in watch mode. Then in another terminal: + +```bash +npm run start +``` + +#### Via manual testing + +1. Build the package: +```bash +npm run build -w packages/decap-cms-media-library-bunny +``` + +2. Create a test HTML file: + +```html + + + + Bunny.net Media Library Test + + + + + +
+ + + + + + +``` + +## Test Scenarios + +### Scenario 1: File Browsing + +**Steps:** +1. Click the image field's media library button +2. Verify files load from your Bunny.net storage zone +3. Navigate folders using breadcrumbs +4. Use Back button to navigate up + +**Expected:** +- ✅ Files display in grid +- ✅ File icons show for images +- ✅ File metadata (size, date) displays +- ✅ Breadcrumb navigation works +- ✅ Back button works + +### Scenario 2: Single File Selection + +**Steps:** +1. Open media library on image field +2. Click an image file (not double-click) +3. Verify checkbox appears and file is selected +4. Click "Insert" button + +**Expected:** +- ✅ File URL inserted into field +- ✅ Image preview appears in Decap editor +- ✅ Modal closes automatically +- ✅ URL format: `https://your-zone.b-cdn.net/path/file.jpg` + +### Scenario 3: Multiple File Selection + +**Steps:** +1. Open media library on a list/array field +2. Select multiple images by clicking them +3. Note the counter in the "Insert" button +4. Click "Insert" + +**Expected:** +- ✅ Multiple URLs inserted as array +- ✅ All selected files added to the list field +- ✅ Insert button shows count + +### Scenario 4: File Upload + +**Steps:** +1. Open media library +2. Drag a file into the drop zone (or click to select) +3. Wait for upload to complete +4. Verify file appears in the grid +5. Verify URL is correct + +**Expected:** +- ✅ Progress bar shows upload progress +- ✅ File appears in grid after upload +- ✅ File is accessible via CDN URL +- ✅ Auto-insert on single file upload + +### Scenario 5: File Deletion + +**Steps:** +1. Open media library +2. Hover over a file +3. Click the 🗑️ delete button +4. Confirm deletion +5. Verify file is removed from grid + +**Expected:** +- ✅ Confirmation dialog appears +- ✅ File deleted from Bunny.net +- ✅ File removed from grid +- ✅ No error messages + +### Scenario 6: Image Filtering + +**Steps:** +1. Create a mixed folder with images and documents +2. Open media library with image widget +3. Verify only images display +4. Open media library with generic field +5. Verify all files display + +**Expected:** +- ✅ Image widget shows only .jpg, .png, .gif, .webp, .svg, .ico, .bmp files +- ✅ Generic field shows all files +- ✅ Folders always visible + +### Scenario 7: Error Handling + +**Steps:** +1. Set invalid API key in config +2. Try to open media library +3.Verify error message displays +4. Fix credentials +5. Retry and verify it works + +**Expected:** +- ✅ Clear error messages +- ✅ No crashes +- ✅ Can try again after fixing + +## Browser Compatibility Testing + +Test in these browsers: +- Chrome/Edge (latest) +- Firefox (latest) +- Safari (latest) +- Mobile browsers + + +## Performance Testing + +### Large Folder Test + +1. Create a storage zone with 1000+ files +2. Open media library +3. Measure load time and responsiveness +4. Scroll through files + +**Expected:** +- ✅ Loads within reasonable time (<5s) +- ✅ Scrolling is smooth +- ✅ No memory issues + +### Upload Performance Test + +1. Upload a large file (>100MB) +2. Monitor progress bar +3. Verify completion and insertion + +**Expected:** +- ✅ Progress bar updates smoothly +- ✅ Upload completes successfully +- ✅ File is available in CDN + +## Reporting Issues + +If you encounter issues, include: + +1. **Browser & OS:** Which browser/OS you're testing on +2. **Steps to reproduce:** Exact steps that cause the issue +3. **Expected vs actual:** What should happen vs what happens +4. **Screenshot/video:** If applicable +5. **Console errors:** Any JavaScript errors in browser console +6. **Config:** Sanitized config.yml (redact credentials) + +File issues at: https://github.com/decaporg/decap-cms/issues + +--- + +**Thank you for testing!** diff --git a/packages/decap-cms-media-library-bunny/package.json b/packages/decap-cms-media-library-bunny/package.json new file mode 100644 index 000000000000..2cb643a64911 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/package.json @@ -0,0 +1,39 @@ +{ + "name": "decap-cms-media-library-bunny", + "description": "Bunny.net integration for Decap CMS", + "version": "0.1.0", + "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-media-library-bunny", + "bugs": "https://github.com/decaporg/decap-cms/issues", + "module": "dist/esm/index.js", + "main": "dist/decap-cms-media-library-bunny.js", + "license": "MIT", + "keywords": [ + "decap-cms", + "bunny", + "bunny.net", + "image", + "images", + "media", + "assets", + "files", + "uploads", + "storage" + ], + "sideEffects": false, + "scripts": { + "develop": "npm run build:esm -- --watch", + "build": "cross-env NODE_ENV=production webpack", + "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --extensions \".js,.jsx,.ts,.tsx\" --ignore \"**/__tests__\" --root-mode upward", + "test": "jest" + }, + "peerDependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "decap-cms-lib-util": "^3.0.0" + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "decap-cms-ui-default": "^3.0.0" + } +} diff --git a/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts b/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts new file mode 100644 index 000000000000..f421c3f2b8f8 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts @@ -0,0 +1,84 @@ +import { BunnyAuthManager } from '../api/authManager'; + +describe('BunnyAuthManager', () => { + beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, '', '/'); + jest.restoreAllMocks(); + }); + + it('stores and retrieves storage credentials', () => { + BunnyAuthManager.setStoredApiKey('storage-password'); + BunnyAuthManager.setStoredStorageZoneName('my-zone'); + + expect(BunnyAuthManager.getStoredApiKey()).toBe('storage-password'); + expect(BunnyAuthManager.getStoredStorageZoneName()).toBe('my-zone'); + expect(BunnyAuthManager.isAuthenticated()).toBe(true); + }); + + it('clears all stored credentials', () => { + BunnyAuthManager.setStoredApiKey('storage-password'); + BunnyAuthManager.setStoredAccountApiKey('account-key'); + BunnyAuthManager.setStoredStorageZoneName('my-zone'); + + BunnyAuthManager.clearStoredApiKey(); + + expect(BunnyAuthManager.getStoredApiKey()).toBeNull(); + expect(BunnyAuthManager.getStoredAccountApiKey()).toBeNull(); + expect(BunnyAuthManager.getStoredStorageZoneName()).toBeNull(); + }); + + it('extracts credentials from search params', () => { + window.history.replaceState({}, '', '/admin/?accessKey=test-key&storageName=test-zone'); + + expect(BunnyAuthManager.extractCredentialsFromUrl()).toEqual({ + apiKey: 'test-key', + storageName: 'test-zone', + }); + }); + + it('extracts credentials from hash query params', () => { + window.history.replaceState( + {}, + '', + '/admin/#/collections/posts/new?api_key=hash-key&storage_zone_name=hash-zone', + ); + + expect(BunnyAuthManager.extractCredentialsFromUrl()).toEqual({ + apiKey: 'hash-key', + storageName: 'hash-zone', + }); + }); + + it('sanitizes auth params while preserving route and other params', () => { + const sanitized = BunnyAuthManager.sanitizeReturnUrl( + 'http://localhost:8080/admin/?foo=1&accessKey=secret#/collections/posts/new?storageName=zone&bar=2', + ); + + expect(sanitized).toBe('/admin/?foo=1#/collections/posts/new?bar=2'); + }); + + it('cleans auth params from current URL', () => { + const replaceSpy = jest.spyOn(window.history, 'replaceState'); + window.history.replaceState( + {}, + '', + '/admin/?apiKey=secret&keep=1#/collections/posts/new?storage_name=zone&ok=2', + ); + + BunnyAuthManager.cleanAuthParamsFromUrl(); + + expect(replaceSpy).toHaveBeenCalledWith({}, '', '/admin/?keep=1#/collections/posts/new?ok=2'); + }); + + it('saves and resolves sanitized return URL', () => { + BunnyAuthManager.saveReturnUrl( + 'http://localhost:8080/admin/?x=1&token=secret#/collections/posts/new?storageZoneName=zone&y=2', + ); + + expect(BunnyAuthManager.resolveReturnUrl()).toBe('/admin/?x=1#/collections/posts/new?y=2'); + + BunnyAuthManager.clearReturnUrl(); + expect(BunnyAuthManager.resolveReturnUrl()).toBeNull(); + }); +}); diff --git a/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts b/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts new file mode 100644 index 000000000000..671da4ba30c4 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for Bunny.net API Client + */ + +import { BunnyClient } from '../api/client'; + +// Mock fetch +global.fetch = jest.fn(); + +describe('BunnyClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with correct parameters', () => { + const client = new BunnyClient({ + storageZoneName: 'test-zone', + apiKey: 'test-key', + region: 'us', + }); + + expect(client).toBeTruthy(); + }); + + it('should list files successfully', async () => { + const mockResponse = [ + { + Guid: '123', + StorageZoneName: 'test-zone', + Path: '/', + ObjectName: 'file.jpg', + Length: 1024, + LastChanged: '2024-01-01T00:00:00Z', + IsDirectory: false, + DateCreated: '2024-01-01T00:00:00Z', + StorageZoneId: 1, + }, + ]; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + headers: new Map([['content-type', 'application/json']]), + json: async () => mockResponse, + }); + + const client = new BunnyClient({ + storageZoneName: 'test-zone', + apiKey: 'test-key', + }); + + const files = await client.listFiles('/'); + + expect(files).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('test-zone'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + AccessKey: 'test-key', + }), + }), + ); + }); + + it('should handle API errors', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: { + entries: () => [], + }, + text: async () => 'Unauthorized', + }); + + const client = new BunnyClient({ + storageZoneName: 'test-zone', + apiKey: 'invalid-key', + }); + + await expect(client.listFiles('/')).rejects.toThrow('Bunny.net API error: 401'); + }); + + it('should generate public URL correctly', () => { + const client = new BunnyClient({ + storageZoneName: 'test-zone', + apiKey: 'test-key', + }); + + const url = client.generatePublicUrl('https://cdn.example.com', '/folder/file.jpg'); + + expect(url).toBe('https://cdn.example.com/folder/file.jpg'); + }); + + it('should handle URL generation with trailing slash', () => { + const client = new BunnyClient({ + storageZoneName: 'test-zone', + apiKey: 'test-key', + }); + + const url = client.generatePublicUrl('https://cdn.example.com/', '/folder/file.jpg'); + + expect(url).toBe('https://cdn.example.com/folder/file.jpg'); + }); +}); diff --git a/packages/decap-cms-media-library-bunny/src/__tests__/fileManager.test.ts b/packages/decap-cms-media-library-bunny/src/__tests__/fileManager.test.ts new file mode 100644 index 000000000000..3a0196f26dcf --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/fileManager.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for Bunny.net File Manager + */ + +import { BunnyFileManager } from '../api/fileManager'; + +// Mock the BunnyClient +jest.mock('../api/client', () => { + return { + BunnyClient: jest.fn().mockImplementation(() => ({ + listFiles: jest.fn(), + generatePublicUrl: jest.fn((prefix, path) => `${prefix}${path}`), + })), + }; +}); + +describe('BunnyFileManager', () => { + const mockConfig = { + storageZoneName: 'test-zone', + apiKey: 'test-key', + cdnUrlPrefix: 'https://cdn.example.com', + }; + + it('should initialize with correct parameters', () => { + const manager = new BunnyFileManager(mockConfig); + expect(manager).toBeTruthy(); + }); + + it('should filter image files correctly', () => { + const manager = new BunnyFileManager(mockConfig); + + const files = [ + { + Guid: '1', + StorageZoneName: 'test-zone', + Path: '/', + ObjectName: 'image.jpg', + Length: 1024, + LastChanged: '2024-01-01T00:00:00Z', + IsDirectory: false, + DateCreated: '2024-01-01T00:00:00Z', + StorageZoneId: 1, + }, + { + Guid: '2', + StorageZoneName: 'test-zone', + Path: '/', + ObjectName: 'document.pdf', + Length: 2048, + LastChanged: '2024-01-01T00:00:00Z', + IsDirectory: false, + DateCreated: '2024-01-01T00:00:00Z', + StorageZoneId: 1, + }, + { + Guid: '3', + StorageZoneName: 'test-zone', + Path: '/', + ObjectName: 'video.png', + Length: 512, + LastChanged: '2024-01-01T00:00:00Z', + IsDirectory: false, + DateCreated: '2024-01-01T00:00:00Z', + StorageZoneId: 1, + }, + ]; + + const filtered = manager.filterImageFiles(files); + + expect(filtered).toHaveLength(2); + expect(filtered[0].ObjectName).toBe('image.jpg'); + expect(filtered[1].ObjectName).toBe('video.png'); + }); + + it('should normalize paths correctly', () => { + const manager = new BunnyFileManager(mockConfig); + + expect(manager.normalizePath('/')).toBe('/'); + expect(manager.normalizePath('folder')).toBe('/folder/'); + expect(manager.normalizePath('/folder')).toBe('/folder/'); + expect(manager.normalizePath('/folder/')).toBe('/folder/'); + expect(manager.normalizePath('')).toBe('/'); + }); + + it('should get parent path correctly', () => { + const manager = new BunnyFileManager(mockConfig); + + expect(manager.getParentPath('/')).toBe('/'); + expect(manager.getParentPath('/folder/')).toBe('/'); + expect(manager.getParentPath('/folder/subfolder/')).toBe('/folder/'); + }); +}); diff --git a/packages/decap-cms-media-library-bunny/src/__tests__/managementApi.test.ts b/packages/decap-cms-media-library-bunny/src/__tests__/managementApi.test.ts new file mode 100644 index 000000000000..0e8a962da602 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/managementApi.test.ts @@ -0,0 +1,84 @@ +import { BunnyManagementApi } from '../api/managementApi'; + +describe('BunnyManagementApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn() as unknown as typeof fetch; + }); + + it('fetches storage zone password by zone name', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => [ + { Id: 1, Name: 'Zone-A', Password: 'pass-a', Region: 'DE' }, + { Id: 2, Name: 'My-Zone', Password: 'zone-password', Region: 'UK' }, + ], + }); + + const password = await BunnyManagementApi.fetchStorageZonePassword('account-key', 'my-zone'); + + expect(password).toBe('zone-password'); + expect(global.fetch).toHaveBeenCalledWith('https://api.bunny.net/storagezone', { + method: 'GET', + headers: { + AccessKey: 'account-key', + 'Content-Type': 'application/json', + }, + }); + }); + + it('throws a helpful error when storage zone is not found', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => [{ Id: 1, Name: 'Other-Zone', Password: 'pass-a', Region: 'DE' }], + }); + + await expect( + BunnyManagementApi.fetchStorageZonePassword('account-key', 'missing-zone'), + ).rejects.toThrow('Storage zone "missing-zone" not found. Available zones: Other-Zone'); + }); + + it('throws API error when listing zones fails', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect(BunnyManagementApi.fetchStorageZonePassword('bad-key', 'zone')).rejects.toThrow( + 'Failed to fetch storage zones: 401 - Unauthorized', + ); + }); + + it('fetches a storage zone by id', async () => { + const zone = { Id: 7, Name: 'Zone-7', Password: 'zone-7-pass', Region: 'DE' }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => zone, + }); + + const result = await BunnyManagementApi.fetchStorageZoneById('account-key', 7); + + expect(result).toEqual(zone); + expect(global.fetch).toHaveBeenCalledWith('https://api.bunny.net/storagezone/7', { + method: 'GET', + headers: { + AccessKey: 'account-key', + 'Content-Type': 'application/json', + }, + }); + }); + + it('throws API error when fetching zone by id fails', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not found', + }); + + await expect(BunnyManagementApi.fetchStorageZoneById('account-key', 42)).rejects.toThrow( + 'Failed to fetch storage zone: 404 - Not found', + ); + }); +}); diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts new file mode 100644 index 000000000000..dabf37c8899f --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -0,0 +1,245 @@ +/** + * Authentication Manager for Bunny.net + * Handles OAuth-style authentication flow and credential storage + */ + +const STORAGE_API_KEY = 'bunny_auth_key'; +const ACCOUNT_API_KEY = 'bunny_account_api_key'; +const STORAGE_ZONE_NAME_KEY = 'bunny_storage_zone_name'; +const RETURN_URL_KEY = 'bunny_return_url'; + +// Helper functions for safe localStorage access +function safeGetItem(key: string, errorMsg: string): string | null { + try { + return localStorage.getItem(key); + } catch (e) { + console.error(errorMsg, e); + return null; + } +} + +function safeSetItem(key: string, value: string, errorMsg: string): void { + try { + localStorage.setItem(key, value); + } catch (e) { + console.error(errorMsg, e); + } +} + +function safeRemoveItem(key: string, errorMsg: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + console.error(errorMsg, e); + } +} + +export class BunnyAuthManager { + static getStoredApiKey(): string | null { + return safeGetItem(STORAGE_API_KEY, 'Failed to retrieve stored API key:'); + } + + static setStoredApiKey(apiKey: string): void { + safeSetItem(STORAGE_API_KEY, apiKey, '[Bunny Auth] Failed to store Storage Zone Password:'); + } + + static getStoredAccountApiKey(): string | null { + return safeGetItem(ACCOUNT_API_KEY, '[Bunny Auth] Failed to retrieve Account API Key:'); + } + + static setStoredAccountApiKey(apiKey: string): void { + safeSetItem(ACCOUNT_API_KEY, apiKey, '[Bunny Auth] Failed to store Account API Key:'); + } + + static getStoredStorageZoneName(): string | null { + return safeGetItem(STORAGE_ZONE_NAME_KEY, 'Failed to retrieve storage zone name:'); + } + + static setStoredStorageZoneName(zoneName: string): void { + safeSetItem(STORAGE_ZONE_NAME_KEY, zoneName, 'Failed to store storage zone name:'); + } + + static clearStoredApiKey(): void { + try { + localStorage.removeItem(STORAGE_API_KEY); + localStorage.removeItem(ACCOUNT_API_KEY); + localStorage.removeItem(STORAGE_ZONE_NAME_KEY); + } catch (e) { + console.error('Failed to clear stored credentials:', e); + } + } + + static saveReturnUrl(url: string = window.location.href): void { + const sanitizedUrl = this.sanitizeReturnUrl(url); + safeSetItem(RETURN_URL_KEY, sanitizedUrl, '[Bunny Auth] Failed to save return URL:'); + } + + static getReturnUrl(): string | null { + return safeGetItem(RETURN_URL_KEY, '[Bunny Auth] Failed to retrieve return URL:'); + } + + static clearReturnUrl(): void { + safeRemoveItem(RETURN_URL_KEY, '[Bunny Auth] Failed to clear return URL:'); + } + + // Auth parameter names to check in URLs + private static AUTH_PARAM_NAMES = [ + 'accessKey', + 'apiKey', + 'api_key', + 'password', + 'token', + 'storageName', + 'storage_name', + 'storageZoneName', + 'storage_zone_name', + 'zoneName', + 'zone_name', + ]; + + // Helper to parse URL parameters from search and hash + private static parseUrlParams(url: URL = new URL(window.location.href)) { + const searchParams = new URLSearchParams(url.search); + const hashContent = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash; + const hashQueryIndex = hashContent.indexOf('?'); + const hashRoute = hashQueryIndex >= 0 ? hashContent.slice(0, hashQueryIndex) : hashContent; + const hashQueryParams = + hashQueryIndex >= 0 + ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) + : hashContent.includes('=') + ? new URLSearchParams(hashContent) + : new URLSearchParams(); + + return { searchParams, hashRoute, hashQueryParams }; + } + + // Helper to get param value from multiple sources + private static getParamValue( + paramNames: string[], + searchParams: URLSearchParams, + hashQueryParams: URLSearchParams, + ): string | null { + for (const name of paramNames) { + const value = searchParams.get(name) || hashQueryParams.get(name); + if (value) return value; + } + return null; + } + + // Helper to remove auth parameters from URLSearchParams + private static removeAuthParams(params: URLSearchParams): boolean { + let removed = false; + this.AUTH_PARAM_NAMES.forEach(param => { + if (params.has(param)) { + params.delete(param); + removed = true; + } + }); + return removed; + } + + // Helper to rebuild URL from components + private static rebuildUrl( + pathname: string, + searchParams: URLSearchParams, + hashRoute: string, + hashQueryParams: URLSearchParams, + ): string { + const searchQuery = searchParams.toString(); + const hashQuery = hashQueryParams.toString(); + const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; + const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; + return `${pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; + } + + /** + * Generate the Bunny authentication URL + */ + static generateAuthUrl(): string { + const currentDomain = window.location.origin; + const callbackUrl = currentDomain + window.location.pathname; + const authUrl = 'https://dash.bunny.net/auth/login'; + const params = new URLSearchParams({ + source: 'decap', + domain: currentDomain, + callbackUrl, + }); + return `${authUrl}?${params.toString()}`; + } + + static resolveReturnUrl(): string | null { + return this.getReturnUrl(); + } + + static redirectToAuth(): void { + this.saveReturnUrl(); + window.location.href = this.generateAuthUrl(); + } + + /** + * Extract Account API key and storage zone name from URL parameters + */ + static extractCredentialsFromUrl(): { apiKey: string | null; storageName: string | null } { + const { searchParams, hashQueryParams } = this.parseUrlParams(); + + const apiKeyNames = ['accessKey', 'apiKey', 'api_key', 'password', 'token']; + const storageNames = [ + 'storageName', + 'storage_name', + 'storageZoneName', + 'storage_zone_name', + 'zoneName', + 'zone_name', + ]; + + return { + apiKey: this.getParamValue(apiKeyNames, searchParams, hashQueryParams), + storageName: this.getParamValue(storageNames, searchParams, hashQueryParams), + }; + } + + /** + * Clean URL by removing auth parameters + */ + static cleanAuthParamsFromUrl(): void { + const { searchParams, hashRoute, hashQueryParams } = this.parseUrlParams(); + + const searchRemoved = this.removeAuthParams(searchParams); + const hashRemoved = this.removeAuthParams(hashQueryParams); + + if (searchRemoved || hashRemoved) { + const newUrl = this.rebuildUrl( + window.location.pathname, + searchParams, + hashRoute, + hashQueryParams, + ); + window.history.replaceState({}, '', newUrl); + } + } + + /** + * Remove auth parameters from an arbitrary URL while preserving hash routes + */ + static sanitizeReturnUrl(url: string): string { + try { + const parsedUrl = new URL(url, window.location.origin); + const { searchParams, hashRoute, hashQueryParams } = this.parseUrlParams(parsedUrl); + + this.removeAuthParams(searchParams); + this.removeAuthParams(hashQueryParams); + + return this.rebuildUrl(parsedUrl.pathname, searchParams, hashRoute, hashQueryParams); + } catch (e) { + console.warn('[Bunny Auth] Failed to sanitize return URL, using raw value'); + return url; + } + } + + /** + * Check if fully authenticated + */ + static isAuthenticated(): boolean { + return !!(this.getStoredApiKey() && this.getStoredStorageZoneName()); + } +} diff --git a/packages/decap-cms-media-library-bunny/src/api/client.ts b/packages/decap-cms-media-library-bunny/src/api/client.ts new file mode 100644 index 000000000000..0c5578ff7939 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/client.ts @@ -0,0 +1,137 @@ +/** + * HTTP Client for Bunny.net Storage API + * Handles authentication and request/response formatting + */ + +import type { BunnyFile } from '../types'; + +const BUNNY_STORAGE_ENDPOINTS = { + us: 'https://storage.bunnycdn.com', + eu: 'https://storage.eu.bunnycdn.com', + asia: 'https://storage.asia.bunnycdn.com', + sydney: 'https://storage.sg.bunnycdn.com', +}; + +export type BunnyRegion = keyof typeof BUNNY_STORAGE_ENDPOINTS; + +interface BunnyClientOptions { + storageZoneName: string; + apiKey?: string; + region?: BunnyRegion; +} + +export class BunnyClient { + private storageZoneName: string; + private apiKey: string | null; + private baseUrl: string; + + constructor({ storageZoneName, apiKey, region = 'us' }: BunnyClientOptions) { + this.storageZoneName = storageZoneName; + this.apiKey = apiKey || null; + this.baseUrl = BUNNY_STORAGE_ENDPOINTS[region]; + } + + /** + * Update the API key at runtime + */ + updateApiKey(apiKey: string): void { + this.apiKey = apiKey; + } + + /** + * Check if client is authenticated + */ + isAuthenticated(): boolean { + return !!this.apiKey; + } + + private getHeaders(): HeadersInit { + if (!this.apiKey) { + console.error('[Bunny Client] getHeaders called but API key is not set!'); + throw new Error('API key not set. Please authenticate first.'); + } + return { + AccessKey: this.apiKey, + 'Content-Type': 'application/json', + }; + } + + private buildUrl(path: string): string { + // Normalize path + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + // URL format: https://storage.bunnycdn.com/{storageZoneName}{path} + const url = `${this.baseUrl}/${this.storageZoneName}${normalizedPath}`; + return url; + } + + private async handleResponse(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.text(); + console.error('[Bunny Client] API Error Response:', { + status: response.status, + statusText: response.statusText, + body: errorBody, + headers: Object.fromEntries(response.headers.entries()), + }); + throw new Error(`Bunny.net API error: ${response.status} - ${errorBody}`); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + + return response.text() as unknown as T; + } + + async listFiles(path = '/'): Promise { + const url = this.buildUrl(path); + const headers = this.getHeaders(); + const response = await fetch(url, { + method: 'GET', + headers, + }); + + const data = await this.handleResponse(response); + return Array.isArray(data) ? data : []; + } + + async uploadFile(filePath: string, file: Blob): Promise { + if (!this.apiKey) { + throw new Error('API key not set. Please authenticate first.'); + } + + const url = this.buildUrl(filePath); + const arrayBuffer = await file.arrayBuffer(); + + const response = await fetch(url, { + method: 'PUT', + headers: { + AccessKey: this.apiKey, + }, + body: arrayBuffer, + }); + + await this.handleResponse(response); + } + + async deleteFile(filePath: string): Promise { + const url = this.buildUrl(filePath); + const response = await fetch(url, { + method: 'DELETE', + headers: this.getHeaders(), + }); + + await this.handleResponse(response); + } + + /** + * Generates a public CDN URL for a file + */ + generatePublicUrl(cdnPrefix: string, filePath: string): string { + // Remove leading slash from filePath for URL construction + const cleanPath = filePath.startsWith('/') ? filePath.slice(1) : filePath; + const cleanPrefix = cdnPrefix.endsWith('/') ? cdnPrefix.slice(0, -1) : cdnPrefix; + return `${cleanPrefix}/${cleanPath}`; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/api/fileManager.ts b/packages/decap-cms-media-library-bunny/src/api/fileManager.ts new file mode 100644 index 000000000000..2f1d61cdd2e6 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/fileManager.ts @@ -0,0 +1,113 @@ +/** + * File Manager for Bunny.net + * Provides high-level operations for file management + */ + +import { BunnyClient } from './client'; + +import type { BunnyFile, AddressedMediaFile } from '../types'; + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp']; + +export interface FileManagerOptions { + storageZoneName: string; + apiKey: string; + cdnUrlPrefix: string; + region?: 'us' | 'eu' | 'asia' | 'sydney'; +} + +export class BunnyFileManager { + private client: BunnyClient; + private cdnUrlPrefix: string; + + constructor({ storageZoneName, apiKey, cdnUrlPrefix, region = 'us' }: FileManagerOptions) { + this.client = new BunnyClient({ storageZoneName, apiKey, region }); + this.cdnUrlPrefix = cdnUrlPrefix; + } + + /** + * List files and directories in a given path + */ + async listFiles(path = '/'): Promise { + try { + const files = await this.client.listFiles(path); + return files; + } catch (error) { + console.error('Error listing files:', error); + throw error; + } + } + + /** + * Filter files to only include images + */ + filterImageFiles(files: BunnyFile[]): BunnyFile[] { + return files.filter(file => { + if (file.IsDirectory) return false; + const ext = file.ObjectName.split('.').pop()?.toLowerCase(); + return ext && IMAGE_EXTENSIONS.includes(ext); + }); + } + + /** + * Get files with public URLs + */ + async getFilesWithUrls(path = '/', imagesOnly = false): Promise { + const files = await this.listFiles(path); + const filtered = imagesOnly ? this.filterImageFiles(files) : files; + + return filtered.map(file => ({ + ...file, + publicUrl: this.client.generatePublicUrl( + this.cdnUrlPrefix, + `${path === '/' ? '' : path}/${file.ObjectName}`.replace(/\/+/g, '/'), + ), + })); + } + + /** + * Upload a file to a specific path + */ + async uploadFile(filePath: string, file: Blob, fileName: string): Promise { + try { + const fullPath = `${filePath}/${fileName}`.replace(/\/+/g, '/'); + await this.client.uploadFile(fullPath, file); + return this.client.generatePublicUrl(this.cdnUrlPrefix, fullPath); + } catch (error) { + console.error('Error uploading file:', error); + throw error; + } + } + + /** + * Delete a file or directory + */ + async deleteFile(filePath: string): Promise { + try { + await this.client.deleteFile(filePath); + } catch (error) { + console.error('Error deleting file:', error); + throw error; + } + } + + /** + * Get parent directory path + */ + getParentPath(currentPath: string): string { + if (currentPath === '/') return '/'; + const parts = currentPath.split('/').filter(p => p); + parts.pop(); + return parts.length === 0 ? '/' : `/${parts.join('/')}/`; + } + + /** + * Normalize a path + */ + normalizePath(path: string): string { + if (!path || path === '') return '/'; + if (!path.startsWith('/')) path = '/' + path; + if (path !== '/' && !path.endsWith('/')) path = path + '/'; + return path.replace(/\/+/g, '/'); + } +} diff --git a/packages/decap-cms-media-library-bunny/src/api/managementApi.ts b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts new file mode 100644 index 000000000000..e974f8bfa847 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts @@ -0,0 +1,105 @@ +/** + * Bunny.net Management API Client + * Used to fetch storage zone details including passwords + */ + +const BUNNY_API_BASE = 'https://api.bunny.net'; + +interface StorageZone { + Id: number; + Name: string; + Password: string; + ReadOnlyPassword: string; + Region: string; + ReplicationZones: string[]; + // ... other fields +} + +export class BunnyManagementApi { + /** + * Fetch storage zone password using Account API Key + * @param accountApiKey - The account-level API key from OAuth + * @param storageZoneName - Name of the storage zone + * @returns Storage zone password for Storage API + */ + static async fetchStorageZonePassword( + accountApiKey: string, + storageZoneName: string, + ): Promise { + try { + // First, list all storage zones to find the one we need + const listUrl = `${BUNNY_API_BASE}/storagezone`; + + const response = await fetch(listUrl, { + method: 'GET', + headers: { + AccessKey: accountApiKey, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error('[Bunny Management API] Error response:', { + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + throw new Error(`Failed to fetch storage zones: ${response.status} - ${errorBody}`); + } + + const storageZones: StorageZone[] = await response.json(); + + // Find the storage zone by name + const targetZone = storageZones.find( + zone => zone.Name.toLowerCase() === storageZoneName.toLowerCase(), + ); + + if (!targetZone) { + console.error('[Bunny Management API] Storage zone not found:', storageZoneName); + console.error( + '[Bunny Management API] Available zones:', + storageZones.map(z => z.Name), + ); + throw new Error( + `Storage zone "${storageZoneName}" not found. Available zones: ${storageZones + .map(z => z.Name) + .join(', ')}`, + ); + } + + if (!targetZone.Password) { + throw new Error(`Storage zone "${storageZoneName}" has no password set`); + } + + return targetZone.Password; + } catch (error) { + console.error('[Bunny Management API] Failed to fetch storage zone password:', error); + throw error; + } + } + + /** + * Fetch storage zone details by ID + */ + static async fetchStorageZoneById( + accountApiKey: string, + storageZoneId: number, + ): Promise { + const url = `${BUNNY_API_BASE}/storagezone/${storageZoneId}`; + const response = await fetch(url, { + method: 'GET', + headers: { + AccessKey: accountApiKey, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Failed to fetch storage zone: ${response.status} - ${errorBody}`); + } + + return response.json(); + } +} diff --git a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.css b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.css new file mode 100644 index 000000000000..22f2dc4dd94f --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.css @@ -0,0 +1,171 @@ +/* Bunny.net Widget Main Stylesheet */ + +.widget { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; +} + +.backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: -1; +} + +.container { + position: relative; + width: 90%; + max-width: 1200px; + height: 90vh; + background: white; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Header */ +.header { + padding: 20px 24px; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; + background: #f9f9f9; +} + +.header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #333; +} + +.closeButton { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + transition: color 0.2s; +} + +.closeButton:hover { + color: #333; +} + +/* Error Message */ +.error { + padding: 12px 24px; + background-color: #fee; + color: #c33; + border-bottom: 1px solid #e0e0e0; + font-size: 14px; +} + +/* Main Content Area */ +.fileGridContainer { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + background: white; +} + +.loading, +.empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #999; + font-size: 16px; +} + +/* Footer */ +.footer { + padding: 16px 24px; + border-top: 1px solid #e0e0e0; + background: #f9f9f9; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* Buttons */ +.buttonPrimary, +.buttonSecondary { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.buttonPrimary { + background-color: #0066cc; + color: white; +} + +.buttonPrimary:hover:not(:disabled) { + background-color: #0052a3; +} + +.buttonPrimary:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +.buttonSecondary { + background-color: #e0e0e0; + color: #333; +} + +.buttonSecondary:hover { + background-color: #d0d0d0; +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + width: 98%; + height: 95vh; + } + + .header { + padding: 16px 16px; + } + + .header h2 { + font-size: 18px; + } + + .fileGridContainer { + padding: 16px; + } + + .footer { + padding: 12px 16px; + flex-wrap: wrap; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx new file mode 100644 index 000000000000..715212db1811 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx @@ -0,0 +1,336 @@ +/** + * Main Bunny.net Media Library Widget Component + * Provides file browser interface integrated with Decap CMS + */ + +import React, { useState, useEffect, useRef } from 'react'; + +import { BunnyFileManager } from '../api/fileManager'; +import { BunnyAuthManager } from '../api/authManager'; +import FileGrid from './FileGrid'; +import FileBrowser from './FileBrowser'; +import FileUpload from './FileUpload'; +import LoginPrompt from './LoginPrompt'; +import { + StyledWidget, + StyledBackdrop, + StyledContainer, + StyledHeader, + StyledHeaderTitle, + StyledCloseButton, + StyledError, + StyledFileGridContainer, + StyledLoading, + StyledEmpty, + StyledFooter, + StyledButtonPrimary, + StyledButtonSecondary, +} from './styles'; + +import type { AddressedMediaFile } from '../types'; + +interface BunnyWidgetProps { + config: { + storage_zone_name: string; + cdn_url_prefix: string; + root_path?: string; + }; + onInsert: (value: string | string[]) => void; + onClose: () => void; + allowMultiple?: boolean; + imagesOnly?: boolean; + value?: string | string[]; +} + +export function BunnyWidget({ + config, + onInsert, + onClose, + allowMultiple = false, + imagesOnly = false, +}: BunnyWidgetProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [storageZoneName, setStorageZoneName] = useState(null); + + const [currentPath, setCurrentPath] = useState(config.root_path || '/'); + const [files, setFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + + const fileManagerRef = useRef(null); + + // Check for authentication on mount (from localStorage or URL params after redirect) + useEffect(() => { + // Check if index.js is still processing OAuth callback + // If we have URL params, the index.js will handle them and redirect + const { apiKey: urlApiKey } = BunnyAuthManager.extractCredentialsFromUrl(); + if (urlApiKey) { + // Don't do anything, let index.js handle the OAuth flow + return; + } + + // Check for existing stored credentials (Storage Zone Password) + const storedKey = BunnyAuthManager.getStoredApiKey(); + const storedZoneName = BunnyAuthManager.getStoredStorageZoneName(); + if (storedKey && storedZoneName) { + setApiKey(storedKey); + setStorageZoneName(storedZoneName); + setIsAuthenticated(true); + } + }, []); + + // Initialize file manager when authenticated + useEffect(() => { + if (!isAuthenticated || !apiKey || !storageZoneName) { + fileManagerRef.current = null; + return; + } + + try { + fileManagerRef.current = new BunnyFileManager({ + storageZoneName, + apiKey, + cdnUrlPrefix: config.cdn_url_prefix, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[Bunny Widget] Failed to initialize file manager:', errorMsg); + setError(`Failed to initialize: ${errorMsg}`); + } + }, [isAuthenticated, apiKey, storageZoneName, config.cdn_url_prefix]); + + // Load files when path changes (only when authenticated) + useEffect(() => { + if (!isAuthenticated || !fileManagerRef.current) { + setIsLoading(false); + return; + } + + async function loadFiles() { + try { + setIsLoading(true); + setError(null); + const filesData = await fileManagerRef.current!.getFilesWithUrls(currentPath, imagesOnly); + setFiles(filesData); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[Bunny Widget] Failed to load files:', errorMsg); + setError(`Failed to load files: ${errorMsg}`); + setFiles([]); + } finally { + setIsLoading(false); + } + } + + loadFiles(); + }, [currentPath, imagesOnly, isAuthenticated]); + + function handleLogin() { + // Redirect to Bunny authentication in the same window + BunnyAuthManager.redirectToAuth(); + } + + function handleLogout() { + BunnyAuthManager.clearStoredApiKey(); + BunnyAuthManager.clearReturnUrl(); + setApiKey(null); + setIsAuthenticated(false); + setFiles([]); + setSelectedFiles(new Set()); + setCurrentPath(config.root_path || '/'); + } + + function handleNavigate(path: string) { + setCurrentPath(path); + setSelectedFiles(new Set()); + } + + function handleParentDirectory() { + if (!fileManagerRef.current) return; + const parentPath = fileManagerRef.current.getParentPath(currentPath); + handleNavigate(parentPath); + } + + function handleSelectFile(filePath: string) { + if (allowMultiple) { + const newSelected = new Set(selectedFiles); + if (newSelected.has(filePath)) { + newSelected.delete(filePath); + } else { + newSelected.add(filePath); + } + setSelectedFiles(newSelected); + } else { + setSelectedFiles(new Set([filePath])); + } + } + + function handleFileDoubleClick(file: AddressedMediaFile) { + if (file.IsDirectory) { + handleNavigate(`${currentPath}${file.ObjectName}/`.replace(/\/+/g, '/')); + } else if (!allowMultiple) { + // Auto-insert on double-click if single select + onInsert(file.publicUrl); + onClose(); + } + } + + async function handleDeleteFile(filePath: string) { + if (!fileManagerRef.current) return; + if (!window.confirm('Are you sure you want to delete this file?')) return; + + try { + setError(null); + await fileManagerRef.current.deleteFile(filePath); + // Reload files after deletion + const filesData = await fileManagerRef.current.getFilesWithUrls(currentPath, imagesOnly); + setFiles(filesData); + setSelectedFiles(prev => { + const newSelected = new Set(prev); + newSelected.delete(filePath); + return newSelected; + }); + } catch (err) { + setError(`Failed to delete file: ${err instanceof Error ? err.message : String(err)}`); + } + } + + async function handleUpload(uploadedFiles: File[]) { + if (!fileManagerRef.current) return; + + setIsUploading(true); + setUploadProgress(0); + const urls: string[] = []; + + try { + setError(null); + for (let i = 0; i < uploadedFiles.length; i++) { + const file = uploadedFiles[i]; + const url = await fileManagerRef.current.uploadFile(currentPath, file, file.name); + urls.push(url); + setUploadProgress(Math.round(((i + 1) / uploadedFiles.length) * 100)); + } + + // Reload files after upload + const filesData = await fileManagerRef.current.getFilesWithUrls(currentPath, imagesOnly); + setFiles(filesData); + + // Auto-insert if single file uploaded in single-select mode + if (uploadedFiles.length === 1 && !allowMultiple) { + onInsert(urls[0]); + onClose(); + } + } catch (err) { + setError(`Upload failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + } + + function handleInsertSelected() { + const selectedUrls = Array.from(selectedFiles) + .map(path => files.find(f => f.publicUrl === path)) + .filter(Boolean) + .map(f => (f as AddressedMediaFile).publicUrl); + + if (selectedUrls.length === 0) { + setError('Please select at least one file'); + return; + } + + onInsert(allowMultiple ? selectedUrls : selectedUrls[0]); + onClose(); + } + + // Show login prompt if not authenticated + if (!isAuthenticated) { + return ( + + + + Bunny.net Media Library + + ✕ + + + + + + + ); + } + + // Main widget UI (after authentication) + return ( + + + {/* Header */} + + Bunny.net Media Library + + ✕ + + + + {/* Error Message */} + {error && {error}} + + {/* Navigation */} + + + {/* Upload Area */} + + + {/* File Grid */} + + {isLoading ? ( + Loading files... + ) : files.length === 0 ? ( + No files found + ) : ( + + )} + + + {/* Footer Actions */} + + + Logout + + Cancel + {selectedFiles.size > 0 && ( + + Insert ({selectedFiles.size}) + + )} + + + + {/* Backdrop */} + + + ); +} + +export default BunnyWidget; diff --git a/packages/decap-cms-media-library-bunny/src/components/FileBrowser.css b/packages/decap-cms-media-library-bunny/src/components/FileBrowser.css new file mode 100644 index 000000000000..e2e1b20e9e9c --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileBrowser.css @@ -0,0 +1,109 @@ +/* File Browser Navigation Stylesheet */ + +.browser { + padding: 16px 24px; + border-bottom: 1px solid #e0e0e0; + background: #f9f9f9; +} + +/* Navigation Controls */ +.controls { + margin-bottom: 12px; +} + +.backButton { + padding: 6px 12px; + background-color: #f0f0f0; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.backButton:hover:not(:disabled) { + background-color: #e0e0e0; + border-color: #999; +} + +.backButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Breadcrumbs */ +.breadcrumbs { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 8px; + overflow-x: auto; + padding-bottom: 4px; +} + +.breadcrumb { + padding: 4px 8px; + background: transparent; + border: none; + color: #0066cc; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + transition: all 0.2s; + border-radius: 3px; +} + +.breadcrumb:hover { + background-color: rgba(0, 102, 204, 0.1); + text-decoration: underline; +} + +.breadcrumb.active { + color: #333; + font-weight: 600; + background-color: rgba(0, 102, 204, 0.05); + cursor: default; +} + +.separator { + color: #999; + margin: 0 2px; + font-size: 12px; + user-select: none; +} + +/* Path Display */ +.pathDisplay { + font-size: 11px; + color: #999; + font-family: 'Monaco', 'Courier New', monospace; + background-color: white; + padding: 4px 8px; + border-radius: 3px; + border: 1px solid #eee; + word-break: break-all; + max-height: 40px; + overflow-y: auto; +} + +/* Responsive */ +@media (max-width: 768px) { + .browser { + padding: 12px 16px; + } + + .breadcrumbs { + margin-bottom: 6px; + } + + .breadcrumb { + font-size: 12px; + padding: 3px 6px; + } + + .pathDisplay { + font-size: 10px; + padding: 3px 6px; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/components/FileBrowser.tsx b/packages/decap-cms-media-library-bunny/src/components/FileBrowser.tsx new file mode 100644 index 000000000000..6b2581a3cd1f --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileBrowser.tsx @@ -0,0 +1,161 @@ +/** + * File Browser Navigation Component + * Displays breadcrumbs and navigation controls + */ + +import React from 'react'; + +const styles = { + browser: { + padding: '16px 24px', + borderBottom: '1px solid #e0e0e0', + backgroundColor: '#f9f9f9', + }, + controls: { + marginBottom: '12px', + }, + backButton: { + padding: '6px 12px', + backgroundColor: '#f0f0f0', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + cursor: 'pointer', + transition: 'all 0.2s', + }, + backButtonDisabled: { + opacity: 0.5, + cursor: 'not-allowed' as const, + }, + breadcrumbs: { + display: 'flex' as const, + alignItems: 'center' as const, + gap: '4px', + flexWrap: 'wrap' as const, + marginBottom: '8px', + overflowX: 'auto' as const, + paddingBottom: '4px', + }, + breadcrumb: { + padding: '4px 8px', + backgroundColor: 'transparent', + border: 'none', + color: '#0066cc', + cursor: 'pointer', + fontSize: '13px', + whiteSpace: 'nowrap' as const, + transition: 'all 0.2s', + borderRadius: '3px', + }, + breadcrumbActive: { + color: '#333', + fontWeight: 600 as const, + backgroundColor: 'rgba(0, 102, 204, 0.05)', + cursor: 'default', + }, + separator: { + color: '#999', + margin: '0 2px', + fontSize: '12px', + userSelect: 'none' as const, + }, + pathDisplay: { + fontSize: '11px', + color: '#999', + fontFamily: '"Monaco", "Courier New", monospace', + backgroundColor: 'white', + padding: '4px 8px', + borderRadius: '3px', + border: '1px solid #eee', + wordBreak: 'break-all' as const, + maxHeight: '40px', + overflowY: 'auto' as const, + }, +}; + +interface FileBrowserProps { + currentPath: string; + onNavigate: (path: string) => void; + onParentDirectory: () => void; +} + +export function FileBrowser({ currentPath, onNavigate, onParentDirectory }: FileBrowserProps) { + function getBreadcrumbs(path: string): { label: string; path: string }[] { + const breadcrumbs = [{ label: 'Root', path: '/' }]; + + if (path === '/') { + return breadcrumbs; + } + + const parts = path.split('/').filter(p => p); + let currentBreadcrumbPath = '/'; + + parts.forEach(part => { + currentBreadcrumbPath = `${currentBreadcrumbPath}${part}/`; + breadcrumbs.push({ label: part, path: currentBreadcrumbPath }); + }); + + return breadcrumbs; + } + + const breadcrumbs = getBreadcrumbs(currentPath); + const canGoUp = currentPath !== '/'; + + return ( +
+ {/* Navigation Controls */} +
+ +
+ + {/* Breadcrumb Trail */} +
+ {breadcrumbs.map((breadcrumb, index) => ( + + {index > 0 && /} + + + ))} +
+ + {/* Current Path Display */} +
{currentPath}
+
+ ); +} + +export default FileBrowser; diff --git a/packages/decap-cms-media-library-bunny/src/components/FileGrid.css b/packages/decap-cms-media-library-bunny/src/components/FileGrid.css new file mode 100644 index 000000000000..0d0cee10f544 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileGrid.css @@ -0,0 +1,158 @@ +/* File Grid Stylesheet */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 16px; + width: 100%; +} + +.item { + display: flex; + flex-direction: column; + cursor: pointer; + border: 2px solid transparent; + border-radius: 6px; + padding: 8px; + transition: all 0.2s; + background: white; + position: relative; +} + +.item:hover { + background-color: #f5f5f5; + border-color: #ddd; +} + +.item.selected { + background-color: #e3f2fd; + border-color: #0066cc; +} + +/* Thumbnail */ +.thumbnail { + position: relative; + width: 100%; + aspect-ratio: 1; + background-color: #f0f0f0; + border-radius: 4px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.folderIcon, +.fileIcon { + font-size: 48px; + color: #999; +} + +/* Checkbox */ +.checkbox { + position: absolute; + top: 4px; + left: 4px; + background: white; + border-radius: 3px; + padding: 2px; + opacity: 0; + transition: opacity 0.2s; +} + +.item:hover .checkbox { + opacity: 1; +} + +.checkbox input { + cursor: pointer; + width: 18px; + height: 18px; +} + +.item.selected .checkbox { + opacity: 1; +} + +/* Delete Button */ +.deleteButton { + position: absolute; + top: 4px; + right: 4px; + background: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 3px; + width: 28px; + height: 28px; + font-size: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.item:hover .deleteButton { + opacity: 1; +} + +.deleteButton:hover { + background: rgba(255, 0, 0, 0.1); +} + +/* File Name */ +.name { + font-size: 13px; + font-weight: 500; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +/* File Info */ +.info { + display: flex; + flex-direction: column; + font-size: 11px; + color: #999; + gap: 2px; +} + +.size { + font-weight: 500; +} + +.date { + color: #bbb; +} + +/* Responsive */ +@media (max-width: 1024px) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + } +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 10px; + } + + .folderIcon, + .fileIcon { + font-size: 32px; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx b/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx new file mode 100644 index 000000000000..ab6d3301bc63 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx @@ -0,0 +1,149 @@ +/** + * File Grid Component + * Displays files and directories in a grid layout + */ + +import React, { useMemo } from 'react'; + +import { + StyledCheckboxContainer, + StyledCheckboxInput, + StyledDeleteButton, + StyledFileDate, + StyledFileGrid, + StyledFileGridItemContainer, + StyledFileIcon, + StyledFileInfo, + StyledFileName, + StyledFileSize, + StyledThumbnail, + StyledThumbnailImage, +} from './styles'; + +import type { AddressedMediaFile } from '../types'; + +interface FileGridProps { + files: AddressedMediaFile[]; + selectedFiles: Set; + onSelectFile: (fileUrl: string) => void; + onDoubleClick: (file: AddressedMediaFile) => void; + onDelete: (filePath: string) => void; + allowMultiple?: boolean; +} + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp']; + +export function FileGrid({ + files, + selectedFiles, + onSelectFile, + onDoubleClick, + onDelete, + allowMultiple, +}: FileGridProps) { + const [hoveredItem, setHoveredItem] = React.useState(null); + + function getFileExtension(filename: string): string { + return filename.split('.').pop()?.toLowerCase() || ''; + } + + function isImageFile(filename: string): boolean { + const ext = getFileExtension(filename); + return IMAGE_EXTENSIONS.includes(ext); + } + + const sortedFiles = useMemo(() => { + return [...files].sort((a, b) => { + if (a.IsDirectory !== b.IsDirectory) { + return a.IsDirectory ? -1 : 1; + } + return a.ObjectName.localeCompare(b.ObjectName); + }); + }, [files]); + + return ( + + {sortedFiles.map(file => { + const isSelected = selectedFiles.has(file.publicUrl); + const isImage = !file.IsDirectory && isImageFile(file.ObjectName); + const itemKey = `${file.Path}${file.ObjectName}`; + const isHovered = hoveredItem === itemKey; + + return ( + onDoubleClick(file)} + onClick={() => { + if (!file.IsDirectory) { + onSelectFile(file.publicUrl); + } + }} + onMouseEnter={() => setHoveredItem(itemKey)} + onMouseLeave={() => setHoveredItem(null)} + > + + {file.IsDirectory ? ( + 📁 + ) : isImage ? ( + + ) : ( + 📄 + )} + + {!file.IsDirectory && ( + + onSelectFile(file.publicUrl)} + onClick={e => e.stopPropagation()} + /> + + )} + + {!file.IsDirectory && ( + { + e.stopPropagation(); + onDelete(`${file.Path}${file.ObjectName}`); + }} + title="Delete file" + > + 🗑️ + + )} + + + {file.ObjectName} + + + {!file.IsDirectory && {formatFileSize(file.Length)}} + {formatDate(file.LastChanged)} + + + ); + })} + + ); +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +function formatDate(dateString: string): string { + try { + return new Date(dateString).toLocaleDateString(); + } catch { + return ''; + } +} + +export default FileGrid; diff --git a/packages/decap-cms-media-library-bunny/src/components/FileUpload.css b/packages/decap-cms-media-library-bunny/src/components/FileUpload.css new file mode 100644 index 000000000000..a160392bd948 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileUpload.css @@ -0,0 +1,108 @@ +/* File Upload Stylesheet */ + +.uploadContainer { + padding: 16px 24px 0; +} + +/* Drop Zone */ +.dropZone { + border: 2px dashed #ddd; + border-radius: 6px; + padding: 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background-color: #fafafa; +} + +.dropZone:hover { + border-color: #0066cc; + background-color: #f5f9ff; +} + +.dropZone.dragging { + border-color: #0066cc; + background-color: #e3f2fd; +} + +.dropZone.uploading { + border-color: #ccc; + background-color: #f0f0f0; + cursor: default; +} + +/* Drop Content */ +.dropContent { + pointer-events: none; +} + +.dropIcon { + font-size: 32px; + margin-bottom: 8px; +} + +.dropText { + margin: 8px 0 4px; + font-size: 14px; + font-weight: 500; + color: #333; +} + +.dropSubtext { + margin: 0; + font-size: 12px; + color: #999; +} + +/* Uploading Content */ +.uploadingContent { + pointer-events: none; +} + +.progressBar { + width: 100%; + height: 6px; + background-color: #e0e0e0; + border-radius: 3px; + overflow: hidden; + margin-bottom: 16px; +} + +.progressFill { + height: 100%; + background-color: #0066cc; + transition: width 0.3s ease; + border-radius: 3px; +} + +.uploadingText { + margin: 0; + font-size: 14px; + font-weight: 500; + color: #0066cc; +} + +/* Responsive */ +@media (max-width: 768px) { + .uploadContainer { + padding: 12px 16px 0; + } + + .dropZone { + padding: 16px; + } + + .dropIcon { + font-size: 24px; + margin-bottom: 6px; + } + + .dropText { + font-size: 13px; + margin: 6px 0 2px; + } + + .dropSubtext { + font-size: 11px; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx b/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx new file mode 100644 index 000000000000..549d54835e61 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx @@ -0,0 +1,121 @@ +/** + * File Upload Component + * Handles file uploads with drag-and-drop support + */ + +import React, { useRef, useState } from 'react'; + +import { + StyledDropContent, + StyledDropIcon, + StyledDropSubtext, + StyledDropText, + StyledDropZone, + StyledFileUploadContainer, + StyledHiddenInput, + StyledProgressBarContainer, + StyledProgressBarFill, + StyledUploadingContent, + StyledUploadingText, +} from './styles'; + +interface FileUploadProps { + onUpload: (files: File[]) => void; + isUploading: boolean; + uploadProgress: number; + currentPath: string; +} + +export function FileUpload({ + onUpload, + isUploading, + uploadProgress, + currentPath, +}: FileUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + function handleDragEnter(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const droppedFiles = Array.from(e.dataTransfer.files); + if (droppedFiles.length > 0 && !isUploading) { + onUpload(droppedFiles); + } + } + + function handleFileInputChange(e: React.ChangeEvent) { + const selectedFiles = Array.from(e.target.files || []); + if (selectedFiles.length > 0) { + onUpload(selectedFiles); + } + // Reset input so same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + + function handleClick() { + if (!isUploading && fileInputRef.current) { + fileInputRef.current.click(); + } + } + + return ( + + + + + {isUploading ? ( + + + + + Uploading... {uploadProgress}% + + ) : ( + + 📤 + Drag files here or click to upload + Uploading to: {currentPath} + + )} + + + ); +} + +export default FileUpload; diff --git a/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx b/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx new file mode 100644 index 000000000000..c8dd44bc6b10 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx @@ -0,0 +1,36 @@ +/** + * Login Prompt Component for Bunny.net Authentication + * Shows when user needs to authenticate with Bunny + */ + +import React from 'react'; + +import { + StyledLoginButton, + StyledLoginCard, + StyledLoginContainer, + StyledLoginDescription, + StyledLoginIcon, + StyledLoginTitle, +} from './styles'; + +interface LoginPromptProps { + onLogin: () => void; +} + +export function LoginPrompt({ onLogin }: LoginPromptProps) { + return ( + + + 🔐 + Authenticate with Bunny + + Sign in to your Bunny.net account to access and manage your storage files. + + Login with Bunny + + + ); +} + +export default LoginPrompt; diff --git a/packages/decap-cms-media-library-bunny/src/components/styles.ts b/packages/decap-cms-media-library-bunny/src/components/styles.ts new file mode 100644 index 000000000000..f186b9b52f52 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/styles.ts @@ -0,0 +1,548 @@ +import styled from '@emotion/styled'; +import { css } from '@emotion/react'; + +// Design system tokens +export const designTokens = { + colors: { + primary: '#0066cc', + primaryLight: '#e3f2fd', + secondary: '#e0e0e0', + background: '#f9f9f9', + foreground: '#ffffff', + text: '#333333', + textSecondary: '#666666', + textTertiary: '#999999', + border: '#e0e0e0', + error: '#fee', + errorText: '#c33', + hover: '#f5f5f5', + }, + spacing: { + xs: '4px', + sm: '8px', + md: '12px', + lg: '16px', + xl: '20px', + xxl: '24px', + }, + radius: { + sm: '4px', + md: '6px', + lg: '8px', + }, + font: { + family: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + size: { + sm: '12px', + base: '14px', + lg: '16px', + xl: '20px', + xxl: '24px', + }, + weight: { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + }, + shadow: { + sm: '0 2px 4px rgba(0, 0, 0, 0.1)', + md: '0 4px 8px rgba(0, 0, 0, 0.1)', + lg: '0 20px 60px rgba(0, 0, 0, 0.3)', + }, + transition: '0.2s ease', + zIndex: { + modal: 99999, + backdrop: -1, + }, +}; + +// Modal widget styles +export const StyledWidget = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: ${designTokens.zIndex.modal}; + font-family: ${designTokens.font.family}; +`; + +export const StyledBackdrop = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: ${designTokens.zIndex.backdrop}; +`; + +export const StyledContainer = styled.div` + position: relative; + width: 90%; + max-width: 1200px; + height: 90vh; + background: ${designTokens.colors.foreground}; + border-radius: ${designTokens.radius.lg}; + box-shadow: ${designTokens.shadow.lg}; + display: flex; + flex-direction: column; + overflow: hidden; +`; + +// Header styles +export const StyledHeader = styled.div` + padding: ${designTokens.spacing.xl} ${designTokens.spacing.xxl}; + border-bottom: 1px solid ${designTokens.colors.border}; + display: flex; + justify-content: space-between; + align-items: center; + background: ${designTokens.colors.background}; +`; + +export const StyledHeaderTitle = styled.h2` + margin: 0; + font-size: ${designTokens.font.size.xl}; + font-weight: ${designTokens.font.weight.semibold}; + color: ${designTokens.colors.text}; +`; + +export const StyledCloseButton = styled.button` + background: none; + border: none; + font-size: ${designTokens.font.size.xxl}; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: ${designTokens.colors.textSecondary}; + transition: color ${designTokens.transition}; + + &:hover { + color: ${designTokens.colors.text}; + } +`; + +// Error message +export const StyledError = styled.div` + padding: ${designTokens.spacing.md} ${designTokens.spacing.xxl}; + background-color: ${designTokens.colors.error}; + color: ${designTokens.colors.errorText}; + border-bottom: 1px solid ${designTokens.colors.border}; + font-size: ${designTokens.font.size.base}; +`; + +// File grid container +export const StyledFileGridContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: ${designTokens.spacing.xl} ${designTokens.spacing.xxl}; + background: ${designTokens.colors.foreground}; +`; + +export const StyledFileGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: ${designTokens.spacing.lg}; + width: 100%; +`; + +export const StyledLoading = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: ${designTokens.colors.textTertiary}; + font-size: ${designTokens.font.size.lg}; +`; + +export const StyledEmpty = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: ${designTokens.colors.textTertiary}; + font-size: ${designTokens.font.size.lg}; +`; + +// Footer & buttons +export const StyledFooter = styled.div` + padding: ${designTokens.spacing.lg} ${designTokens.spacing.xxl}; + border-top: 1px solid ${designTokens.colors.border}; + background: ${designTokens.colors.background}; + display: flex; + justify-content: flex-end; + gap: ${designTokens.spacing.md}; +`; + +export const baseButtonStyles = css` + padding: ${designTokens.spacing.sm} ${designTokens.spacing.lg}; + border: none; + border-radius: ${designTokens.radius.sm}; + font-size: ${designTokens.font.size.base}; + font-weight: ${designTokens.font.weight.medium}; + cursor: pointer; + transition: all ${designTokens.transition}; + outline: none; + + &:focus { + outline: 2px solid ${designTokens.colors.primary}; + outline-offset: 2px; + } +`; + +export const StyledButtonPrimary = styled.button` + ${baseButtonStyles} + background-color: ${designTokens.colors.primary}; + color: ${designTokens.colors.foreground}; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + background-color: ${designTokens.colors.secondary}; + cursor: not-allowed; + } +`; + +export const StyledButtonSecondary = styled.button` + ${baseButtonStyles} + background-color: ${designTokens.colors.secondary}; + color: ${designTokens.colors.text}; + + &:hover:not(:disabled) { + background-color: #d0d0d0; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +`; + +// File grid item styles +export const StyledFileGridItem = styled.div<{ selected?: boolean; isDirectory?: boolean }>` + display: flex; + flex-direction: column; + cursor: ${props => (props.isDirectory ? 'pointer' : 'pointer')}; + border-width: 2px; + border-style: solid; + border-color: ${props => (props.selected ? designTokens.colors.primary : 'transparent')}; + border-radius: ${designTokens.radius.md}; + padding: ${designTokens.spacing.sm}; + transition: all ${designTokens.transition}; + background-color: ${props => + props.selected ? designTokens.colors.primaryLight : designTokens.colors.foreground}; + + &:hover { + background-color: ${designTokens.colors.hover}; + border-color: ${designTokens.colors.border}; + } +`; + +export const StyledFileThumbnail = styled.div` + position: relative; + width: 100%; + aspect-ratio: 1; + background-color: #f0f0f0; + border-radius: ${designTokens.radius.sm}; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: ${designTokens.spacing.sm}; +`; + +export const StyledFileImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +export const StyledFileIcon = styled.div` + font-size: 32px; + color: ${designTokens.colors.textTertiary}; +`; + +export const StyledFileName = styled.div` + font-size: ${designTokens.font.size.sm}; + color: ${designTokens.colors.text}; + text-align: center; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +// File browser styles +export const StyledFileBrowser = styled.div` + display: flex; + flex-direction: column; + gap: ${designTokens.spacing.md}; +`; + +export const StyledBreadcrumb = styled.div` + display: flex; + align-items: center; + gap: ${designTokens.spacing.sm}; + font-size: ${designTokens.font.size.base}; + color: ${designTokens.colors.textSecondary}; + padding-bottom: ${designTokens.spacing.md}; + border-bottom: 1px solid ${designTokens.colors.border}; +`; + +export const StyledBreadcrumbItem = styled.button` + background: none; + border: none; + color: ${designTokens.colors.primary}; + cursor: pointer; + font-size: ${designTokens.font.size.base}; + padding: 0; + transition: color ${designTokens.transition}; + + &:hover { + text-decoration: underline; + } +`; + +export const StyledBreadcrumbSeparator = styled.span` + color: ${designTokens.colors.textTertiary}; + margin: 0 ${designTokens.spacing.xs}; +`; + +// FileGrid-specific item styles +export const StyledFileGridItemContainer = styled.div<{ + isSelected?: boolean; + isHovered?: boolean; +}>` + display: flex; + flex-direction: column; + cursor: pointer; + border-width: 2px; + border-style: solid; + border-color: ${props => (props.isSelected ? designTokens.colors.primary : 'transparent')}; + border-radius: ${designTokens.radius.md}; + padding: ${designTokens.spacing.sm}; + transition: all ${designTokens.transition}; + background-color: ${props => + props.isSelected + ? designTokens.colors.primaryLight + : props.isHovered + ? designTokens.colors.hover + : designTokens.colors.foreground}; + position: relative; +`; + +export const StyledThumbnail = styled.div` + position: relative; + width: 100%; + aspect-ratio: 1; + background-color: ${designTokens.colors.background}; + border-radius: ${designTokens.radius.sm}; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: ${designTokens.spacing.sm}; +`; + +export const StyledThumbnailImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +export const StyledCheckboxContainer = styled.div<{ visible?: boolean }>` + position: absolute; + top: 4px; + left: 4px; + background-color: ${designTokens.colors.foreground}; + border-radius: ${designTokens.radius.sm}; + padding: 2px; + opacity: ${props => (props.visible ? 1 : 0)}; + transition: opacity ${designTokens.transition}; +`; + +export const StyledCheckboxInput = styled.input` + cursor: pointer; + width: 18px; + height: 18px; +`; + +export const StyledDeleteButton = styled.button<{ visible?: boolean }>` + position: absolute; + top: 4px; + right: 4px; + background-color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: ${designTokens.radius.sm}; + width: 28px; + height: 28px; + font-size: 16px; + cursor: pointer; + opacity: ${props => (props.visible ? 1 : 0)}; + transition: opacity ${designTokens.transition}; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + + &:hover { + background-color: rgba(255, 0, 0, 0.1); + } +`; + +export const StyledFileInfo = styled.div` + display: flex; + flex-direction: column; + font-size: 11px; + color: ${designTokens.colors.textTertiary}; + gap: 2px; +`; + +export const StyledFileSize = styled.span` + font-weight: ${designTokens.font.weight.medium}; +`; + +export const StyledFileDate = styled.span` + color: #bbb; +`; + +// Login prompt styles +export const StyledLoginContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding: ${designTokens.spacing.xl}; +`; + +export const StyledLoginCard = styled.div` + text-align: center; + padding: 40px 60px; + background-color: ${designTokens.colors.foreground}; + border-radius: ${designTokens.radius.lg}; + box-shadow: ${designTokens.shadow.md}; +`; + +export const StyledLoginIcon = styled.div` + font-size: 48px; + margin-bottom: ${designTokens.spacing.xl}; + color: ${designTokens.colors.primary}; +`; + +export const StyledLoginTitle = styled.h2` + margin: 0 0 ${designTokens.spacing.md} 0; + font-size: ${designTokens.font.size.xxl}; + font-weight: ${designTokens.font.weight.semibold}; + color: ${designTokens.colors.text}; +`; + +export const StyledLoginDescription = styled.p` + margin: 0 0 30px 0; + font-size: ${designTokens.font.size.base}; + color: ${designTokens.colors.textSecondary}; + line-height: 1.5; +`; + +export const StyledLoginButton = styled(StyledButtonPrimary)` + padding: ${designTokens.spacing.md} ${designTokens.spacing.xxl}; + font-size: ${designTokens.font.size.lg}; + font-weight: ${designTokens.font.weight.semibold}; +`; + +// File upload styles +export const StyledFileUploadContainer = styled.div` + padding: ${designTokens.spacing.lg} ${designTokens.spacing.xxl} 0; +`; + +export const StyledDropZone = styled.div<{ isDragging?: boolean; isUploading?: boolean }>` + border-width: 2px; + border-style: dashed; + border-color: ${props => + props.isDragging ? designTokens.colors.primary : designTokens.colors.border}; + border-radius: ${designTokens.radius.md}; + padding: ${designTokens.spacing.xxl}; + text-align: center; + cursor: ${props => (props.isUploading ? 'default' : 'pointer')}; + transition: all ${designTokens.transition}; + background-color: ${props => + props.isUploading + ? designTokens.colors.background + : props.isDragging + ? designTokens.colors.primaryLight + : '#fafafa'}; + + &:hover { + border-color: ${props => + props.isUploading ? designTokens.colors.border : designTokens.colors.primary}; + background-color: ${props => + props.isUploading ? designTokens.colors.background : designTokens.colors.primaryLight}; + } +`; + +export const StyledDropContent = styled.div` + pointer-events: none; +`; + +export const StyledDropIcon = styled.div` + font-size: 32px; + margin-bottom: ${designTokens.spacing.sm}; +`; + +export const StyledDropText = styled.p` + margin: ${designTokens.spacing.sm} 0 ${designTokens.spacing.xs}; + font-size: ${designTokens.font.size.base}; + font-weight: ${designTokens.font.weight.medium}; + color: ${designTokens.colors.text}; +`; + +export const StyledDropSubtext = styled.p` + margin: 0; + font-size: ${designTokens.font.size.sm}; + color: ${designTokens.colors.textTertiary}; +`; + +export const StyledUploadingContent = styled.div` + pointer-events: none; +`; + +export const StyledProgressBarContainer = styled.div` + width: 100%; + height: 6px; + background-color: ${designTokens.colors.secondary}; + border-radius: 3px; + overflow: hidden; + margin-bottom: ${designTokens.spacing.lg}; +`; + +export const StyledProgressBarFill = styled.div<{ progress: number }>` + height: 100%; + background-color: ${designTokens.colors.primary}; + transition: width 0.3s ease; + border-radius: 3px; + width: ${props => props.progress}%; +`; + +export const StyledUploadingText = styled.p` + margin: 0; + font-size: ${designTokens.font.size.base}; + font-weight: ${designTokens.font.weight.medium}; + color: ${designTokens.colors.primary}; +`; + +export const StyledHiddenInput = styled.input` + display: none; +`; diff --git a/packages/decap-cms-media-library-bunny/src/index.js b/packages/decap-cms-media-library-bunny/src/index.js new file mode 100644 index 000000000000..ac36ec245e9a --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -0,0 +1,168 @@ +/** + * Decap CMS Media Library Integration for Bunny.net + * Main entry point that exports the media library interface + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import BunnyWidget from './components/BunnyWidget'; + +/** + * Initialize the Bunny.net media library + * @param options - Configuration options including storageZoneId, cdnUrlPrefix + * @param handleInsert - Callback function when user inserts files + * @returns MediaLibraryInstance with show, hide, and other required methods + */ +async function init({ options = {}, handleInsert = () => {} } = {}) { + const { config: providedConfig = {} } = options; + + // Validate required configuration + if (!providedConfig.storage_zone_name) { + throw new Error('storage_zone_name is required in media_library config'); + } + if (!providedConfig.cdn_url_prefix) { + throw new Error('cdn_url_prefix is required in media_library config'); + } + + const { BunnyAuthManager } = await import('./api/authManager'); + + function safelyRedirectToReturnUrl(returnUrl) { + try { + const safeUrl = new URL(returnUrl, window.location.origin); + if (safeUrl.origin === window.location.origin) { + window.location.replace(safeUrl.toString()); + } + } catch { + // keep current page when returnUrl is invalid + } + } + + async function processAuthCallback(accountApiKey, zoneName) { + BunnyAuthManager.setStoredAccountApiKey(accountApiKey); + BunnyAuthManager.setStoredStorageZoneName(zoneName); + + const { BunnyManagementApi } = await import('./api/managementApi'); + const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( + accountApiKey, + zoneName, + ); + BunnyAuthManager.setStoredApiKey(storageZonePassword); + + const returnUrl = BunnyAuthManager.resolveReturnUrl(); + BunnyAuthManager.cleanAuthParamsFromUrl(); + + if (returnUrl) { + BunnyAuthManager.clearReturnUrl(); + setTimeout(() => safelyRedirectToReturnUrl(returnUrl), 100); + } + } + + const { apiKey: urlApiKey, storageName: urlStorageName } = + BunnyAuthManager.extractCredentialsFromUrl(); + + if (urlApiKey) { + const storageZoneName = urlStorageName || providedConfig.storage_zone_name; + + try { + await processAuthCallback(urlApiKey, storageZoneName); + } catch (error) { + alert( + `Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${storageZoneName}"`, + ); + return null; + } + } + + const config = providedConfig; + let widgetContainer = null; + let widgetRoot = null; + let isOpen = false; + + const mediaLibraryInstance = { + /** + * Show the media library widget + */ + show: ({ allowMultiple = false, imagesOnly = false } = {}) => { + if (isOpen) return; + + // Create container if it doesn't exist + if (!widgetContainer) { + widgetContainer = document.createElement('div'); + document.body.appendChild(widgetContainer); + } + + isOpen = true; + + // Create React root + widgetRoot = createRoot(widgetContainer); + + // Render the widget + widgetRoot.render( + React.createElement(BunnyWidget, { + config, + onInsert: insertedValue => { + handleInsert(insertedValue); + mediaLibraryInstance.hide(); + }, + onClose: () => { + mediaLibraryInstance.hide(); + }, + allowMultiple, + imagesOnly, + }), + ); + }, + + /** + * Hide the media library widget + */ + hide: () => { + if (!isOpen || !widgetRoot) return; + + isOpen = false; + + // Unmount React component + widgetRoot.unmount(); + + // Remove container from DOM + if (widgetContainer && widgetContainer.parentNode) { + widgetContainer.parentNode.removeChild(widgetContainer); + widgetContainer = null; + widgetRoot = null; + } + }, + + /** + * Handle field clear - currently no-op + */ + onClearControl: () => { + // No-op for this implementation + }, + + /** + * Handle field removal - currently no-op + */ + onRemoveControl: () => { + // No-op for this implementation + }, + + /** + * Enable standalone mode - allows widget to appear in toolbar and field buttons + */ + enableStandalone: () => true, + }; + + return mediaLibraryInstance; +} + +/** + * Export the media library instance for Decap CMS + */ +const bunnyMediaLibrary = { + name: 'bunny', + init, +}; + +export const DecapCmsMediaLibraryBunny = bunnyMediaLibrary; +export default bunnyMediaLibrary; diff --git a/packages/decap-cms-media-library-bunny/src/types.ts b/packages/decap-cms-media-library-bunny/src/types.ts new file mode 100644 index 000000000000..3f98f1c13c82 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/types.ts @@ -0,0 +1,65 @@ +/** + * Bunny.net Storage API Types + */ + +export interface BunnyFile { + Guid: string; + StorageZoneName: string; + Path: string; + ObjectName: string; + Length: number; + LastChanged: string; + IsDirectory: boolean; + DateCreated: string; + StorageZoneId: number; +} + +export interface BunnyListResponse { + files: BunnyFile[]; +} + +export interface BunnyConfig { + storage_zone_name: string; + cdn_url_prefix: string; + root_path?: string; +} + +export interface AuthState { + isAuthenticated: boolean; + isAuthenticating: boolean; + apiKey: string | null; + error: string | null; +} + +export interface BunnyIntegrationOptions { + config: BunnyConfig; + images_only?: boolean; +} + +export interface BunnyInitOptions { + options?: BunnyIntegrationOptions & Record; + handleInsert?: (value: string | string[]) => void; +} + +export interface MediaLibraryInstance { + show: (args?: { + id?: string; + value?: string | string[]; + config?: Record; + allowMultiple?: boolean; + imagesOnly?: boolean; + }) => void; + hide: () => void; + onClearControl?: (args: { id: string }) => void; + onRemoveControl?: (args: { id: string }) => void; + enableStandalone: () => boolean; +} + +export interface BunnyMediaLibrary { + name: 'bunny'; + init: (options: BunnyInitOptions) => Promise; +} + +export interface AddressedMediaFile extends BunnyFile { + publicUrl: string; +} diff --git a/packages/decap-cms-media-library-bunny/webpack.config.js b/packages/decap-cms-media-library-bunny/webpack.config.js new file mode 100644 index 000000000000..42edd361d4a7 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/packages/decap-cms/package.json b/packages/decap-cms/package.json index a03f03efd7ee..2c217e54495b 100644 --- a/packages/decap-cms/package.json +++ b/packages/decap-cms/package.json @@ -23,6 +23,7 @@ "codemirror": "^5.46.0", "create-react-class": "^15.7.0", "decap-cms-app": "^3.10.1", + "decap-cms-media-library-bunny": "^0.1.0", "decap-cms-media-library-cloudinary": "^3.1.0", "decap-cms-media-library-uploadcare": "^3.0.2", "file-loader": "^6.2.0", diff --git a/packages/decap-cms/src/extensions.js b/packages/decap-cms/src/extensions.js index 9b097dbad095..52b07b8e177d 100644 --- a/packages/decap-cms/src/extensions.js +++ b/packages/decap-cms/src/extensions.js @@ -2,6 +2,8 @@ import { DecapCmsApp as CMS } from 'decap-cms-app'; // Media libraries import uploadcare from 'decap-cms-media-library-uploadcare'; import cloudinary from 'decap-cms-media-library-cloudinary'; +import BunnyMediaLibrary from 'decap-cms-media-library-bunny'; CMS.registerMediaLibrary(uploadcare); CMS.registerMediaLibrary(cloudinary); +CMS.registerMediaLibrary(BunnyMediaLibrary);