Spaces:
Sleeping
Sleeping
| let source_path; | |
| let apps = []; | |
| let sortBy = 'created_at'; | |
| let sortDirection = 'desc'; | |
| let currently_editing_app; | |
| let dropped_items; | |
| let search_query; | |
| let originalValues = {}; | |
| const APP_CATEGORIES = [ | |
| { id: 'games', label: 'Games' }, | |
| { id: 'developer-tools', label: 'Developer Tools' }, | |
| { id: 'photo-video', label: 'Photo & Video' }, | |
| { id: 'productivity', label: 'Productivity' }, | |
| { id: 'utilities', label: 'Utilities' }, | |
| { id: 'education', label: 'Education' }, | |
| { id: 'business', label: 'Business' }, | |
| { id: 'social', label: 'Social' }, | |
| { id: 'graphics-design', label: 'Graphics & Design' }, | |
| { id: 'music-audio', label: 'Music & Audio' }, | |
| { id: 'news', label: 'News' }, | |
| { id: 'entertainment', label: 'Entertainment' }, | |
| { id: 'finance', label: 'Finance' }, | |
| { id: 'health-fitness', label: 'Health & Fitness' }, | |
| { id: 'lifestyle', label: 'Lifestyle' }, | |
| ]; | |
| async function init_apps () { | |
| setTimeout(async function () { | |
| puter.ui.onLaunchedWithItems(async function (items) { | |
| source_path = items[0].path; | |
| // if source_path is provided, this means that the user is creating a new app/updating an existing app | |
| // by deploying an existing Puter folder. So we create the app and deploy it. | |
| if ( source_path ) { | |
| // todo if there are no apps, go straight to creating a new app | |
| $('.insta-deploy-modal').get(0).showModal(); | |
| // set item name | |
| $('.insta-deploy-item-name').html(html_encode(items[0].name)); | |
| } | |
| }); | |
| // Get dev profile. This is only for puter.com for now as we don't have dev profiles in self-hosted Puter | |
| if ( domain === 'puter.com' ) { | |
| puter.apps.getDeveloperProfile(async function (dev_profile) { | |
| window.developer = dev_profile; | |
| if ( dev_profile.approved_for_incentive_program && !dev_profile.joined_incentive_program ) { | |
| $('#join-incentive-program').show(); | |
| } | |
| // show earn money c2a only if dev is not approved for incentive program or has already joined | |
| if ( !dev_profile.approved_for_incentive_program || dev_profile.joined_incentive_program ) { | |
| puter.kv.get('earn-money-c2a-closed').then((value) => { | |
| if ( value?.result || value === true || value === 'true' ) | |
| { | |
| return; | |
| } | |
| $('#earn-money').get(0).showModal(); | |
| }); | |
| } | |
| // show payout method tab if dev has joined incentive program | |
| if ( dev_profile.joined_incentive_program ) { | |
| $('.tab-btn[data-tab="payout-method"]').show(); | |
| $('#payout-method-email').html(dev_profile.paypal); | |
| $('.tab-btn-separator').show(); | |
| } | |
| }); | |
| } | |
| // Get apps | |
| puter.apps.list({ icon_size: 64 }).then((resp) => { | |
| apps = resp; | |
| // hide loading | |
| puter.ui.hideSpinner(); | |
| // set apps | |
| if ( apps.length > 0 ) { | |
| if ( window.activeTab === 'apps' ) { | |
| $('#no-apps-notice').hide(); | |
| $('#app-list').show(); | |
| } | |
| $('.app-card').remove(); | |
| apps.forEach(app => { | |
| $('#app-list-table > tbody').append(generate_app_card(app)); | |
| }); | |
| count_apps(); | |
| sort_apps(); | |
| activate_tippy(); | |
| } else { | |
| $('#no-apps-notice').show(); | |
| } | |
| }); | |
| }, 1000); | |
| } | |
| /** | |
| * Refreshes the list of apps in the UI. | |
| * | |
| * @param {boolean} [show_loading=false] - Whether to show a loading indicator while refreshing. | |
| * | |
| */ | |
| window.refresh_app_list = (show_loading = false) => { | |
| if ( show_loading ) | |
| { | |
| puter.ui.showSpinner(); | |
| } | |
| // get apps | |
| setTimeout(function () { | |
| // uncheck the select all checkbox | |
| $('.select-all-apps').prop('checked', false); | |
| puter.apps.list({ icon_size: 64 }).then((apps_res) => { | |
| puter.ui.hideSpinner(); | |
| apps = apps_res; | |
| if ( apps.length > 0 ) { | |
| if ( window.activeTab === 'apps' ) { | |
| $('#no-apps-notice').hide(); | |
| $('#app-list').show(); | |
| } | |
| $('.app-card').remove(); | |
| apps.forEach(app => { | |
| $('#app-list-table > tbody').append(generate_app_card(app)); | |
| }); | |
| count_apps(); | |
| sort_apps(); | |
| } else { | |
| $('#no-apps-notice').show(); | |
| $('#app-list').hide(); | |
| } | |
| activate_tippy(); | |
| puter.ui.hideSpinner(); | |
| }); | |
| }, show_loading ? 1000 : 0); | |
| }; | |
| $(document).on('click', '.create-an-app-btn', async function (e) { | |
| let title = await puter.ui.prompt('Please enter a title for your app:', 'My Awesome App'); | |
| if ( title.length > 60 ) { | |
| puter.ui.alert('Title cannot be longer than 60.', [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| // todo go back to create an app prompt and prefill the title input with the title the user entered | |
| return; | |
| } | |
| else if ( title ) { | |
| create_app(title); | |
| } | |
| }); | |
| if ( ! (await puter.auth.getUser()).hasDevAccountAccess ) $('.setup-account-btn').hide(); | |
| $('.setup-account-btn').on('click', async () => { | |
| await puter.ui.openDevPaymentsAccount(); | |
| }); | |
| async function create_app (title, source_path = null, items = null) { | |
| // name | |
| let name = slugify(title, { | |
| lower: true, | |
| strict: true, | |
| }); | |
| // icon | |
| let icon = await getBase64ImageFromUrl('./img/app.svg'); | |
| // open the 'Creting new app...' modal | |
| let start_ts = Date.now(); | |
| puter.ui.showSpinner(); | |
| //---------------------------------------------------- | |
| // Create app | |
| //---------------------------------------------------- | |
| puter.apps.create({ | |
| title: title, | |
| name: name, | |
| indexURL: 'https://dev-center.puter.com/coming-soon.html', | |
| icon: icon, | |
| description: ' ', | |
| maximizeOnStart: false, | |
| background: false, | |
| dedupeName: true, | |
| metadata: { | |
| window_resizable: true, | |
| fullpage_on_landing: true, | |
| }, | |
| }) | |
| .then(async (app) => { | |
| let app_dir; | |
| // ---------------------------------------------------- | |
| // Create app directory in AppData | |
| // ---------------------------------------------------- | |
| app_dir = await puter.fs.mkdir(`/${auth_username}/AppData/${dev_center_uid}/${app.uid}`, | |
| { overwrite: true, recursive: true, rename: false }); | |
| // ---------------------------------------------------- | |
| // Create a router for the app with a fresh hostname | |
| // ---------------------------------------------------- | |
| let subdomain = `${name}-${Math.random().toString(36).substring(2)}`; | |
| await puter.hosting.create(subdomain, app_dir.path); | |
| // ---------------------------------------------------- | |
| // Update the app with the new hostname | |
| // ---------------------------------------------------- | |
| puter.apps.update(app.name, { | |
| title: title, | |
| indexURL: source_path ? `${protocol}://${subdomain}.${static_hosting_domain}` : 'https://dev-center.puter.com/coming-soon.html', | |
| icon: icon, | |
| description: ' ', | |
| maximizeOnStart: false, | |
| background: false, | |
| metadata: { | |
| category: null, // default category on creation | |
| window_resizable: true, | |
| fullpage_on_landing: true, | |
| }, | |
| }).then(async (app) => { | |
| // refresh app list | |
| puter.apps.list({ icon_size: 64 }).then(async (resp) => { | |
| apps = resp; | |
| // Close the 'Creating new app...' modal | |
| // but make sure it was shown for at least 2 seconds | |
| setTimeout(() => { | |
| // open edit app section | |
| edit_app_section(app.name); | |
| // set drop area if source_path was provided or items were dropped | |
| if ( source_path || items ) { | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| } | |
| puter.ui.hideSpinner(); | |
| // deploy app if source_path was provided | |
| if ( source_path ) { | |
| deploy(app, source_path); | |
| } else if ( items ) { | |
| deploy(app, items); | |
| } | |
| activate_tippy(); | |
| }, (Date.now() - start_ts) > 2000 ? 1 : 2000 - (Date.now() - start_ts)); | |
| }); | |
| }).catch(async (err) => { | |
| console.log(err); | |
| }); | |
| // ---------------------------------------------------- | |
| // Create a "shortcut" on the desktop | |
| // ---------------------------------------------------- | |
| puter.fs.upload(new File([], app.title), | |
| `/${auth_username}/Desktop`, | |
| { | |
| name: app.title, | |
| dedupeName: true, | |
| overwrite: false, | |
| appUID: app.uid, | |
| }); | |
| //---------------------------------------------------- | |
| // Increment app count | |
| //---------------------------------------------------- | |
| $('.app-count').html(parseInt($('.app-count').html() ?? 0) + 1); | |
| }).catch(async (err) => { | |
| $('#create-app-error').show(); | |
| $('#create-app-error').html(err.message); | |
| // scroll to top so that user sees error message | |
| document.body.scrollTop = document.documentElement.scrollTop = 0; | |
| }); | |
| } | |
| $(document).on('click', '.deploy-btn', function (e) { | |
| deploy(currently_editing_app, dropped_items); | |
| }); | |
| $(document).on('click', '.edit-app, .go-to-edit-app', function (e) { | |
| const cur_app_name = $(this).attr('data-app-name'); | |
| edit_app_section(cur_app_name); | |
| }); | |
| $(document).on('click', '.delete-app', async function (e) { | |
| }); | |
| // generate app link | |
| function applink (app) { | |
| return `${protocol}://${domain}${port ? `:${port}` : ''}/app/${app.name}`; | |
| } | |
| /** | |
| * Generates the HTML for the app editing section. | |
| * | |
| * @param {Object} app - The app object containing details of the app to be edited. | |
| * * | |
| * @returns {string} HTML string for the app editing section. | |
| * | |
| * @description | |
| * This function creates the HTML for the app editing interface, including: | |
| * - App icon and title display | |
| * - Options to open, add to desktop, or delete the app | |
| * - Tabs for deployment and settings | |
| * - Form fields for editing various app properties | |
| * - Display of app statistics | |
| * | |
| * The generated HTML includes interactive elements and placeholders for | |
| * dynamic content to be filled or updated by other functions. | |
| * | |
| * @example | |
| * const appEditHTML = generate_edit_app_section(myAppObject); | |
| * $('#edit-app').html(appEditHTML); | |
| */ | |
| function generate_edit_app_section (app) { | |
| if ( app.result ) | |
| { | |
| app = app.result; | |
| } | |
| let maximize_on_start = app.maximize_on_start ? 'checked' : ''; | |
| let h = ''; | |
| h += ` | |
| <div class="edit-app-navbar"> | |
| <div style="flex-grow:1;"> | |
| <img class="app-icon" data-uid="${html_encode(app.uid)}" src="${html_encode(!app.icon ? './img/app.svg' : app.icon)}"> | |
| <h3 class="app-title" data-uid="${html_encode(app.uid)}">${html_encode(app.title)}${app.metadata?.locked ? lock_svg_tippy : ''}</h3> | |
| <div style="margin-top: 4px; margin-bottom: 4px;"> | |
| <span class="open-app-btn" data-app-uid="${html_encode(app.uid)}" data-app-name="${html_encode(app.name)}">Open</span> | |
| <span style="margin: 5px; opacity: 0.3;">•</span> | |
| <span class="add-app-to-desktop" data-app-uid="${html_encode(app.uid)}" data-app-title="${html_encode(app.title)}">Add Shortcut to Desktop</span> | |
| <span style="margin: 5px; opacity: 0.3;">•</span> | |
| <span title="Delete app" class="delete-app-settings" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(app.title)}" data-app-uid="${html_encode(app.uid)}">Delete</span> | |
| </div> | |
| <a class="app-url" target="_blank" data-uid="${html_encode(app.uid)}" href="${html_encode(applink(app))}">${html_encode(applink(app))}</a> | |
| </div> | |
| <button class="back-to-main-btn button button-default">Back</button> | |
| </div> | |
| <ul class="section-tab-buttons disable-user-select"> | |
| <li class="section-tab-btn active" data-tab="deploy"><span>Deploy</span></li> | |
| <li class="section-tab-btn" data-tab="info"><span>Settings</span></li> | |
| <li class="section-tab-btn" data-tab="analytics"><span>Analytics</span></li> | |
| </ul> | |
| <div class="section-tab active" data-tab="deploy"> | |
| <div class="success deploy-success-msg"> | |
| New version deployed successfully 🎉<span class="close-success-msg">×</span> | |
| <p style="margin-bottom:0;"><span class="open-app button button-action" data-uid="${html_encode(app.uid)}" data-app-name="${html_encode(app.name)}">Give it a try!</span></p> | |
| </div> | |
| <div class="drop-area disable-user-select">${drop_area_placeholder}</div> | |
| <button class="deploy-btn disable-user-select button button-primary disabled">Deploy Now</button> | |
| </div> | |
| <div class="section-tab" data-tab="info"> | |
| <form style="clear:both; padding-bottom: 50px;"> | |
| <div class="error" id="edit-app-error"></div> | |
| <div class="success" id="edit-app-success">App has been successfully updated.<span class="close-success-msg">×</span> | |
| <p style="margin-bottom:0;"><span class="open-app button button-action" data-uid="${html_encode(app.uid)}" data-app-name="${html_encode(app.name)}">Give it a try!</span></p> | |
| </div> | |
| <input type="hidden" id="edit-app-uid" value="${html_encode(app.uid)}"> | |
| <h3 style="font-size: 23px; border-bottom: 1px solid #EEE; margin-top: 40px;">Basic</h3> | |
| <label for="edit-app-title">Title</label> | |
| <input type="text" id="edit-app-title" placeholder="My Awesome App!" value="${html_encode(app.title)}"> | |
| <label for="edit-app-name">Name</label> | |
| <input type="text" id="edit-app-name" placeholder="my-awesome-app" style="font-family: monospace;" value="${html_encode(app.name)}"> | |
| <label for="edit-app-index-url">Index URL</label> | |
| <input type="text" id="edit-app-index-url" placeholder="https://example-app.com/index.html" value="${html_encode(app.index_url)}"> | |
| <label for="edit-app-app-id">App ID</label> | |
| <div style="overflow:hidden;"> | |
| <input type="text" style="width: 362px; float:left;" class="app-uid" value="${html_encode(app.uid)}" readonly><span class="copy-app-uid" style="cursor: pointer; height: 35px; display: inline-block; width: 50px; text-align: center; line-height: 35px; margin-left:5px;">${copy_svg}</span> | |
| </div> | |
| <label for="edit-app-icon">Icon</label> | |
| <div id="edit-app-icon" style="background-image:url(${!app.icon ? './img/app.svg' : html_encode(app.icon)});" ${app.icon ? `data-url="${html_encode(app.icon)}"` : ''} ${app.icon ? `data-base64="${html_encode(app.icon)}"` : ''} > | |
| <div id="change-app-icon">Change App Icon</div> | |
| </div> | |
| <span id="edit-app-icon-delete" style="${app.icon ? 'display:block;' : ''}">Remove icon</span> | |
| ${generateSocialImageSection(app)} | |
| <label for="edit-app-description">Description</label> | |
| <textarea id="edit-app-description">${html_encode(app.description)}</textarea> | |
| <label for="edit-app-category">Category</label> | |
| <select id="edit-app-category" class="category-select"> | |
| <option value="">Select a category</option> | |
| ${APP_CATEGORIES.map(category => | |
| `<option value="${html_encode(category.id)}" ${app.metadata?.category === category.id ? 'selected' : ''}>${html_encode(category.label)}</option>`).join('')} | |
| </select> | |
| <label for="edit-app-filetype-associations">File Associations</label> | |
| <p style="margin-top: 10px; font-size:13px;">A list of file type specifiers. For example if you include <code>.txt</code> your apps could be opened when a user clicks on a TXT file.</p> | |
| <p style="margin-top: 5px; font-size:13px;">You can paste multiple extensions at once (comma, space, or tab separated) or press comma to add each extension.</p> | |
| <textarea id="edit-app-filetype-associations" placeholder="Paste multiple extensions like: .txt, .doc, .pdf, application/json">${JSON.stringify(app.filetype_associations.map(item => ({ 'value': item })), null, app.filetype_associations.length).replace(/</g, '\\u003c')}</textarea> | |
| <h3 style="font-size: 23px; border-bottom: 1px solid #EEE; margin-top: 50px; margin-bottom: 0px;">Window</h3> | |
| <div> | |
| <input type="checkbox" id="edit-app-background" name="edit-app-background" value="true" style="margin-top:30px;" ${app.background ? 'checked' : ''}> | |
| <label for="edit-app-background" style="display: inline;">Run as a background process.</label> | |
| </div> | |
| <div> | |
| <input type="checkbox" id="edit-app-fullpage-on-landing" name="edit-app-fullpage-on-landing" value="true" style="margin-top:30px;" ${app.metadata?.fullpage_on_landing ? 'checked' : ''} ${app.background ? 'disabled' : ''}> | |
| <label for="edit-app-fullpage-on-landing" style="display: inline;">Load in full-page mode when a user lands directly on this app.</label> | |
| </div> | |
| <div> | |
| <input type="checkbox" id="edit-app-maximize-on-start" name="edit-app-maximize-on-start" value="true" style="margin-top:30px;" ${maximize_on_start ? 'checked' : ''} ${app.background ? 'disabled' : ''}> | |
| <label for="edit-app-maximize-on-start" style="display: inline;">Maximize window on start</label> | |
| </div> | |
| <div> | |
| <label for="edit-app-window-width">Initial window width</label> | |
| <input type="number" id="edit-app-window-width" placeholder="680" value="${html_encode(app.metadata?.window_size?.width ?? 680)}" style="width:200px;" ${maximize_on_start || app.background ? 'disabled' : ''}> | |
| <label for="edit-app-window-height">Initial window height</label> | |
| <input type="number" id="edit-app-window-height" placeholder="380" value="${html_encode(app.metadata?.window_size?.height ?? 380)}" style="width:200px;" ${maximize_on_start || app.background ? 'disabled' : ''}> | |
| </div> | |
| <div style="margin-top:30px;"> | |
| <label for="edit-app-window-top">Initial window top</label> | |
| <input type="number" id="edit-app-window-top" placeholder="100" value="${app.metadata?.window_position?.top ? html_encode(app.metadata.window_position.top) : ''}" style="width:200px;" ${maximize_on_start || app.background ? 'disabled' : ''}> | |
| <label for="edit-app-window-left">Initial window left</label> | |
| <input type="number" id="edit-app-window-left" placeholder="100" value="${app.metadata?.window_position?.left ? html_encode(app.metadata.window_position.left) : ''}" style="width:200px;" ${maximize_on_start || app.background ? 'disabled' : ''}> | |
| </div> | |
| <div style="margin-top:30px;"> | |
| <input type="checkbox" id="edit-app-window-resizable" name="edit-app-window-resizable" value="true" ${app.metadata?.window_resizable ? 'checked' : ''} ${app.background ? 'disabled' : ''}> | |
| <label for="edit-app-window-resizable" style="display: inline;">Resizable window</label> | |
| </div> | |
| <div style="margin-top:30px;"> | |
| <input type="checkbox" id="edit-app-hide-titlebar" name="edit-app-hide-titlebar" value="true" ${app.metadata?.hide_titlebar ? 'checked' : ''} ${app.background ? 'disabled' : ''}> | |
| <label for="edit-app-hide-titlebar" style="display: inline;">Hide window titlebar</label> | |
| </div> | |
| <div style="margin-top:30px;"> | |
| <input type="checkbox" id="edit-app-set-title-to-file" name="edit-app-set-title-to-file" value="true" ${app.metadata?.set_title_to_opened_file ? 'checked' : ''} ${app.background ? 'disabled' : ''}> | |
| <label for="edit-app-set-title-to-file" style="display: inline;">Automatically set window title to opened file's name</label> | |
| <p>This will set your app's window title to the opened file's name when a user opens a file in your app.</p> | |
| </div> | |
| <h3 style="font-size: 23px; border-bottom: 1px solid #EEE; margin-top: 50px; margin-bottom: 0px;">Misc</h3> | |
| <div style="margin-top:30px;"> | |
| <input type="checkbox" id="edit-app-locked" name="edit-app-locked" value="true" ${app.metadata?.locked ? 'checked' : ''}> | |
| <label for="edit-app-locked" style="display: inline;">Delete Protection${lock_svg}</label> | |
| <p>When enabled, the app cannot be deleted. This is useful for preventing accidental deletion of important apps.</p> | |
| </div> | |
| <div style="z-index: 999; box-shadow: 10px 10px 15px #8c8c8c; overflow: hidden; position: fixed; bottom: 0; background: white; padding: 10px; width: 100%; left: 0;"> | |
| <button type="button" class="edit-app-save-btn button button-primary" style="margin-right: 40px;">Save</button> | |
| <button type="button" class="edit-app-reset-btn button button-secondary">Reset</button> | |
| </div> | |
| </form> | |
| </div> | |
| <div class="section-tab" data-tab="analytics"> | |
| <label for="analytics-period">Period</label> | |
| <select id="analytics-period" class="category-select"> | |
| <option value="today">Today</option> | |
| <option value="yesterday">Yesterday</option> | |
| <optgroup label="──────"></optgroup> | |
| <option value="this_week">This week</option> | |
| <option value="last_week">Last week</option> | |
| <option value="7d">Last 7 days</option> | |
| <option value="30d">Last 30 days</option> | |
| <optgroup label="──────"></optgroup> | |
| <option value="this_month">This month</option> | |
| <option value="last_month">Last month</option> | |
| <optgroup label="──────"></optgroup> | |
| <option value="this_year">This year</option> | |
| <option value="last_year">Last year</option> | |
| <optgroup label="──────"></optgroup> | |
| <option value="12m">Last 12 months</option> | |
| <option value="all">All time</option> | |
| </select> | |
| <div style="overflow:hidden;"> | |
| <div class="analytics-card" id="analytics-users"> | |
| <h3 style="margin-top:0;">Users</h3> | |
| <div class="count" style="font-size: 35px;"></div> | |
| </div> | |
| <div class="analytics-card" id="analytics-opens"> | |
| <h3 style="margin-top:0;">Opens</h3> | |
| <div class="count" style="font-size: 35px;"></div> | |
| </div> | |
| </div> | |
| <hr style="margin-top: 50px;"> | |
| <p>Timezone: UTC</p> | |
| <p>More analytics features coming soon...</p> | |
| </div> | |
| `; | |
| return h; | |
| } | |
| /* This function keeps track of the original values of the app before it is edited*/ | |
| function trackOriginalValues () { | |
| originalValues = { | |
| title: $('#edit-app-title').val(), | |
| name: $('#edit-app-name').val(), | |
| indexURL: $('#edit-app-index-url').val(), | |
| description: $('#edit-app-description').val(), | |
| icon: $('#edit-app-icon').attr('data-base64'), | |
| fileAssociations: $('#edit-app-filetype-associations').val(), | |
| category: $('#edit-app-category').val(), | |
| socialImage: $('#edit-app-social-image').attr('data-base64'), | |
| windowSettings: { | |
| width: $('#edit-app-window-width').val(), | |
| height: $('#edit-app-window-height').val(), | |
| top: $('#edit-app-window-top').val(), | |
| left: $('#edit-app-window-left').val(), | |
| }, | |
| checkboxes: { | |
| maximizeOnStart: $('#edit-app-maximize-on-start').is(':checked'), | |
| background: $('#edit-app-background').is(':checked'), | |
| resizableWindow: $('#edit-app-window-resizable').is(':checked'), | |
| hideTitleBar: $('#edit-app-hide-titlebar').is(':checked'), | |
| locked: $('#edit-app-locked').is(':checked'), | |
| fullPageOnLanding: $('#edit-app-fullpage-on-landing').is(':checked'), | |
| setTitleToFile: $('#edit-app-set-title-to-file').is(':checked'), | |
| }, | |
| }; | |
| } | |
| /* This function compares for all fields and checks if anything has changed from before editting*/ | |
| function hasChanges () { | |
| // is icon changed | |
| if ( $('#edit-app-icon').attr('data-base64') !== originalValues.icon ) { | |
| return true; | |
| } | |
| // if social image is changed | |
| if ( $('#edit-app-social-image').attr('data-base64') !== originalValues.socialImage ) { | |
| return true; | |
| } | |
| // if any of the fields have changed | |
| return ( | |
| $('#edit-app-title').val() !== originalValues.title || | |
| $('#edit-app-name').val() !== originalValues.name || | |
| $('#edit-app-index-url').val() !== originalValues.indexURL || | |
| $('#edit-app-description').val() !== originalValues.description || | |
| $('#edit-app-icon').attr('data-base64') !== originalValues.icon || | |
| $('#edit-app-filetype-associations').val() !== originalValues.fileAssociations || | |
| $('#edit-app-category').val() !== originalValues.category || | |
| $('#edit-app-social-image').attr('data-base64') !== originalValues.socialImage || | |
| $('#edit-app-window-width').val() !== originalValues.windowSettings.width || | |
| $('#edit-app-window-height').val() !== originalValues.windowSettings.height || | |
| $('#edit-app-window-top').val() !== originalValues.windowSettings.top || | |
| $('#edit-app-window-left').val() !== originalValues.windowSettings.left || | |
| $('#edit-app-maximize-on-start').is(':checked') !== originalValues.checkboxes.maximizeOnStart || | |
| $('#edit-app-background').is(':checked') !== originalValues.checkboxes.background || | |
| $('#edit-app-window-resizable').is(':checked') !== originalValues.checkboxes.resizableWindow || | |
| $('#edit-app-hide-titlebar').is(':checked') !== originalValues.checkboxes.hideTitleBar || | |
| $('#edit-app-locked').is(':checked') !== originalValues.checkboxes.locked || | |
| $('#edit-app-fullpage-on-landing').is(':checked') !== originalValues.checkboxes.fullPageOnLanding || | |
| $('#edit-app-set-title-to-file').is(':checked') !== originalValues.checkboxes.setTitleToFile | |
| ); | |
| } | |
| /* This function enables or disables the save button if there are any changes made */ | |
| function toggleSaveButton () { | |
| if ( hasChanges() ) { | |
| $('.edit-app-save-btn').prop('disabled', false); | |
| } else { | |
| $('.edit-app-save-btn').prop('disabled', true); | |
| } | |
| } | |
| /* This function enables or disables the reset button if there are any changes made */ | |
| function toggleResetButton () { | |
| if ( hasChanges() ) { | |
| $('.edit-app-reset-btn').prop('disabled', false); | |
| } else { | |
| $('.edit-app-reset-btn').prop('disabled', true); | |
| } | |
| } | |
| window.reset_drop_area = () => { | |
| dropped_items = null; | |
| $('.drop-area').html(drop_area_placeholder); | |
| $('.drop-area').removeClass('drop-area-ready-to-deploy'); | |
| $('.deploy-btn').addClass('disabled'); | |
| }; | |
| /* This function revers the changes made back to the original values of the edit form */ | |
| function resetToOriginalValues () { | |
| $('#edit-app-title').val(originalValues.title); | |
| $('#edit-app-name').val(originalValues.name); | |
| $('#edit-app-index-url').val(originalValues.indexURL); | |
| $('#edit-app-description').val(originalValues.description); | |
| $('#edit-app-filetype-associations').val(originalValues.fileAssociations); | |
| $('#edit-app-category').val(originalValues.category); | |
| $('#edit-app-window-width').val(originalValues.windowSettings.width); | |
| $('#edit-app-window-height').val(originalValues.windowSettings.height); | |
| $('#edit-app-window-top').val(originalValues.windowSettings.top); | |
| $('#edit-app-window-left').val(originalValues.windowSettings.left); | |
| $('#edit-app-maximize-on-start').prop('checked', originalValues.checkboxes.maximizeOnStart); | |
| $('#edit-app-background').prop('checked', originalValues.checkboxes.background); | |
| $('#edit-app-window-resizable').prop('checked', originalValues.checkboxes.resizableWindow); | |
| $('#edit-app-hide-titlebar').prop('checked', originalValues.checkboxes.hideTitleBar); | |
| $('#edit-app-locked').prop('checked', originalValues.checkboxes.locked); | |
| $('#edit-app-fullpage-on-landing').prop('checked', originalValues.checkboxes.fullPageOnLanding); | |
| $('#edit-app-set-title-to-file').prop('checked', originalValues.checkboxes.setTitleToFile); | |
| if ( originalValues.icon ) { | |
| $('#edit-app-icon').css('background-image', `url(${originalValues.icon})`); | |
| $('#edit-app-icon').attr('data-url', originalValues.icon); | |
| $('#edit-app-icon').attr('data-base64', originalValues.icon); | |
| $('#edit-app-icon-delete').show(); | |
| } else { | |
| $('#edit-app-icon').css('background-image', ''); | |
| $('#edit-app-icon').removeAttr('data-url'); | |
| $('#edit-app-icon').removeAttr('data-base64'); | |
| $('#edit-app-icon-delete').hide(); | |
| } | |
| if ( originalValues.socialImage ) { | |
| $('#edit-app-social-image').css('background-image', `url(${originalValues.socialImage})`); | |
| $('#edit-app-social-image').attr('data-url', originalValues.socialImage); | |
| $('#edit-app-social-image').attr('data-base64', originalValues.socialImage); | |
| } else { | |
| $('#edit-app-social-image').css('background-image', ''); | |
| $('#edit-app-social-image').removeAttr('data-url'); | |
| $('#edit-app-social-image').removeAttr('data-base64'); | |
| } | |
| } | |
| async function edit_app_section (cur_app_name, tab = 'deploy') { | |
| puter.ui.showSpinner(); | |
| $('section:not(.sidebar)').hide(); | |
| $('.tab-btn').removeClass('active'); | |
| $('.tab-btn[data-tab="apps"]').addClass('active'); | |
| let cur_app = await puter.apps.get(cur_app_name, { icon_size: 128, stats_period: 'today' }); | |
| currently_editing_app = cur_app; | |
| // generate edit app section | |
| $('#edit-app').html(generate_edit_app_section(cur_app)); | |
| trackOriginalValues(); // Track initial field values | |
| toggleSaveButton(); // Ensure Save button is initially disabled | |
| toggleResetButton(); // Ensure Reset button is initially disabled | |
| $('#edit-app').show(); | |
| // analytics | |
| $('#analytics-users .count').html(cur_app.stats.user_count); | |
| $('#analytics-opens .count').html(cur_app.stats.open_count); | |
| render_analytics('today'); | |
| // show the correct tab | |
| $('.section-tab').hide(); | |
| $(`.section-tab[data-tab="${tab}"]`).show(); | |
| $('.section-tab-buttons .section-tab-btn').removeClass('active'); | |
| $(`.section-tab-buttons .section-tab-btn[data-tab="${tab}"]`).addClass('active'); | |
| const filetype_association_input = document.querySelector('textarea[id=edit-app-filetype-associations]'); | |
| let tagify = new Tagify(filetype_association_input, { | |
| pattern: /\.(?:[a-z0-9]+)|(?:[a-z]+\/(?:[a-z0-9.-]+|\*))/, | |
| delimiters: ',', // Use comma as delimiter | |
| duplicates: false, // Prevent duplicate tags | |
| enforceWhitelist: false, | |
| dropdown: { | |
| // show the dropdown immediately on focus (0 character typed) | |
| enabled: 0, | |
| }, | |
| whitelist: [ | |
| // MIME type patterns | |
| 'text/*', 'image/*', 'audio/*', 'video/*', 'application/*', | |
| // Documents | |
| '.doc', '.docx', '.pdf', '.txt', '.odt', '.rtf', '.tex', '.md', '.pages', '.epub', '.mobi', '.azw', '.azw3', '.djvu', '.xps', '.oxps', '.fb2', '.textile', '.markdown', '.asciidoc', '.rst', '.wpd', '.wps', '.abw', '.zabw', | |
| // Spreadsheets | |
| '.xls', '.xlsx', '.csv', '.ods', '.numbers', '.tsv', '.gnumeric', '.xlt', '.xltx', '.xlsm', '.xltm', '.xlam', '.xlsb', | |
| // Presentations | |
| '.ppt', '.pptx', '.key', '.odp', '.pps', '.ppsx', '.pptm', '.potx', '.potm', '.ppam', | |
| // Images | |
| '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.svg', '.webp', '.ico', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef', '.orf', '.sr2', '.heic', '.heif', '.avif', '.jxr', '.hdp', '.wdp', '.jng', '.xcf', '.pgm', '.pbm', '.ppm', '.pnm', | |
| // Video | |
| '.mp4', '.avi', '.mov', '.wmv', '.mkv', '.flv', '.webm', '.m4v', '.mpeg', '.mpg', '.3gp', '.3g2', '.ogv', '.vob', '.drc', '.gifv', '.mng', '.qt', '.yuv', '.rm', '.rmvb', '.asf', '.amv', '.m2v', '.svi', | |
| // Audio | |
| '.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.ape', '.au', '.mid', '.midi', '.mka', '.pcm', '.ra', '.ram', '.snd', '.wv', '.opus', | |
| // Code/Development | |
| '.js', '.ts', '.html', '.css', '.json', '.xml', '.php', '.py', '.java', '.cpp', '.c', '.cs', '.h', '.hpp', '.hxx', '.rs', '.go', '.rb', '.pl', '.swift', '.kt', '.kts', '.scala', '.coffee', '.sass', '.scss', '.less', '.jsx', '.tsx', '.vue', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.sql', '.r', '.dart', '.f', '.f90', '.for', '.lua', '.m', '.mm', '.clj', '.erl', '.ex', '.exs', '.elm', '.hs', '.lhs', '.lisp', '.ml', '.mli', '.nim', '.pl', '.rkt', '.v', '.vhd', | |
| // Archives | |
| '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.z', '.lz', '.lzma', '.tlz', '.txz', '.tgz', '.tbz2', '.bz', '.br', '.lzo', '.ar', '.cpio', '.shar', '.lrz', '.lz4', '.lz2', '.rz', '.sfark', '.sz', '.zoo', | |
| // Database | |
| '.db', '.sql', '.sqlite', '.sqlite3', '.dbf', '.mdb', '.accdb', '.db3', '.s3db', '.dbx', | |
| // Fonts | |
| '.ttf', '.otf', '.woff', '.woff2', '.eot', '.pfa', '.pfb', '.sfd', | |
| // CAD and 3D | |
| '.dwg', '.dxf', '.stl', '.obj', '.fbx', '.dae', '.3ds', '.blend', '.max', '.ma', '.mb', '.c4d', '.skp', '.usd', '.usda', '.usdc', '.abc', | |
| // Scientific/Technical | |
| '.mat', '.fig', '.nb', '.cdf', '.fits', '.fts', '.fit', '.gmsh', '.msh', '.fem', '.neu', '.hdf', '.h5', '.nx', '.unv', | |
| // System | |
| '.exe', '.dll', '.so', '.dylib', '.app', '.dmg', '.iso', '.img', '.bin', '.msi', '.apk', '.ipa', '.deb', '.rpm', | |
| // Directory | |
| '.directory', | |
| ], | |
| }); | |
| // -------------------------------------------------------- | |
| // Dragster | |
| // -------------------------------------------------------- | |
| let drop_area_content = drop_area_placeholder; | |
| $('.drop-area').dragster({ | |
| enter: function (dragsterEvent, event) { | |
| drop_area_content = $('.drop-area').html(); | |
| $('.drop-area').addClass('drop-area-hover'); | |
| $('.drop-area').html(drop_area_placeholder); | |
| }, | |
| leave: function (dragsterEvent, event) { | |
| $('.drop-area').html(drop_area_content); | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| }, | |
| drop: async function (dragsterEvent, event) { | |
| const e = event.originalEvent; | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| // hide previous success message | |
| $('.deploy-success-msg').fadeOut(); | |
| // remove hover class | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| //---------------------------------------------------- | |
| // Puter items dropped | |
| //---------------------------------------------------- | |
| if ( e.detail?.items?.length > 0 ) { | |
| let items = e.detail.items; | |
| // ---------------------------------------------------- | |
| // One Puter file dropped | |
| // ---------------------------------------------------- | |
| if ( items.length === 1 && !items[0].isDirectory ) { | |
| if ( items[0].name.toLowerCase() === 'index.html' ) { | |
| dropped_items = items[0].path; | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| drop_area_content = '<p style="margin-bottom:0; font-weight: 500;">index.html</p><p>Ready to deploy 🚀</p><p class="reset-deploy"><span>Cancel</span></p>'; | |
| $('.drop-area').html(drop_area_content); | |
| // enable deploy button | |
| $('.deploy-btn').removeClass('disabled'); | |
| } else { | |
| puter.ui.alert('You need to have an index.html file in your deployment.', [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| $('.drop-area').removeClass('drop-area-ready-to-deploy'); | |
| $('.deploy-btn').addClass('disabled'); | |
| dropped_items = []; | |
| } | |
| return; | |
| } | |
| // ---------------------------------------------------- | |
| // Multiple Puter files dropped | |
| // ---------------------------------------------------- | |
| else if ( items.length > 1 ) { | |
| let hasIndexHtml = false; | |
| for ( let item of items ) { | |
| if ( item.name.toLowerCase() === 'index.html' ) { | |
| hasIndexHtml = true; | |
| break; | |
| } | |
| } | |
| if ( hasIndexHtml ) { | |
| dropped_items = items; | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| drop_area_content = `<p style="margin-bottom:0; font-weight: 500;">${items.length} items</p><p>Ready to deploy 🚀</p><p class="reset-deploy"><span>Cancel</span></p>`; | |
| $('.drop-area').html(drop_area_content); | |
| // enable deploy button | |
| $('.deploy-btn').removeClass('disabled'); | |
| } else { | |
| puter.ui.alert('You need to have an index.html file in your deployment.', [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| $('.drop-area').removeClass('drop-area-ready-to-deploy'); | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.deploy-btn').addClass('disabled'); | |
| dropped_items = []; | |
| } | |
| return; | |
| } | |
| // ---------------------------------------------------- | |
| // One Puter directory dropped | |
| // ---------------------------------------------------- | |
| else if ( items.length === 1 && items[0].isDirectory ) { | |
| let children = await puter.fs.readdir(items[0].path); | |
| // check if index.html exists, if found, deploy entire directory | |
| for ( let child of children ) { | |
| if ( child.name === 'index.html' ) { | |
| // deploy(currently_editing_app, items[0].path); | |
| dropped_items = items[0].path; | |
| let rootItems = ''; | |
| if ( children.length === 1 ) | |
| { | |
| rootItems = children[0].name; | |
| } | |
| else if ( children.length === 2 ) | |
| { | |
| rootItems = `${children[0].name}, ${children[1].name}`; | |
| } | |
| else if ( children.length === 3 ) | |
| { | |
| rootItems = `${children[0].name}, ${children[1].name}, and${children[1].name}`; | |
| } | |
| else if ( children.length > 3 ) | |
| { | |
| rootItems = `${children[0].name}, ${children[1].name}, and ${children.length - 2} more item${children.length - 2 > 1 ? 's' : ''}`; | |
| } | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| drop_area_content = `<p style="margin-bottom:0; font-weight: 500;">${rootItems}</p><p>Ready to deploy 🚀</p><p class="reset-deploy"><span>Cancel</span></p>`; | |
| $('.drop-area').html(drop_area_content); | |
| // enable deploy button | |
| $('.deploy-btn').removeClass('disabled'); | |
| return; | |
| } | |
| } | |
| // no index.html in directory | |
| puter.ui.alert(index_missing_error, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| $('.drop-area').removeClass('drop-area-ready-to-deploy'); | |
| $('.deploy-btn').addClass('disabled'); | |
| dropped_items = []; | |
| } | |
| return false; | |
| } | |
| //----------------------------------------------------------------------------- | |
| // Local items dropped | |
| //----------------------------------------------------------------------------- | |
| if ( !e.dataTransfer || !e.dataTransfer.items || e.dataTransfer.items.length === 0 ) | |
| { | |
| return; | |
| } | |
| // get dropped items | |
| dropped_items = await puter.ui.getEntriesFromDataTransferItems(e.dataTransfer.items); | |
| // generate a flat array of full paths from the dropped items | |
| let paths = []; | |
| for ( let item of dropped_items ) { | |
| paths.push(`/${item.fullPath ?? item.filepath}`); | |
| } | |
| // generate a directory tree from the paths | |
| let tree = generateDirTree(paths); | |
| dropped_items = setRootDirTree(tree, dropped_items); | |
| // alert if no index.html in root | |
| if ( ! hasRootIndexHtml(tree) ) { | |
| puter.ui.alert(index_missing_error, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| $('.drop-area').removeClass('drop-area-ready-to-deploy'); | |
| $('.deploy-btn').addClass('disabled'); | |
| dropped_items = []; | |
| return; | |
| } | |
| // Get all keys (directories and files) in the root | |
| const rootKeys = Object.keys(tree); | |
| // generate a list of items in the root in the form of a string (e.g. /index.html, /css/style.css) with maximum of 3 items | |
| let rootItems = ''; | |
| if ( rootKeys.length === 1 ) | |
| { | |
| rootItems = rootKeys[0]; | |
| } | |
| else if ( rootKeys.length === 2 ) | |
| { | |
| rootItems = `${rootKeys[0]}, ${rootKeys[1]}`; | |
| } | |
| else if ( rootKeys.length === 3 ) | |
| { | |
| rootItems = `${rootKeys[0]}, ${rootKeys[1]}, and${rootKeys[1]}`; | |
| } | |
| else if ( rootKeys.length > 3 ) | |
| { | |
| rootItems = `${rootKeys[0]}, ${rootKeys[1]}, and ${rootKeys.length - 2} more item${rootKeys.length - 2 > 1 ? 's' : ''}`; | |
| } | |
| rootItems = html_encode(rootItems); | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| drop_area_content = `<p style="margin-bottom:0; font-weight: 500;">${rootItems}</p><p>Ready to deploy 🚀</p><p class="reset-deploy"><span>Cancel</span></p>`; | |
| $('.drop-area').html(drop_area_content); | |
| // enable deploy button | |
| $('.deploy-btn').removeClass('disabled'); | |
| return false; | |
| }, | |
| }); | |
| // Focus on the first input | |
| $('#edit-app-title').focus(); | |
| try { | |
| activate_tippy(); | |
| } catch (e) { | |
| console.log('no tippy:', e); | |
| } | |
| // Custom function to handle bulk pasting of file extensions | |
| if ( tagify ) { | |
| // Create a completely separate paste handler | |
| const handleBulkPaste = function (e) { | |
| const clipboardData = e.clipboardData || window.clipboardData; | |
| if ( ! clipboardData ) return; | |
| const pastedText = clipboardData.getData('text'); | |
| if ( ! pastedText ) return; | |
| // Check if the pasted text contains delimiters | |
| if ( /[,;\t\s]/.test(pastedText) ) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| // Process the pasted text to extract extensions | |
| const extensions = pastedText.split(/[,;\t\s]+/) | |
| .map(ext => ext.trim()) | |
| .filter(ext => ext && (ext.startsWith('.') || ext.includes('/'))); | |
| if ( extensions.length > 0 ) { | |
| // Get existing values to prevent duplicates | |
| const existingValues = tagify.value.map(tag => tag.value); | |
| // Only add extensions that don't already exist | |
| const newExtensions = extensions.filter(ext => !existingValues.includes(ext)); | |
| if ( newExtensions.length > 0 ) { | |
| // Add the new tags | |
| tagify.addTags(newExtensions); | |
| // Update the UI | |
| setTimeout(() => { | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }, 10); | |
| } | |
| } | |
| // Clear the input element to prevent any text concatenation | |
| setTimeout(() => { | |
| if ( tagify.DOM.input ) { | |
| tagify.DOM.input.textContent = ''; | |
| } | |
| }, 10); | |
| } | |
| }; | |
| // Add the paste handler directly to the tagify wrapper element | |
| const tagifyWrapper = tagify.DOM.scope; | |
| if ( tagifyWrapper ) { | |
| tagifyWrapper.addEventListener('paste', handleBulkPaste, true); | |
| } | |
| // Also add it to the input element for better coverage | |
| if ( tagify.DOM.input ) { | |
| tagify.DOM.input.addEventListener('paste', handleBulkPaste, true); | |
| } | |
| // Add a comma key handler to support adding tags with comma | |
| tagify.DOM.input.addEventListener('keydown', function (e) { | |
| if ( e.key === ',' && tagify.DOM.input.textContent.trim() ) { | |
| e.preventDefault(); | |
| const text = tagify.DOM.input.textContent.trim(); | |
| // Only add valid extensions | |
| if ( (text.startsWith('.') || text.includes('/')) && | |
| tagify.settings.pattern.test(text) ) { | |
| // Check for duplicates | |
| const existingValues = tagify.value.map(tag => tag.value); | |
| if ( ! existingValues.includes(text) ) { | |
| tagify.addTags([text]); | |
| // Update UI | |
| setTimeout(() => { | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }, 10); | |
| } | |
| // Always clear the input | |
| tagify.DOM.input.textContent = ''; | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| $(document).on('click', '.edit-app-save-btn', async function (e) { | |
| const title = $('#edit-app-title').val(); | |
| const name = $('#edit-app-name').val(); | |
| const index_url = $('#edit-app-index-url').val(); | |
| const description = $('#edit-app-description').val(); | |
| const uid = $('#edit-app-uid').val(); | |
| const height = $('#edit-app-window-height').val(); | |
| const width = $('#edit-app-window-width').val(); | |
| const top = $('#edit-app-window-top').val(); | |
| const left = $('#edit-app-window-left').val(); | |
| const category = $('#edit-app-category').val(); | |
| let filetype_associations = $('#edit-app-filetype-associations').val(); | |
| let icon; | |
| let error; | |
| //validation | |
| if ( title === '' ) | |
| { | |
| error = '<strong>Title</strong> is required.'; | |
| } | |
| else if ( title.length > 60 ) | |
| { | |
| error = `<strong>Title</strong> cannot be longer than ${60}.`; | |
| } | |
| else if ( name === '' ) | |
| { | |
| error = '<strong>Name</strong> is required.'; | |
| } | |
| else if ( name.length > 60 ) | |
| { | |
| error = `<strong>Name</strong> cannot be longer than ${60}.`; | |
| } | |
| else if ( index_url === '' ) | |
| { | |
| error = '<strong>Index URL</strong> is required.'; | |
| } | |
| else if ( ! name.match(/^[a-zA-Z0-9-_-]+$/) ) | |
| { | |
| error = '<strong>Name</strong> can only contain letters, numbers, dash (-) and underscore (_).'; | |
| } | |
| else if ( ! is_valid_url(index_url) ) | |
| { | |
| error = '<strong>Index URL</strong> must be a valid url.'; | |
| } | |
| else if ( !index_url.toLowerCase().startsWith('https://') && !index_url.toLowerCase().startsWith('http://') ) | |
| { | |
| error = '<strong>Index URL</strong> must start with \'https://\' or \'http://\'.'; | |
| } | |
| // height must be a number | |
| else if ( isNaN(height) ) | |
| { | |
| error = '<strong>Window Height</strong> must be a number.'; | |
| } | |
| // height must be greater than 0 | |
| else if ( height <= 0 ) | |
| { | |
| error = '<strong>Window Height</strong> must be greater than 0.'; | |
| } | |
| // width must be a number | |
| else if ( isNaN(width) ) | |
| { | |
| error = '<strong>Window Width</strong> must be a number.'; | |
| } | |
| // width must be greater than 0 | |
| else if ( width <= 0 ) | |
| { | |
| error = '<strong>Window Width</strong> must be greater than 0.'; | |
| } | |
| // top must be a number | |
| else if ( top && isNaN(top) ) | |
| { | |
| error = '<strong>Window Top</strong> must be a number.'; | |
| } | |
| // left must be a number | |
| else if ( left && isNaN(left) ) | |
| { | |
| error = '<strong>Window Left</strong> must be a number.'; | |
| } | |
| // download icon from URL | |
| else { | |
| let icon_url = $('#edit-app-icon').attr('data-url'); | |
| let icon_base64 = $('#edit-app-icon').attr('data-base64'); | |
| if ( icon_base64 ) { | |
| icon = icon_base64; | |
| } else if ( icon_url ) { | |
| icon = await getBase64ImageFromUrl(icon_url); | |
| let app_max_icon_size = 5 * 1024 * 1024; | |
| if ( icon.length > app_max_icon_size ) | |
| { | |
| error = `Icon cannot be larger than ${byte_format(app_max_icon_size)}`; | |
| } | |
| // make sure icon is an image | |
| else if ( !icon.startsWith('data:image/') && !icon.startsWith('data:application/octet-stream') ) | |
| { | |
| error = 'Icon must be an image.'; | |
| } | |
| } else { | |
| icon = null; | |
| } | |
| } | |
| // parse filetype_associations | |
| if ( filetype_associations !== '' ) { | |
| filetype_associations = JSON.parse(filetype_associations); | |
| filetype_associations = filetype_associations.map((type) => { | |
| const fileType = type.value; | |
| if ( | |
| !fileType || | |
| fileType === '.' || | |
| fileType === '/' | |
| ) { | |
| error = '<strong>File Association Type</strong> must be valid.'; | |
| return null; // Return null for invalid cases | |
| } | |
| const lower = fileType.toLocaleLowerCase(); | |
| if ( fileType.includes('/') ) { | |
| return lower; | |
| } else if ( fileType.includes('.') ) { | |
| return `.${lower.split('.')[1]}`; | |
| } else { | |
| return `.${lower}`; | |
| } | |
| }).filter(Boolean); | |
| } | |
| // error? | |
| if ( error ) { | |
| $('#edit-app-error').show(); | |
| $('#edit-app-error').html(error); | |
| document.body.scrollTop = document.documentElement.scrollTop = 0; | |
| return; | |
| } | |
| // show working spinner | |
| puter.ui.showSpinner(); | |
| // disable submit button | |
| $('.edit-app-save-btn').prop('disabled', true); | |
| let socialImageUrl = null; | |
| if ( $('#edit-app-social-image').attr('data-base64') ) { | |
| socialImageUrl = await handleSocialImageUpload(name, $('#edit-app-social-image').attr('data-base64')); | |
| } else if ( $('#edit-app-social-image').attr('data-url') ) { | |
| socialImageUrl = $('#edit-app-social-image').attr('data-url'); | |
| } | |
| puter.apps.update(currently_editing_app.name, { | |
| title: title, | |
| name: name, | |
| indexURL: index_url, | |
| icon: icon, | |
| description: description, | |
| maximizeOnStart: $('#edit-app-maximize-on-start').is(':checked'), | |
| background: $('#edit-app-background').is(':checked'), | |
| metadata: { | |
| fullpage_on_landing: $('#edit-app-fullpage-on-landing').is(':checked'), | |
| social_image: socialImageUrl, | |
| category: category || null, | |
| window_size: { | |
| width: width ?? 800, | |
| height: height ?? 600, | |
| }, | |
| window_position: { | |
| top: top, | |
| left: left, | |
| }, | |
| window_resizable: $('#edit-app-window-resizable').is(':checked'), | |
| hide_titlebar: $('#edit-app-hide-titlebar').is(':checked'), | |
| locked: $('#edit-app-locked').is(':checked') ?? false, | |
| set_title_to_opened_file: $('#edit-app-set-title-to-file').is(':checked'), | |
| }, | |
| filetypeAssociations: filetype_associations, | |
| }).then(async (app) => { | |
| currently_editing_app = app; | |
| trackOriginalValues(); // Update original values after save | |
| toggleSaveButton(); //Disable Save Button after succesful save | |
| toggleResetButton(); //DIsable Reset Button after succesful save | |
| $('#edit-app-error').hide(); | |
| $('#edit-app-success').show(); | |
| document.body.scrollTop = document.documentElement.scrollTop = 0; | |
| // Update open-app-btn | |
| $(`.open-app-btn[data-app-uid="${uid}"]`).attr('data-app-name', app.name); | |
| $(`.open-app[data-uid="${uid}"]`).attr('data-app-name', app.name); | |
| // Update title | |
| $(`.app-title[data-uid="${uid}"]`).html(html_encode(app.title)); | |
| // Update app link | |
| $(`.app-url[data-uid="${uid}"]`).html(applink(app)); | |
| $(`.app-url[data-uid="${uid}"]`).attr('href', applink(app)); | |
| // Update icons | |
| $(`.app-icon[data-uid="${uid}"]`).attr('src', html_encode(app.icon ? app.icon : './img/app.svg')); | |
| $(`[data-app-uid="${uid}"]`).attr('data-app-title', html_encode(app.title)); | |
| $(`[data-app-name="${uid}"]`).attr('data-app-name', html_encode(app.name)); | |
| }).catch((err) => { | |
| $('#edit-app-success').hide(); | |
| $('#edit-app-error').show(); | |
| $('#edit-app-error').html(err.error?.message); | |
| // scroll to top so that user sees error message | |
| document.body.scrollTop = document.documentElement.scrollTop = 0; | |
| // re-enable submit button | |
| $('.edit-app-save-btn').prop('disabled', false); | |
| }).finally(() => { | |
| puter.ui.hideSpinner(); | |
| }); | |
| }); | |
| $(document).on('input change', '#edit-app input, #edit-app textarea, #edit-app select', () => { | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }); | |
| $(document).on('click', '.edit-app-reset-btn', function () { | |
| resetToOriginalValues(); | |
| toggleSaveButton(); // Disable Save button since values are reverted to original | |
| toggleResetButton(); //Disable Reset button since values are reverted to original | |
| }); | |
| $(document).on('click', '.open-app-btn', async function (e) { | |
| puter.ui.launchApp($(this).attr('data-app-name')); | |
| }); | |
| $(document).on('click', '.edit-app-open-app-btn', async function (e) { | |
| puter.ui.launchApp($(this).attr('data-app-name')); | |
| }); | |
| $(document).on('click', '.delete-app-settings', async function (e) { | |
| let app_uid = $(this).attr('data-app-uid'); | |
| let app_name = $(this).attr('data-app-name'); | |
| let app_title = $(this).attr('data-app-title'); | |
| // check if app is locked | |
| const app_data = await puter.apps.get(app_name, { icon_size: 16 }); | |
| if ( app_data.metadata?.locked ) { | |
| puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ], { | |
| type: 'warning', | |
| }); | |
| return; | |
| } | |
| // confirm delete | |
| const alert_resp = await puter.ui.alert(`Are you sure you want to premanently delete <strong>${html_encode(app_title)}</strong>?`, | |
| [ | |
| { | |
| label: 'Yes, delete permanently', | |
| value: 'delete', | |
| type: 'danger', | |
| }, | |
| { | |
| label: 'Cancel', | |
| }, | |
| ]); | |
| if ( alert_resp === 'delete' ) { | |
| let init_ts = Date.now(); | |
| puter.ui.showSpinner(); | |
| puter.apps.delete(app_name).then(async (app) => { | |
| setTimeout(() => { | |
| puter.ui.hideSpinner(); | |
| $('.back-to-main-btn').trigger('click'); | |
| }, | |
| // make sure the modal was shown for at least 2 seconds | |
| (Date.now() - init_ts) > 2000 ? 1 : 2000 - (Date.now() - init_ts)); | |
| // get app directory | |
| puter.fs.stat({ | |
| path: `/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| returnSubdomains: true, | |
| }).then(async (stat) => { | |
| // delete subdomain associated with the app dir | |
| puter.hosting.delete(stat.subdomains[0].subdomain); | |
| // delete app directory | |
| puter.fs.delete(`/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| { recursive: true }); | |
| }); | |
| }).catch(async (err) => { | |
| setTimeout(() => { | |
| puter.ui.hideSpinner(); | |
| puter.ui.alert(err?.message, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| }, | |
| (Date.now() - init_ts) > 2000 ? 1 : (2000 - (Date.now() - init_ts))); | |
| }); | |
| } | |
| }); | |
| $(document).on('click', '.edit-app', async function (e) { | |
| $('#edit-app-uid').val($(this).attr('data-app-uid')); | |
| }); | |
| $(document).on('click', '.back-to-main-btn', function (e) { | |
| $('section:not(.sidebar)').hide(); | |
| $('.tab-btn').removeClass('active'); | |
| $('.tab-btn[data-tab="apps"]').addClass('active'); | |
| // get apps | |
| puter.ui.showSpinner(); | |
| setTimeout(function () { | |
| puter.apps.list({ icon_size: 64 }).then((apps_res) => { | |
| // uncheck the select all checkbox | |
| $('.select-all-apps').prop('checked', false); | |
| puter.ui.hideSpinner(); | |
| apps = apps_res; | |
| if ( apps.length > 0 ) { | |
| if ( window.activeTab === 'apps' ) { | |
| $('#no-apps-notice').hide(); | |
| $('#app-list').show(); | |
| } | |
| $('.app-card').remove(); | |
| apps.forEach(app => { | |
| $('#app-list-table > tbody').append(generate_app_card(app)); | |
| }); | |
| count_apps(); | |
| sort_apps(); | |
| activate_tippy(); | |
| } else | |
| { | |
| $('#no-apps-notice').show(); | |
| } | |
| }); | |
| }, 1000); | |
| }); | |
| function count_apps () { | |
| let count = 0; | |
| $('.app-card').each(function () { | |
| count++; | |
| }); | |
| $('.app-count').html(count ? count : ''); | |
| return count; | |
| } | |
| $(document).on('click', '#edit-app-icon-delete', async function (e) { | |
| $('#edit-app-icon').css('background-image', ''); | |
| $('#edit-app-icon').removeAttr('data-url'); | |
| $('#edit-app-icon').removeAttr('data-base64'); | |
| $('#edit-app-icon-delete').hide(); | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }); | |
| $(document).on('click', '#edit-app-icon', async function (e) { | |
| const res2 = await puter.ui.showOpenFilePicker({ | |
| accept: 'image/*', | |
| }); | |
| const icon = await puter.fs.read(res2.path); | |
| // convert blob to base64 | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(icon); | |
| reader.onloadend = function () { | |
| let image = reader.result; | |
| // Get file extension | |
| let fileExtension = res2.name.split('.').pop(); | |
| // Get MIME type | |
| let mimeType = getMimeType(fileExtension); | |
| // Replace MIME type in the data URL | |
| image = image.replace('data:application/octet-stream;base64', `data:${mimeType};base64`); | |
| $('#edit-app-icon').css('background-image', `url(${image})`); | |
| $('#edit-app-icon').attr('data-base64', image); | |
| $('#edit-app-icon-delete').show(); | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }; | |
| }); | |
| /** | |
| * Generates HTML for an individual app card in the app list. | |
| * | |
| * @param {Object} app - The app object containing details of the app. | |
| * * | |
| * @returns {string} HTML string representing the app card. | |
| * | |
| * @description | |
| * This function creates an HTML string for an app card, which includes: | |
| * - Checkbox for app selection | |
| * - App icon and title | |
| * - Links to open, edit, add to desktop, or delete the app | |
| * - Display of app statistics (user count, open count) | |
| * - Creation date | |
| * - Incentive program status badge (if applicable) | |
| * | |
| * The generated HTML is designed to be inserted into the app list table. | |
| * It includes data attributes for various interactive features and | |
| * event handling. | |
| * | |
| * @example | |
| * const appCardHTML = generate_app_card(myAppObject); | |
| * $('#app-list-table > tbody').append(appCardHTML); | |
| */ | |
| function generate_app_card (app) { | |
| let h = ''; | |
| h += `<tr class="app-card" data-uid="${html_encode(app.uid)}" data-title="${html_encode(app.title)}" data-name="${html_encode(app.name)}" style="height: 86px;">`; | |
| // check box | |
| h += '<td style="height: 60px; width: 20px; display: flex ; align-items: center;">'; | |
| h += '<div style="width: 20px; height: 20px; margin-top: 20px; margin-right: 10px; flex-shrink:0;">'; | |
| h += `<input type="checkbox" class="app-checkbox" data-app-uid="${html_encode(app.uid)}" data-app-name="${html_encode(app.name)}">`; | |
| h += '</div>'; | |
| h += '</td>'; | |
| // App info (title, category, toolbar) | |
| h += '<td style="height: 72px; width: 450px;">'; | |
| // Wrapper for icon + content side by side | |
| h += '<div style="display: flex; flex-direction: row; align-items: center; height: 86px; overflow: hidden;">'; | |
| // Icon | |
| h += `<div class="go-to-edit-app" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(app.title)}" data-app-locked="${html_encode(app.metadata?.locked)}" data-app-uid="${html_encode(app.uid)}" style=" | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| background-size: 92%; | |
| background-image: url(${app.icon === null ? './img/app.svg' : app.icon}); | |
| width: 60px; | |
| height: 60px; | |
| margin-right: 10px; | |
| color: #414b56; | |
| cursor: pointer; | |
| background-color: white; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| "></div>`; | |
| // App info content | |
| h += '<div style="display: flex; flex-direction: column; justify-content: space-between; height: 100%; overflow: visible;">'; | |
| // Info block with fixed layout | |
| h += '<div style="display: flex; flex-direction: column; justify-content: center; padding-left: 10px; flex-grow: 1; overflow: hidden; gap: 1px; height: 100%;">'; | |
| // Title | |
| h += `<h3 class="go-to-edit-app app-card-title" style=" | |
| margin: 0; | |
| font-size: 16px; | |
| line-height: 20px; | |
| height: 20px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| " data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(app.title)}" data-app-uid="${html_encode(app.uid)}"> | |
| ${html_encode(app.title)}${app.metadata?.locked ? lock_svg_tippy : ''} | |
| </h3>`; | |
| // Category (optional) | |
| if ( app.metadata?.category ) { | |
| const category = APP_CATEGORIES.find(c => c.id === app.metadata.category); | |
| if ( category ) { | |
| h += `<span class="app-category" >${html_encode(category.label)}</span>`; | |
| } | |
| } | |
| // Link | |
| h += `<a class="app-card-link" href="${html_encode(applink(app))}" target="_blank" style=" | |
| font-size: 13px; | |
| margin: 2px 0 0 0; | |
| color: #2563eb; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| text-decoration: none; | |
| ">${html_encode(applink(app))}</a>`; | |
| h += '</div>'; | |
| h += '</div>'; // end info column | |
| h += '</div>'; // end row | |
| h += '</td>'; | |
| // users count | |
| h += '<td style="margin-top:10px; font-size:15px; vertical-align:middle;">'; | |
| h += `<span class="stats-cell" data-app-name="${html_encode(app.name)}" data-app-uid="${html_encode(app.uid)}" title="Users" style="margin-right:20px;"><img src="./img/users.svg">${number_format((app.stats.referral_count ?? 0) + app.stats.user_count)}</span>`; | |
| h += '</td>'; | |
| // opens | |
| h += '<td style="margin-top:10px; font-size:15px; vertical-align:middle;">'; | |
| h += `<span class="stats-cell" data-app-name="${html_encode(app.name)}" data-app-uid="${html_encode(app.uid)}" title="Opens"><img src="./img/views.svg">${number_format(app.stats.open_count)}</span>`; | |
| h += '</td>'; | |
| // Created | |
| h += '<td style="margin-top:10px; font-size:15px; vertical-align:middle;">'; | |
| h += `<span title="Created" style="width: 130px; display: inline-block; font-size: 14px;">${moment(app.created_at).format('MMM Do, YYYY')}</span>`; | |
| h += '</td>'; | |
| h += '<td style="vertical-align:middle; min-width:200px;">'; | |
| h += '<div style="overflow: hidden; height: 100%; display: flex; justify-content: center; align-items: center;">'; | |
| // "Approved for listing" | |
| h += `<span class="tippy approval-badge approval-badge-lsiting ${app.approved_for_listing ? 'active' : ''}" title="${app.approved_for_listing ? '✅ Approved for listing in the App Center' : '❌ Not approved for listing in the App Center'}"></span>`; | |
| // "Approved for opening items" | |
| h += `<span class="tippy approval-badge approval-badge-opening ${app.approved_for_opening_items ? 'active' : ''}" title="${app.approved_for_opening_items ? '✅ Approved for opening items' : '❌ Not approved for opening items'}"></span>`; | |
| // "Approved for incentive program" | |
| h += `<span class="tippy approval-badge approval-badge-incentive ${app.approved_for_incentive_program ? 'active' : ''}" title="${app.approved_for_incentive_program ? '✅ Approved for the incentive program' : '❌ Not approved for the incentive program'}"></span>`; | |
| h += '</div>'; | |
| h += '</td>'; | |
| // options | |
| h += `<td style="vertical-align: middle;"><img class="options-icon options-icon-app" data-app-name="${html_encode(app.name)}" data-app-uid="${html_encode(app.uid)}" data-app-title="${html_encode(app.title)}" src="./img/options.svg"></td>`; | |
| h += '</tr>'; | |
| return h; | |
| } | |
| $('th.sort').on('click', function (e) { | |
| // determine what column to sort by | |
| const sortByColumn = $(this).attr('data-column'); | |
| // toggle sort direction | |
| if ( sortByColumn === sortBy ) { | |
| if ( sortDirection === 'asc' ) | |
| { | |
| sortDirection = 'desc'; | |
| } | |
| else | |
| { | |
| sortDirection = 'asc'; | |
| } | |
| } | |
| else { | |
| sortBy = sortByColumn; | |
| sortDirection = 'desc'; | |
| } | |
| // update arrow | |
| $('.sort-arrow').css('display', 'none'); | |
| $('#app-list-table').find('th').removeClass('sorted'); | |
| $(this).find(`.sort-arrow-${sortDirection}`).css('display', 'inline'); | |
| $(this).addClass('sorted'); | |
| sort_apps(); | |
| }); | |
| function sort_apps () { | |
| let sorted_apps; | |
| // sort | |
| if ( sortDirection === 'asc' ) { | |
| sorted_apps = apps.sort((a, b) => { | |
| if ( sortBy === 'name' ) { | |
| return a[sortBy].localeCompare(b[sortBy]); | |
| } else if ( sortBy === 'created_at' ) { | |
| return new Date(a[sortBy]) - new Date(b[sortBy]); | |
| } else if ( sortBy === 'user_count' || sortBy === 'open_count' ) { | |
| return a.stats[sortBy] - b.stats[sortBy]; | |
| } else { | |
| a[sortBy] > b[sortBy] ? 1 : -1; | |
| } | |
| }); | |
| } else { | |
| sorted_apps = apps.sort((a, b) => { | |
| if ( sortBy === 'name' ) { | |
| return b[sortBy].localeCompare(a[sortBy]); | |
| } else if ( sortBy === 'created_at' ) { | |
| return new Date(b[sortBy]) - new Date(a[sortBy]); | |
| } else if ( sortBy === 'user_count' || sortBy === 'open_count' ) { | |
| return b.stats[sortBy] - a.stats[sortBy]; | |
| } else { | |
| b[sortBy] > a[sortBy] ? 1 : -1; | |
| } | |
| }); | |
| } | |
| // refresh app list | |
| $('.app-card').remove(); | |
| sorted_apps.forEach(app => { | |
| $('#app-list-table > tbody').append(generate_app_card(app)); | |
| }); | |
| count_apps(); | |
| // show apps that match search_query and hide apps that don't | |
| if ( search_query ) { | |
| // show apps that match search_query and hide apps that don't | |
| apps.forEach((app) => { | |
| if ( app.title.toLowerCase().includes(search_query.toLowerCase()) ) { | |
| $(`.app-card[data-name="${html_encode(app.name)}"]`).show(); | |
| } else { | |
| $(`.app-card[data-name="${html_encode(app.name)}"]`).hide(); | |
| } | |
| }); | |
| } | |
| } | |
| /** | |
| * Checks if the items being deployed contain a .git directory | |
| * @param {Array|string} items - Items to check (can be path string or array of items) | |
| * @returns {Promise<boolean>} - True if .git directory is found | |
| */ | |
| async function hasGitDirectory (items) { | |
| // Case 1: Single Puter path | |
| if ( typeof items === 'string' && (items.startsWith('/') || items.startsWith('~')) ) { | |
| const stat = await puter.fs.stat(items); | |
| if ( stat.is_dir ) { | |
| const files = await puter.fs.readdir(items); | |
| return files.some(file => file.name === '.git' && file.is_dir); | |
| } | |
| return false; | |
| } | |
| // Case 2: Array of Puter items | |
| if ( Array.isArray(items) && items[0]?.uid ) { | |
| return items.some(item => item.name === '.git' && item.is_dir); | |
| } | |
| // Case 3: Local items (DataTransferItems) | |
| if ( Array.isArray(items) ) { | |
| for ( let item of items ) { | |
| if ( item.fullPath?.includes('/.git/') || | |
| item.path?.includes('/.git/') || | |
| item.filepath?.includes('/.git/') ) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Shows a warning dialog about .git directory deployment | |
| * @returns {Promise<boolean>} - True if the user wants to proceed with deployment | |
| */ | |
| async function showGitWarningDialog () { | |
| try { | |
| // Check if the user has chosen to skip the warning | |
| const skipWarning = await puter.kv.get('skip-git-warning'); | |
| // Log retrieved value for debugging | |
| console.log('Retrieved skip-git-warning:', skipWarning); | |
| // If the user opted to skip the warning, proceed without showing it | |
| if ( skipWarning === true ) { | |
| return true; | |
| } | |
| } catch ( error ) { | |
| console.error('Error accessing KV store:', error); | |
| // If KV store access fails, fall back to showing the dialog | |
| } | |
| // Create the modal dialog | |
| const modal = document.createElement('div'); | |
| modal.innerHTML = ` | |
| <div style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); z-index: 10000;"> | |
| <h3 style="margin-top: 0;">Warning: Git Repository Detected</h3> | |
| <p>A .git directory was found in your deployment files. Deploying .git directories may:</p> | |
| <ul> | |
| <li>Expose sensitive information like commit history and configuration</li> | |
| <li>Significantly increase deployment size</li> | |
| </ul> | |
| <div style="margin-top: 15px; display: flex; align-items: center;"> | |
| <input type="checkbox" id="skip-git-warning" style="margin-right: 10px;"> | |
| <label for="skip-git-warning" style="margin-top:0;">Don't show this warning again</label> | |
| </div> | |
| <div style="margin-top: 15px; display: flex; justify-content: flex-end;"> | |
| <button id="cancel-deployment" style="margin-right: 10px; padding: 10px 15px; background: #f0f0f0; border: none; border-radius: 4px; cursor: pointer;">Cancel</button> | |
| <button id="continue-deployment" style="padding: 10px 15px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">Continue Deployment</button> | |
| </div> | |
| </div> | |
| <div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 9999;"></div> | |
| `; | |
| document.body.appendChild(modal); | |
| return new Promise((resolve) => { | |
| // Handle "Continue Deployment" | |
| document.getElementById('continue-deployment').addEventListener('click', async () => { | |
| try { | |
| const skipChecked = document.getElementById('skip-git-warning')?.checked; | |
| if ( skipChecked ) { | |
| console.log("Saving 'skip-git-warning' preference as true"); | |
| await puter.kv.set('skip-git-warning', true); | |
| } | |
| } catch ( error ) { | |
| console.error('Error saving user preference to KV store:', error); | |
| } finally { | |
| document.body.removeChild(modal); | |
| resolve(true); // Continue deployment | |
| } | |
| }); | |
| // Handle "Cancel Deployment" | |
| document.getElementById('cancel-deployment').addEventListener('click', () => { | |
| document.body.removeChild(modal); | |
| resolve(false); // Cancel deployment | |
| }); | |
| }); | |
| } | |
| window.deploy = async function (app, items) { | |
| // Check for .git directory before proceeding | |
| try { | |
| if ( await hasGitDirectory(items) ) { | |
| const shouldProceed = await showGitWarningDialog(); | |
| if ( ! shouldProceed ) { | |
| reset_drop_area(); | |
| return; | |
| } | |
| } | |
| } catch ( err ) { | |
| console.error('Error checking for .git directory:', err); | |
| } | |
| let appdata_dir, current_app_dir; | |
| // disable deploy button | |
| $('.deploy-btn').addClass('disabled'); | |
| // change drop area text | |
| $('.drop-area').html(`${deploying_spinner} <div>Deploying <span class="deploy-percent">(0%)</span></div>`); | |
| if ( typeof items === 'string' && (items.startsWith('/') || items.startsWith('~')) ) { | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| } | |
| // -------------------------------------------------------------------- | |
| // Get current directory, we need to delete the existing hostname | |
| // later on | |
| // -------------------------------------------------------------------- | |
| try { | |
| current_app_dir = await puter.fs.stat({ | |
| path: `/${auth_username}/AppData/${dev_center_uid}/${app.uid ?? app.uuid}`, | |
| returnSubdomains: true, | |
| }); | |
| } catch ( err ) { | |
| console.log(err); | |
| } | |
| // -------------------------------------------------------------------- | |
| // Delete existing hostnames attached to this app directory if they exist | |
| // -------------------------------------------------------------------- | |
| if ( current_app_dir?.subdomains.length > 0 ) { | |
| for ( let subdomain of current_app_dir?.subdomains ) { | |
| puter.hosting.delete(subdomain.subdomain); | |
| } | |
| } | |
| // -------------------------------------------------------------------- | |
| // Delete existing app directory | |
| // -------------------------------------------------------------------- | |
| try { | |
| await puter.fs.delete(current_app_dir.path); | |
| } catch ( err ) { | |
| console.log(err); | |
| } | |
| // -------------------------------------------------------------------- | |
| // Make an app directory under AppData | |
| // if the directory already exists, it should be overwritten | |
| // -------------------------------------------------------------------- | |
| try { | |
| appdata_dir = await puter.fs.mkdir( | |
| // path | |
| `/${auth_username}/AppData/${dev_center_uid}/${app.uid ?? app.uuid}`, | |
| // options | |
| { overwrite: true, recursive: true, rename: false }); | |
| } catch ( err ) { | |
| console.log(err); | |
| } | |
| // -------------------------------------------------------------------- | |
| // (A) One Puter Item: If 'items' is a string and starts with /, it's a path to a Puter item | |
| // -------------------------------------------------------------------- | |
| if ( typeof items === 'string' && (items.startsWith('/') || items.startsWith('~')) ) { | |
| // perform stat on 'items' | |
| const stat = await puter.fs.stat(items); | |
| // -------------------------------------------------------------------- | |
| // Puter Directory | |
| // -------------------------------------------------------------------- | |
| // Perform readdir on 'items' | |
| // todo there is apparently a bug in Puter where sometimes path is literally missing from the items | |
| // returned by readdir. This is the 'path' that readdit didn't return a path for: "~/Desktop/particle-clicker-master" | |
| if ( stat.is_dir ) { | |
| const files = await puter.fs.readdir(items); | |
| // copy the 'files' to the app directory | |
| if ( files.length > 0 ) { | |
| for ( let file of files ) { | |
| // perform copy | |
| await puter.fs.copy(file.path, | |
| appdata_dir.path, | |
| { overwrite: true }); | |
| // update progress | |
| $('.deploy-percent').text(`(${Math.round((files.indexOf(file) / files.length) * 100)}%)`); | |
| } | |
| } | |
| } | |
| // -------------------------------------------------------------------- | |
| // Puter File | |
| // -------------------------------------------------------------------- | |
| else { | |
| // copy the 'files' to the app directory | |
| await puter.fs.copy(items, | |
| appdata_dir.path, | |
| { overwrite: true }); | |
| } | |
| // generate new hostname with a random suffix | |
| let hostname = `${currently_editing_app.name}-${(Math.random() + 1).toString(36).substring(7)}`; | |
| // -------------------------------------------------------------------- | |
| // Create a router for the app with the fresh hostname | |
| // we change hostname every time to prevent caching issues | |
| // -------------------------------------------------------------------- | |
| puter.hosting.create(hostname, appdata_dir.path).then(async (res) => { | |
| // TODO this endpoint needs to be able to update only the specified fields | |
| puter.apps.update(currently_editing_app.name, { | |
| indexURL: `${protocol}://${hostname}.${static_hosting_domain}`, | |
| title: currently_editing_app.title, | |
| name: currently_editing_app.name, | |
| icon: currently_editing_app.icon, | |
| description: currently_editing_app.description, | |
| maximizeOnStart: currently_editing_app.maximize_on_start, | |
| background: currently_editing_app.background, | |
| filetypeAssociations: currently_editing_app.filetype_associations, | |
| }); | |
| // set the 'Index URL' field for the 'Settings' tab | |
| $('#edit-app-index-url').val(`${protocol}://${hostname}.${static_hosting_domain}`); | |
| // show success message | |
| $('.deploy-success-msg').show(); | |
| // reset drop area | |
| reset_drop_area(); | |
| }); | |
| } | |
| // -------------------------------------------------------------------- | |
| // (B) Multiple Puter Items: If `items` is an Array `items[0]` has `uid` | |
| // then it's a Puter Item Array. | |
| // -------------------------------------------------------------------- | |
| else if ( Array.isArray(items) && items[0].uid ) { | |
| // If there's no index.html in the root, return | |
| if ( ! hasRootIndexHtml ) | |
| { | |
| return; | |
| } | |
| // copy the 'files' to the app directory | |
| for ( let item of items ) { | |
| // perform copy | |
| await puter.fs.copy(item.fullPath ? item.fullPath : item.path ? item.path : item.filepath, | |
| appdata_dir.path, | |
| { overwrite: true }); | |
| // update progress | |
| $('.deploy-percent').text(`(${Math.round((items.indexOf(item) / items.length) * 100)}%)`); | |
| } | |
| // generate new hostname with a random suffix | |
| let hostname = `${currently_editing_app.name}-${(Math.random() + 1).toString(36).substring(7)}`; | |
| // -------------------------------------------------------------------- | |
| // Create a router for the app with the fresh hostname | |
| // we change hostname every time to prevent caching issues | |
| // -------------------------------------------------------------------- | |
| puter.hosting.create(hostname, appdata_dir.path).then(async (res) => { | |
| // TODO this endpoint needs to be able to update only the specified fields | |
| puter.apps.update(currently_editing_app.name, { | |
| indexURL: `${protocol}://${hostname}.${static_hosting_domain}`, | |
| title: currently_editing_app.title, | |
| name: currently_editing_app.name, | |
| icon: currently_editing_app.icon, | |
| description: currently_editing_app.description, | |
| maximizeOnStart: currently_editing_app.maximize_on_start, | |
| background: currently_editing_app.background, | |
| filetypeAssociations: currently_editing_app.filetype_associations, | |
| }); | |
| // set the 'Index URL' field for the 'Settings' tab | |
| $('#edit-app-index-url').val(`${protocol}://${hostname}.${static_hosting_domain}`); | |
| // show success message | |
| $('.deploy-success-msg').show(); | |
| // reset drop area | |
| reset_drop_area(); | |
| }); | |
| } | |
| // -------------------------------------------------------------------- | |
| // (C) Local Items: Upload new deploy | |
| // -------------------------------------------------------------------- | |
| else { | |
| puter.fs.upload(items, | |
| `/${auth_username}/AppData/${dev_center_uid}/${currently_editing_app.uid}`, | |
| { | |
| dedupeName: false, | |
| overwrite: false, | |
| parsedDataTransferItems: true, | |
| createMissingAncestors: true, | |
| progress: function (operation_id, op_progress) { | |
| $('.deploy-percent').text(`(${op_progress}%)`); | |
| }, | |
| }).then(async (uploaded) => { | |
| // new hostname | |
| let hostname = `${currently_editing_app.name}-${(Math.random() + 1).toString(36).substring(7)}`; | |
| // ---------------------------------------- | |
| // Create a router for the app with a fresh hostname | |
| // we change hostname every time to prevent caching issues | |
| // ---------------------------------------- | |
| puter.hosting.create(hostname, appdata_dir.path).then(async (res) => { | |
| // TODO this endpoint needs to be able to update only the specified fields | |
| puter.apps.update(currently_editing_app.name, { | |
| indexURL: `${protocol}://${hostname}.${static_hosting_domain}`, | |
| title: currently_editing_app.title, | |
| name: currently_editing_app.name, | |
| icon: currently_editing_app.icon, | |
| description: currently_editing_app.description, | |
| maximizeOnStart: currently_editing_app.maximize_on_start, | |
| background: currently_editing_app.background, | |
| filetypeAssociations: currently_editing_app.filetype_associations, | |
| }); | |
| // set the 'Index URL' field for the 'Settings' tab | |
| $('#edit-app-index-url').val(`${protocol}://${hostname}.${static_hosting_domain}`); | |
| // show success message | |
| $('.deploy-success-msg').show(); | |
| // reset drop area | |
| reset_drop_area(); | |
| }); | |
| }); | |
| } | |
| }; | |
| function generateDirTree (paths) { | |
| const root = {}; | |
| for ( let path of paths ) { | |
| let parts = path.split('/'); | |
| let currentNode = root; | |
| for ( let part of parts ) { | |
| if ( ! part ) continue; // skip empty parts, especially leading one | |
| if ( ! currentNode[part] ) { | |
| currentNode[part] = {}; | |
| } | |
| currentNode = currentNode[part]; | |
| } | |
| } | |
| return root; | |
| } | |
| function setRootDirTree (tree, items) { | |
| // Get all keys (directories and files) in the root | |
| const rootKeys = Object.keys(tree); | |
| // If there's only one object in the root, check if it's non-empty and return it | |
| if ( rootKeys.length === 1 && typeof tree[rootKeys[0]] === 'object' && Object.keys(tree[rootKeys[0]]).length > 0 ) { | |
| let newItems = []; | |
| for ( let item of items ) { | |
| if ( item.fullPath ) | |
| { | |
| item.finalPath = item.fullPath.replace(rootKeys[0], ''); | |
| } | |
| else if ( item.path ) | |
| { | |
| item.path = item.path.replace(rootKeys[0], ''); | |
| } | |
| else | |
| { | |
| item.filepath = item.filepath.replace(rootKeys[0], ''); | |
| } | |
| newItems.push(item); | |
| } | |
| return newItems; | |
| } else { | |
| return items; | |
| } | |
| } | |
| function hasRootIndexHtml (tree) { | |
| // Check if index.html exists in the root | |
| if ( tree['index.html'] ) { | |
| return true; | |
| } | |
| // Get all keys (directories and files) in the root | |
| const rootKeys = Object.keys(tree); | |
| // If there's only one directory in the root, check if index.html exists in that directory | |
| if ( rootKeys.length === 1 && typeof tree[rootKeys[0]] === 'object' && tree[rootKeys[0]]['index.html'] ) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| $(document).on('click', '.open-app', function (e) { | |
| puter.ui.launchApp($(this).attr('data-app-name')); | |
| }); | |
| $(document).on('click', '.insta-deploy-to-new-app', async function (e) { | |
| $('.insta-deploy-modal').get(0).close(); | |
| let title = await puter.ui.prompt('Please enter a title for your app:', 'My Awesome App'); | |
| if ( title.length > 60 ) { | |
| puter.ui.alert('Title cannot be longer than 60.', [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| // todo go back to create an app prompt and prefill the title input with the title the user entered | |
| $('.insta-deploy-modal').get(0).showModal(); | |
| } | |
| else if ( title ) { | |
| if ( source_path ) { | |
| create_app(title, source_path); | |
| source_path = null; | |
| } else { | |
| create_app(title, null, dropped_items); | |
| dropped_items = null; | |
| } | |
| } else | |
| { | |
| $('.insta-deploy-modal').get(0).showModal(); | |
| } | |
| return; | |
| }); | |
| $(document).on('click', '.insta-deploy-to-existing-app', function (e) { | |
| $('.insta-deploy-modal').get(0).close(); | |
| $('.insta-deploy-existing-app-select').get(0).showModal(); | |
| $('.insta-deploy-existing-app-list').html(`<div style="margin: 100px auto 10px auto; width: 40px; height:40px;">${loading_spinner}</div>`); | |
| puter.apps.list({ icon_size: 64 }).then((apps) => { | |
| setTimeout(() => { | |
| $('.insta-deploy-existing-app-list').html(''); | |
| if ( apps.length === 0 ) | |
| { | |
| $('.insta-deploy-existing-app-list').html(` | |
| <div class="no-existing-apps"> | |
| <img src="./img/apps-black.svg" style="width: 40px; height: 40px; opacity: 0.2; display: block; margin: 100px auto 10px auto;"> | |
| You have no existing apps. | |
| </div> | |
| `); | |
| } | |
| else { | |
| for ( let app of apps ) { | |
| $('.insta-deploy-existing-app-list').append( | |
| `<div class="insta-deploy-app-selector" data-uid="${app.uid}" data-name="${html_encode(app.name)}"> | |
| <img class="insta-deploy-app-icon" data-uid="${app.uid}" data-name="${html_encode(app.name)}" src="${app.icon ? html_encode(app.icon) : './img/app.svg'}"> | |
| <span style="display: inline-block; font-weight: 500; overflow: hidden; text-overflow: ellipsis; width: 180px; text-wrap: nowrap;" data-uid="${app.uid}" data-uid="${html_encode(app.name)}">${html_encode(app.title)}</span> | |
| <div style="margin-top: 10px; font-size:14px; opacity:0.7; display:inline-block;"> | |
| <span title="Users" style="width:90px; display: inline-block;"><img style="width: 15px; margin-right: 5px; margin-bottom: -2px;" src="./img/users.svg">${number_format((app.stats.referral_count ?? 0) + app.stats.user_count)}</span> | |
| <span title="Opens" style="display: inline-block;"><img style="width: 15px; margin-right: 5px; margin-bottom: -2px;" src="./img/views.svg">${number_format(app.stats.open_count)}</span> | |
| </div> | |
| </div>`); | |
| } | |
| } | |
| }, 500); | |
| }); | |
| // todo reset .insta-deploy-existing-app-list on close | |
| }); | |
| $(document).on('click', '.insta-deploy-app-selector', function (e) { | |
| $('.insta-deploy-app-selector').removeClass('active'); | |
| $(this).addClass('active'); | |
| // enable deploy button | |
| $('.insta-deploy-existing-app-deploy-btn').removeClass('disabled'); | |
| }); | |
| $(document).on('click', '.insta-deploy-existing-app-deploy-btn', function (e) { | |
| $('.insta-deploy-existing-app-deploy-btn').addClass('disabled'); | |
| $('.insta-deploy-existing-app-select')?.get(0)?.close(); | |
| const app_item = $('.insta-deploy-app-selector.active'); | |
| // load the 'App Settings' section | |
| edit_app_section(app_item.attr('data-name')); | |
| $('.drop-area').removeClass('drop-area-hover'); | |
| $('.drop-area').addClass('drop-area-ready-to-deploy'); | |
| let drop_area_content = '<p style="margin-bottom:0; font-weight: 500;">Ready to deploy 🚀</p><p class="reset-deploy"><span>Cancel</span></p>'; | |
| $('.drop-area').html(drop_area_content); | |
| // deploy | |
| console.log('data uid is present?', $(e.target).attr('data-uid'), app_item.attr('data-uid')); | |
| deploy({ uid: app_item.attr('data-uid') }, source_path ?? dropped_items); | |
| $('.insta-deploy-existing-app-list').html(''); | |
| }); | |
| $(document).on('click', '.insta-deploy-cancel', function (e) { | |
| $(this).closest('dialog')?.get(0)?.close(); | |
| }); | |
| $(document).on('click', '.insta-deploy-existing-app-back', function (e) { | |
| $('.insta-deploy-existing-app-select')?.get(0)?.close(); | |
| $('.insta-deploy-modal')?.get(0)?.showModal(); | |
| // disable deploy button | |
| $('.insta-deploy-existing-app-deploy-btn').addClass('disabled'); | |
| // todo disable the 'an existing app' option if there are no existing apps | |
| }); | |
| $('.insta-deploy-existing-app-select').on('close', function (e) { | |
| $('.insta-deploy-existing-app-list').html(''); | |
| }); | |
| $('.refresh-app-list').on('click', function (e) { | |
| puter.ui.showSpinner(); | |
| puter.apps.list({ icon_size: 64 }).then((resp) => { | |
| setTimeout(() => { | |
| apps = resp; | |
| $('.app-card').remove(); | |
| apps.forEach(app => { | |
| $('#app-list-table > tbody').append(generate_app_card(app)); | |
| }); | |
| count_apps(); | |
| // preserve search query | |
| if ( search_query ) { | |
| // show apps that match search_query and hide apps that don't | |
| apps.forEach((app) => { | |
| if ( app.title.toLowerCase().includes(search_query.toLowerCase()) ) { | |
| $(`.app-card[data-name="${app.name}"]`).show(); | |
| } else { | |
| $(`.app-card[data-name="${app.name}"]`).hide(); | |
| } | |
| }); | |
| } | |
| // preserve sort | |
| sort_apps(); | |
| activate_tippy(); | |
| puter.ui.hideSpinner(); | |
| }, 1000); | |
| }); | |
| }); | |
| $(document).on('click', '.search-apps', function (e) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| // don't let click bubble up to window | |
| e.stopImmediatePropagation(); | |
| }); | |
| $(document).on('input change keyup keypress keydown paste cut', '.search-apps', function (e) { | |
| search_apps(); | |
| }); | |
| window.search_apps = function () { | |
| // search apps for query | |
| search_query = $('.search-apps').val().toLowerCase(); | |
| if ( search_query === '' ) { | |
| // hide 'clear search' button | |
| $('.search-clear-apps').hide(); | |
| // show all apps again | |
| $('.app-card').show(); | |
| // remove 'has-value' class from search input | |
| $('.search-apps').removeClass('has-value'); | |
| } else { | |
| // show 'clear search' button | |
| $('.search-clear-apps').show(); | |
| // show apps that match search_query and hide apps that don't | |
| apps.forEach((app) => { | |
| if ( | |
| app.title.toLowerCase().includes(search_query.toLowerCase()) | |
| || app.name.toLowerCase().includes(search_query.toLowerCase()) | |
| || app.description.toLowerCase().includes(search_query.toLowerCase()) | |
| || app.uid.toLowerCase().includes(search_query.toLowerCase()) | |
| ) | |
| { | |
| $(`.app-card[data-name="${app.name}"]`).show(); | |
| } else { | |
| $(`.app-card[data-name="${app.name}"]`).hide(); | |
| } | |
| }); | |
| // add 'has-value' class to search input | |
| $('.search-apps').addClass('has-value'); | |
| } | |
| }; | |
| $(document).on('click', '.search-clear-apps', function (e) { | |
| $('.search-apps').val(''); | |
| $('.search-apps').trigger('change'); | |
| $('.search-apps').focus(); | |
| search_query = ''; | |
| // remove 'has-value' class from search input | |
| $('.search-apps').removeClass('has-value'); | |
| }); | |
| $(document).on('click', '.app-checkbox', function (e) { | |
| // was shift key pressed? | |
| if ( e.originalEvent && e.originalEvent.shiftKey ) { | |
| // select all checkboxes in range | |
| const currentIndex = $('.app-checkbox').index(this); | |
| const startIndex = Math.min(window.last_clicked_app_checkbox_index, currentIndex); | |
| const endIndex = Math.max(window.last_clicked_app_checkbox_index, currentIndex); | |
| // set all checkboxes in range to the same state as current checkbox | |
| for ( let i = startIndex; i <= endIndex; i++ ) { | |
| const checkbox = $('.app-checkbox').eq(i); | |
| checkbox.prop('checked', $(this).is(':checked')); | |
| // activate row | |
| if ( $(checkbox).is(':checked') ) | |
| { | |
| $(checkbox).closest('tr').addClass('active'); | |
| } | |
| else | |
| { | |
| $(checkbox).closest('tr').removeClass('active'); | |
| } | |
| } | |
| } | |
| // determine if select-all checkbox should be checked, indeterminate, or unchecked | |
| if ( $('.app-checkbox:checked').length === $('.app-checkbox').length ) { | |
| $('.select-all-apps').prop('indeterminate', false); | |
| $('.select-all-apps').prop('checked', true); | |
| } else if ( $('.app-checkbox:checked').length > 0 ) { | |
| $('.select-all-apps').prop('indeterminate', true); | |
| $('.select-all-apps').prop('checked', false); | |
| } | |
| else { | |
| $('.select-all-apps').prop('indeterminate', false); | |
| $('.select-all-apps').prop('checked', false); | |
| } | |
| // activate row | |
| if ( $(this).is(':checked') ) | |
| { | |
| $(this).closest('tr').addClass('active'); | |
| } | |
| else | |
| { | |
| $(this).closest('tr').removeClass('active'); | |
| } | |
| // enable delete button if at least one checkbox is checked | |
| if ( $('.app-checkbox:checked').length > 0 ) | |
| { | |
| $('.delete-apps-btn').removeClass('disabled'); | |
| } | |
| else | |
| { | |
| $('.delete-apps-btn').addClass('disabled'); | |
| } | |
| // store the index of the last clicked checkbox | |
| window.last_clicked_app_checkbox_index = $('.app-checkbox').index(this); | |
| }); | |
| function remove_app_card (app_uid, callback = null) { | |
| $(`.app-card[data-uid="${app_uid}"]`).fadeOut(200, function () { | |
| $(this).remove(); | |
| if ( $('.app-card').length === 0 ) { | |
| $('section:not(.sidebar)').hide(); | |
| $('#no-apps-notice').show(); | |
| } else { | |
| $('section:not(.sidebar)').hide(); | |
| $('#app-list').show(); | |
| } | |
| // update select-all-apps checkbox's state | |
| if ( $('.app-checkbox:checked').length === 0 ) { | |
| $('.select-all-apps').prop('indeterminate', false); | |
| $('.select-all-apps').prop('checked', false); | |
| } | |
| else if ( $('.app-checkbox:checked').length === $('.app-card').length ) { | |
| $('.select-all-apps').prop('indeterminate', false); | |
| $('.select-all-apps').prop('checked', true); | |
| } | |
| else { | |
| $('.select-all-apps').prop('indeterminate', true); | |
| } | |
| count_apps(); | |
| if ( callback ) callback(); | |
| }); | |
| } | |
| $(document).on('click', '.delete-apps-btn', async function (e) { | |
| // show confirmation alert | |
| let resp = await puter.ui.alert('Are you sure you want to delete the selected apps?', [ | |
| { | |
| label: 'Delete', | |
| type: 'danger', | |
| value: 'delete', | |
| }, | |
| { | |
| label: 'Cancel', | |
| }, | |
| ], { | |
| type: 'warning', | |
| }); | |
| if ( resp === 'delete' ) { | |
| // show 'deleting' modal | |
| puter.ui.showSpinner(); | |
| let start_ts = Date.now(); | |
| const apps = $('.app-checkbox:checked').toArray(); | |
| // delete all checked apps | |
| for ( let app of apps ) { | |
| // get app uid | |
| const app_uid = $(app).attr('data-app-uid'); | |
| const app_name = $(app).attr('data-app-name'); | |
| // get app | |
| const app_data = await puter.apps.get(app_name, { icon_size: 64 }); | |
| if ( app_data.metadata?.locked ) { | |
| if ( apps.length === 1 ) { | |
| puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ], { | |
| type: 'warning', | |
| }); | |
| break; | |
| } | |
| let resp = await puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [ | |
| { | |
| label: 'Skip and Continue', | |
| value: 'Continue', | |
| type: 'primary', | |
| }, | |
| { | |
| label: 'Cancel', | |
| }, | |
| ], { | |
| type: 'warning', | |
| }); | |
| if ( resp === 'Cancel' ) | |
| { | |
| break; | |
| } | |
| else if ( resp === 'Continue' ) | |
| { | |
| continue; | |
| } | |
| else | |
| { | |
| continue; | |
| } | |
| } | |
| // delete app | |
| await puter.apps.delete(app_name); | |
| // remove app card | |
| remove_app_card(app_uid); | |
| try { | |
| // get app directory | |
| const stat = await puter.fs.stat({ | |
| path: `/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| returnSubdomains: true, | |
| }); | |
| // delete subdomain associated with the app directory | |
| if ( stat?.subdomains[0]?.subdomain ) { | |
| await puter.hosting.delete(stat.subdomains[0].subdomain); | |
| } | |
| // delete app directory | |
| await puter.fs.delete(`/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| { recursive: true }); | |
| count_apps(); | |
| } catch ( err ) { | |
| console.log(err); | |
| } | |
| } | |
| // close 'deleting' modal | |
| setTimeout(() => { | |
| puter.ui.hideSpinner(); | |
| if ( $('.app-checkbox:checked').length === 0 ) { | |
| // disable delete button | |
| $('.delete-apps-btn').addClass('disabled'); | |
| // reset the 'select all' checkbox | |
| $('.select-all-apps').prop('indeterminate', false); | |
| $('.select-all-apps').prop('checked', false); | |
| } | |
| }, (start_ts - Date.now()) > 500 ? 0 : 500); | |
| } | |
| }); | |
| $(document).on('change', '.select-all-apps', function (e) { | |
| if ( $(this).is(':checked') ) { | |
| $('.app-checkbox').prop('checked', true); | |
| $('.app-card').addClass('active'); | |
| $('.delete-apps-btn').removeClass('disabled'); | |
| } else { | |
| $('.app-checkbox').prop('checked', false); | |
| $('.app-card').removeClass('active'); | |
| $('.delete-apps-btn').addClass('disabled'); | |
| } | |
| }); | |
| // if edit-app-maximize-on-start is checked, disable window size and position fields | |
| $(document).on('change', '#edit-app-maximize-on-start', function (e) { | |
| if ( $(this).is(':checked') ) { | |
| $('#edit-app-window-width, #edit-app-window-height').prop('disabled', true); | |
| $('#edit-app-window-top, #edit-app-window-left').prop('disabled', true); | |
| } else { | |
| $('#edit-app-window-width, #edit-app-window-height').prop('disabled', false); | |
| $('#edit-app-window-top, #edit-app-window-left').prop('disabled', false); | |
| } | |
| }); | |
| $(document).on('change', '#edit-app-background', function (e) { | |
| if ( $('#edit-app-background').is(':checked') ) { | |
| disable_window_settings(); | |
| } else { | |
| enable_window_settings(); | |
| } | |
| }); | |
| function disable_window_settings () { | |
| $('#edit-app-maximize-on-start').prop('disabled', true); | |
| $('#edit-app-fullpage-on-landing').prop('disabled', true); | |
| $('#edit-app-window-width, #edit-app-window-height').prop('disabled', true); | |
| $('#edit-app-window-top, #edit-app-window-left').prop('disabled', true); | |
| $('#edit-app-window-resizable').prop('disabled', true); | |
| $('#edit-app-hide-titlebar').prop('disabled', true); | |
| } | |
| function enable_window_settings () { | |
| $('#edit-app-maximize-on-start').prop('disabled', false); | |
| $('#edit-app-fullpage-on-landing').prop('disabled', false); | |
| $('#edit-app-window-width, #edit-app-window-height').prop('disabled', false); | |
| $('#edit-app-window-top, #edit-app-window-left').prop('disabled', false); | |
| $('#edit-app-window-resizable').prop('disabled', false); | |
| $('#edit-app-hide-titlebar').prop('disabled', false); | |
| } | |
| $(document).on('click', '.reset-deploy', function (e) { | |
| reset_drop_area(); | |
| }); | |
| window.initializeAssetsDirectory = async () => { | |
| try { | |
| // Check if assets_url exists | |
| const existingURL = await puter.kv.get('assets_url'); | |
| if ( ! existingURL ) { | |
| // Create assets directory | |
| const assetsDir = await puter.fs.mkdir(`/${auth_username}/AppData/${dev_center_uid}/assets`, | |
| { overwrite: false }); | |
| // Publish the directory | |
| const hostname = `assets-${Math.random().toString(36).substring(2)}`; | |
| const route = await puter.hosting.create(hostname, assetsDir.path); | |
| // Store the URL | |
| await puter.kv.set('assets_url', `https://${hostname}.puter.site`); | |
| } | |
| } catch ( err ) { | |
| console.error('Error initializing assets directory:', err); | |
| } | |
| }; | |
| window.generateSocialImageSection = (app) => { | |
| return ` | |
| <label for="edit-app-social-image">Social Graph Image (1200×630 strongly recommended)</label> | |
| <div id="edit-app-social-image" class="social-image-preview" ${app.metadata?.social_image ? `style="background-image:url(${html_encode(app.metadata.social_image)})" data-url="${html_encode(app.metadata.social_image)}" data-base64="${html_encode(app.metadata.social_image)}"` : ''}> | |
| <div id="change-social-image">Change Social Image</div> | |
| </div> | |
| <span id="edit-app-social-image-delete" style="${app.metadata?.social_image ? 'display:block;' : ''}">Remove social image</span> | |
| <p class="social-image-help">This image will be displayed when your app is shared on social media.</p> | |
| `; | |
| }; | |
| $(document).on('click', '#edit-app-social-image', async function (e) { | |
| const res = await puter.ui.showOpenFilePicker({ | |
| accept: 'image/*', | |
| }); | |
| const socialImage = await puter.fs.read(res.path); | |
| // Convert blob to base64 for preview | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(socialImage); | |
| reader.onloadend = function () { | |
| let image = reader.result; | |
| // Get file extension | |
| let fileExtension = res.name.split('.').pop(); | |
| // Get MIME type | |
| let mimeType = getMimeType(fileExtension); | |
| // Replace MIME type in the data URL | |
| image = image.replace('data:application/octet-stream;base64', `data:image/${mimeType};base64`); | |
| $('#edit-app-social-image').css('background-image', `url(${image})`); | |
| $('#edit-app-social-image').attr('data-base64', image); | |
| $('#edit-app-social-image-delete').show(); | |
| toggleSaveButton(); | |
| toggleResetButton(); | |
| }; | |
| }); | |
| $(document).on('click', '#edit-app-social-image-delete', async function (e) { | |
| $('#edit-app-social-image').css('background-image', ''); | |
| $('#edit-app-social-image').removeAttr('data-url'); | |
| $('#edit-app-social-image').removeAttr('data-base64'); | |
| $('#edit-app-social-image-delete').hide(); | |
| }); | |
| window.handleSocialImageUpload = async (app_name, socialImageData) => { | |
| if ( ! socialImageData ) return null; | |
| try { | |
| const assets_url = await puter.kv.get('assets_url'); | |
| if ( ! assets_url ) throw new Error('Assets URL not found'); | |
| // Convert base64 to blob | |
| const base64Response = await fetch(socialImageData); | |
| const blob = await base64Response.blob(); | |
| // Get assets directory path | |
| const assetsDir = `/${auth_username}/AppData/${dev_center_uid}/assets`; | |
| // Upload new image | |
| await puter.fs.upload(new File([blob], `${app_name}.png`, { type: 'image/png' }), | |
| assetsDir, | |
| { overwrite: true }); | |
| return `${assets_url}/${app_name}.png`; | |
| } catch ( err ) { | |
| console.error('Error uploading social image:', err); | |
| throw err; | |
| } | |
| }; | |
| $(document).on('click', '.copy-app-uid', function (e) { | |
| const appUID = $('#edit-app-uid').val(); | |
| navigator.clipboard.writeText(appUID); | |
| // change to 'copied' | |
| $(this).html('Copied'); | |
| setTimeout(() => { | |
| $(this).html(copy_svg); | |
| }, 2000); | |
| }); | |
| $(document).on('change', '#analytics-period', async function (e) { | |
| let period = $(this).val(); | |
| render_analytics(period); | |
| }); | |
| async function render_analytics (period) { | |
| puter.ui.showSpinner(); | |
| // set a sensible stats_grouping based on the selected period | |
| let stats_grouping; | |
| if ( period === 'today' || period === 'yesterday' ) { | |
| stats_grouping = 'hour'; | |
| } | |
| else if ( period === 'this_week' || period === 'last_week' || period === 'this_month' || period === 'last_month' || period === '7d' || period === '30d' ) { | |
| stats_grouping = 'day'; | |
| } | |
| else if ( period === 'this_year' || period === 'last_year' || period === '12m' || period === 'all' ) { | |
| stats_grouping = 'month'; | |
| } | |
| const app = await puter.apps.get(currently_editing_app.name, | |
| { | |
| icon_size: 16, | |
| stats_period: period, | |
| stats_grouping: stats_grouping, | |
| }); | |
| $('#analytics-users .count').html(number_format(app.stats.user_count)); | |
| $('#analytics-opens .count').html(number_format(app.stats.open_count)); | |
| // Clear existing chart if any | |
| $('#analytics-chart').remove(); | |
| $('.analytics-container').remove(); | |
| // Create new canvas | |
| const container = $('<div class="analytics-container" style="width:100%; height:400px; margin-top:30px;"></div>'); | |
| const canvas = $('<canvas id="analytics-chart"></canvas>'); | |
| container.append(canvas); | |
| $('#analytics-opens').parent().after(container); | |
| // Format the data | |
| const labels = app.stats.grouped_stats.open_count.map(item => { | |
| let date; | |
| if ( stats_grouping === 'month' ) { | |
| // Handle YYYY-MM format explicitly | |
| const [year, month] = item.period.split('-'); | |
| date = new Date(parseInt(year), parseInt(month) - 1); // month is 0-based in JS | |
| } else { | |
| date = new Date(item.period); | |
| } | |
| if ( stats_grouping === 'hour' ) { | |
| return date.toLocaleString('en-US', { hour: 'numeric', hour12: true }).toLowerCase(); | |
| } else if ( stats_grouping === 'day' ) { | |
| return date.toLocaleString('en-US', { month: 'short', day: 'numeric' }); | |
| } else { | |
| return date.toLocaleString('en-US', { month: 'short', year: 'numeric' }); | |
| } | |
| }); | |
| const openData = app.stats.grouped_stats.open_count.map(item => item.count); | |
| const userData = app.stats.grouped_stats.user_count.map(item => item.count); | |
| // Create chart | |
| const ctx = document.getElementById('analytics-chart').getContext('2d'); | |
| new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: labels, | |
| datasets: [ | |
| { | |
| label: 'Opens', | |
| data: openData, | |
| borderColor: '#346beb', | |
| tension: 0, | |
| fill: false, | |
| }, | |
| { | |
| label: 'Users', | |
| data: userData, | |
| borderColor: '#27cc32', | |
| tension: 0, | |
| fill: false, | |
| }, | |
| ], | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { | |
| display: true, | |
| title: { | |
| display: true, | |
| text: 'Period', | |
| }, | |
| ticks: { | |
| maxRotation: 45, | |
| minRotation: 45, | |
| }, | |
| }, | |
| y: { | |
| display: true, | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Count', | |
| }, | |
| ticks: { | |
| precision: 0, // Show whole numbers only | |
| stepSize: 1, // Increment by 1 | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| puter.ui.hideSpinner(); | |
| } | |
| $(document).on('click', '.stats-cell', function (e) { | |
| edit_app_section($(this).attr('data-app-name'), 'analytics'); | |
| }); | |
| function app_context_menu (app_name, app_title, app_uid) { | |
| puter.ui.contextMenu({ | |
| items: [ | |
| { | |
| label: 'Open App', | |
| type: 'primary', | |
| action: () => { | |
| puter.ui.launchApp(app_name); | |
| }, | |
| }, | |
| '-', | |
| { | |
| label: 'Edit', | |
| type: 'primary', | |
| action: () => { | |
| edit_app_section(app_name); | |
| }, | |
| }, | |
| { | |
| label: 'Add Shortcut to Desktop', | |
| type: 'primary', | |
| action: () => { | |
| puter.fs.upload(new File([], app_title), | |
| `/${auth_username}/Desktop`, | |
| { | |
| name: app_title, | |
| dedupeName: true, | |
| overwrite: false, | |
| appUID: app_uid, | |
| }).then(async (uploaded) => { | |
| puter.ui.alert(`<strong>${app_title}</strong> shortcut has been added to your desktop.`, [ | |
| { | |
| label: 'Ok', | |
| type: 'primary', | |
| }, | |
| ], { | |
| type: 'success', | |
| }); | |
| }); | |
| }, | |
| }, | |
| '-', | |
| { | |
| label: 'Delete', | |
| type: 'danger', | |
| action: () => { | |
| attempt_delete_app(app_name, app_title, app_uid); | |
| }, | |
| }, | |
| ], | |
| }); | |
| } | |
| $(document).on('click', '.options-icon-app', function (e) { | |
| let app_name = $(this).attr('data-app-name'); | |
| let app_title = $(this).attr('data-app-title'); | |
| let app_uid = $(this).attr('data-app-uid'); | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| app_context_menu(app_name, app_title, app_uid); | |
| }); | |
| async function attempt_delete_app (app_name, app_title, app_uid) { | |
| // get app | |
| const app_data = await puter.apps.get(app_name, { icon_size: 16 }); | |
| if ( app_data.metadata?.locked ) { | |
| puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ], { | |
| type: 'warning', | |
| }); | |
| return; | |
| } | |
| // confirm delete | |
| const alert_resp = await puter.ui.alert(`Are you sure you want to premanently delete <strong>${html_encode(app_title)}</strong>?`, | |
| [ | |
| { | |
| label: 'Yes, delete permanently', | |
| value: 'delete', | |
| type: 'danger', | |
| }, | |
| { | |
| label: 'Cancel', | |
| }, | |
| ]); | |
| if ( alert_resp === 'delete' ) { | |
| remove_app_card(app_uid); | |
| // delete app | |
| puter.apps.delete(app_name).then(async (app) => { | |
| // get app directory | |
| puter.fs.stat({ | |
| path: `/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| returnSubdomains: true, | |
| }).then(async (stat) => { | |
| // delete subdomain associated with the app dir | |
| puter.hosting.delete(stat.subdomains[0].subdomain); | |
| // delete app directory | |
| puter.fs.delete(`/${auth_username}/AppData/${dev_center_uid}/${app_uid}`, | |
| { recursive: true }); | |
| }); | |
| }).catch(async (err) => { | |
| puter.ui.hideSpinner(); | |
| puter.ui.alert(err?.message, [ | |
| { | |
| label: 'Ok', | |
| }, | |
| ]); | |
| }); | |
| } | |
| } | |
| export default init_apps; |