From 944307ccac065c753f8ed0676fd53fb3236468d1 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Fri, 20 Feb 2026 14:51:23 +0100 Subject: [PATCH 01/11] feat: bunny media library - initial commit --- package-lock.json | 15 + .../ARCHITECTURE.md | 395 +++++++++++++++++ .../CHANGELOG.md | 90 ++++ .../QUICKSTART.md | 320 ++++++++++++++ .../decap-cms-media-library-bunny/README.md | 109 +++++ .../decap-cms-media-library-bunny/SETUP.md | 207 +++++++++ .../decap-cms-media-library-bunny/TESTING.md | 255 +++++++++++ .../package.json | 34 ++ .../src/__tests__/client.test.ts | 101 +++++ .../src/__tests__/fileManager.test.ts | 92 ++++ .../src/api/client.ts | 104 +++++ .../src/api/fileManager.ts | 113 +++++ .../src/components/BunnyWidget.css | 171 ++++++++ .../src/components/BunnyWidget.tsx | 405 ++++++++++++++++++ .../src/components/FileBrowser.css | 109 +++++ .../src/components/FileBrowser.tsx | 161 +++++++ .../src/components/FileGrid.css | 158 +++++++ .../src/components/FileGrid.tsx | 282 ++++++++++++ .../src/components/FileUpload.css | 108 +++++ .../src/components/FileUpload.tsx | 201 +++++++++ .../src/index.js | 122 ++++++ .../src/types.ts | 59 +++ .../webpack.config.js | 3 + packages/decap-cms/package.json | 1 + packages/decap-cms/src/extensions.js | 2 + 25 files changed, 3617 insertions(+) create mode 100644 packages/decap-cms-media-library-bunny/ARCHITECTURE.md create mode 100644 packages/decap-cms-media-library-bunny/CHANGELOG.md create mode 100644 packages/decap-cms-media-library-bunny/QUICKSTART.md create mode 100644 packages/decap-cms-media-library-bunny/README.md create mode 100644 packages/decap-cms-media-library-bunny/SETUP.md create mode 100644 packages/decap-cms-media-library-bunny/TESTING.md create mode 100644 packages/decap-cms-media-library-bunny/package.json create mode 100644 packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts create mode 100644 packages/decap-cms-media-library-bunny/src/__tests__/fileManager.test.ts create mode 100644 packages/decap-cms-media-library-bunny/src/api/client.ts create mode 100644 packages/decap-cms-media-library-bunny/src/api/fileManager.ts create mode 100644 packages/decap-cms-media-library-bunny/src/components/BunnyWidget.css create mode 100644 packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileBrowser.css create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileBrowser.tsx create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileGrid.css create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileUpload.css create mode 100644 packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx create mode 100644 packages/decap-cms-media-library-bunny/src/index.js create mode 100644 packages/decap-cms-media-library-bunny/src/types.ts create mode 100644 packages/decap-cms-media-library-bunny/webpack.config.js diff --git a/package-lock.json b/package-lock.json index fb4b60cb0a87..a94f9bc524e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "version": "0.0.0", "dependencies": { "@emotion/babel-preset-css-prop": "^11.11.0", + "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-keywords": "^5.1.0", "browserify": "^17.0.0", @@ -11751,6 +11752,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 @@ -33845,6 +33850,7 @@ "codemirror": "^5.46.0", "create-react-class": "^15.7.0", "decap-cms-app": "^3.10.0", + "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", @@ -34304,6 +34310,15 @@ "version": "3.5.0", "license": "MIT" }, + "packages/decap-cms-media-library-bunny": { + "version": "0.1.0", + "license": "MIT", + "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..d7f53981aee0 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/README.md @@ -0,0 +1,109 @@ +# 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) + +- 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..bac4c7fbd2eb --- /dev/null +++ b/packages/decap-cms-media-library-bunny/package.json @@ -0,0 +1,34 @@ +{ + "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" + } +} 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..965b09203343 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts @@ -0,0 +1,101 @@ +/** + * 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, + 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/api/client.ts b/packages/decap-cms-media-library-bunny/src/api/client.ts new file mode 100644 index 000000000000..961ed84a3410 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/client.ts @@ -0,0 +1,104 @@ +/** + * HTTP Client for Bunny.net Storage API + * Handles authentication and request/response formatting + */ + +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; + private baseUrl: string; + + constructor({ storageZoneName, apiKey, region = 'us' }: BunnyClientOptions) { + this.storageZoneName = storageZoneName; + this.apiKey = apiKey; + this.baseUrl = BUNNY_STORAGE_ENDPOINTS[region]; + } + + private getHeaders(): HeadersInit { + return { + AccessKey: this.apiKey, + 'Content-Type': 'application/json', + }; + } + + private buildUrl(path: string): string { + // Normalize path + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${this.baseUrl}/${this.storageZoneName}${normalizedPath}`; + } + + private async handleResponse(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.text(); + 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 response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + const data = await this.handleResponse(response); + return Array.isArray(data) ? data : []; + } + + async uploadFile(filePath: string, file: Blob): Promise { + 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/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..688db790cc10 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx @@ -0,0 +1,405 @@ +/** + * 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 FileGrid from './FileGrid'; +import FileBrowser from './FileBrowser'; +import FileUpload from './FileUpload'; + +import type { AddressedMediaFile } from '../types'; + +const styles = { + widget: { + position: 'fixed' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + zIndex: 99999, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + }, + backdrop: { + position: 'fixed' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: -1, + }, + container: { + position: 'relative' as const, + width: '90%', + maxWidth: '1200px', + height: '90vh', + background: 'white', + borderRadius: '8px', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)', + display: 'flex' as const, + flexDirection: 'column' as const, + overflow: 'hidden' as const, + }, + header: { + padding: '20px 24px', + borderBottom: '1px solid #e0e0e0', + display: 'flex' as const, + justifyContent: 'space-between' as const, + alignItems: 'center' as const, + background: '#f9f9f9', + }, + headerTitle: { + margin: 0, + fontSize: '20px', + fontWeight: 600, + color: '#333', + }, + closeButton: { + background: 'none', + border: 'none', + fontSize: '24px', + cursor: 'pointer', + padding: 0, + width: '32px', + height: '32px', + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + color: '#666', + transition: 'color 0.2s', + }, + error: { + padding: '12px 24px', + backgroundColor: '#fee', + color: '#c33', + borderBottom: '1px solid #e0e0e0', + fontSize: '14px', + }, + fileGridContainer: { + flex: 1, + overflowY: 'auto' as const, + padding: '20px 24px', + background: 'white', + }, + loading: { + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + height: '100%', + color: '#999', + fontSize: '16px', + }, + empty: { + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + height: '100%', + color: '#999', + fontSize: '16px', + }, + footer: { + padding: '16px 24px', + borderTop: '1px solid #e0e0e0', + background: '#f9f9f9', + display: 'flex' as const, + justifyContent: 'flex-end' as const, + gap: '12px', + }, + buttonPrimary: { + padding: '8px 16px', + backgroundColor: '#0066cc', + color: 'white', + border: 'none', + borderRadius: '4px', + fontSize: '14px', + fontWeight: 500 as const, + cursor: 'pointer', + transition: 'all 0.2s', + }, + buttonPrimaryDisabled: { + backgroundColor: '#ccc', + cursor: 'not-allowed', + }, + buttonSecondary: { + padding: '8px 16px', + backgroundColor: '#e0e0e0', + color: '#333', + border: 'none', + borderRadius: '4px', + fontSize: '14px', + fontWeight: 500 as const, + cursor: 'pointer', + transition: 'all 0.2s', + }, +}; + +interface BunnyWidgetProps { + config: { + storage_zone_name: string; + api_key: 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 [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); + + // Initialize file manager + useEffect(() => { + try { + fileManagerRef.current = new BunnyFileManager({ + storageZoneName: config.storage_zone_name, + apiKey: config.api_key, + cdnUrlPrefix: config.cdn_url_prefix, + }); + } catch (err) { + setError(`Failed to initialize: ${err instanceof Error ? err.message : String(err)}`); + } + }, [config]); + + // Load files when path changes + useEffect(() => { + if (!fileManagerRef.current) return; + + async function loadFiles() { + try { + setIsLoading(true); + setError(null); + const filesData = await fileManagerRef.current!.getFilesWithUrls(currentPath, imagesOnly); + setFiles(filesData); + } catch (err) { + setError(`Failed to load files: ${err instanceof Error ? err.message : String(err)}`); + setFiles([]); + } finally { + setIsLoading(false); + } + } + + loadFiles(); + }, [currentPath, imagesOnly]); + + 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(); + } + + 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 */} +
+ + {selectedFiles.size > 0 && ( + + )} +
+
+ + {/* 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..5531968cd10b --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx @@ -0,0 +1,282 @@ +/** + * File Grid Component + * Displays files and directories in a grid layout + */ + +import React, { useMemo } from 'react'; + +import type { AddressedMediaFile } from '../types'; + +const styles = { + grid: { + display: 'grid' as const, + gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', + gap: '16px', + width: '100%', + }, + item: { + display: 'flex' as const, + flexDirection: 'column' as const, + cursor: 'pointer', + borderWidth: '2px', + borderStyle: 'solid', + borderColor: 'transparent', + borderRadius: '6px', + padding: '8px', + transition: 'all 0.2s', + backgroundColor: 'white', + position: 'relative' as const, + }, + itemHover: { + backgroundColor: '#f5f5f5', + borderColor: '#ddd', + }, + itemSelected: { + backgroundColor: '#e3f2fd', + borderColor: '#0066cc', + }, + thumbnail: { + position: 'relative' as const, + width: '100%', + aspectRatio: '1', + backgroundColor: '#f0f0f0', + borderRadius: '4px', + overflow: 'hidden' as const, + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginBottom: '8px', + }, + image: { + width: '100%', + height: '100%', + objectFit: 'cover' as const, + }, + folderIcon: { + fontSize: '48px', + color: '#999', + }, + fileIcon: { + fontSize: '48px', + color: '#999', + }, + checkbox: { + position: 'absolute' as const, + top: '4px', + left: '4px', + backgroundColor: 'white', + borderRadius: '3px', + padding: '2px', + opacity: 0, + transition: 'opacity 0.2s', + }, + checkboxVisible: { + opacity: 1, + }, + checkboxInput: { + cursor: 'pointer', + width: '18px', + height: '18px', + }, + deleteButton: { + position: 'absolute' as const, + top: '4px', + right: '4px', + backgroundColor: 'rgba(255, 255, 255, 0.9)', + border: 'none', + borderRadius: '3px', + width: '28px', + height: '28px', + fontSize: '16px', + cursor: 'pointer', + opacity: 0, + transition: 'opacity 0.2s', + display: 'flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + padding: 0, + }, + deleteButtonVisible: { + opacity: 1, + }, + name: { + fontSize: '13px', + fontWeight: 500, + color: '#333', + whiteSpace: 'nowrap' as const, + overflow: 'hidden' as const, + textOverflow: 'ellipsis' as const, + marginBottom: '4px', + }, + info: { + display: 'flex' as const, + flexDirection: 'column' as const, + fontSize: '11px', + color: '#999', + gap: '2px', + }, + size: { + fontWeight: 500, + }, + date: { + color: '#bbb', + }, +}; + +interface FileGridProps { + files: AddressedMediaFile[]; + selectedFiles: Set; + onSelectFile: (fileUrl: string) => void; + onDoubleClick: (file: AddressedMediaFile) => void; + onDelete: (filePath: string) => void; + allowMultiple?: boolean; +} + +// Image extensions for preview +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(() => { + // Directories first, then files, sorted alphabetically + 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)} + > + {/* Thumbnail */} +
+ {file.IsDirectory ? ( +
📁
+ ) : isImage ? ( + {file.ObjectName} + ) : ( +
📄
+ )} + + {/* Selection Checkbox */} + {!file.IsDirectory && ( +
+ onSelectFile(file.publicUrl)} + onClick={e => e.stopPropagation()} + style={styles.checkboxInput} + /> +
+ )} + + {/* Delete Button */} + {!file.IsDirectory && ( + + )} +
+ + {/* File Name */} +
+ {file.ObjectName} +
+ + {/* File Info */} +
+ {!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..52e2b7e9345c --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx @@ -0,0 +1,201 @@ +/** + * File Upload Component + * Handles file uploads with drag-and-drop support + */ + +import React, { useRef, useState } from 'react'; + +const styles = { + uploadContainer: { + padding: '16px 24px 0', + }, + dropZone: { + borderWidth: '2px', + borderStyle: 'dashed', + borderColor: '#ddd', + borderRadius: '6px', + padding: '24px', + textAlign: 'center' as const, + cursor: 'pointer', + transition: 'all 0.2s', + backgroundColor: '#fafafa', + }, + dropZoneHover: { + borderColor: '#0066cc', + backgroundColor: '#f5f9ff', + }, + dropZoneDragging: { + borderColor: '#0066cc', + backgroundColor: '#e3f2fd', + }, + dropZoneUploading: { + borderColor: '#ccc', + backgroundColor: '#f0f0f0', + cursor: 'default' as const, + }, + dropContent: { + pointerEvents: 'none' as const, + }, + dropIcon: { + fontSize: '32px', + marginBottom: '8px', + }, + dropText: { + margin: '8px 0 4px', + fontSize: '14px', + fontWeight: 500 as const, + color: '#333', + }, + dropSubtext: { + margin: 0, + fontSize: '12px', + color: '#999', + }, + uploadingContent: { + pointerEvents: 'none' as const, + }, + progressBar: { + width: '100%', + height: '6px', + backgroundColor: '#e0e0e0', + borderRadius: '3px', + overflow: 'hidden' as const, + marginBottom: '16px', + }, + progressFill: { + height: '100%', + backgroundColor: '#0066cc', + transition: 'width 0.3s ease', + borderRadius: '3px', + }, + uploadingText: { + margin: 0, + fontSize: '14px', + fontWeight: 500 as const, + color: '#0066cc', + }, + hiddenInput: { + display: 'none' as const, + }, +}; + +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); + const [isHovering, setIsHovering] = useState(false); + + 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(); + } + } + + function getDropZoneStyle() { + let style = { ...styles.dropZone }; + if (isUploading) { + style = { ...style, ...styles.dropZoneUploading }; + } else if (isDragging) { + style = { ...style, ...styles.dropZoneDragging }; + } else if (isHovering) { + style = { ...style, ...styles.dropZoneHover }; + } + return style; + } + + return ( +
+
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + + {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/index.js b/packages/decap-cms-media-library-bunny/src/index.js new file mode 100644 index 000000000000..5e6404903d7c --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -0,0 +1,122 @@ +/** + * 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 storageZoneName, apiKey, 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.api_key) { + throw new Error('api_key is required in media_library config'); + } + if (!providedConfig.cdn_url_prefix) { + throw new Error('cdn_url_prefix is required in media_library config'); + } + + 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..2033299d9907 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/types.ts @@ -0,0 +1,59 @@ +/** + * 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; + api_key: string; + cdn_url_prefix: string; + root_path?: string; +} + +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 ad2f760c7683..a3e57e77f8ac 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.0", + "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); From 52595a682810956253edf2ab7ce2d1b598d2bcbd Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Mon, 23 Feb 2026 09:22:23 +0100 Subject: [PATCH 02/11] chore: config --- dev-test/config.yml | 381 ++++++++++++-------------------------------- 1 file changed, 106 insertions(+), 275 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index f985684151a7..6a1f89b3e8df 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -1,288 +1,119 @@ +local_backend: true + backend: - name: test-repo + name: git-gateway + branch: local + squash_merges: true -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 + api_key: 053a7248-17a6-42bd-a32bea3b0436-f0c5-4a23 + 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} - - 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: 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}, + ]}, + {label: Copyright, name: copyright, widget: string}, + {label: Copyright Owner, name: copyrightOwner, widget: string}, + ] - - 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"} From f3e16d0565aa4c38cf323b045e88ab215b074eae Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Fri, 27 Feb 2026 09:13:31 +0100 Subject: [PATCH 03/11] feat: implement OAuth authentication flow for Bunny.net media library - Added BunnyAuthManager to handle OAuth-style authentication and credential storage. - Introduced BunnyManagementApi to fetch storage zone passwords using Account API Key. - Updated BunnyClient to support optional API key and added methods for authentication checks. - Enhanced BunnyWidget to manage authentication state and display login prompt. - Created LoginPrompt component for user authentication interface. - Modified index.js to handle OAuth callback and manage authentication flow. - Updated config.yml to remove API key from configuration. - Adjusted types to include AuthState for managing authentication state. --- dev-test/config.yml | 22 +- .../src/api/authManager.ts | 405 ++++++++++++++++++ .../src/api/client.ts | 72 +++- .../src/api/managementApi.ts | 118 +++++ .../src/components/BunnyWidget.tsx | 149 ++++++- .../src/components/LoginPrompt.tsx | 74 ++++ .../src/index.js | 156 ++++++- .../src/types.ts | 8 +- 8 files changed, 978 insertions(+), 26 deletions(-) create mode 100644 packages/decap-cms-media-library-bunny/src/api/authManager.ts create mode 100644 packages/decap-cms-media-library-bunny/src/api/managementApi.ts create mode 100644 packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx diff --git a/dev-test/config.yml b/dev-test/config.yml index 6a1f89b3e8df..887f866a9525 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -2,8 +2,6 @@ local_backend: true backend: name: git-gateway - branch: local - squash_merges: true display_url: http://localhost:1313 logo_url: /media/brand/logo.svg @@ -14,8 +12,26 @@ media_library: name: bunny config: storage_zone_name: cmt-docs - api_key: 053a7248-17a6-42bd-a32bea3b0436-f0c5-4a23 cdn_url_prefix: https://cmt-docs-cdn.b-cdn.net + # Note: API authentication happens in the browser via OAuth flow + # Users will be prompted to log in when opening the media library + + + + # https://docs.bunny.net/api-reference/core/storage-zone/get-storage-zone + + # example of login url + # callback in domena morata bit ista, sicer gre pa vse skozi + # mogoče čekirajo referrer header + # https://dash.bunny.net/auth/login?source=wp-plugin&domain=https:%2F%2Fdemo.example.com&callbackUrl=https:%2F%2Fdemo.example.com%2Fwp-admin%2Fadmin.php%3Fpage%3Dbunnycdn + + +# media_library: +# name: cloudinary +# config: +# cloud_name: poslovnimediji +# api_key: 712288282278836 +# folder: pm-www slug: encoding: ascii 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..6ce544b96118 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -0,0 +1,405 @@ +/** + * 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'; +const AUTO_OPEN_FLAG_KEY = 'bunny_auto_open'; + +export class BunnyAuthManager { + /** + * Get stored API key (storage zone password) from localStorage + */ + static getStoredApiKey(): string | null { + try { + return localStorage.getItem(STORAGE_API_KEY); + } catch (e) { + console.error('Failed to retrieve stored API key:', e); + return null; + } + } + + /** + * Set (store) Storage Zone Password (for Storage API) in localStorage + */ + static setStoredApiKey(apiKey: string): void { + try { + console.log('[Bunny Auth] Storing Storage Zone Password, length:', apiKey ? apiKey.length : 0); + localStorage.setItem(STORAGE_API_KEY, apiKey); + // Verify it was stored + const stored = localStorage.getItem(STORAGE_API_KEY); + console.log('[Bunny Auth] Storage Zone Password stored successfully, verified length:', stored ? stored.length : 0); + } catch (e) { + console.error('[Bunny Auth] Failed to store Storage Zone Password:', e); + } + } + + /** + * Get stored Account API Key from localStorage + */ + static getStoredAccountApiKey(): string | null { + try { + return localStorage.getItem(ACCOUNT_API_KEY); + } catch (e) { + console.error('[Bunny Auth] Failed to retrieve Account API Key:', e); + return null; + } + } + + /** + * Set (store) Account API Key (from OAuth) in localStorage + */ + static setStoredAccountApiKey(apiKey: string): void { + try { + console.log('[Bunny Auth] Storing Account API Key, length:', apiKey ? apiKey.length : 0); + localStorage.setItem(ACCOUNT_API_KEY, apiKey); + // Verify it was stored + const stored = localStorage.getItem(ACCOUNT_API_KEY); + console.log('[Bunny Auth] Account API Key stored successfully, verified length:', stored ? stored.length : 0); + } catch (e) { + console.error('[Bunny Auth] Failed to store Account API Key:', e); + } + } + + /** + * Get stored storage zone name from localStorage + */ + static getStoredStorageZoneName(): string | null { + try { + return localStorage.getItem(STORAGE_ZONE_NAME_KEY); + } catch (e) { + console.error('Failed to retrieve storage zone name:', e); + return null; + } + } + + /** + * Set (store) storage zone name in localStorage + */ + static setStoredStorageZoneName(zoneName: string): void { + try { + localStorage.setItem(STORAGE_ZONE_NAME_KEY, zoneName); + } catch (e) { + console.error('Failed to store storage zone name:', e); + } + } + + /** + * Clear stored credentials from localStorage + */ + 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); + } + } + + /** + * Save the current page URL before redirecting to authentication + * This allows us to return to the exact page after login + */ + static saveReturnUrl(url: string = window.location.href): void { + try { + const sanitizedUrl = this.sanitizeReturnUrl(url); + console.log('[Bunny Auth] Saving return URL:', sanitizedUrl); + localStorage.setItem(RETURN_URL_KEY, sanitizedUrl); + } catch (e) { + console.error('[Bunny Auth] Failed to save return URL:', e); + } + } + + /** + * Get the saved return URL + */ + static getReturnUrl(): string | null { + try { + return localStorage.getItem(RETURN_URL_KEY); + } catch (e) { + console.error('[Bunny Auth] Failed to retrieve return URL:', e); + return null; + } + } + + /** + * Clear the saved return URL + */ + static clearReturnUrl(): void { + try { + localStorage.removeItem(RETURN_URL_KEY); + } catch (e) { + console.error('[Bunny Auth] Failed to clear return URL:', e); + } + } + + /** + * Set flag to auto-open media library after authentication + */ + static setAutoOpenFlag(): void { + try { + localStorage.setItem(AUTO_OPEN_FLAG_KEY, 'true'); + console.log('[Bunny Auth] Auto-open flag set'); + } catch (e) { + console.error('[Bunny Auth] Failed to set auto-open flag:', e); + } + } + + /** + * Check if media library should auto-open after auth + */ + static shouldAutoOpen(): boolean { + try { + return localStorage.getItem(AUTO_OPEN_FLAG_KEY) === 'true'; + } catch (e) { + console.error('[Bunny Auth] Failed to check auto-open flag:', e); + return false; + } + } + + /** + * Clear auto-open flag + */ + static clearAutoOpenFlag(): void { + try { + localStorage.removeItem(AUTO_OPEN_FLAG_KEY); + console.log('[Bunny Auth] Auto-open flag cleared'); + } catch (e) { + console.error('[Bunny Auth] Failed to clear auto-open flag:', e); + } + } + + /** + * Generate the Bunny authentication URL + * Redirects back to CMS root after authentication + */ + static generateAuthUrl(): string { + const currentDomain = window.location.origin; + // Callback URL is the CMS root - Bunny will redirect there with API key in params + const callbackUrl = currentDomain; + + const authUrl = 'https://dash.bunny.net/auth/login'; + const params = new URLSearchParams({ + source: 'decap', + domain: currentDomain, + callbackUrl, + }); + + console.log('[Bunny Auth] Generated auth URL:', authUrl); + return `${authUrl}?${params.toString()}`; + } + + /** + * Resolve return URL from stored value + */ + static resolveReturnUrl(): string | null { + return this.getReturnUrl(); + } + + /** + * Redirect to Bunny authentication in the same window + */ + static redirectToAuth(): void { + console.log('[Bunny Auth] Initiating redirect to authentication'); + // Save current location before redirecting + this.saveReturnUrl(); + // Set flag to auto-open media library after auth + this.setAutoOpenFlag(); + const authUrl = this.generateAuthUrl(); + console.log('[Bunny Auth] Redirecting to:', authUrl); + window.location.href = authUrl; + } + + /** + * Extract Account API key and storage zone name from URL parameters + * Bunny OAuth returns the Account API Key (not Storage Zone Password) + * We'll use this Account API Key to fetch the actual Storage Zone Password + */ + static extractCredentialsFromUrl(): { apiKey: string | null; storageName: string | null } { + const searchParams = new URLSearchParams(window.location.search); + const hashContent = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash; + const hashQueryIndex = hashContent.indexOf('?'); + const hashParams = new URLSearchParams(hashContent); + const hashQueryParams = + hashQueryIndex >= 0 ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) : null; + + // Try multiple parameter names for API key + const apiKey = + searchParams.get('accessKey') || + searchParams.get('apiKey') || + searchParams.get('api_key') || + searchParams.get('password') || + searchParams.get('token') || + hashParams.get('accessKey') || + hashParams.get('apiKey') || + hashParams.get('api_key') || + hashParams.get('password') || + hashParams.get('token') || + hashQueryParams?.get('accessKey') || + hashQueryParams?.get('apiKey') || + hashQueryParams?.get('api_key') || + hashQueryParams?.get('password') || + hashQueryParams?.get('token') || + null; + + // Try multiple parameter names for storage zone name + const storageName = + searchParams.get('storageName') || + searchParams.get('storage_name') || + searchParams.get('storageZoneName') || + searchParams.get('storage_zone_name') || + searchParams.get('zoneName') || + searchParams.get('zone_name') || + hashParams.get('storageName') || + hashParams.get('storage_name') || + hashParams.get('storageZoneName') || + hashParams.get('storage_zone_name') || + hashParams.get('zoneName') || + hashParams.get('zone_name') || + hashQueryParams?.get('storageName') || + hashQueryParams?.get('storage_name') || + hashQueryParams?.get('storageZoneName') || + hashQueryParams?.get('storage_zone_name') || + hashQueryParams?.get('zoneName') || + hashQueryParams?.get('zone_name') || + null; + + if (apiKey || storageName) { + console.log('[Bunny Auth] Credentials extracted from URL', { + hasApiKey: !!apiKey, + hasStorageName: !!storageName, + storageName, + apiKeyFormat: apiKey ? `${apiKey.slice(0, 8)}...${apiKey.slice(Math.max(0, apiKey.length - 8))} (length: ${apiKey.length})` : 'N/A' + }); + console.log('[Bunny Auth] IMPORTANT: The apiKey from Bunny OAuth must be your Storage Zone Password, not an account API key'); + } + + return { apiKey, storageName }; + } + + /** + * Clean URL by removing auth parameters + */ + static cleanAuthParamsFromUrl(): void { + const searchParams = new URLSearchParams(window.location.search); + const hashContent = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash; + const hashQueryIndex = hashContent.indexOf('?'); + const hasHashQuery = hashQueryIndex >= 0; + const hashRoute = hasHashQuery ? hashContent.slice(0, hashQueryIndex) : hashContent; + const hashQueryParams = hasHashQuery + ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) + : hashContent.includes('=') + ? new URLSearchParams(hashContent) + : new URLSearchParams(); + const authParamNames = [ + 'accessKey', + 'apiKey', + 'api_key', + 'password', + 'token', + 'storageName', + 'storage_name', + 'storageZoneName', + 'storage_zone_name', + 'zoneName', + 'zone_name', + ]; + let hasAuthParams = false; + + authParamNames.forEach(param => { + if (searchParams.has(param)) { + searchParams.delete(param); + hasAuthParams = true; + } + if (hashQueryParams.has(param)) { + hashQueryParams.delete(param); + hasAuthParams = true; + } + }); + + if (hasAuthParams) { + const searchQuery = searchParams.toString(); + const hashQuery = hashQueryParams.toString(); + const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; + const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; + const newUrl = `${window.location.pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; + console.log('[Bunny Auth] Cleaned auth params from URL, new URL:', newUrl); + 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 = new URLSearchParams(parsedUrl.search); + const hashContent = parsedUrl.hash.startsWith('#') + ? parsedUrl.hash.slice(1) + : parsedUrl.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(); + + const authParamNames = [ + 'accessKey', + 'apiKey', + 'api_key', + 'password', + 'token', + 'storageName', + 'storage_name', + 'storageZoneName', + 'storage_zone_name', + 'zoneName', + 'zone_name', + ]; + + authParamNames.forEach(param => { + if (searchParams.has(param)) { + searchParams.delete(param); + } + if (hashQueryParams.has(param)) { + hashQueryParams.delete(param); + } + }); + + const searchQuery = searchParams.toString(); + const hashQuery = hashQueryParams.toString(); + const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; + const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; + + return `${parsedUrl.pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; + } catch (e) { + console.warn('[Bunny Auth] Failed to sanitize return URL, using raw value'); + return url; + } + } + + /** + * Check if fully authenticated (both API key and storage zone name) + */ + static isAuthenticated(): boolean { + const hasKey = !!this.getStoredApiKey(); + const hasZoneName = !!this.getStoredStorageZoneName(); + const isAuth = hasKey && hasZoneName; + if (isAuth) { + console.log('[Bunny Auth] Authenticated: true'); + } + return isAuth; + } +} diff --git a/packages/decap-cms-media-library-bunny/src/api/client.ts b/packages/decap-cms-media-library-bunny/src/api/client.ts index 961ed84a3410..8b472b9d35a0 100644 --- a/packages/decap-cms-media-library-bunny/src/api/client.ts +++ b/packages/decap-cms-media-library-bunny/src/api/client.ts @@ -3,6 +3,8 @@ * 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', @@ -14,22 +16,47 @@ export type BunnyRegion = keyof typeof BUNNY_STORAGE_ENDPOINTS; interface BunnyClientOptions { storageZoneName: string; - apiKey: string; + apiKey?: string; region?: BunnyRegion; } export class BunnyClient { private storageZoneName: string; - private apiKey: string; + private apiKey: string | null; private baseUrl: string; constructor({ storageZoneName, apiKey, region = 'us' }: BunnyClientOptions) { this.storageZoneName = storageZoneName; - this.apiKey = apiKey; + this.apiKey = apiKey || null; this.baseUrl = BUNNY_STORAGE_ENDPOINTS[region]; + console.log('[Bunny Client] Initialized with:', { + storageZoneName, + hasApiKey: !!apiKey, + apiKeyLength: apiKey ? apiKey.length : 0, + 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.'); + } + console.log('[Bunny Client] getHeaders called, API key length:', this.apiKey.length); return { AccessKey: this.apiKey, 'Content-Type': 'application/json', @@ -39,12 +66,21 @@ export class BunnyClient { private buildUrl(path: string): string { // Normalize path const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return `${this.baseUrl}/${this.storageZoneName}${normalizedPath}`; + // URL format: https://storage.bunnycdn.com/{storageZoneName}{path} + const url = `${this.baseUrl}/${this.storageZoneName}${normalizedPath}`; + console.log('[Bunny Client] Built URL:', url); + 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}`); } @@ -56,19 +92,38 @@ export class BunnyClient { return response.text() as unknown as T; } - async listFiles(path = '/'): Promise { + async listFiles(path = '/'): Promise { const url = this.buildUrl(path); + const headers = this.getHeaders(); + console.log('[Bunny Client] Listing files from:', url); + const maskedKey = + this.apiKey && this.apiKey.length > 16 + ? `${this.apiKey.slice(0, 8)}...${this.apiKey.slice(-8)}` + : 'NOT SET'; + console.log('[Bunny Client] Request headers:', { + 'Content-Type': 'application/json', + AccessKey: maskedKey, + }); const response = await fetch(url, { method: 'GET', - headers: this.getHeaders(), + headers, }); - const data = await this.handleResponse(response); + const data = await this.handleResponse(response); + console.log( + '[Bunny Client] Listed files, count:', + Array.isArray(data) ? data.length : 0, + ); 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); + console.log('[Bunny Client] Uploading file to:', url); const arrayBuffer = await file.arrayBuffer(); const response = await fetch(url, { @@ -79,16 +134,19 @@ export class BunnyClient { body: arrayBuffer, }); + console.log('[Bunny Client] File uploaded successfully'); await this.handleResponse(response); } async deleteFile(filePath: string): Promise { const url = this.buildUrl(filePath); + console.log('[Bunny Client] Deleting file from:', url); const response = await fetch(url, { method: 'DELETE', headers: this.getHeaders(), }); + console.log('[Bunny Client] File deleted successfully'); await this.handleResponse(response); } 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..f9a93147655a --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts @@ -0,0 +1,118 @@ +/** + * 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 { + console.log('[Bunny Management API] Fetching storage zone password for:', storageZoneName); + console.log('[Bunny Management API] Using account API key length:', accountApiKey.length); + + try { + // First, list all storage zones to find the one we need + const listUrl = `${BUNNY_API_BASE}/storagezone`; + console.log('[Bunny Management API] Requesting:', listUrl); + + 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(); + console.log('[Bunny Management API] Fetched', storageZones.length, 'storage zones'); + + // 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(', ')}`, + ); + } + + console.log('[Bunny Management API] Found storage zone:', { + id: targetZone.Id, + name: targetZone.Name, + region: targetZone.Region, + hasPassword: !!targetZone.Password, + passwordLength: targetZone.Password ? targetZone.Password.length : 0, + }); + + if (!targetZone.Password) { + throw new Error(`Storage zone "${storageZoneName}" has no password set`); + } + + console.log('[Bunny Management API] Successfully retrieved storage zone password'); + 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 { + console.log('[Bunny Management API] Fetching storage zone by ID:', storageZoneId); + + 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.tsx b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx index 688db790cc10..2ae0de67b6e5 100644 --- a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx @@ -6,9 +6,11 @@ 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 type { AddressedMediaFile } from '../types'; @@ -143,7 +145,6 @@ const styles = { interface BunnyWidgetProps { config: { storage_zone_name: string; - api_key: string; cdn_url_prefix: string; root_path?: string; }; @@ -161,6 +162,10 @@ export function BunnyWidget({ 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()); @@ -171,31 +176,98 @@ export function BunnyWidget({ const fileManagerRef = useRef(null); - // Initialize file manager + // Check for authentication on mount (from localStorage or URL params after redirect) + useEffect(() => { + console.log('[Bunny Widget] Mounting, checking authentication state...'); + + // 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) { + console.log('[Bunny Widget] OAuth callback detected, waiting for index.js to process...'); + // 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(); + console.log('[Bunny Widget] Checking stored credentials:', { + hasStoredKey: !!storedKey, + storedKeyLength: storedKey ? storedKey.length : 0, + hasStoredZoneName: !!storedZoneName, + storedZoneName + }); + if (storedKey && storedZoneName) { + console.log('[Bunny Widget] Found stored credentials, authenticating'); + setApiKey(storedKey); + setStorageZoneName(storedZoneName); + setIsAuthenticated(true); + } else { + console.log('[Bunny Widget] No credentials found, authentication required'); + } + }, []); + + // Log state changes for debugging + useEffect(() => { + console.log('[Bunny Widget] State updated:', { + isAuthenticated, + hasApiKey: !!apiKey, + apiKeyLength: apiKey ? apiKey.length : 0, + hasStorageZoneName: !!storageZoneName, + storageZoneName + }); + }, []); + + // Initialize file manager when authenticated useEffect(() => { + console.log('[Bunny Widget] File manager init effect, checking conditions:', { + isAuthenticated, + hasApiKey: !!apiKey, + apiKeyLength: apiKey ? apiKey.length : 0, + hasStorageZoneName: !!storageZoneName, + storageZoneName + }); + if (!isAuthenticated || !apiKey || !storageZoneName) { + console.log('[Bunny Widget] Not authenticated or missing credentials, skipping file manager init'); + fileManagerRef.current = null; + return; + } + + console.log('[Bunny Widget] Initializing file manager with zone:', storageZoneName, 'and API key length:', apiKey.length); try { fileManagerRef.current = new BunnyFileManager({ - storageZoneName: config.storage_zone_name, - apiKey: config.api_key, + storageZoneName, + apiKey, cdnUrlPrefix: config.cdn_url_prefix, }); + console.log('[Bunny Widget] File manager initialized successfully'); } catch (err) { - setError(`Failed to initialize: ${err instanceof Error ? err.message : String(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}`); } - }, [config]); + }, [isAuthenticated, apiKey, storageZoneName, config.cdn_url_prefix]); - // Load files when path changes + // Load files when path changes (only when authenticated) useEffect(() => { - if (!fileManagerRef.current) return; + if (!isAuthenticated || !fileManagerRef.current) { + setIsLoading(false); + return; + } async function loadFiles() { try { + console.log('[Bunny Widget] Loading files from path:', currentPath); setIsLoading(true); setError(null); const filesData = await fileManagerRef.current!.getFilesWithUrls(currentPath, imagesOnly); + console.log('[Bunny Widget] Loaded', filesData.length, 'files from', currentPath); setFiles(filesData); } catch (err) { - setError(`Failed to load files: ${err instanceof Error ? err.message : String(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); @@ -203,7 +275,24 @@ export function BunnyWidget({ } loadFiles(); - }, [currentPath, imagesOnly]); + }, [currentPath, imagesOnly, isAuthenticated]); + + function handleLogin() { + console.log('[Bunny Widget] Handle login called'); + // Redirect to Bunny authentication in the same window + BunnyAuthManager.redirectToAuth(); + } + + function handleLogout() { + console.log('[Bunny Widget] Handle logout called'); + BunnyAuthManager.clearStoredApiKey(); + BunnyAuthManager.clearReturnUrl(); + setApiKey(null); + setIsAuthenticated(false); + setFiles([]); + setSelectedFiles(new Set()); + setCurrentPath(config.root_path || '/'); + } function handleNavigate(path: string) { setCurrentPath(path); @@ -308,6 +397,35 @@ export function BunnyWidget({ onClose(); } + const closeButtonStyle = { + ...styles.closeButton, + }; + + // Show login prompt if not authenticated + if (!isAuthenticated) { + return ( +
+
+
+

Bunny.net Media Library

+ +
+ +
+
+
+ ); + } + + // Main widget UI (after authentication) return (
@@ -315,7 +433,7 @@ export function BunnyWidget({

Bunny.net Media Library

+
+
+ ); +} + +export default LoginPrompt; diff --git a/packages/decap-cms-media-library-bunny/src/index.js b/packages/decap-cms-media-library-bunny/src/index.js index 5e6404903d7c..d9c4fa54c73d 100644 --- a/packages/decap-cms-media-library-bunny/src/index.js +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -10,7 +10,7 @@ import BunnyWidget from './components/BunnyWidget'; /** * Initialize the Bunny.net media library - * @param options - Configuration options including storageZoneName, apiKey, cdnUrlPrefix + * @param options - Configuration options including storageZoneId, cdnUrlPrefix * @param handleInsert - Callback function when user inserts files * @returns MediaLibraryInstance with show, hide, and other required methods */ @@ -21,13 +21,134 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { if (!providedConfig.storage_zone_name) { throw new Error('storage_zone_name is required in media_library config'); } - if (!providedConfig.api_key) { - throw new Error('api_key is required in media_library config'); - } if (!providedConfig.cdn_url_prefix) { throw new Error('cdn_url_prefix is required in media_library config'); } + // Import auth manager at module level + const { BunnyAuthManager } = await import('./api/authManager'); + + // Check for auth params in URL immediately on init + // This handles the case where Bunny redirects back with credentials + console.log('[Bunny Init] Checking for auth parameters in URL...'); + const { apiKey: urlApiKey, storageName: urlStorageName } = + BunnyAuthManager.extractCredentialsFromUrl(); + if (urlApiKey && urlStorageName) { + console.log('[Bunny Init] Found auth credentials (Account API Key), fetching Storage Zone Password'); + // Store the Account API Key (from OAuth) + BunnyAuthManager.setStoredAccountApiKey(urlApiKey); + BunnyAuthManager.setStoredStorageZoneName(urlStorageName); + + // Fetch the Storage Zone Password using the Account API Key + const { BunnyManagementApi } = await import('./api/managementApi'); + try { + const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( + urlApiKey, + urlStorageName + ); + // Store the Storage Zone Password for Storage API + BunnyAuthManager.setStoredApiKey(storageZonePassword); + console.log('[Bunny Init] Successfully fetched and stored Storage Zone Password'); + } catch (error) { + console.error('[Bunny Init] Failed to fetch Storage Zone Password:', error); + alert(`Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${urlStorageName}"`); + return null; + } + + // Check if we should auto-open the media library + const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); + console.log('[Bunny Init] Should auto-open media library:', shouldAutoOpen); + + // Redirect back to the original page that initiated the auth flow + const returnUrl = BunnyAuthManager.resolveReturnUrl(); + BunnyAuthManager.cleanAuthParamsFromUrl(); + if (returnUrl) { + console.log('[Bunny Init] Redirecting to original page:', returnUrl); + BunnyAuthManager.clearReturnUrl(); + // Use setTimeout to avoid potential race conditions + setTimeout(() => { + try { + const safeUrl = new URL(returnUrl, window.location.origin); + if (safeUrl.origin === window.location.origin) { + window.location.replace(safeUrl.toString()); + } else { + console.warn('[Bunny Init] Return URL origin mismatch, staying on current page'); + } + } catch (e) { + console.warn('[Bunny Init] Invalid return URL, staying on current page'); + } + }, 100); + } else { + console.log('[Bunny Init] No return URL found, staying on current page'); + // If there's no return URL but we should auto-open, trigger after delay + if (shouldAutoOpen) { + console.log('[Bunny Init] No redirect needed, auto-opening media library'); + BunnyAuthManager.clearAutoOpenFlag(); + setTimeout(() => { + // Trigger a custom event that the CMS can listen to + window.dispatchEvent(new CustomEvent('bunny-auth-complete')); + }, 500); + } + } + } + + if (urlApiKey && !urlStorageName) { + console.log('[Bunny Init] Account API Key found without storage name, using config zone name'); + // Store the Account API Key (from OAuth) + BunnyAuthManager.setStoredAccountApiKey(urlApiKey); + BunnyAuthManager.setStoredStorageZoneName(providedConfig.storage_zone_name); + + // Fetch the Storage Zone Password using the Account API Key + const { BunnyManagementApi } = await import('./api/managementApi'); + try { + const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( + urlApiKey, + providedConfig.storage_zone_name + ); + // Store the Storage Zone Password for Storage API + BunnyAuthManager.setStoredApiKey(storageZonePassword); + console.log('[Bunny Init] Successfully fetched and stored Storage Zone Password'); + } catch (error) { + console.error('[Bunny Init] Failed to fetch Storage Zone Password:', error); + alert(`Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${providedConfig.storage_zone_name}"`); + return null; + } + + // Check if we should auto-open the media library + const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); + console.log('[Bunny Init] Should auto-open media library:', shouldAutoOpen); + + const returnUrl = BunnyAuthManager.resolveReturnUrl(); + BunnyAuthManager.cleanAuthParamsFromUrl(); + if (returnUrl) { + console.log('[Bunny Init] Redirecting to original page:', returnUrl); + BunnyAuthManager.clearReturnUrl(); + setTimeout(() => { + try { + const safeUrl = new URL(returnUrl, window.location.origin); + if (safeUrl.origin === window.location.origin) { + window.location.replace(safeUrl.toString()); + } else { + console.warn('[Bunny Init] Return URL origin mismatch, staying on current page'); + } + } catch (e) { + console.warn('[Bunny Init] Invalid return URL, staying on current page'); + } + }, 100); + } else { + console.log('[Bunny Init] No return URL found, staying on current page'); + // If there's no return URL but we should auto-open, trigger after delay + if (shouldAutoOpen) { + console.log('[Bunny Init] No redirect needed, auto-opening media library'); + BunnyAuthManager.clearAutoOpenFlag(); + setTimeout(() => { + // Trigger a custom event that the CMS can listen to + window.dispatchEvent(new CustomEvent('bunny-auth-complete')); + }, 500); + } + } + } + const config = providedConfig; let widgetContainer = null; let widgetRoot = null; @@ -107,6 +228,33 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { enableStandalone: () => true, }; + // Listen for auth completion event to auto-open media library + window.addEventListener('bunny-auth-complete', () => { + console.log('[Bunny Init] Auth complete event received, auto-opening media library'); + setTimeout(() => { + mediaLibraryInstance.show({}); + }, 100); + }); + + // Check if we just completed auth and should auto-open + // This handles the case where we redirected back to the same page + if (BunnyAuthManager.shouldAutoOpen() && BunnyAuthManager.getStoredApiKey()) { + console.log('[Bunny Init] Auto-open flag detected after page load, opening media library'); + BunnyAuthManager.clearAutoOpenFlag(); + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { + mediaLibraryInstance.show({}); + }, 500); + }); + } else { + setTimeout(() => { + mediaLibraryInstance.show({}); + }, 500); + } + } + return mediaLibraryInstance; } diff --git a/packages/decap-cms-media-library-bunny/src/types.ts b/packages/decap-cms-media-library-bunny/src/types.ts index 2033299d9907..3f98f1c13c82 100644 --- a/packages/decap-cms-media-library-bunny/src/types.ts +++ b/packages/decap-cms-media-library-bunny/src/types.ts @@ -20,11 +20,17 @@ export interface BunnyListResponse { export interface BunnyConfig { storage_zone_name: string; - api_key: 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; From ca1ca96b5aed7952acf15498af50daeebdfca00a Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 13:54:12 +0100 Subject: [PATCH 04/11] feat: add Bunny.net media library integration with authentication and file management tests --- .../media_library_bunny_spec_test_backend.js | 62 ++++++++ dev-test/config.yml | 42 ++++- .../src/__tests__/authManager.test.ts | 98 ++++++++++++ .../src/__tests__/client.test.ts | 4 + .../src/__tests__/managementApi.test.ts | 84 ++++++++++ .../src/api/authManager.ts | 22 +-- .../src/api/client.ts | 25 --- .../src/api/managementApi.ts | 16 -- .../src/index.js | 145 +++++------------- 9 files changed, 323 insertions(+), 175 deletions(-) create mode 100644 cypress/e2e/media_library_bunny_spec_test_backend.js create mode 100644 packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts create mode 100644 packages/decap-cms-media-library-bunny/src/__tests__/managementApi.test.ts 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 887f866a9525..a3ce43388c12 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -54,7 +54,35 @@ aliases: *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 + ] collections: - name: posts @@ -75,6 +103,16 @@ collections: *VISIBLE_IN_CMS, ] + - 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: @@ -112,8 +150,6 @@ collections: *HREF, {label: Logo, name: logo, widget: object, fields: *IMAGE_OBJECT_FIELDS}, ]}, - {label: Copyright, name: copyright, widget: string}, - {label: Copyright Owner, name: copyrightOwner, widget: string}, ] - label: Header 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..df81f5c17041 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts @@ -0,0 +1,98 @@ +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('stores and clears auto-open flag', () => { + expect(BunnyAuthManager.shouldAutoOpen()).toBe(false); + + BunnyAuthManager.setAutoOpenFlag(); + expect(BunnyAuthManager.shouldAutoOpen()).toBe(true); + + BunnyAuthManager.clearAutoOpenFlag(); + expect(BunnyAuthManager.shouldAutoOpen()).toBe(false); + }); + + 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 index 965b09203343..671da4ba30c4 100644 --- a/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts +++ b/packages/decap-cms-media-library-bunny/src/__tests__/client.test.ts @@ -66,6 +66,10 @@ describe('BunnyClient', () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 401, + statusText: 'Unauthorized', + headers: { + entries: () => [], + }, text: async () => 'Unauthorized', }); 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 index 6ce544b96118..5d675f4fc098 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -27,11 +27,9 @@ export class BunnyAuthManager { */ static setStoredApiKey(apiKey: string): void { try { - console.log('[Bunny Auth] Storing Storage Zone Password, length:', apiKey ? apiKey.length : 0); localStorage.setItem(STORAGE_API_KEY, apiKey); // Verify it was stored const stored = localStorage.getItem(STORAGE_API_KEY); - console.log('[Bunny Auth] Storage Zone Password stored successfully, verified length:', stored ? stored.length : 0); } catch (e) { console.error('[Bunny Auth] Failed to store Storage Zone Password:', e); } @@ -54,11 +52,9 @@ export class BunnyAuthManager { */ static setStoredAccountApiKey(apiKey: string): void { try { - console.log('[Bunny Auth] Storing Account API Key, length:', apiKey ? apiKey.length : 0); localStorage.setItem(ACCOUNT_API_KEY, apiKey); // Verify it was stored const stored = localStorage.getItem(ACCOUNT_API_KEY); - console.log('[Bunny Auth] Account API Key stored successfully, verified length:', stored ? stored.length : 0); } catch (e) { console.error('[Bunny Auth] Failed to store Account API Key:', e); } @@ -107,7 +103,6 @@ export class BunnyAuthManager { static saveReturnUrl(url: string = window.location.href): void { try { const sanitizedUrl = this.sanitizeReturnUrl(url); - console.log('[Bunny Auth] Saving return URL:', sanitizedUrl); localStorage.setItem(RETURN_URL_KEY, sanitizedUrl); } catch (e) { console.error('[Bunny Auth] Failed to save return URL:', e); @@ -143,7 +138,6 @@ export class BunnyAuthManager { static setAutoOpenFlag(): void { try { localStorage.setItem(AUTO_OPEN_FLAG_KEY, 'true'); - console.log('[Bunny Auth] Auto-open flag set'); } catch (e) { console.error('[Bunny Auth] Failed to set auto-open flag:', e); } @@ -167,7 +161,6 @@ export class BunnyAuthManager { static clearAutoOpenFlag(): void { try { localStorage.removeItem(AUTO_OPEN_FLAG_KEY); - console.log('[Bunny Auth] Auto-open flag cleared'); } catch (e) { console.error('[Bunny Auth] Failed to clear auto-open flag:', e); } @@ -189,7 +182,6 @@ export class BunnyAuthManager { callbackUrl, }); - console.log('[Bunny Auth] Generated auth URL:', authUrl); return `${authUrl}?${params.toString()}`; } @@ -204,13 +196,11 @@ export class BunnyAuthManager { * Redirect to Bunny authentication in the same window */ static redirectToAuth(): void { - console.log('[Bunny Auth] Initiating redirect to authentication'); // Save current location before redirecting this.saveReturnUrl(); // Set flag to auto-open media library after auth this.setAutoOpenFlag(); const authUrl = this.generateAuthUrl(); - console.log('[Bunny Auth] Redirecting to:', authUrl); window.location.href = authUrl; } @@ -271,13 +261,7 @@ export class BunnyAuthManager { null; if (apiKey || storageName) { - console.log('[Bunny Auth] Credentials extracted from URL', { - hasApiKey: !!apiKey, - hasStorageName: !!storageName, - storageName, - apiKeyFormat: apiKey ? `${apiKey.slice(0, 8)}...${apiKey.slice(Math.max(0, apiKey.length - 8))} (length: ${apiKey.length})` : 'N/A' - }); - console.log('[Bunny Auth] IMPORTANT: The apiKey from Bunny OAuth must be your Storage Zone Password, not an account API key'); + // Credentials found in URL } return { apiKey, storageName }; @@ -331,7 +315,6 @@ export class BunnyAuthManager { const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; const newUrl = `${window.location.pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; - console.log('[Bunny Auth] Cleaned auth params from URL, new URL:', newUrl); window.history.replaceState({}, '', newUrl); } } @@ -397,9 +380,6 @@ export class BunnyAuthManager { const hasKey = !!this.getStoredApiKey(); const hasZoneName = !!this.getStoredStorageZoneName(); const isAuth = hasKey && hasZoneName; - if (isAuth) { - console.log('[Bunny Auth] Authenticated: true'); - } return isAuth; } } diff --git a/packages/decap-cms-media-library-bunny/src/api/client.ts b/packages/decap-cms-media-library-bunny/src/api/client.ts index 8b472b9d35a0..0526007eafc4 100644 --- a/packages/decap-cms-media-library-bunny/src/api/client.ts +++ b/packages/decap-cms-media-library-bunny/src/api/client.ts @@ -29,12 +29,6 @@ export class BunnyClient { this.storageZoneName = storageZoneName; this.apiKey = apiKey || null; this.baseUrl = BUNNY_STORAGE_ENDPOINTS[region]; - console.log('[Bunny Client] Initialized with:', { - storageZoneName, - hasApiKey: !!apiKey, - apiKeyLength: apiKey ? apiKey.length : 0, - region - }); } /** @@ -56,7 +50,6 @@ export class BunnyClient { console.error('[Bunny Client] getHeaders called but API key is not set!'); throw new Error('API key not set. Please authenticate first.'); } - console.log('[Bunny Client] getHeaders called, API key length:', this.apiKey.length); return { AccessKey: this.apiKey, 'Content-Type': 'application/json', @@ -68,7 +61,6 @@ export class BunnyClient { const normalizedPath = path.startsWith('/') ? path : `/${path}`; // URL format: https://storage.bunnycdn.com/{storageZoneName}{path} const url = `${this.baseUrl}/${this.storageZoneName}${normalizedPath}`; - console.log('[Bunny Client] Built URL:', url); return url; } @@ -95,25 +87,12 @@ export class BunnyClient { async listFiles(path = '/'): Promise { const url = this.buildUrl(path); const headers = this.getHeaders(); - console.log('[Bunny Client] Listing files from:', url); - const maskedKey = - this.apiKey && this.apiKey.length > 16 - ? `${this.apiKey.slice(0, 8)}...${this.apiKey.slice(-8)}` - : 'NOT SET'; - console.log('[Bunny Client] Request headers:', { - 'Content-Type': 'application/json', - AccessKey: maskedKey, - }); const response = await fetch(url, { method: 'GET', headers, }); const data = await this.handleResponse(response); - console.log( - '[Bunny Client] Listed files, count:', - Array.isArray(data) ? data.length : 0, - ); return Array.isArray(data) ? data : []; } @@ -123,7 +102,6 @@ export class BunnyClient { } const url = this.buildUrl(filePath); - console.log('[Bunny Client] Uploading file to:', url); const arrayBuffer = await file.arrayBuffer(); const response = await fetch(url, { @@ -134,19 +112,16 @@ export class BunnyClient { body: arrayBuffer, }); - console.log('[Bunny Client] File uploaded successfully'); await this.handleResponse(response); } async deleteFile(filePath: string): Promise { const url = this.buildUrl(filePath); - console.log('[Bunny Client] Deleting file from:', url); const response = await fetch(url, { method: 'DELETE', headers: this.getHeaders(), }); - console.log('[Bunny Client] File deleted successfully'); await this.handleResponse(response); } diff --git a/packages/decap-cms-media-library-bunny/src/api/managementApi.ts b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts index f9a93147655a..36378f231fcc 100644 --- a/packages/decap-cms-media-library-bunny/src/api/managementApi.ts +++ b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts @@ -26,13 +26,9 @@ export class BunnyManagementApi { accountApiKey: string, storageZoneName: string, ): Promise { - console.log('[Bunny Management API] Fetching storage zone password for:', storageZoneName); - console.log('[Bunny Management API] Using account API key length:', accountApiKey.length); - try { // First, list all storage zones to find the one we need const listUrl = `${BUNNY_API_BASE}/storagezone`; - console.log('[Bunny Management API] Requesting:', listUrl); const response = await fetch(listUrl, { method: 'GET', @@ -55,7 +51,6 @@ export class BunnyManagementApi { } const storageZones: StorageZone[] = await response.json(); - console.log('[Bunny Management API] Fetched', storageZones.length, 'storage zones'); // Find the storage zone by name const targetZone = storageZones.find( @@ -70,19 +65,10 @@ export class BunnyManagementApi { ); } - console.log('[Bunny Management API] Found storage zone:', { - id: targetZone.Id, - name: targetZone.Name, - region: targetZone.Region, - hasPassword: !!targetZone.Password, - passwordLength: targetZone.Password ? targetZone.Password.length : 0, - }); - if (!targetZone.Password) { throw new Error(`Storage zone "${storageZoneName}" has no password set`); } - console.log('[Bunny Management API] Successfully retrieved storage zone password'); return targetZone.Password; } catch (error) { console.error('[Bunny Management API] Failed to fetch storage zone password:', error); @@ -97,8 +83,6 @@ export class BunnyManagementApi { accountApiKey: string, storageZoneId: number, ): Promise { - console.log('[Bunny Management API] Fetching storage zone by ID:', storageZoneId); - const url = `${BUNNY_API_BASE}/storagezone/${storageZoneId}`; const response = await fetch(url, { method: 'GET', diff --git a/packages/decap-cms-media-library-bunny/src/index.js b/packages/decap-cms-media-library-bunny/src/index.js index d9c4fa54c73d..a9307759e4a3 100644 --- a/packages/decap-cms-media-library-bunny/src/index.js +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -25,128 +25,59 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { throw new Error('cdn_url_prefix is required in media_library config'); } - // Import auth manager at module level const { BunnyAuthManager } = await import('./api/authManager'); - // Check for auth params in URL immediately on init - // This handles the case where Bunny redirects back with credentials - console.log('[Bunny Init] Checking for auth parameters in URL...'); - const { apiKey: urlApiKey, storageName: urlStorageName } = - BunnyAuthManager.extractCredentialsFromUrl(); - if (urlApiKey && urlStorageName) { - console.log('[Bunny Init] Found auth credentials (Account API Key), fetching Storage Zone Password'); - // Store the Account API Key (from OAuth) - BunnyAuthManager.setStoredAccountApiKey(urlApiKey); - BunnyAuthManager.setStoredStorageZoneName(urlStorageName); - - // Fetch the Storage Zone Password using the Account API Key - const { BunnyManagementApi } = await import('./api/managementApi'); + function safelyRedirectToReturnUrl(returnUrl) { try { - const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( - urlApiKey, - urlStorageName - ); - // Store the Storage Zone Password for Storage API - BunnyAuthManager.setStoredApiKey(storageZonePassword); - console.log('[Bunny Init] Successfully fetched and stored Storage Zone Password'); - } catch (error) { - console.error('[Bunny Init] Failed to fetch Storage Zone Password:', error); - alert(`Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${urlStorageName}"`); - return null; + 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 } + } - // Check if we should auto-open the media library - const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); - console.log('[Bunny Init] Should auto-open media library:', shouldAutoOpen); + 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); - // Redirect back to the original page that initiated the auth flow + const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); const returnUrl = BunnyAuthManager.resolveReturnUrl(); BunnyAuthManager.cleanAuthParamsFromUrl(); + if (returnUrl) { - console.log('[Bunny Init] Redirecting to original page:', returnUrl); BunnyAuthManager.clearReturnUrl(); - // Use setTimeout to avoid potential race conditions + setTimeout(() => safelyRedirectToReturnUrl(returnUrl), 100); + return; + } + + if (shouldAutoOpen) { + BunnyAuthManager.clearAutoOpenFlag(); setTimeout(() => { - try { - const safeUrl = new URL(returnUrl, window.location.origin); - if (safeUrl.origin === window.location.origin) { - window.location.replace(safeUrl.toString()); - } else { - console.warn('[Bunny Init] Return URL origin mismatch, staying on current page'); - } - } catch (e) { - console.warn('[Bunny Init] Invalid return URL, staying on current page'); - } - }, 100); - } else { - console.log('[Bunny Init] No return URL found, staying on current page'); - // If there's no return URL but we should auto-open, trigger after delay - if (shouldAutoOpen) { - console.log('[Bunny Init] No redirect needed, auto-opening media library'); - BunnyAuthManager.clearAutoOpenFlag(); - setTimeout(() => { - // Trigger a custom event that the CMS can listen to - window.dispatchEvent(new CustomEvent('bunny-auth-complete')); - }, 500); - } + window.dispatchEvent(new CustomEvent('bunny-auth-complete')); + }, 500); } } - if (urlApiKey && !urlStorageName) { - console.log('[Bunny Init] Account API Key found without storage name, using config zone name'); - // Store the Account API Key (from OAuth) - BunnyAuthManager.setStoredAccountApiKey(urlApiKey); - BunnyAuthManager.setStoredStorageZoneName(providedConfig.storage_zone_name); - - // Fetch the Storage Zone Password using the Account API Key - const { BunnyManagementApi } = await import('./api/managementApi'); + const { apiKey: urlApiKey, storageName: urlStorageName } = + BunnyAuthManager.extractCredentialsFromUrl(); + + if (urlApiKey) { + const storageZoneName = urlStorageName || providedConfig.storage_zone_name; + try { - const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( - urlApiKey, - providedConfig.storage_zone_name - ); - // Store the Storage Zone Password for Storage API - BunnyAuthManager.setStoredApiKey(storageZonePassword); - console.log('[Bunny Init] Successfully fetched and stored Storage Zone Password'); + await processAuthCallback(urlApiKey, storageZoneName); } catch (error) { - console.error('[Bunny Init] Failed to fetch Storage Zone Password:', error); - alert(`Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${providedConfig.storage_zone_name}"`); + alert( + `Failed to fetch storage credentials: ${error.message}\n\nPlease ensure you have access to the storage zone "${storageZoneName}"`, + ); return null; } - - // Check if we should auto-open the media library - const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); - console.log('[Bunny Init] Should auto-open media library:', shouldAutoOpen); - - const returnUrl = BunnyAuthManager.resolveReturnUrl(); - BunnyAuthManager.cleanAuthParamsFromUrl(); - if (returnUrl) { - console.log('[Bunny Init] Redirecting to original page:', returnUrl); - BunnyAuthManager.clearReturnUrl(); - setTimeout(() => { - try { - const safeUrl = new URL(returnUrl, window.location.origin); - if (safeUrl.origin === window.location.origin) { - window.location.replace(safeUrl.toString()); - } else { - console.warn('[Bunny Init] Return URL origin mismatch, staying on current page'); - } - } catch (e) { - console.warn('[Bunny Init] Invalid return URL, staying on current page'); - } - }, 100); - } else { - console.log('[Bunny Init] No return URL found, staying on current page'); - // If there's no return URL but we should auto-open, trigger after delay - if (shouldAutoOpen) { - console.log('[Bunny Init] No redirect needed, auto-opening media library'); - BunnyAuthManager.clearAutoOpenFlag(); - setTimeout(() => { - // Trigger a custom event that the CMS can listen to - window.dispatchEvent(new CustomEvent('bunny-auth-complete')); - }, 500); - } - } } const config = providedConfig; @@ -228,20 +159,14 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { enableStandalone: () => true, }; - // Listen for auth completion event to auto-open media library window.addEventListener('bunny-auth-complete', () => { - console.log('[Bunny Init] Auth complete event received, auto-opening media library'); setTimeout(() => { mediaLibraryInstance.show({}); }, 100); }); - // Check if we just completed auth and should auto-open - // This handles the case where we redirected back to the same page if (BunnyAuthManager.shouldAutoOpen() && BunnyAuthManager.getStoredApiKey()) { - console.log('[Bunny Init] Auto-open flag detected after page load, opening media library'); BunnyAuthManager.clearAutoOpenFlag(); - // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { From 02928c745dbaf90938e4e42761305cf0456ae985 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 14:16:16 +0100 Subject: [PATCH 05/11] refactor: clean up code formatting and remove unnecessary console logs in Bunny.net integration --- .../src/__tests__/authManager.test.ts | 6 +-- .../src/api/authManager.ts | 24 ++++++------ .../src/api/client.ts | 2 +- .../src/api/managementApi.ts | 13 ++++--- .../src/components/BunnyWidget.tsx | 37 ------------------- .../src/index.js | 5 ++- 6 files changed, 25 insertions(+), 62 deletions(-) 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 index df81f5c17041..e5ec9a715800 100644 --- a/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts +++ b/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts @@ -68,11 +68,7 @@ describe('BunnyAuthManager', () => { BunnyAuthManager.cleanAuthParamsFromUrl(); - expect(replaceSpy).toHaveBeenCalledWith( - {}, - '', - '/admin/?keep=1#/collections/posts/new?ok=2', - ); + expect(replaceSpy).toHaveBeenCalledWith({}, '', '/admin/?keep=1#/collections/posts/new?ok=2'); }); it('stores and clears auto-open flag', () => { diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index 5d675f4fc098..88b8e76a9478 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -28,8 +28,6 @@ export class BunnyAuthManager { static setStoredApiKey(apiKey: string): void { try { localStorage.setItem(STORAGE_API_KEY, apiKey); - // Verify it was stored - const stored = localStorage.getItem(STORAGE_API_KEY); } catch (e) { console.error('[Bunny Auth] Failed to store Storage Zone Password:', e); } @@ -53,8 +51,6 @@ export class BunnyAuthManager { static setStoredAccountApiKey(apiKey: string): void { try { localStorage.setItem(ACCOUNT_API_KEY, apiKey); - // Verify it was stored - const stored = localStorage.getItem(ACCOUNT_API_KEY); } catch (e) { console.error('[Bunny Auth] Failed to store Account API Key:', e); } @@ -281,8 +277,8 @@ export class BunnyAuthManager { const hashQueryParams = hasHashQuery ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) : hashContent.includes('=') - ? new URLSearchParams(hashContent) - : new URLSearchParams(); + ? new URLSearchParams(hashContent) + : new URLSearchParams(); const authParamNames = [ 'accessKey', 'apiKey', @@ -314,7 +310,9 @@ export class BunnyAuthManager { const hashQuery = hashQueryParams.toString(); const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; - const newUrl = `${window.location.pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; + const newUrl = `${window.location.pathname}${ + searchQuery ? `?${searchQuery}` : '' + }${hashPrefix}${hashSuffix}`; window.history.replaceState({}, '', newUrl); } } @@ -326,17 +324,15 @@ export class BunnyAuthManager { try { const parsedUrl = new URL(url, window.location.origin); const searchParams = new URLSearchParams(parsedUrl.search); - const hashContent = parsedUrl.hash.startsWith('#') - ? parsedUrl.hash.slice(1) - : parsedUrl.hash; + const hashContent = parsedUrl.hash.startsWith('#') ? parsedUrl.hash.slice(1) : parsedUrl.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(); + ? new URLSearchParams(hashContent) + : new URLSearchParams(); const authParamNames = [ 'accessKey', @@ -366,7 +362,9 @@ export class BunnyAuthManager { const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; - return `${parsedUrl.pathname}${searchQuery ? `?${searchQuery}` : ''}${hashPrefix}${hashSuffix}`; + return `${parsedUrl.pathname}${ + searchQuery ? `?${searchQuery}` : '' + }${hashPrefix}${hashSuffix}`; } catch (e) { console.warn('[Bunny Auth] Failed to sanitize return URL, using raw value'); return url; diff --git a/packages/decap-cms-media-library-bunny/src/api/client.ts b/packages/decap-cms-media-library-bunny/src/api/client.ts index 0526007eafc4..0c5578ff7939 100644 --- a/packages/decap-cms-media-library-bunny/src/api/client.ts +++ b/packages/decap-cms-media-library-bunny/src/api/client.ts @@ -71,7 +71,7 @@ export class BunnyClient { status: response.status, statusText: response.statusText, body: errorBody, - headers: Object.fromEntries(response.headers.entries()) + headers: Object.fromEntries(response.headers.entries()), }); throw new Error(`Bunny.net API error: ${response.status} - ${errorBody}`); } diff --git a/packages/decap-cms-media-library-bunny/src/api/managementApi.ts b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts index 36378f231fcc..e974f8bfa847 100644 --- a/packages/decap-cms-media-library-bunny/src/api/managementApi.ts +++ b/packages/decap-cms-media-library-bunny/src/api/managementApi.ts @@ -45,9 +45,7 @@ export class BunnyManagementApi { statusText: response.statusText, body: errorBody, }); - throw new Error( - `Failed to fetch storage zones: ${response.status} - ${errorBody}`, - ); + throw new Error(`Failed to fetch storage zones: ${response.status} - ${errorBody}`); } const storageZones: StorageZone[] = await response.json(); @@ -59,9 +57,14 @@ export class BunnyManagementApi { if (!targetZone) { console.error('[Bunny Management API] Storage zone not found:', storageZoneName); - console.error('[Bunny Management API] Available zones:', storageZones.map(z => z.Name)); + 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(', ')}`, + `Storage zone "${storageZoneName}" not found. Available zones: ${storageZones + .map(z => z.Name) + .join(', ')}`, ); } diff --git a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx index 2ae0de67b6e5..71a05375ac20 100644 --- a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx @@ -178,13 +178,10 @@ export function BunnyWidget({ // Check for authentication on mount (from localStorage or URL params after redirect) useEffect(() => { - console.log('[Bunny Widget] Mounting, checking authentication state...'); - // 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) { - console.log('[Bunny Widget] OAuth callback detected, waiting for index.js to process...'); // Don't do anything, let index.js handle the OAuth flow return; } @@ -192,56 +189,26 @@ export function BunnyWidget({ // Check for existing stored credentials (Storage Zone Password) const storedKey = BunnyAuthManager.getStoredApiKey(); const storedZoneName = BunnyAuthManager.getStoredStorageZoneName(); - console.log('[Bunny Widget] Checking stored credentials:', { - hasStoredKey: !!storedKey, - storedKeyLength: storedKey ? storedKey.length : 0, - hasStoredZoneName: !!storedZoneName, - storedZoneName - }); if (storedKey && storedZoneName) { - console.log('[Bunny Widget] Found stored credentials, authenticating'); setApiKey(storedKey); setStorageZoneName(storedZoneName); setIsAuthenticated(true); - } else { - console.log('[Bunny Widget] No credentials found, authentication required'); } }, []); - // Log state changes for debugging - useEffect(() => { - console.log('[Bunny Widget] State updated:', { - isAuthenticated, - hasApiKey: !!apiKey, - apiKeyLength: apiKey ? apiKey.length : 0, - hasStorageZoneName: !!storageZoneName, - storageZoneName - }); - }, []); - // Initialize file manager when authenticated useEffect(() => { - console.log('[Bunny Widget] File manager init effect, checking conditions:', { - isAuthenticated, - hasApiKey: !!apiKey, - apiKeyLength: apiKey ? apiKey.length : 0, - hasStorageZoneName: !!storageZoneName, - storageZoneName - }); if (!isAuthenticated || !apiKey || !storageZoneName) { - console.log('[Bunny Widget] Not authenticated or missing credentials, skipping file manager init'); fileManagerRef.current = null; return; } - console.log('[Bunny Widget] Initializing file manager with zone:', storageZoneName, 'and API key length:', apiKey.length); try { fileManagerRef.current = new BunnyFileManager({ storageZoneName, apiKey, cdnUrlPrefix: config.cdn_url_prefix, }); - console.log('[Bunny Widget] File manager initialized successfully'); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); console.error('[Bunny Widget] Failed to initialize file manager:', errorMsg); @@ -258,11 +225,9 @@ export function BunnyWidget({ async function loadFiles() { try { - console.log('[Bunny Widget] Loading files from path:', currentPath); setIsLoading(true); setError(null); const filesData = await fileManagerRef.current!.getFilesWithUrls(currentPath, imagesOnly); - console.log('[Bunny Widget] Loaded', filesData.length, 'files from', currentPath); setFiles(filesData); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); @@ -278,13 +243,11 @@ export function BunnyWidget({ }, [currentPath, imagesOnly, isAuthenticated]); function handleLogin() { - console.log('[Bunny Widget] Handle login called'); // Redirect to Bunny authentication in the same window BunnyAuthManager.redirectToAuth(); } function handleLogout() { - console.log('[Bunny Widget] Handle logout called'); BunnyAuthManager.clearStoredApiKey(); BunnyAuthManager.clearReturnUrl(); setApiKey(null); diff --git a/packages/decap-cms-media-library-bunny/src/index.js b/packages/decap-cms-media-library-bunny/src/index.js index a9307759e4a3..560aefe27b26 100644 --- a/packages/decap-cms-media-library-bunny/src/index.js +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -43,7 +43,10 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { BunnyAuthManager.setStoredStorageZoneName(zoneName); const { BunnyManagementApi } = await import('./api/managementApi'); - const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword(accountApiKey, zoneName); + const storageZonePassword = await BunnyManagementApi.fetchStorageZonePassword( + accountApiKey, + zoneName, + ); BunnyAuthManager.setStoredApiKey(storageZonePassword); const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); From b60d7b7c1de850ea5fe8a957a2baf62706e74d73 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 14:46:51 +0100 Subject: [PATCH 06/11] feat: add styled components for BunnyWidget and update dependencies --- package-lock.json | 5 + .../package.json | 5 + .../src/components/BunnyWidget.tsx | 253 ++--------- .../src/components/styles.ts | 406 ++++++++++++++++++ 4 files changed, 463 insertions(+), 206 deletions(-) create mode 100644 packages/decap-cms-media-library-bunny/src/components/styles.ts diff --git a/package-lock.json b/package-lock.json index a94f9bc524e4..4b45b943b007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34313,6 +34313,11 @@ "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", diff --git a/packages/decap-cms-media-library-bunny/package.json b/packages/decap-cms-media-library-bunny/package.json index bac4c7fbd2eb..2cb643a64911 100644 --- a/packages/decap-cms-media-library-bunny/package.json +++ b/packages/decap-cms-media-library-bunny/package.json @@ -30,5 +30,10 @@ "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/components/BunnyWidget.tsx b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx index 71a05375ac20..715212db1811 100644 --- a/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/BunnyWidget.tsx @@ -11,137 +11,24 @@ 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'; -const styles = { - widget: { - position: 'fixed' as const, - top: 0, - left: 0, - right: 0, - bottom: 0, - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - zIndex: 99999, - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - }, - backdrop: { - position: 'fixed' as const, - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - zIndex: -1, - }, - container: { - position: 'relative' as const, - width: '90%', - maxWidth: '1200px', - height: '90vh', - background: 'white', - borderRadius: '8px', - boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)', - display: 'flex' as const, - flexDirection: 'column' as const, - overflow: 'hidden' as const, - }, - header: { - padding: '20px 24px', - borderBottom: '1px solid #e0e0e0', - display: 'flex' as const, - justifyContent: 'space-between' as const, - alignItems: 'center' as const, - background: '#f9f9f9', - }, - headerTitle: { - margin: 0, - fontSize: '20px', - fontWeight: 600, - color: '#333', - }, - closeButton: { - background: 'none', - border: 'none', - fontSize: '24px', - cursor: 'pointer', - padding: 0, - width: '32px', - height: '32px', - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - color: '#666', - transition: 'color 0.2s', - }, - error: { - padding: '12px 24px', - backgroundColor: '#fee', - color: '#c33', - borderBottom: '1px solid #e0e0e0', - fontSize: '14px', - }, - fileGridContainer: { - flex: 1, - overflowY: 'auto' as const, - padding: '20px 24px', - background: 'white', - }, - loading: { - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - height: '100%', - color: '#999', - fontSize: '16px', - }, - empty: { - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - height: '100%', - color: '#999', - fontSize: '16px', - }, - footer: { - padding: '16px 24px', - borderTop: '1px solid #e0e0e0', - background: '#f9f9f9', - display: 'flex' as const, - justifyContent: 'flex-end' as const, - gap: '12px', - }, - buttonPrimary: { - padding: '8px 16px', - backgroundColor: '#0066cc', - color: 'white', - border: 'none', - borderRadius: '4px', - fontSize: '14px', - fontWeight: 500 as const, - cursor: 'pointer', - transition: 'all 0.2s', - }, - buttonPrimaryDisabled: { - backgroundColor: '#ccc', - cursor: 'not-allowed', - }, - buttonSecondary: { - padding: '8px 16px', - backgroundColor: '#e0e0e0', - color: '#333', - border: 'none', - borderRadius: '4px', - fontSize: '14px', - fontWeight: 500 as const, - cursor: 'pointer', - transition: 'all 0.2s', - }, -}; - interface BunnyWidgetProps { config: { storage_zone_name: string; @@ -360,54 +247,38 @@ export function BunnyWidget({ onClose(); } - const closeButtonStyle = { - ...styles.closeButton, - }; - // 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}
} + {error && {error}} {/* Navigation */} {/* File Grid */} -
+ {isLoading ? ( -
Loading files...
+ Loading files... ) : files.length === 0 ? ( -
No files found
+ No files found ) : ( )} -
+ {/* Footer Actions */} -
- - + + Cancel {selectedFiles.size > 0 && ( - + )} -
-
+ + {/* Backdrop */} -
-
+ + ); } 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..a376a4b5b5c2 --- /dev/null +++ b/packages/decap-cms-media-library-bunny/src/components/styles.ts @@ -0,0 +1,406 @@ +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}; +`; + +// Login prompt styles +export const StyledLoginPrompt = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: ${designTokens.spacing.lg}; + padding: ${designTokens.spacing.xxl}; + text-align: center; +`; + +export const StyledLoginPromptTitle = styled.h2` + margin: 0; + font-size: ${designTokens.font.size.xl}; + font-weight: ${designTokens.font.weight.semibold}; + color: ${designTokens.colors.text}; +`; + +export const StyledLoginPromptMessage = styled.p` + margin: 0; + font-size: ${designTokens.font.size.base}; + color: ${designTokens.colors.textSecondary}; + max-width: 400px; +`; + +export const StyledLoginButton = styled(StyledButtonPrimary)` + margin-top: ${designTokens.spacing.md}; + padding: ${designTokens.spacing.md} ${designTokens.spacing.xl}; +`; + +// File upload styles +export const StyledFileUpload = styled.div` + display: flex; + flex-direction: column; + gap: ${designTokens.spacing.md}; + padding: ${designTokens.spacing.lg}; + background: ${designTokens.colors.background}; + border-radius: ${designTokens.radius.md}; +`; + +export const StyledUploadArea = styled.div<{ isDragActive?: boolean }>` + border: 2px dashed + ${props => (props.isDragActive ? designTokens.colors.primary : designTokens.colors.border)}; + border-radius: ${designTokens.radius.md}; + padding: ${designTokens.spacing.xl}; + text-align: center; + cursor: pointer; + transition: all ${designTokens.transition}; + background-color: ${props => + props.isDragActive ? designTokens.colors.primaryLight : 'transparent'}; + + &:hover { + border-color: ${designTokens.colors.primary}; + background-color: ${designTokens.colors.primaryLight}; + } +`; + +export const StyledUploadText = styled.p` + margin: 0; + font-size: ${designTokens.font.size.base}; + color: ${designTokens.colors.textSecondary}; +`; + +export const StyledUploadInput = styled.input` + display: none; +`; + +export const StyledProgressBar = styled.div` + width: 100%; + height: 4px; + background-color: ${designTokens.colors.border}; + border-radius: 2px; + overflow: hidden; +`; + +export const StyledProgressFill = styled.div<{ progress: number }>` + height: 100%; + background-color: ${designTokens.colors.primary}; + width: ${props => props.progress}%; + transition: width ${designTokens.transition}; +`; From cea5ecd44eb5ab2d9e1acc117f8b505292c7885d Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 15:16:11 +0100 Subject: [PATCH 07/11] feat: refactor BunnyAuthManager for safer localStorage access - improve URL parameter handling; enhance FileGrid - FileUpload components with styled components --- .../src/api/authManager.ts | 408 ++++++------------ .../src/components/FileGrid.tsx | 207 ++------- .../src/components/FileUpload.tsx | 142 ++---- .../src/components/LoginPrompt.tsx | 72 +--- .../src/components/styles.ts | 218 ++++++++-- 5 files changed, 408 insertions(+), 639 deletions(-) diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index 88b8e76a9478..4e255a412ea2 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -9,79 +9,57 @@ const STORAGE_ZONE_NAME_KEY = 'bunny_storage_zone_name'; const RETURN_URL_KEY = 'bunny_return_url'; const AUTO_OPEN_FLAG_KEY = 'bunny_auto_open'; +// 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 { - /** - * Get stored API key (storage zone password) from localStorage - */ static getStoredApiKey(): string | null { - try { - return localStorage.getItem(STORAGE_API_KEY); - } catch (e) { - console.error('Failed to retrieve stored API key:', e); - return null; - } + return safeGetItem(STORAGE_API_KEY, 'Failed to retrieve stored API key:'); } - /** - * Set (store) Storage Zone Password (for Storage API) in localStorage - */ static setStoredApiKey(apiKey: string): void { - try { - localStorage.setItem(STORAGE_API_KEY, apiKey); - } catch (e) { - console.error('[Bunny Auth] Failed to store Storage Zone Password:', e); - } + safeSetItem(STORAGE_API_KEY, apiKey, '[Bunny Auth] Failed to store Storage Zone Password:'); } - /** - * Get stored Account API Key from localStorage - */ static getStoredAccountApiKey(): string | null { - try { - return localStorage.getItem(ACCOUNT_API_KEY); - } catch (e) { - console.error('[Bunny Auth] Failed to retrieve Account API Key:', e); - return null; - } + return safeGetItem(ACCOUNT_API_KEY, '[Bunny Auth] Failed to retrieve Account API Key:'); } - /** - * Set (store) Account API Key (from OAuth) in localStorage - */ static setStoredAccountApiKey(apiKey: string): void { - try { - localStorage.setItem(ACCOUNT_API_KEY, apiKey); - } catch (e) { - console.error('[Bunny Auth] Failed to store Account API Key:', e); - } + safeSetItem(ACCOUNT_API_KEY, apiKey, '[Bunny Auth] Failed to store Account API Key:'); } - /** - * Get stored storage zone name from localStorage - */ static getStoredStorageZoneName(): string | null { - try { - return localStorage.getItem(STORAGE_ZONE_NAME_KEY); - } catch (e) { - console.error('Failed to retrieve storage zone name:', e); - return null; - } + return safeGetItem(STORAGE_ZONE_NAME_KEY, 'Failed to retrieve storage zone name:'); } - /** - * Set (store) storage zone name in localStorage - */ static setStoredStorageZoneName(zoneName: string): void { - try { - localStorage.setItem(STORAGE_ZONE_NAME_KEY, zoneName); - } catch (e) { - console.error('Failed to store storage zone name:', e); - } + safeSetItem(STORAGE_ZONE_NAME_KEY, zoneName, 'Failed to store storage zone name:'); } - /** - * Clear stored credentials from localStorage - */ static clearStoredApiKey(): void { try { localStorage.removeItem(STORAGE_API_KEY); @@ -92,199 +70,136 @@ export class BunnyAuthManager { } } - /** - * Save the current page URL before redirecting to authentication - * This allows us to return to the exact page after login - */ static saveReturnUrl(url: string = window.location.href): void { - try { - const sanitizedUrl = this.sanitizeReturnUrl(url); - localStorage.setItem(RETURN_URL_KEY, sanitizedUrl); - } catch (e) { - console.error('[Bunny Auth] Failed to save return URL:', e); - } + const sanitizedUrl = this.sanitizeReturnUrl(url); + safeSetItem(RETURN_URL_KEY, sanitizedUrl, '[Bunny Auth] Failed to save return URL:'); } - /** - * Get the saved return URL - */ static getReturnUrl(): string | null { - try { - return localStorage.getItem(RETURN_URL_KEY); - } catch (e) { - console.error('[Bunny Auth] Failed to retrieve return URL:', e); - return null; - } + return safeGetItem(RETURN_URL_KEY, '[Bunny Auth] Failed to retrieve return URL:'); } - /** - * Clear the saved return URL - */ static clearReturnUrl(): void { - try { - localStorage.removeItem(RETURN_URL_KEY); - } catch (e) { - console.error('[Bunny Auth] Failed to clear return URL:', e); - } + safeRemoveItem(RETURN_URL_KEY, '[Bunny Auth] Failed to clear return URL:'); } - /** - * Set flag to auto-open media library after authentication - */ static setAutoOpenFlag(): void { - try { - localStorage.setItem(AUTO_OPEN_FLAG_KEY, 'true'); - } catch (e) { - console.error('[Bunny Auth] Failed to set auto-open flag:', e); - } + safeSetItem(AUTO_OPEN_FLAG_KEY, 'true', '[Bunny Auth] Failed to set auto-open flag:'); } - /** - * Check if media library should auto-open after auth - */ static shouldAutoOpen(): boolean { - try { - return localStorage.getItem(AUTO_OPEN_FLAG_KEY) === 'true'; - } catch (e) { - console.error('[Bunny Auth] Failed to check auto-open flag:', e); - return false; - } + return ( + safeGetItem(AUTO_OPEN_FLAG_KEY, '[Bunny Auth] Failed to check auto-open flag:') === 'true' + ); } - /** - * Clear auto-open flag - */ static clearAutoOpenFlag(): void { - try { - localStorage.removeItem(AUTO_OPEN_FLAG_KEY); - } catch (e) { - console.error('[Bunny Auth] Failed to clear auto-open flag:', e); + safeRemoveItem(AUTO_OPEN_FLAG_KEY, '[Bunny Auth] Failed to clear auto-open flag:'); + } + + // 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 - * Redirects back to CMS root after authentication */ static generateAuthUrl(): string { const currentDomain = window.location.origin; - // Callback URL is the CMS root - Bunny will redirect there with API key in params const callbackUrl = currentDomain; - const authUrl = 'https://dash.bunny.net/auth/login'; const params = new URLSearchParams({ source: 'decap', domain: currentDomain, callbackUrl, }); - return `${authUrl}?${params.toString()}`; } - /** - * Resolve return URL from stored value - */ static resolveReturnUrl(): string | null { return this.getReturnUrl(); } - /** - * Redirect to Bunny authentication in the same window - */ static redirectToAuth(): void { - // Save current location before redirecting this.saveReturnUrl(); - // Set flag to auto-open media library after auth this.setAutoOpenFlag(); - const authUrl = this.generateAuthUrl(); - window.location.href = authUrl; + window.location.href = this.generateAuthUrl(); } /** * Extract Account API key and storage zone name from URL parameters - * Bunny OAuth returns the Account API Key (not Storage Zone Password) - * We'll use this Account API Key to fetch the actual Storage Zone Password */ static extractCredentialsFromUrl(): { apiKey: string | null; storageName: string | null } { - const searchParams = new URLSearchParams(window.location.search); - const hashContent = window.location.hash.startsWith('#') - ? window.location.hash.slice(1) - : window.location.hash; - const hashQueryIndex = hashContent.indexOf('?'); - const hashParams = new URLSearchParams(hashContent); - const hashQueryParams = - hashQueryIndex >= 0 ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) : null; - - // Try multiple parameter names for API key - const apiKey = - searchParams.get('accessKey') || - searchParams.get('apiKey') || - searchParams.get('api_key') || - searchParams.get('password') || - searchParams.get('token') || - hashParams.get('accessKey') || - hashParams.get('apiKey') || - hashParams.get('api_key') || - hashParams.get('password') || - hashParams.get('token') || - hashQueryParams?.get('accessKey') || - hashQueryParams?.get('apiKey') || - hashQueryParams?.get('api_key') || - hashQueryParams?.get('password') || - hashQueryParams?.get('token') || - null; - - // Try multiple parameter names for storage zone name - const storageName = - searchParams.get('storageName') || - searchParams.get('storage_name') || - searchParams.get('storageZoneName') || - searchParams.get('storage_zone_name') || - searchParams.get('zoneName') || - searchParams.get('zone_name') || - hashParams.get('storageName') || - hashParams.get('storage_name') || - hashParams.get('storageZoneName') || - hashParams.get('storage_zone_name') || - hashParams.get('zoneName') || - hashParams.get('zone_name') || - hashQueryParams?.get('storageName') || - hashQueryParams?.get('storage_name') || - hashQueryParams?.get('storageZoneName') || - hashQueryParams?.get('storage_zone_name') || - hashQueryParams?.get('zoneName') || - hashQueryParams?.get('zone_name') || - null; - - if (apiKey || storageName) { - // Credentials found in URL - } + const { searchParams, hashQueryParams } = this.parseUrlParams(); - return { apiKey, storageName }; - } - - /** - * Clean URL by removing auth parameters - */ - static cleanAuthParamsFromUrl(): void { - const searchParams = new URLSearchParams(window.location.search); - const hashContent = window.location.hash.startsWith('#') - ? window.location.hash.slice(1) - : window.location.hash; - const hashQueryIndex = hashContent.indexOf('?'); - const hasHashQuery = hashQueryIndex >= 0; - const hashRoute = hasHashQuery ? hashContent.slice(0, hashQueryIndex) : hashContent; - const hashQueryParams = hasHashQuery - ? new URLSearchParams(hashContent.slice(hashQueryIndex + 1)) - : hashContent.includes('=') - ? new URLSearchParams(hashContent) - : new URLSearchParams(); - const authParamNames = [ - 'accessKey', - 'apiKey', - 'api_key', - 'password', - 'token', + const apiKeyNames = ['accessKey', 'apiKey', 'api_key', 'password', 'token']; + const storageNames = [ 'storageName', 'storage_name', 'storageZoneName', @@ -292,27 +207,29 @@ export class BunnyAuthManager { 'zoneName', 'zone_name', ]; - let hasAuthParams = false; - authParamNames.forEach(param => { - if (searchParams.has(param)) { - searchParams.delete(param); - hasAuthParams = true; - } - if (hashQueryParams.has(param)) { - hashQueryParams.delete(param); - hasAuthParams = true; - } - }); + return { + apiKey: this.getParamValue(apiKeyNames, searchParams, hashQueryParams), + storageName: this.getParamValue(storageNames, searchParams, hashQueryParams), + }; + } - if (hasAuthParams) { - const searchQuery = searchParams.toString(); - const hashQuery = hashQueryParams.toString(); - const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; - const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; - const newUrl = `${window.location.pathname}${ - searchQuery ? `?${searchQuery}` : '' - }${hashPrefix}${hashSuffix}`; + /** + * 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); } } @@ -323,48 +240,12 @@ export class BunnyAuthManager { static sanitizeReturnUrl(url: string): string { try { const parsedUrl = new URL(url, window.location.origin); - const searchParams = new URLSearchParams(parsedUrl.search); - const hashContent = parsedUrl.hash.startsWith('#') ? parsedUrl.hash.slice(1) : parsedUrl.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(); - - const authParamNames = [ - 'accessKey', - 'apiKey', - 'api_key', - 'password', - 'token', - 'storageName', - 'storage_name', - 'storageZoneName', - 'storage_zone_name', - 'zoneName', - 'zone_name', - ]; - - authParamNames.forEach(param => { - if (searchParams.has(param)) { - searchParams.delete(param); - } - if (hashQueryParams.has(param)) { - hashQueryParams.delete(param); - } - }); - - const searchQuery = searchParams.toString(); - const hashQuery = hashQueryParams.toString(); - const hashPrefix = hashRoute ? `#${hashRoute}` : hashQuery ? '#' : ''; - const hashSuffix = hashQuery ? `${hashRoute ? '?' : ''}${hashQuery}` : ''; - - return `${parsedUrl.pathname}${ - searchQuery ? `?${searchQuery}` : '' - }${hashPrefix}${hashSuffix}`; + 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; @@ -372,12 +253,9 @@ export class BunnyAuthManager { } /** - * Check if fully authenticated (both API key and storage zone name) + * Check if fully authenticated */ static isAuthenticated(): boolean { - const hasKey = !!this.getStoredApiKey(); - const hasZoneName = !!this.getStoredStorageZoneName(); - const isAuth = hasKey && hasZoneName; - return isAuth; + return !!(this.getStoredApiKey() && this.getStoredStorageZoneName()); } } diff --git a/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx b/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx index 5531968cd10b..ab6d3301bc63 100644 --- a/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/FileGrid.tsx @@ -5,123 +5,22 @@ import React, { useMemo } from 'react'; -import type { AddressedMediaFile } from '../types'; +import { + StyledCheckboxContainer, + StyledCheckboxInput, + StyledDeleteButton, + StyledFileDate, + StyledFileGrid, + StyledFileGridItemContainer, + StyledFileIcon, + StyledFileInfo, + StyledFileName, + StyledFileSize, + StyledThumbnail, + StyledThumbnailImage, +} from './styles'; -const styles = { - grid: { - display: 'grid' as const, - gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', - gap: '16px', - width: '100%', - }, - item: { - display: 'flex' as const, - flexDirection: 'column' as const, - cursor: 'pointer', - borderWidth: '2px', - borderStyle: 'solid', - borderColor: 'transparent', - borderRadius: '6px', - padding: '8px', - transition: 'all 0.2s', - backgroundColor: 'white', - position: 'relative' as const, - }, - itemHover: { - backgroundColor: '#f5f5f5', - borderColor: '#ddd', - }, - itemSelected: { - backgroundColor: '#e3f2fd', - borderColor: '#0066cc', - }, - thumbnail: { - position: 'relative' as const, - width: '100%', - aspectRatio: '1', - backgroundColor: '#f0f0f0', - borderRadius: '4px', - overflow: 'hidden' as const, - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginBottom: '8px', - }, - image: { - width: '100%', - height: '100%', - objectFit: 'cover' as const, - }, - folderIcon: { - fontSize: '48px', - color: '#999', - }, - fileIcon: { - fontSize: '48px', - color: '#999', - }, - checkbox: { - position: 'absolute' as const, - top: '4px', - left: '4px', - backgroundColor: 'white', - borderRadius: '3px', - padding: '2px', - opacity: 0, - transition: 'opacity 0.2s', - }, - checkboxVisible: { - opacity: 1, - }, - checkboxInput: { - cursor: 'pointer', - width: '18px', - height: '18px', - }, - deleteButton: { - position: 'absolute' as const, - top: '4px', - right: '4px', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - border: 'none', - borderRadius: '3px', - width: '28px', - height: '28px', - fontSize: '16px', - cursor: 'pointer', - opacity: 0, - transition: 'opacity 0.2s', - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - padding: 0, - }, - deleteButtonVisible: { - opacity: 1, - }, - name: { - fontSize: '13px', - fontWeight: 500, - color: '#333', - whiteSpace: 'nowrap' as const, - overflow: 'hidden' as const, - textOverflow: 'ellipsis' as const, - marginBottom: '4px', - }, - info: { - display: 'flex' as const, - flexDirection: 'column' as const, - fontSize: '11px', - color: '#999', - gap: '2px', - }, - size: { - fontWeight: 500, - }, - date: { - color: '#bbb', - }, -}; +import type { AddressedMediaFile } from '../types'; interface FileGridProps { files: AddressedMediaFile[]; @@ -132,7 +31,6 @@ interface FileGridProps { allowMultiple?: boolean; } -// Image extensions for preview const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp']; export function FileGrid({ @@ -155,7 +53,6 @@ export function FileGrid({ } const sortedFiles = useMemo(() => { - // Directories first, then files, sorted alphabetically return [...files].sort((a, b) => { if (a.IsDirectory !== b.IsDirectory) { return a.IsDirectory ? -1 : 1; @@ -165,7 +62,7 @@ export function FileGrid({ }, [files]); return ( -
+ {sortedFiles.map(file => { const isSelected = selectedFiles.has(file.publicUrl); const isImage = !file.IsDirectory && isImageFile(file.ObjectName); @@ -173,13 +70,10 @@ export function FileGrid({ const isHovered = hoveredItem === itemKey; return ( -
onDoubleClick(file)} onClick={() => { if (!file.IsDirectory) { @@ -189,77 +83,50 @@ export function FileGrid({ onMouseEnter={() => setHoveredItem(itemKey)} onMouseLeave={() => setHoveredItem(null)} > - {/* Thumbnail */} -
+ {file.IsDirectory ? ( -
📁
+ 📁 ) : isImage ? ( - {file.ObjectName} + ) : ( -
📄
+ 📄 )} - {/* Selection Checkbox */} {!file.IsDirectory && ( -
- + onSelectFile(file.publicUrl)} onClick={e => e.stopPropagation()} - style={styles.checkboxInput} /> -
+ )} - {/* Delete Button */} {!file.IsDirectory && ( - + )} -
+ - {/* File Name */} -
- {file.ObjectName} -
+ {file.ObjectName} - {/* File Info */} -
- {!file.IsDirectory && {formatFileSize(file.Length)}} - {formatDate(file.LastChanged)} -
-
+ + {!file.IsDirectory && {formatFileSize(file.Length)}} + {formatDate(file.LastChanged)} + + ); })} -
+ ); } diff --git a/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx b/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx index 52e2b7e9345c..549d54835e61 100644 --- a/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/FileUpload.tsx @@ -5,79 +5,19 @@ import React, { useRef, useState } from 'react'; -const styles = { - uploadContainer: { - padding: '16px 24px 0', - }, - dropZone: { - borderWidth: '2px', - borderStyle: 'dashed', - borderColor: '#ddd', - borderRadius: '6px', - padding: '24px', - textAlign: 'center' as const, - cursor: 'pointer', - transition: 'all 0.2s', - backgroundColor: '#fafafa', - }, - dropZoneHover: { - borderColor: '#0066cc', - backgroundColor: '#f5f9ff', - }, - dropZoneDragging: { - borderColor: '#0066cc', - backgroundColor: '#e3f2fd', - }, - dropZoneUploading: { - borderColor: '#ccc', - backgroundColor: '#f0f0f0', - cursor: 'default' as const, - }, - dropContent: { - pointerEvents: 'none' as const, - }, - dropIcon: { - fontSize: '32px', - marginBottom: '8px', - }, - dropText: { - margin: '8px 0 4px', - fontSize: '14px', - fontWeight: 500 as const, - color: '#333', - }, - dropSubtext: { - margin: 0, - fontSize: '12px', - color: '#999', - }, - uploadingContent: { - pointerEvents: 'none' as const, - }, - progressBar: { - width: '100%', - height: '6px', - backgroundColor: '#e0e0e0', - borderRadius: '3px', - overflow: 'hidden' as const, - marginBottom: '16px', - }, - progressFill: { - height: '100%', - backgroundColor: '#0066cc', - transition: 'width 0.3s ease', - borderRadius: '3px', - }, - uploadingText: { - margin: 0, - fontSize: '14px', - fontWeight: 500 as const, - color: '#0066cc', - }, - hiddenInput: { - display: 'none' as const, - }, -}; +import { + StyledDropContent, + StyledDropIcon, + StyledDropSubtext, + StyledDropText, + StyledDropZone, + StyledFileUploadContainer, + StyledHiddenInput, + StyledProgressBarContainer, + StyledProgressBarFill, + StyledUploadingContent, + StyledUploadingText, +} from './styles'; interface FileUploadProps { onUpload: (files: File[]) => void; @@ -94,7 +34,6 @@ export function FileUpload({ }: FileUploadProps) { const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); - const [isHovering, setIsHovering] = useState(false); function handleDragEnter(e: React.DragEvent) { e.preventDefault(); @@ -141,60 +80,41 @@ export function FileUpload({ } } - function getDropZoneStyle() { - let style = { ...styles.dropZone }; - if (isUploading) { - style = { ...style, ...styles.dropZoneUploading }; - } else if (isDragging) { - style = { ...style, ...styles.dropZoneDragging }; - } else if (isHovering) { - style = { ...style, ...styles.dropZoneHover }; - } - return style; - } - return ( -
-
+ setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} > - {isUploading ? ( -
-
-
-
-

Uploading... {uploadProgress}%

-
+ + + + + Uploading... {uploadProgress}% + ) : ( -
-
📤
-

Drag files here or click to upload

-

Uploading to: {currentPath}

-
+ + 📤 + Drag files here or click to upload + Uploading to: {currentPath} + )} -
-
+ + ); } diff --git a/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx b/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx index 1b5037036c4d..c8dd44bc6b10 100644 --- a/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx +++ b/packages/decap-cms-media-library-bunny/src/components/LoginPrompt.tsx @@ -5,50 +5,14 @@ import React from 'react'; -const styles = { - container: { - display: 'flex' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - minHeight: '100%', - padding: '20px', - }, - card: { - textAlign: 'center' as const, - padding: '40px 60px', - backgroundColor: 'white', - borderRadius: '8px', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - }, - icon: { - fontSize: '48px', - marginBottom: '20px', - color: '#0066cc', - }, - title: { - margin: '0 0 12px 0', - fontSize: '24px', - fontWeight: 600 as const, - color: '#333', - }, - description: { - margin: '0 0 30px 0', - fontSize: '14px', - color: '#666', - lineHeight: 1.5, - }, - button: { - padding: '12px 24px', - backgroundColor: '#0066cc', - color: 'white', - border: 'none', - borderRadius: '4px', - fontSize: '16px', - fontWeight: 600 as const, - cursor: 'pointer', - transition: 'all 0.2s', - }, -}; +import { + StyledLoginButton, + StyledLoginCard, + StyledLoginContainer, + StyledLoginDescription, + StyledLoginIcon, + StyledLoginTitle, +} from './styles'; interface LoginPromptProps { onLogin: () => void; @@ -56,18 +20,16 @@ interface LoginPromptProps { export function LoginPrompt({ onLogin }: LoginPromptProps) { return ( -
-
-
🔐
-

Authenticate with Bunny

-

+ + + 🔐 + Authenticate with Bunny + Sign in to your Bunny.net account to access and manage your storage files. -

- -
-
+ + Login with Bunny + + ); } diff --git a/packages/decap-cms-media-library-bunny/src/components/styles.ts b/packages/decap-cms-media-library-bunny/src/components/styles.ts index a376a4b5b5c2..f186b9b52f52 100644 --- a/packages/decap-cms-media-library-bunny/src/components/styles.ts +++ b/packages/decap-cms-media-library-bunny/src/components/styles.ts @@ -322,85 +322,227 @@ export const StyledBreadcrumbSeparator = styled.span` margin: 0 ${designTokens.spacing.xs}; `; -// Login prompt styles -export const StyledLoginPrompt = styled.div` +// 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%; - gap: ${designTokens.spacing.lg}; - padding: ${designTokens.spacing.xxl}; + 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 StyledLoginPromptTitle = styled.h2` - margin: 0; - font-size: ${designTokens.font.size.xl}; +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 StyledLoginPromptMessage = styled.p` - margin: 0; +export const StyledLoginDescription = styled.p` + margin: 0 0 30px 0; font-size: ${designTokens.font.size.base}; color: ${designTokens.colors.textSecondary}; - max-width: 400px; + line-height: 1.5; `; export const StyledLoginButton = styled(StyledButtonPrimary)` - margin-top: ${designTokens.spacing.md}; - padding: ${designTokens.spacing.md} ${designTokens.spacing.xl}; + padding: ${designTokens.spacing.md} ${designTokens.spacing.xxl}; + font-size: ${designTokens.font.size.lg}; + font-weight: ${designTokens.font.weight.semibold}; `; // File upload styles -export const StyledFileUpload = styled.div` - display: flex; - flex-direction: column; - gap: ${designTokens.spacing.md}; - padding: ${designTokens.spacing.lg}; - background: ${designTokens.colors.background}; - border-radius: ${designTokens.radius.md}; +export const StyledFileUploadContainer = styled.div` + padding: ${designTokens.spacing.lg} ${designTokens.spacing.xxl} 0; `; -export const StyledUploadArea = styled.div<{ isDragActive?: boolean }>` - border: 2px dashed - ${props => (props.isDragActive ? designTokens.colors.primary : designTokens.colors.border)}; +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.xl}; + padding: ${designTokens.spacing.xxl}; text-align: center; - cursor: pointer; + cursor: ${props => (props.isUploading ? 'default' : 'pointer')}; transition: all ${designTokens.transition}; background-color: ${props => - props.isDragActive ? designTokens.colors.primaryLight : 'transparent'}; + props.isUploading + ? designTokens.colors.background + : props.isDragging + ? designTokens.colors.primaryLight + : '#fafafa'}; &:hover { - border-color: ${designTokens.colors.primary}; - background-color: ${designTokens.colors.primaryLight}; + border-color: ${props => + props.isUploading ? designTokens.colors.border : designTokens.colors.primary}; + background-color: ${props => + props.isUploading ? designTokens.colors.background : designTokens.colors.primaryLight}; } `; -export const StyledUploadText = styled.p` - margin: 0; +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}; - color: ${designTokens.colors.textSecondary}; + font-weight: ${designTokens.font.weight.medium}; + color: ${designTokens.colors.text}; `; -export const StyledUploadInput = styled.input` - display: none; +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 StyledProgressBar = styled.div` +export const StyledProgressBarContainer = styled.div` width: 100%; - height: 4px; - background-color: ${designTokens.colors.border}; - border-radius: 2px; + height: 6px; + background-color: ${designTokens.colors.secondary}; + border-radius: 3px; overflow: hidden; + margin-bottom: ${designTokens.spacing.lg}; `; -export const StyledProgressFill = styled.div<{ progress: number }>` +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}%; - transition: width ${designTokens.transition}; +`; + +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; `; From bafd3eed007d1361a250cd93fdb11b58b7ae882a Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 15:47:34 +0100 Subject: [PATCH 08/11] refactor: remove auto-open flag functionality from BunnyAuthManager --- .../src/__tests__/authManager.test.ts | 10 ------- .../src/api/authManager.ts | 16 ---------- .../src/index.js | 30 ------------------- 3 files changed, 56 deletions(-) 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 index e5ec9a715800..f421c3f2b8f8 100644 --- a/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts +++ b/packages/decap-cms-media-library-bunny/src/__tests__/authManager.test.ts @@ -71,16 +71,6 @@ describe('BunnyAuthManager', () => { expect(replaceSpy).toHaveBeenCalledWith({}, '', '/admin/?keep=1#/collections/posts/new?ok=2'); }); - it('stores and clears auto-open flag', () => { - expect(BunnyAuthManager.shouldAutoOpen()).toBe(false); - - BunnyAuthManager.setAutoOpenFlag(); - expect(BunnyAuthManager.shouldAutoOpen()).toBe(true); - - BunnyAuthManager.clearAutoOpenFlag(); - expect(BunnyAuthManager.shouldAutoOpen()).toBe(false); - }); - it('saves and resolves sanitized return URL', () => { BunnyAuthManager.saveReturnUrl( 'http://localhost:8080/admin/?x=1&token=secret#/collections/posts/new?storageZoneName=zone&y=2', diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index 4e255a412ea2..5bb4237f84a9 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -7,7 +7,6 @@ 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'; -const AUTO_OPEN_FLAG_KEY = 'bunny_auto_open'; // Helper functions for safe localStorage access function safeGetItem(key: string, errorMsg: string): string | null { @@ -83,20 +82,6 @@ export class BunnyAuthManager { safeRemoveItem(RETURN_URL_KEY, '[Bunny Auth] Failed to clear return URL:'); } - static setAutoOpenFlag(): void { - safeSetItem(AUTO_OPEN_FLAG_KEY, 'true', '[Bunny Auth] Failed to set auto-open flag:'); - } - - static shouldAutoOpen(): boolean { - return ( - safeGetItem(AUTO_OPEN_FLAG_KEY, '[Bunny Auth] Failed to check auto-open flag:') === 'true' - ); - } - - static clearAutoOpenFlag(): void { - safeRemoveItem(AUTO_OPEN_FLAG_KEY, '[Bunny Auth] Failed to clear auto-open flag:'); - } - // Auth parameter names to check in URLs private static AUTH_PARAM_NAMES = [ 'accessKey', @@ -188,7 +173,6 @@ export class BunnyAuthManager { static redirectToAuth(): void { this.saveReturnUrl(); - this.setAutoOpenFlag(); window.location.href = this.generateAuthUrl(); } diff --git a/packages/decap-cms-media-library-bunny/src/index.js b/packages/decap-cms-media-library-bunny/src/index.js index 560aefe27b26..ac36ec245e9a 100644 --- a/packages/decap-cms-media-library-bunny/src/index.js +++ b/packages/decap-cms-media-library-bunny/src/index.js @@ -49,21 +49,12 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { ); BunnyAuthManager.setStoredApiKey(storageZonePassword); - const shouldAutoOpen = BunnyAuthManager.shouldAutoOpen(); const returnUrl = BunnyAuthManager.resolveReturnUrl(); BunnyAuthManager.cleanAuthParamsFromUrl(); if (returnUrl) { BunnyAuthManager.clearReturnUrl(); setTimeout(() => safelyRedirectToReturnUrl(returnUrl), 100); - return; - } - - if (shouldAutoOpen) { - BunnyAuthManager.clearAutoOpenFlag(); - setTimeout(() => { - window.dispatchEvent(new CustomEvent('bunny-auth-complete')); - }, 500); } } @@ -162,27 +153,6 @@ async function init({ options = {}, handleInsert = () => {} } = {}) { enableStandalone: () => true, }; - window.addEventListener('bunny-auth-complete', () => { - setTimeout(() => { - mediaLibraryInstance.show({}); - }, 100); - }); - - if (BunnyAuthManager.shouldAutoOpen() && BunnyAuthManager.getStoredApiKey()) { - BunnyAuthManager.clearAutoOpenFlag(); - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - setTimeout(() => { - mediaLibraryInstance.show({}); - }, 500); - }); - } else { - setTimeout(() => { - mediaLibraryInstance.show({}); - }, 500); - } - } - return mediaLibraryInstance; } From c0f5ab26d4f6f6accb4a27297446b6203738050d Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Wed, 4 Mar 2026 16:15:14 +0100 Subject: [PATCH 09/11] fix: update callback URL in generateAuthUrl to include pathname --- packages/decap-cms-media-library-bunny/src/api/authManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index 5bb4237f84a9..dabf37c8899f 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -157,7 +157,7 @@ export class BunnyAuthManager { */ static generateAuthUrl(): string { const currentDomain = window.location.origin; - const callbackUrl = currentDomain; + const callbackUrl = currentDomain + window.location.pathname; const authUrl = 'https://dash.bunny.net/auth/login'; const params = new URLSearchParams({ source: 'decap', From da3968175dd5e0e73c6546f5a773a799c82f0666 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Thu, 5 Mar 2026 10:12:03 +0100 Subject: [PATCH 10/11] fix: update callback URL in BunnyAuthManager to address OAuth limitations --- dev-test/config.yml | 19 ------------------- .../decap-cms-media-library-bunny/README.md | 1 + .../src/api/authManager.ts | 3 ++- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index a3ce43388c12..0cffbe51bc7d 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -13,25 +13,6 @@ media_library: config: storage_zone_name: cmt-docs cdn_url_prefix: https://cmt-docs-cdn.b-cdn.net - # Note: API authentication happens in the browser via OAuth flow - # Users will be prompted to log in when opening the media library - - - - # https://docs.bunny.net/api-reference/core/storage-zone/get-storage-zone - - # example of login url - # callback in domena morata bit ista, sicer gre pa vse skozi - # mogoče čekirajo referrer header - # https://dash.bunny.net/auth/login?source=wp-plugin&domain=https:%2F%2Fdemo.example.com&callbackUrl=https:%2F%2Fdemo.example.com%2Fwp-admin%2Fadmin.php%3Fpage%3Dbunnycdn - - -# media_library: -# name: cloudinary -# config: -# cloud_name: poslovnimediji -# api_key: 712288282278836 -# folder: pm-www slug: encoding: ascii diff --git a/packages/decap-cms-media-library-bunny/README.md b/packages/decap-cms-media-library-bunny/README.md index d7f53981aee0..063d0363619d 100644 --- a/packages/decap-cms-media-library-bunny/README.md +++ b/packages/decap-cms-media-library-bunny/README.md @@ -90,6 +90,7 @@ collections: ## 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) diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index dabf37c8899f..c5c7bfa4e4dd 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -157,7 +157,8 @@ export class BunnyAuthManager { */ static generateAuthUrl(): string { const currentDomain = window.location.origin; - const callbackUrl = currentDomain + window.location.pathname; + // TODO: Once Bunny fixes callbackUrl support, use: currentDomain + window.location.pathname + const callbackUrl = currentDomain; const authUrl = 'https://dash.bunny.net/auth/login'; const params = new URLSearchParams({ source: 'decap', From d8f6903ae904fc663e78fd15816b5559b635e51c Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Fri, 6 Mar 2026 09:01:49 +0100 Subject: [PATCH 11/11] fix: update callback URL in generateAuthUrl to include pathname --- packages/decap-cms-media-library-bunny/src/api/authManager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/decap-cms-media-library-bunny/src/api/authManager.ts b/packages/decap-cms-media-library-bunny/src/api/authManager.ts index c5c7bfa4e4dd..dabf37c8899f 100644 --- a/packages/decap-cms-media-library-bunny/src/api/authManager.ts +++ b/packages/decap-cms-media-library-bunny/src/api/authManager.ts @@ -157,8 +157,7 @@ export class BunnyAuthManager { */ static generateAuthUrl(): string { const currentDomain = window.location.origin; - // TODO: Once Bunny fixes callbackUrl support, use: currentDomain + window.location.pathname - const callbackUrl = currentDomain; + const callbackUrl = currentDomain + window.location.pathname; const authUrl = 'https://dash.bunny.net/auth/login'; const params = new URLSearchParams({ source: 'decap',