mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-07 04:12:36 +00:00
3075 lines
124 KiB
JavaScript
3075 lines
124 KiB
JavaScript
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' },
|
||
];
|
||
|
||
const PREVIEW_DEVICES = [
|
||
{ id: 'desktop', label: 'Desktop' },
|
||
{ id: 'tablet', label: 'Tablet' },
|
||
{ id: 'mobile', label: 'Mobile' },
|
||
];
|
||
|
||
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)}
|
||
${generatePreviewImagesSection()}
|
||
<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'),
|
||
previewImages: getPreviewImagesState(),
|
||
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 ( serializePreviewState(getPreviewImagesState()) !== serializePreviewState(originalValues.previewImages) ) {
|
||
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');
|
||
}
|
||
|
||
applyPreviewImages(originalValues.previewImages);
|
||
}
|
||
|
||
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));
|
||
applyPreviewImages(cur_app.metadata?.preview_images);
|
||
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');
|
||
}
|
||
|
||
const previewImagesState = getPreviewImagesState();
|
||
const previewImages = await handlePreviewImagesUpload(name, previewImagesState);
|
||
|
||
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'),
|
||
preview_images: previewImages,
|
||
},
|
||
filetypeAssociations: filetype_associations,
|
||
}).then(async (app) => {
|
||
currently_editing_app = app;
|
||
currently_editing_app.metadata = currently_editing_app.metadata || {};
|
||
currently_editing_app.metadata.preview_images = previewImages;
|
||
applyPreviewImages(previewImages);
|
||
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 assetsPath = `/${auth_username}/AppData/${dev_center_uid}/assets`;
|
||
try {
|
||
await puter.fs.mkdir(assetsPath,
|
||
{ overwrite: false, recursive: true, rename: false });
|
||
} catch ( err ) {
|
||
if ( err.code !== 'item_with_same_name_exists' ) {
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// Publish the directory
|
||
const hostname = `assets-${Math.random().toString(36).substring(2)}`;
|
||
const route = await puter.hosting.create(hostname, assetsPath);
|
||
|
||
// Store the URL
|
||
await puter.kv.set('assets_url', `https://${hostname}.puter.site`);
|
||
}
|
||
} catch ( err ) {
|
||
console.error('Error initializing assets directory:', err);
|
||
}
|
||
};
|
||
|
||
async function ensureAssetsURL () {
|
||
let assets_url = await puter.kv.get('assets_url');
|
||
if ( ! assets_url ) {
|
||
await initializeAssetsDirectory();
|
||
assets_url = await puter.kv.get('assets_url');
|
||
}
|
||
|
||
if ( ! assets_url ) throw new Error('Assets URL not found');
|
||
return assets_url;
|
||
}
|
||
|
||
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>
|
||
`;
|
||
};
|
||
|
||
window.generatePreviewImagesSection = () => {
|
||
return `
|
||
<h3 style="font-size: 23px; border-bottom: 1px solid #EEE; margin-top: 40px;">Preview Images</h3>
|
||
<p class="preview-images-help">Upload up to 10 images for each device type to showcase your app listing.</p>
|
||
<div class="preview-images-container">
|
||
${PREVIEW_DEVICES.map((device) => `
|
||
<div class="preview-device" data-device="${html_encode(device.id)}">
|
||
<div class="preview-device-header">
|
||
<div class="preview-device-title">${html_encode(device.label)} <span class="preview-count" data-device="${html_encode(device.id)}">0/10</span></div>
|
||
<button type="button" class="button button-small add-preview-image" data-device="${html_encode(device.id)}">Add images</button>
|
||
</div>
|
||
<div class="preview-grid" data-device="${html_encode(device.id)}"></div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
$(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();
|
||
});
|
||
|
||
function getDefaultPreviewImagesState () {
|
||
return {
|
||
desktop: [],
|
||
tablet: [],
|
||
mobile: [],
|
||
};
|
||
}
|
||
|
||
function createPreviewThumbElement (item) {
|
||
const previewItem = item || {};
|
||
const $thumb = $('<div class="preview-thumb"></div>');
|
||
const background = previewItem.base64 || previewItem.url;
|
||
|
||
if ( previewItem.url ) {
|
||
$thumb.attr('data-url', previewItem.url);
|
||
}
|
||
|
||
if ( previewItem.base64 ) {
|
||
$thumb.attr('data-base64', previewItem.base64);
|
||
}
|
||
|
||
if ( background ) {
|
||
$thumb.css('background-image', `url(${background})`);
|
||
}
|
||
|
||
$thumb.append('<span class="remove-preview-image" title="Remove">×</span>');
|
||
return $thumb;
|
||
}
|
||
|
||
function applyPreviewImages (previewImages) {
|
||
const state = previewImages ?? getDefaultPreviewImagesState();
|
||
|
||
PREVIEW_DEVICES.forEach((device) => {
|
||
const grid = $(`.preview-grid[data-device="${device.id}"]`);
|
||
grid.empty();
|
||
|
||
const images = Array.isArray(state?.[device.id]) ? state[device.id].slice(0, 10) : [];
|
||
images.forEach((image) => {
|
||
const imageObj = typeof image === 'string' ? { url: image } : image;
|
||
const thumb = createPreviewThumbElement(imageObj);
|
||
grid.append(thumb);
|
||
});
|
||
});
|
||
|
||
updatePreviewCounts();
|
||
}
|
||
|
||
function updatePreviewCounts () {
|
||
PREVIEW_DEVICES.forEach((device) => {
|
||
const count = $(`.preview-grid[data-device="${device.id}"] .preview-thumb`).length;
|
||
$(`.preview-count[data-device="${device.id}"]`).text(`${count}/10`);
|
||
});
|
||
}
|
||
|
||
function getPreviewImagesState () {
|
||
const state = getDefaultPreviewImagesState();
|
||
|
||
PREVIEW_DEVICES.forEach((device) => {
|
||
state[device.id] = [];
|
||
$(`.preview-grid[data-device="${device.id}"] .preview-thumb`).each(function () {
|
||
state[device.id].push({
|
||
url: $(this).attr('data-url') ?? null,
|
||
base64: $(this).attr('data-base64') ?? null,
|
||
});
|
||
});
|
||
});
|
||
|
||
return state;
|
||
}
|
||
|
||
function serializePreviewState (state) {
|
||
const normalized = {};
|
||
|
||
PREVIEW_DEVICES.forEach((device) => {
|
||
normalized[device.id] = (state?.[device.id] ?? []).map((item) => {
|
||
if ( item?.base64 ) {
|
||
return { base64: item.base64 };
|
||
}
|
||
|
||
return { url: item?.url ?? null };
|
||
});
|
||
});
|
||
|
||
return JSON.stringify(normalized);
|
||
}
|
||
|
||
async function readImageFileAsDataURL (file) {
|
||
const blob = await puter.fs.read(file.path);
|
||
|
||
return await new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = function () {
|
||
let image = reader.result;
|
||
const fileExtension = file.name.split('.').pop();
|
||
const mimeType = getMimeType(fileExtension);
|
||
image = image.replace('data:application/octet-stream;base64', `data:${mimeType};base64`);
|
||
resolve(image);
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
$(document).on('click', '.add-preview-image', async function () {
|
||
const device = $(this).attr('data-device');
|
||
const grid = $(`.preview-grid[data-device="${device}"]`);
|
||
|
||
if ( grid.length === 0 ) return;
|
||
|
||
const currentCount = grid.find('.preview-thumb').length;
|
||
if ( currentCount >= 10 ) {
|
||
puter.ui.alert('You can add up to 10 images for this device.');
|
||
return;
|
||
}
|
||
|
||
let selected;
|
||
try {
|
||
selected = await puter.ui.showOpenFilePicker({
|
||
accept: 'image/*',
|
||
multiple: true,
|
||
});
|
||
} catch ( err ) {
|
||
return;
|
||
}
|
||
|
||
const selections = Array.isArray(selected) ? selected : (selected ? [selected] : []);
|
||
const available = 10 - currentCount;
|
||
const filesToUse = selections.slice(0, available);
|
||
|
||
for ( const file of filesToUse ) {
|
||
const imageData = await readImageFileAsDataURL(file);
|
||
const thumb = createPreviewThumbElement({ base64: imageData });
|
||
grid.append(thumb);
|
||
}
|
||
|
||
if ( selections.length > filesToUse.length ) {
|
||
puter.ui.alert('You can add up to 10 images for this device.');
|
||
}
|
||
|
||
updatePreviewCounts();
|
||
toggleSaveButton();
|
||
toggleResetButton();
|
||
});
|
||
|
||
$(document).on('click', '.remove-preview-image', function (e) {
|
||
e.stopPropagation();
|
||
$(this).closest('.preview-thumb').remove();
|
||
updatePreviewCounts();
|
||
toggleSaveButton();
|
||
toggleResetButton();
|
||
});
|
||
|
||
window.handleSocialImageUpload = async (app_name, socialImageData) => {
|
||
if ( ! socialImageData ) return null;
|
||
|
||
try {
|
||
const assets_url = await ensureAssetsURL();
|
||
|
||
// 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;
|
||
}
|
||
};
|
||
|
||
window.handlePreviewImagesUpload = async (app_name, previewImagesState) => {
|
||
const state = previewImagesState ?? getDefaultPreviewImagesState();
|
||
const hasImages = PREVIEW_DEVICES.some((device) => (state[device.id] ?? []).length > 0);
|
||
|
||
if ( ! hasImages ) {
|
||
return getDefaultPreviewImagesState();
|
||
}
|
||
|
||
const assets_url = await ensureAssetsURL();
|
||
|
||
const assetsDir = `/${auth_username}/AppData/${dev_center_uid}/assets/previews/${app_name}`;
|
||
await puter.fs.mkdir(assetsDir, { overwrite: true, recursive: true, rename: false });
|
||
|
||
const result = getDefaultPreviewImagesState();
|
||
|
||
for ( const device of PREVIEW_DEVICES ) {
|
||
const images = (state[device.id] ?? []).slice(0, 10);
|
||
for ( let i = 0; i < images.length; i++ ) {
|
||
const image = images[i];
|
||
if ( image.base64 ) {
|
||
const base64Response = await fetch(image.base64);
|
||
const blob = await base64Response.blob();
|
||
const filename = `${app_name}-${device.id}-${i + 1}.png`;
|
||
await puter.fs.upload(new File([blob], filename, { type: 'image/png' }),
|
||
assetsDir,
|
||
{ overwrite: true });
|
||
result[device.id].push(`${assets_url}/previews/${app_name}/${filename}`);
|
||
} else if ( image.url ) {
|
||
result[device.id].push(image.url);
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
$(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;
|